[
  {
    "path": ".dockerignore",
    "content": "node_modules\nDockerfile\ndocker-compose.*\n"
  },
  {
    "path": ".editorconfig",
    "content": "root = true\n\n[*]\ncharset = utf-8\nindent_style = space\nindent_size = 4\nend_of_line = lf\ninsert_final_newline = true\ntrim_trailing_whitespace = true\n"
  },
  {
    "path": ".gitattributes",
    "content": "*       text    eol=lf\n*.exe   binary\n*.png   binary\n*.jpg   binary\n*.jpeg  binary\n*.ico   binary\n*.icns  binary\n*.webp  binary\n*.eot   binary\n*.otf   binary\n*.ttf   binary\n*.woff  binary\n*.woff2 binary\n"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "# These are supported funding model platforms\n\nko_fi: jeffvli\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/01-feature_request.yml",
    "content": "name: Feature request\ndescription: Request a feature to be added to Feishin\ntitle: '[Feature]: '\nlabels: ['enhancement']\nbody:\n    - type: checkboxes\n      id: check-duplicate\n      attributes:\n          label: I have already checked through the existing feature requests and found no duplicates\n          options:\n              - label: 'Yes'\n                required: true\n\n    - type: dropdown\n      id: server-specific\n      attributes:\n          label: Is this a server-specific feature?\n          options:\n              - Not server-specific\n              - OpenSubsonic\n              - Jellyfin\n              - Navidrome\n          default: 0\n      validations:\n          required: true\n\n    - type: textarea\n      id: solution\n      attributes:\n          label: What do you want to be added?\n          placeholder: I would like to see [...]\n      validations:\n          required: true\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/02-bug_report.yml",
    "content": "name: Bug report\ndescription: You're having technical issues.\ntitle: '[Bug]: '\nlabels: ['bug']\nbody:\n    - type: checkboxes\n      id: check-duplicate\n      attributes:\n          label: I have already checked through the existing bug reports and found no duplicates\n          options:\n              - label: 'Yes'\n                required: true\n\n    - type: input\n      id: version\n      attributes:\n          label: App Version\n          description: What version of the app are you running?\n          placeholder: ex. 1.0.0\n      validations:\n          required: true\n\n    - type: input\n      id: server-version\n      attributes:\n          label: Music Server and Version\n          description: What music server are you using?\n          placeholder: ex. Navidrome v0.55.0, LMS v3.67.0, Jellyfin v10.10.7, etc.\n      validations:\n          required: true\n\n    - type: dropdown\n      id: environments\n      attributes:\n          label: What local environments are you seeing the problem on?\n          multiple: true\n          options:\n              - Desktop Windows\n              - Desktop macOS\n              - Desktop Linux\n              - Web Firefox\n              - Web Chrome\n              - Web Safari\n              - Web Microsoft Edge\n              - Other (please specify in the next field)\n\n    - type: textarea\n      id: what-happened\n      attributes:\n          label: What happened?\n          description: Also tell us, what did you expect to happen?\n          placeholder: Include screenshots and error logs if possible. The browser devtools can be opened using CTRL + SHIFT + I (Windows/Linux) or CMD + SHIFT + I (macOS).\n      validations:\n          required: true\n\n    - type: textarea\n      id: reproduction\n      attributes:\n          label: Steps to reproduce\n          description: How can we reproduce this issue? Are there any specific settings that are enabled that could be the cause?\n          placeholder: |\n              1. Go to '...'\n              2. Click on '....'\n              3. Scroll down to '....'\n              4. See error\n      validations:\n          required: true\n\n    - type: textarea\n      id: logs\n      attributes:\n          label: Relevant log output\n          description: Please copy and paste any relevant log output. This will be automatically formatted into code.\n          render: shell\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "content": "blank_issues_enabled: false\ncontact_links:\n    - name: Questions or help\n      url: https://github.com/jeffvli/feishin/discussions\n      about: Ask questions or get help in the discussions section\n    - name: Discord Community\n      url: https://discord.gg/FVKpcMDy5f\n      about: The discord/matrix servers are bridged so you can join whichever you prefer\n    - name: Matrix Community\n      url: https://matrix.to/#/#sonixd:matrix.org\n      about: The discord/matrix servers are bridged so you can join whichever you prefer\n"
  },
  {
    "path": ".github/config.yml",
    "content": "requiredHeaders:\n    - Prerequisites\n    - Expected Behavior\n    - Current Behavior\n    - Possible Solution\n    - Your Environment\n"
  },
  {
    "path": ".github/stale.yml",
    "content": "# Number of days of inactivity before an issue becomes stale\ndaysUntilStale: 60\n# Number of days of inactivity before a stale issue is closed\ndaysUntilClose: 7\n# Issues with these labels will never be considered stale\nexemptLabels:\n    - discussion\n    - security\n# Label to use when marking an issue as stale\nstaleLabel: wontfix\n# Comment to post when marking an issue as stale. Set to `false` to disable\nmarkComment: >\n    This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.\n\n\n# Comment to post when closing a stale issue. Set to `false` to disable\ncloseComment: false\n"
  },
  {
    "path": ".github/workflows/publish-alpha.yml",
    "content": "# Alpha builds published to Cloudflare R2 with date versioning (e.g. 1.0.0-alpha-20260205).\n# Required repo secrets: R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY (from R2 API token in Cloudflare dashboard).\nname: Publish Alpha\n\non:\n    workflow_dispatch:\n        inputs:\n            version:\n                description: 'Semantic version number (e.g., 1.0.0) - alpha suffix will be added automatically'\n                required: false\n                type: string\n    schedule:\n        # Run at 3:00 AM PST daily (11:00 UTC; PST = UTC-8)\n        - cron: '0 11 * * *'\n\njobs:\n    check-new-commits:\n        runs-on: ubuntu-latest\n        outputs:\n            has_new_commits: ${{ steps.manual.outputs.has_new_commits || steps.check.outputs['has-new-commits'] }}\n        steps:\n            - name: Set has new commits (manual trigger)\n              id: manual\n              if: github.event_name == 'workflow_dispatch'\n              run: echo \"has_new_commits=true\" >> \"$GITHUB_OUTPUT\"\n\n            - name: Check for new commits (24 hr interval)\n              id: check\n              if: github.event_name != 'workflow_dispatch'\n              uses: adriangl/check-new-commits-action@v1\n              with:\n                  token: ${{ secrets.GITHUB_TOKEN }}\n                  seconds: 86400\n\n    prepare:\n        needs: check-new-commits\n        if: needs.check-new-commits.outputs.has_new_commits == 'true'\n        runs-on: ubuntu-latest\n        outputs:\n            version: ${{ steps.version.outputs.version }}\n        steps:\n            - name: Checkout git repo\n              uses: actions/checkout@v6\n\n            - name: Install Node and PNPM\n              uses: pnpm/action-setup@v4\n              with:\n                  version: 10\n\n            - name: Install dependencies\n              run: pnpm install\n\n            - name: Set date-based alpha version\n              id: version\n              shell: pwsh\n              run: |\n                  $inputVersion = \"${{ github.event.inputs.version }}\"\n                  Write-Host \"Input version: $inputVersion\"\n\n                  if ($inputVersion -eq \"\" -or $inputVersion -eq \"null\") {\n                      # No input version provided (scheduled run or manual without input), auto-increment patch version\n                      Write-Host \"No version provided, auto-incrementing patch version...\"\n\n                      $currentVersion = (Get-Content package.json | ConvertFrom-Json).version\n                      Write-Host \"Current version: $currentVersion\"\n\n                      $cleanVersion = $currentVersion -replace '-.*$', ''\n                      $versionParts = $cleanVersion.Split('.')\n                      if ($versionParts.Length -ne 3) {\n                          Write-Error \"Current version format is invalid: $cleanVersion\"\n                          exit 1\n                      }\n\n                      $major = [int]$versionParts[0]\n                      $minor = [int]$versionParts[1]\n                      $patch = [int]$versionParts[2]\n                      $newPatch = $patch + 1\n                      $inputVersion = \"$major.$minor.$newPatch\"\n                      Write-Host \"Auto-generated version: $inputVersion\"\n                  } else {\n                      # Validate semantic version format (major.minor.patch)\n                      $versionPattern = '^\\d+\\.\\d+\\.\\d+$'\n                      if ($inputVersion -notmatch $versionPattern) {\n                          Write-Error \"Invalid version format. Expected semantic version (e.g., 1.0.0), got: $inputVersion\"\n                          exit 1\n                      }\n                  }\n\n                  # Date in YYYYMMDD (PST / America/Los_Angeles)\n                  $pst = [TimeZoneInfo]::FindSystemTimeZoneById('America/Los_Angeles')\n                  $dateInPst = [TimeZoneInfo]::ConvertTimeFromUtc([DateTime]::UtcNow, $pst)\n                  $dateStr = $dateInPst.ToString(\"yyyyMMdd\")\n                  $alphaVersion = \"$inputVersion-alpha-$dateStr\"\n                  Write-Host \"Alpha version: $alphaVersion\"\n\n                  # Update package.json\n                  $packageJson = Get-Content package.json | ConvertFrom-Json\n                  $packageJson.version = $alphaVersion\n                  $packageJson | ConvertTo-Json -Depth 10 | Set-Content package.json\n\n                  echo \"version=$alphaVersion\" >> $env:GITHUB_OUTPUT\n\n    cleanup:\n        needs: prepare\n        runs-on: ubuntu-latest\n        env:\n            AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}\n            AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}\n            R2_ENDPOINT_URL: ${{ secrets.R2_ENDPOINT_URL }}\n        steps:\n            - name: Delete all objects in R2 bucket\n              run: |\n                  aws s3 rm s3://feishin-nightly --recursive --endpoint-url $R2_ENDPOINT_URL\n\n    publish:\n        needs: [prepare, cleanup]\n        runs-on: ${{ matrix.os }}\n        env:\n            AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}\n            AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}\n\n        strategy:\n            matrix:\n                os: [windows-latest, macos-latest, ubuntu-latest]\n\n        steps:\n            - name: Checkout git repo\n              uses: actions/checkout@v6\n\n            - name: Install Node and PNPM\n              uses: pnpm/action-setup@v4\n              with:\n                  version: 10\n\n            - name: Install dependencies\n              run: pnpm install\n\n            - name: Set version from prepare job\n              shell: pwsh\n              run: |\n                  $version = \"${{ needs.prepare.outputs.version }}\"\n                  Write-Host \"Setting version: $version\"\n                  $packageJson = Get-Content package.json | ConvertFrom-Json\n                  $packageJson.version = $version\n                  $packageJson | ConvertTo-Json -Depth 10 | Set-Content package.json\n\n            - name: Build and Publish to R2 (Windows)\n              if: matrix.os == 'windows-latest'\n              uses: nick-invision/retry@v3.0.2\n              with:\n                  timeout_minutes: 30\n                  max_attempts: 3\n                  retry_on: error\n                  command: |\n                      pnpm run publish:win:alpha\n                  on_retry_command: pnpm cache delete\n\n            - name: Build and Publish to R2 (macOS)\n              if: matrix.os == 'macos-latest'\n              uses: nick-invision/retry@v3.0.2\n              with:\n                  timeout_minutes: 30\n                  max_attempts: 3\n                  retry_on: error\n                  command: |\n                      pnpm run publish:mac:alpha\n                  on_retry_command: pnpm cache delete\n\n            - name: Build and Publish to R2 (Linux)\n              if: matrix.os == 'ubuntu-latest'\n              uses: nick-invision/retry@v3.0.2\n              with:\n                  timeout_minutes: 30\n                  max_attempts: 3\n                  retry_on: error\n                  command: |\n                      pnpm run publish:linux:alpha\n                  on_retry_command: pnpm cache delete\n\n            - name: Build and Publish to R2 (Linux ARM64)\n              if: matrix.os == 'ubuntu-latest'\n              uses: nick-invision/retry@v3.0.2\n              with:\n                  timeout_minutes: 30\n                  max_attempts: 3\n                  retry_on: error\n                  command: |\n                      pnpm run publish:linux-arm64:alpha\n                  on_retry_command: pnpm cache delete\n"
  },
  {
    "path": ".github/workflows/publish-beta.yml",
    "content": "name: Publish Beta (Manual)\n\non:\n    workflow_dispatch:\n        inputs:\n            version:\n                description: 'Semantic version number (e.g., 1.0.0) - beta suffix will be added automatically'\n                required: false\n                type: string\n\njobs:\n    prepare:\n        runs-on: ubuntu-latest\n        outputs:\n            version: ${{ steps.version.outputs.version }}\n        steps:\n            - name: Checkout git repo\n              uses: actions/checkout@v6\n\n            - name: Install Node and PNPM\n              uses: pnpm/action-setup@v4\n              with:\n                  version: 10\n\n            - name: Install dependencies\n              run: pnpm install\n\n            - name: Validate and set version with incrementing beta suffix\n              id: version\n              shell: pwsh\n              env:\n                  GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n              run: |\n                  $inputVersion = \"${{ github.event.inputs.version }}\"\n                  Write-Host \"Input version: $inputVersion\"\n\n                  if ($inputVersion -eq \"\" -or $inputVersion -eq \"null\") {\n                      # No input version provided, auto-increment patch version\n                      Write-Host \"No version provided, auto-incrementing patch version...\"\n\n                      # Get current version from package.json\n                      $currentVersion = (Get-Content package.json | ConvertFrom-Json).version\n                      Write-Host \"Current version: $currentVersion\"\n\n                      # Remove any existing suffix (like -beta) to get clean semantic version\n                      $cleanVersion = $currentVersion -replace '-.*$', ''\n\n                      # Extract major, minor, patch components\n                      $versionParts = $cleanVersion.Split('.')\n                      if ($versionParts.Length -ne 3) {\n                          Write-Error \"Current version format is invalid: $cleanVersion\"\n                          exit 1\n                      }\n\n                      $major = [int]$versionParts[0]\n                      $minor = [int]$versionParts[1]\n                      $patch = [int]$versionParts[2]\n\n                      # Increment patch version\n                      $newPatch = $patch + 1\n                      $inputVersion = \"$major.$minor.$newPatch\"\n                      Write-Host \"Auto-generated version: $inputVersion\"\n                  } else {\n                      # Validate semantic version format (major.minor.patch)\n                      $versionPattern = '^\\d+\\.\\d+\\.\\d+$'\n                      if ($inputVersion -notmatch $versionPattern) {\n                          Write-Error \"Invalid version format. Expected semantic version (e.g., 1.0.0), got: $inputVersion\"\n                          exit 1\n                      }\n                  }\n\n                  # Check for existing beta releases with the same base version\n                  Write-Host \"Checking for existing beta releases with base version: $inputVersion\"\n                  $existingReleases = gh release list --limit 100 --json tagName,isPrerelease | ConvertFrom-Json | Where-Object { $_.isPrerelease -eq $true }\n\n                  $maxBetaNumber = 0\n\n                  foreach ($release in $existingReleases) {\n                      $tagName = $release.tagName\n                      Write-Host \"Checking tag: $tagName\"\n\n                      # Extract beta number from tag name (format: v1.0.0-beta.1)\n                      if ($tagName -match \"v$([regex]::Escape($inputVersion))-beta\\.(\\d+)$\") {\n                          $betaNumber = [int]$matches[1]\n                          Write-Host \"Found beta release with number: $betaNumber\"\n\n                          if ($betaNumber -gt $maxBetaNumber) {\n                              $maxBetaNumber = $betaNumber\n                          }\n                      }\n                  }\n\n                  # Calculate next beta number\n                  $nextBetaNumber = $maxBetaNumber + 1\n                  Write-Host \"Next beta number: $nextBetaNumber\"\n\n                  # Create beta suffix with incrementing number\n                  $betaSuffix = \"beta.$nextBetaNumber\"\n                  $versionWithBeta = \"$inputVersion-$betaSuffix\"\n                  Write-Host \"Setting version to: $versionWithBeta\"\n\n                  # Update package.json\n                  $packageJson = Get-Content package.json | ConvertFrom-Json\n                  $packageJson.version = $versionWithBeta\n                  $packageJson | ConvertTo-Json -Depth 10 | Set-Content package.json\n\n                  Write-Host \"Updated package.json version to: $versionWithBeta\"\n\n                  # Set output for other jobs\n                  echo \"version=$versionWithBeta\" >> $env:GITHUB_OUTPUT\n\n    publish:\n        needs: prepare\n        runs-on: ${{ matrix.os }}\n\n        strategy:\n            matrix:\n                os: [windows-latest, macos-latest, ubuntu-latest]\n\n        steps:\n            - name: Checkout git repo\n              uses: actions/checkout@v6\n\n            - name: Install Node and PNPM\n              uses: pnpm/action-setup@v4\n              with:\n                  version: 10\n\n            - name: Install dependencies\n              run: pnpm install\n\n            - name: Set version from prepare job\n              shell: pwsh\n              run: |\n                  $versionWithBeta = \"${{ needs.prepare.outputs.version }}\"\n                  Write-Host \"Setting version from prepare job: $versionWithBeta\"\n\n                  # Update package.json with the version from prepare job\n                  $packageJson = Get-Content package.json | ConvertFrom-Json\n                  $packageJson.version = $versionWithBeta\n                  $packageJson | ConvertTo-Json -Depth 10 | Set-Content package.json\n\n                  Write-Host \"Updated package.json version to: $versionWithBeta\"\n\n            - name: Build and Publish releases (Windows)\n              if: matrix.os == 'windows-latest'\n              env:\n                  GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n              uses: nick-invision/retry@v3.0.2\n              with:\n                  timeout_minutes: 30\n                  max_attempts: 3\n                  retry_on: error\n                  command: |\n                      pnpm run publish:win:beta\n                  on_retry_command: pnpm cache delete\n\n            - name: Build and Publish releases (macOS)\n              if: matrix.os == 'macos-latest'\n              env:\n                  GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n              uses: nick-invision/retry@v3.0.2\n              with:\n                  timeout_minutes: 30\n                  max_attempts: 3\n                  retry_on: error\n                  command: |\n                      pnpm run publish:mac:beta\n                  on_retry_command: pnpm cache delete\n\n            - name: Build and Publish releases (Linux)\n              if: matrix.os == 'ubuntu-latest'\n              env:\n                  GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n              uses: nick-invision/retry@v3.0.2\n              with:\n                  timeout_minutes: 30\n                  max_attempts: 3\n                  retry_on: error\n                  command: |\n                      pnpm run publish:linux:beta\n                  on_retry_command: pnpm cache delete\n\n            - name: Build and Publish releases (Linux ARM64)\n              if: matrix.os == 'ubuntu-latest'\n              env:\n                  GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n              uses: nick-invision/retry@v3.0.2\n              with:\n                  timeout_minutes: 30\n                  max_attempts: 3\n                  retry_on: error\n                  command: |\n                      pnpm run publish:linux-arm64:beta\n                  on_retry_command: pnpm cache delete\n\n    edit-release:\n        needs: [prepare, publish]\n        runs-on: ubuntu-latest\n        steps:\n            - name: Checkout git repo\n              uses: actions/checkout@v6\n\n            - name: Edit release with commits and title\n              shell: pwsh\n              env:\n                  GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n              run: |\n                  # Get the version from the prepare job\n                  $versionWithBeta = \"${{ needs.prepare.outputs.version }}\"\n                  $tagVersion = \"v\" + $versionWithBeta\n                  Write-Host \"Editing release for tag: $tagVersion\"\n\n                  # Check if release exists\n                  $releaseExists = gh release view $tagVersion 2>$null\n                  if ($LASTEXITCODE -eq 0) {\n                      Write-Host \"Found release with tag $tagVersion\"\n\n                      # Get current release notes\n\n                      # Find the latest non-prerelease tag\n                      Write-Host \"Finding latest non-prerelease tag...\"\n                      $latestNonPrerelease = gh release list --limit 100 --json tagName,isPrerelease | ConvertFrom-Json | Where-Object { $_.isPrerelease -eq $false -and $_.tagName -ne $tagVersion } | Select-Object -First 1\n\n                        if ($latestNonPrerelease) {\n                            $latestTag = $latestNonPrerelease.tagName\n                            Write-Host \"Latest non-prerelease tag: $latestTag\"\n\n                            # Get commits between latest non-prerelease and current HEAD\n                            Write-Host \"Getting commits between $latestTag and HEAD...\"\n\n                            # Use proper git range syntax and handle PowerShell string interpolation\n                            $gitRange = \"$latestTag..HEAD\"\n                            Write-Host \"Git range: $gitRange\"\n\n                            # Get commits using proper git command with datetime\n                            $commits = git log --oneline --pretty=format:\"%ad|%s|%h\" --date=short $gitRange\n\n                            # Check if commits exist\n                            if ($commits -and $commits.Trim() -ne \"\") {\n                                Write-Host \"Found commits:\"\n                                Write-Host $commits\n\n                                # Group commits by date\n                                $groupedCommits = @{}\n                                foreach ($line in $commits) {\n                                    if ($line.Trim() -ne \"\") {\n                                        $parts = $line.Split('|')\n                                        $date = $parts[0]\n                                        $message = $parts[1]\n                                        $hash = $parts[2]\n\n                                        if (-not $groupedCommits.ContainsKey($date)) {\n                                            $groupedCommits[$date] = @()\n                                        }\n                                        $groupedCommits[$date] += \"- $message ($hash)\"\n                                    }\n                                }\n\n                                # Build formatted release notes grouped by date\n                                $commitNotes = \"## Changes since $latestTag`n`n\"\n                                $sortedDates = $groupedCommits.Keys | Sort-Object -Descending\n                                foreach ($date in $sortedDates) {\n                                    $commitNotes += \"### $date`n\"\n                                    foreach ($commit in $groupedCommits[$date]) {\n                                        $commitNotes += \"$commit`n\"\n                                    }\n                                    $commitNotes += \"`n\"\n                                }\n\n                                $releaseNotes = $commitNotes\n                            } else {\n                                Write-Host \"No commits found between $latestTag and HEAD\"\n                                Write-Host \"Trying alternative approach...\"\n\n                                # Alternative: get commits since the tag (not range) with datetime\n                                $commits = git log --oneline --pretty=format:\"%ad|%s|%h\" --date=short $latestTag.. --not $latestTag\n\n                                if ($commits -and $commits.Trim() -ne \"\") {\n                                    Write-Host \"Found commits with alternative method:\"\n                                    Write-Host $commits\n\n                                    # Group commits by date\n                                    $groupedCommits = @{}\n                                    foreach ($line in $commits) {\n                                        if ($line.Trim() -ne \"\") {\n                                            $parts = $line.Split('|')\n                                            $date = $parts[0]\n                                            $message = $parts[1]\n                                            $hash = $parts[2]\n\n                                            if (-not $groupedCommits.ContainsKey($date)) {\n                                                $groupedCommits[$date] = @()\n                                            }\n                                            $groupedCommits[$date] += \"- $message ($hash)\"\n                                        }\n                                    }\n\n                                    # Build formatted release notes grouped by date\n                                    $commitNotes = \"## Changes since $latestTag`n`n\"\n                                    $sortedDates = $groupedCommits.Keys | Sort-Object -Descending\n                                    foreach ($date in $sortedDates) {\n                                        $commitNotes += \"### $date`n\"\n                                        foreach ($commit in $groupedCommits[$date]) {\n                                            $commitNotes += \"$commit`n\"\n                                        }\n                                        $commitNotes += \"`n\"\n                                    }\n\n                                    $releaseNotes = $commitNotes\n                                } else {\n                                    Write-Host \"Still no commits found, using basic release notes\"\n                                    $releaseNotes = \"## Beta Release`n`nThis is a beta release.\"\n                                }\n                            }\n                        } else {\n                            Write-Host \"No non-prerelease tags found, using basic release notes\"\n                            $releaseNotes = \"## Beta Release`n`nThis is a beta release.\"\n                        }\n\n                      # Prepend beta update instructions to release notes\n                      $betaInstructions = \"To receive automatic beta updates, set the release channel to ``Beta`` under ``Advanced`` settings.`n`n\"\n                      $releaseNotes = $betaInstructions + $releaseNotes\n\n                      # Update the release with new title and notes\n                      Write-Host \"Updating release with title 'Beta' and new notes...\"\n                      gh release edit $tagVersion --title \"Beta\" --notes \"$releaseNotes\"\n                      Write-Host \"Successfully updated release title to 'Beta' and added commit notes\"\n                  } else {\n                      Write-Host \"No release found with tag $tagVersion\"\n                  }\n            - name: Set release as prerelease\n              shell: pwsh\n              env:\n                  GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n              run: |\n                  # Get the version from the prepare job\n                  $versionWithBeta = \"${{ needs.prepare.outputs.version }}\"\n                  $tagVersion = \"v\" + $versionWithBeta\n                  Write-Host \"Setting release as prerelease for tag: $tagVersion\"\n                  gh release edit $tagVersion --prerelease --draft=false\n                  Write-Host \"Successfully set release as prerelease\"\n\n    cleanup:\n        needs: [prepare, publish, edit-release]\n        runs-on: ubuntu-latest\n        steps:\n            - name: Checkout git repo\n              uses: actions/checkout@v6\n\n            - name: Delete existing prereleases\n              shell: pwsh\n              env:\n                  GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n              run: |\n                  # Get the current version that was just created\n                  $versionWithBeta = \"${{ needs.prepare.outputs.version }}\"\n                  Write-Host \"Current release version: $versionWithBeta\"\n\n                  # Find and delete any old prereleases (excluding the current one)\n                  Write-Host \"Deleting old prereleases...\"\n                  Write-Host \"Searching for releases with isPrerelease 'true'...\"\n\n                  $betaReleases = gh release list --limit 100 --json tagName,isPrerelease,name | ConvertFrom-Json | Where-Object { $_.isPrerelease -eq $true }\n\n                  if ($betaReleases) {\n                      Write-Host \"Found $($betaReleases.Count) release(s) with isPrerelease 'true':\"\n                      foreach ($release in $betaReleases) {\n                          $tagName = $release.tagName\n                          # Skip the current release\n                          if ($tagName -ne \"v$versionWithBeta\") {\n                              Write-Host \"  - Tag: $tagName, Title: $($release.name)\"\n                              gh release delete $tagName --yes --cleanup-tag\n                              Write-Host \"  Deleted release with tag: $tagName\"\n                          } else {\n                              Write-Host \"  - Skipping current release: $tagName\"\n                          }\n                      }\n                  } else {\n                      Write-Host \"No releases found with isPrerelease 'true'\"\n                  }\n"
  },
  {
    "path": ".github/workflows/publish-docker-auto.yml",
    "content": "# Referenced from: https://docs.github.com/en/actions/publishing-packages/publishing-docker-images#introduction\nname: Publish Docker to GHCR\npermissions: write-all\n\non:\n    workflow_dispatch:\n    push:\n        tags:\n            - 'v*.*.*'\n\nenv:\n    REGISTRY: ghcr.io\n    IMAGE_NAME: ${{ github.repository }}\n\njobs:\n    build-and-push-image:\n        runs-on: ubuntu-latest\n        permissions:\n            contents: read\n            packages: write\n        steps:\n            - name: Checkout repository\n              uses: actions/checkout@v6\n            - name: Log in to the Container registry\n              uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1\n              with:\n                  registry: ${{ env.REGISTRY }}\n                  username: ${{ github.actor }}\n                  password: ${{ secrets.GITHUB_TOKEN }}\n            - name: Extract metadata (tags, labels) for Docker\n              id: meta\n              uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7\n              with:\n                  images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}\n                  tags: |\n                      type=ref,event=branch\n                      type=ref,event=pr\n                      type=semver,pattern={{version}}\n                      type=semver,pattern={{major}}.{{minor}}\n                      type=semver,pattern={{major}}\n            - name: Set up QEMU\n              uses: docker/setup-qemu-action@v3\n            - name: Setup Docker buildx\n              uses: docker/setup-buildx-action@v3\n            - name: Build and push Docker image\n              uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4\n              with:\n                  context: .\n                  push: true\n                  tags: ${{ steps.meta.outputs.tags }}\n                  labels: ${{ steps.meta.outputs.labels }}\n                  platforms: |\n                      linux/amd64\n                      linux/arm/v7\n                      linux/arm64/v8\n"
  },
  {
    "path": ".github/workflows/publish-docker.yml",
    "content": "# Referenced from: https://docs.github.com/en/actions/publishing-packages/publishing-docker-images#introduction\nname: Publish Docker to GHCR (Manual)\n\non: workflow_dispatch\n\nenv:\n    REGISTRY: ghcr.io\n    IMAGE_NAME: ${{ github.repository }}\n\njobs:\n    build-and-push-image:\n        runs-on: ubuntu-latest\n        permissions:\n            contents: read\n            packages: write\n        steps:\n            - name: Checkout repository\n              uses: actions/checkout@v6\n            - name: Log in to the Container registry\n              uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1\n              with:\n                  registry: ${{ env.REGISTRY }}\n                  username: ${{ github.actor }}\n                  password: ${{ secrets.GITHUB_TOKEN }}\n            - name: Extract metadata (tags, labels) for Docker\n              id: meta\n              uses: docker/metadata-action@v5\n              with:\n                  images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}\n            - name: Set up QEMU\n              uses: docker/setup-qemu-action@v3\n            - name: Setup Docker buildx\n              uses: docker/setup-buildx-action@v3\n            - name: Build and push Docker image\n              uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4\n              with:\n                  context: .\n                  push: true\n                  tags: ${{ steps.meta.outputs.tags }}\n                  labels: ${{ steps.meta.outputs.labels }}\n                  platforms: |\n                      linux/amd64\n                      linux/arm/v7\n                      linux/arm64/v8\n"
  },
  {
    "path": ".github/workflows/publish-linux.yml",
    "content": "name: Publish Linux (Manual)\n\non: workflow_dispatch\n\njobs:\n    publish:\n        runs-on: ${{ matrix.os }}\n\n        strategy:\n            matrix:\n                os: [ubuntu-latest]\n\n        steps:\n            - name: Checkout git repo\n              uses: actions/checkout@v6\n\n            - name: Install Node and PNPM\n              uses: pnpm/action-setup@v4\n              with:\n                  version: 10\n\n            - name: Install dependencies\n              run: pnpm install\n\n            - name: Build and Publish releases\n              env:\n                  GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n              uses: nick-invision/retry@v3.0.2\n              with:\n                  timeout_minutes: 30\n                  max_attempts: 3\n                  retry_on: error\n                  command: |\n                      pnpm run publish:linux\n                  on_retry_command: pnpm cache delete\n\n            - name: Build and Publish releases (arm64)\n              env:\n                  GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n              uses: nick-invision/retry@v3.0.2\n              with:\n                  timeout_minutes: 30\n                  max_attempts: 3\n                  retry_on: error\n                  command: |\n                      pnpm run publish:linux-arm64\n                  on_retry_command: pnpm cache delete\n"
  },
  {
    "path": ".github/workflows/publish-macos.yml",
    "content": "name: Publish macOS (Manual)\n\non: workflow_dispatch\n\njobs:\n    publish:\n        runs-on: ${{ matrix.os }}\n\n        strategy:\n            matrix:\n                os: [macos-latest]\n\n        steps:\n            - name: Checkout git repo\n              uses: actions/checkout@v6\n\n            - name: Install Node and PNPM\n              uses: pnpm/action-setup@v4\n              with:\n                  version: 10\n\n            - name: Install dependencies\n              run: pnpm install\n\n            - name: Build and Publish releases\n              env:\n                  GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n              uses: nick-invision/retry@v3.0.2\n              with:\n                  timeout_minutes: 30\n                  max_attempts: 3\n                  retry_on: error\n                  command: |\n                      pnpm run publish:mac\n                  on_retry_command: pnpm cache delete\n"
  },
  {
    "path": ".github/workflows/publish-pr-comment.yml",
    "content": "name: Comment on pull request\non:\n    workflow_run:\n        workflows: ['Publish (PR)']\n        types: [completed]\njobs:\n    pr_comment:\n        if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'\n        runs-on: ubuntu-latest\n        steps:\n            - uses: actions/github-script@v6\n              with:\n                  # This snippet is public-domain, taken from\n                  # https://github.com/oprypin/nightly.link/blob/master/.github/workflows/pr-comment.yml\n                  script: |\n                      async function upsertComment(owner, repo, issue_number, purpose, body) {\n                        const {data: comments} = await github.rest.issues.listComments(\n                          {owner, repo, issue_number});\n                        const marker = `<!-- bot: ${purpose} -->`;\n                        body = marker + \"\\n\" + body;\n                        const existing = comments.filter((c) => c.body.includes(marker));\n                        if (existing.length > 0) {\n                          const last = existing[existing.length - 1];\n                          core.info(`Updating comment ${last.id}`);\n                          await github.rest.issues.updateComment({\n                            owner, repo,\n                            body,\n                            comment_id: last.id,\n                          });\n                        } else {\n                          core.info(`Creating a comment in issue / PR #${issue_number}`);\n                          await github.rest.issues.createComment({issue_number, body, owner, repo});\n                        }\n                      }\n                      const {owner, repo} = context.repo;\n                      const run_id = ${{github.event.workflow_run.id}};\n                      const pull_requests = ${{ toJSON(github.event.workflow_run.pull_requests) }};\n                      if (!pull_requests.length) {\n                        return core.error(\"This workflow doesn't match any pull requests!\");\n                      }\n                      const artifacts = await github.paginate(\n                        github.rest.actions.listWorkflowRunArtifacts, {owner, repo, run_id});\n                      if (!artifacts.length) {\n                        return core.error(`No artifacts found`);\n                      }\n                      let body = `Download the artifacts for this pull request:\\n`;\n                      for (const art of artifacts) {\n                        body += `\\n* [${art.name}.zip](https://nightly.link/${owner}/${repo}/actions/artifacts/${art.id}.zip)`;\n                      }\n                      core.info(\"Review thread message body:\", body);\n                      for (const pr of pull_requests) {\n                        await upsertComment(owner, repo, pr.number,\n                          \"nightly-link\", body);\n                      }\n"
  },
  {
    "path": ".github/workflows/publish-pr.yml",
    "content": "name: Publish (PR)\n\non:\n    workflow_dispatch:\n    pull_request:\n        branches:\n            - development\n        paths:\n            - 'src/**'\n            - 'electron-builder*.yml'\n\njobs:\n    wait-for-lint:\n        if: github.event_name == 'pull_request'\n        runs-on: ubuntu-latest\n        steps:\n            - name: Wait for Test workflow to complete\n              uses: lewagon/wait-on-check-action@v1.4.1\n              with:\n                  ref: ${{ github.event.pull_request.head.sha }}\n                  check-name: 'lint'\n                  repo-token: ${{ secrets.GITHUB_TOKEN }}\n                  wait-interval: 10\n                  allowed-conclusions: success\n\n    publish:\n        needs: wait-for-lint\n        if: always() && (needs.wait-for-lint.result == 'success' || needs.wait-for-lint.result == 'skipped')\n        runs-on: ${{ matrix.os }}\n\n        strategy:\n            matrix:\n                os: [macos-latest, ubuntu-latest, windows-latest]\n\n        steps:\n            - name: Checkout git repo\n              uses: actions/checkout@v6\n\n            - name: Install Node and PNPM\n              uses: pnpm/action-setup@v4\n              with:\n                  version: 10\n\n            - name: Install dependencies\n              run: pnpm install\n\n            - name: Build for Windows\n              if: ${{ matrix.os == 'windows-latest' }}\n              uses: nick-invision/retry@v3.0.2\n              with:\n                  timeout_minutes: 30\n                  max_attempts: 3\n                  retry_on: error\n                  command: |\n                      pnpm run package:win:pr\n\n            - name: Build for Linux\n              if: ${{ matrix.os == 'ubuntu-latest' }}\n              uses: nick-invision/retry@v3.0.2\n              with:\n                  timeout_minutes: 30\n                  max_attempts: 3\n                  retry_on: error\n                  command: |\n                      pnpm run package:linux:pr\n\n            - name: Build for MacOS\n              if: ${{ matrix.os == 'macos-latest' }}\n              uses: nick-invision/retry@v3.0.2\n              with:\n                  timeout_minutes: 30\n                  max_attempts: 3\n                  retry_on: error\n                  command: |\n                      pnpm run package:mac:pr\n\n            - name: Zip Windows Binaries\n              if: ${{ matrix.os == 'windows-latest' }}\n              shell: pwsh\n              run: |\n                  Compress-Archive -Path \"dist/*.exe\" -DestinationPath \"dist/windows-binaries.zip\" -Force\n\n            - name: Zip Linux Binaries\n              if: ${{ matrix.os == 'ubuntu-latest' }}\n              run: |\n                  zip -r dist/linux-binaries.zip dist/*.{AppImage,deb,rpm}\n\n            - name: Zip MacOS Binaries\n              if: ${{ matrix.os == 'macos-latest' }}\n              run: |\n                  zip -r dist/macos-binaries.zip dist/*.dmg\n\n            - name: Upload Windows Binaries\n              if: ${{ matrix.os == 'windows-latest' }}\n              uses: actions/upload-artifact@v7\n              with:\n                  name: windows-binaries\n                  path: dist/windows-binaries.zip\n\n            - name: Upload Linux Binaries\n              if: ${{ matrix.os == 'ubuntu-latest' }}\n              uses: actions/upload-artifact@v7\n              with:\n                  name: linux-binaries\n                  path: dist/linux-binaries.zip\n\n            - name: Upload MacOS Binaries\n              if: ${{ matrix.os == 'macos-latest' }}\n              uses: actions/upload-artifact@v7\n              with:\n                  name: macos-binaries\n                  path: dist/macos-binaries.zip\n"
  },
  {
    "path": ".github/workflows/publish-windows.yml",
    "content": "name: Publish Windows (Manual)\n\non: workflow_dispatch\n\njobs:\n    publish:\n        runs-on: ${{ matrix.os }}\n\n        strategy:\n            matrix:\n                os: [windows-latest]\n\n        steps:\n            - name: Checkout git repo\n              uses: actions/checkout@v6\n\n            - name: Install Node and PNPM\n              uses: pnpm/action-setup@v4\n              with:\n                  version: 10\n\n            - name: Install dependencies\n              run: pnpm install\n\n            - name: Build and Publish releases\n              env:\n                  GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n              uses: nick-invision/retry@v3.0.2\n              with:\n                  timeout_minutes: 30\n                  max_attempts: 3\n                  retry_on: error\n                  command: |\n                      pnpm run publish:win\n                  on_retry_command: pnpm cache delete\n"
  },
  {
    "path": ".github/workflows/publish-winget.yml",
    "content": "name: Publish release to WinGet\non:\n  release:\n    types: [released]\n  workflow_dispatch:\n    inputs:\n      tag_name:\n        description: \"Specific tag name\"\n        required: false\n        type: string\n\njobs:\n  publish:\n    runs-on: windows-latest\n    steps:\n      - uses: vedantmgoyal9/winget-releaser@main\n        with:\n          identifier: jeffvli.Feishin\n          installers-regex: 'Feishin-*-win-(x64|arm64)\\.exe'\n          token: ${{ secrets.WINGET_ACC_TOKEN }}\n"
  },
  {
    "path": ".github/workflows/publish.yml",
    "content": "name: Publish (Manual)\n\non: workflow_dispatch\n\njobs:\n    publish:\n        runs-on: ${{ matrix.os }}\n\n        strategy:\n            matrix:\n                os: [windows-latest, macos-latest, ubuntu-latest]\n\n        steps:\n            - name: Checkout git repo\n              uses: actions/checkout@v6\n\n            - name: Install Node and PNPM\n              uses: pnpm/action-setup@v4\n              with:\n                  version: 10\n\n            - name: Install dependencies\n              run: pnpm install\n\n            - name: Build and Publish releases (Windows)\n              if: matrix.os == 'windows-latest'\n              env:\n                  GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n              uses: nick-invision/retry@v3.0.2\n              with:\n                  timeout_minutes: 30\n                  max_attempts: 3\n                  retry_on: error\n                  command: |\n                      pnpm run publish:win\n                  on_retry_command: pnpm cache delete\n\n            - name: Build and Publish releases (macOS)\n              if: matrix.os == 'macos-latest'\n              env:\n                  GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n              uses: nick-invision/retry@v3.0.2\n              with:\n                  timeout_minutes: 30\n                  max_attempts: 3\n                  retry_on: error\n                  command: |\n                      pnpm run publish:mac\n                  on_retry_command: pnpm cache delete\n\n            - name: Build and Publish releases (Linux)\n              if: matrix.os == 'ubuntu-latest'\n              env:\n                  GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n              uses: nick-invision/retry@v3.0.2\n              with:\n                  timeout_minutes: 30\n                  max_attempts: 3\n                  retry_on: error\n                  command: |\n                      pnpm run publish:linux\n                  on_retry_command: pnpm cache delete\n\n            - name: Build and Publish releases (Linux ARM64)\n              if: matrix.os == 'ubuntu-latest'\n              env:\n                  GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n              uses: nick-invision/retry@v3.0.2\n              with:\n                  timeout_minutes: 30\n                  max_attempts: 3\n                  retry_on: error\n                  command: |\n                      pnpm run publish:linux-arm64\n                  on_retry_command: pnpm cache delete\n"
  },
  {
    "path": ".github/workflows/stale.yml",
    "content": "name: 'Close stale issues and PRs'\non:\n    workflow_dispatch:\n    schedule:\n        - cron: '30 1 * * *'\npermissions:\n    contents: read\njobs:\n    stale:\n        permissions:\n            issues: write\n            pull-requests: write\n        runs-on: ubuntu-latest\n        steps:\n            - uses: dessant/lock-threads@v5\n              with:\n                  process-only: 'issues, prs'\n                  issue-inactive-days: 120\n                  pr-inactive-days: 120\n                  log-output: true\n                  add-issue-labels: 'frozen-due-to-age'\n                  add-pr-labels: 'frozen-due-to-age'\n            - uses: actions/stale@v9\n              with:\n                  operations-per-run: 999\n                  days-before-issue-stale: 180\n                  days-before-pr-stale: 180\n                  days-before-issue-close: 30\n                  days-before-pr-close: 30\n                  stale-issue-message: >\n                      This issue has been automatically marked as stale because it has not had recent activity. The resources of the Feishin team are limited, and so we are asking for your help.\n\n                      If this is a **bug** and you can still reproduce this error on the <code>development</code> branch, please reply with all of the information you have about it in order to keep the issue open.\n\n                      This issue will automatically be closed in the near future if no further activity occurs. Thank you for all your contributions.\n\n\n                  stale-pr-message: >\n                      This PR has been automatically marked as stale because it has not had recent activity. The resources of the Feishin team are limited, and so we are asking for your help.\n\n                      This PR will automatically be closed in the near future if no further activity occurs. Thank you for all your contributions.\n\n\n                  stale-issue-label: 'stale'\n                  exempt-issue-labels: 'keep,security'\n                  stale-pr-label: 'stale'\n                  exempt-pr-labels: 'keep,security'\n"
  },
  {
    "path": ".github/workflows/test.yml",
    "content": "name: Test\n\non: [push, pull_request]\n\njobs:\n    lint:\n        runs-on: ubuntu-latest\n\n        steps:\n            - name: Check out Git repository\n              uses: actions/checkout@v6\n\n            - name: Install Node.js and PNPM\n              uses: pnpm/action-setup@v4\n              with:\n                  version: 10\n\n            - name: Install dependencies\n              run: pnpm install\n\n            - name: Lint Files\n              run: pnpm run lint\n"
  },
  {
    "path": ".gitignore",
    "content": "node_modules\ndist\nout\n.DS_Store\n.eslintcache\n*.log*\nrelease\n"
  },
  {
    "path": ".npmrc",
    "content": "legacy-peer-deps=true\n"
  },
  {
    "path": ".prettierignore",
    "content": "out\ndist\npnpm-lock.yaml\nLICENSE.md\ntsconfig.json\ntsconfig.*.json\n"
  },
  {
    "path": ".prettierrc.yaml",
    "content": "singleQuote: true\nsemi: true\nprintWidth: 100\ntabWidth: 4\ntrailingComma: all\nuseTabs: false\narrowParens: always\nproseWrap: never\nhtmlWhitespaceSensitivity: strict\nendOfLine: lf\nsingleAttributePerLine: false\nbracketSpacing: true\nplugins:\n    - prettier-plugin-packagejson\n"
  },
  {
    "path": ".stylelintrc.json",
    "content": "{\n    \"extends\": [\n        \"stylelint-config-standard\",\n        \"stylelint-config-css-modules\",\n        \"stylelint-config-recess-order\"\n    ],\n    \"rules\": {\n        \"block-no-empty\": null,\n        \"selector-type-case\": [\"lower\", { \"ignoreTypes\": [\"/^\\\\$\\\\w+/\"] }],\n        \"selector-type-no-unknown\": [true, { \"ignoreTypes\": [\"/-styled-mixin/\", \"/^\\\\$\\\\w+/\"] }],\n        \"declaration-block-no-shorthand-property-overrides\": null,\n        \"declaration-block-no-redundant-longhand-properties\": null,\n        \"at-rule-no-unknown\": [true, { \"ignoreAtRules\": [\"mixin\", \"value\"] }],\n        \"function-no-unknown\": [true, { \"ignoreFunctions\": [\"darken\", \"alpha\", \"lighten\"] }],\n        \"declaration-property-value-no-unknown\": null,\n        \"no-descending-specificity\": null,\n        \"no-empty-source\": null\n    }\n}\n"
  },
  {
    "path": ".vscode/extensions.json",
    "content": "{\n  \"recommendations\": [\"dbaeumer.vscode-eslint\"]\n}\n"
  },
  {
    "path": ".vscode/launch.json",
    "content": "{\n  \"version\": \"0.2.0\",\n  \"configurations\": [\n    {\n      \"name\": \"Debug Main Process\",\n      \"type\": \"node\",\n      \"request\": \"launch\",\n      \"cwd\": \"${workspaceRoot}\",\n      \"runtimeExecutable\": \"${workspaceRoot}/node_modules/.bin/electron-vite\",\n      \"windows\": {\n        \"runtimeExecutable\": \"${workspaceRoot}/node_modules/.bin/electron-vite.cmd\"\n      },\n      \"runtimeArgs\": [\"--sourcemap\"],\n      \"env\": {\n        \"REMOTE_DEBUGGING_PORT\": \"9222\"\n      }\n    },\n    {\n      \"name\": \"Debug Renderer Process\",\n      \"port\": 9222,\n      \"request\": \"attach\",\n      \"type\": \"chrome\",\n      \"webRoot\": \"${workspaceFolder}/src/renderer\",\n      \"timeout\": 60000,\n      \"presentation\": {\n        \"hidden\": true\n      }\n    }\n  ],\n  \"compounds\": [\n    {\n      \"name\": \"Debug All\",\n      \"configurations\": [\"Debug Main Process\", \"Debug Renderer Process\"],\n      \"presentation\": {\n        \"order\": 1\n      }\n    }\n  ]\n}\n"
  },
  {
    "path": ".vscode/settings.json",
    "content": "{\n    \"[typescript]\": {\n        \"editor.defaultFormatter\": \"esbenp.prettier-vscode\"\n    },\n    \"[javascript]\": {\n        \"editor.defaultFormatter\": \"esbenp.prettier-vscode\"\n    },\n    \"[json]\": {\n        \"editor.defaultFormatter\": \"esbenp.prettier-vscode\"\n    },\n    \"files.associations\": {\n        \".eslintrc\": \"jsonc\",\n        \".prettierrc\": \"jsonc\",\n        \".eslintignore\": \"ignore\"\n    },\n    \"eslint.validate\": [\"typescript\", \"typescriptreact\"],\n    \"eslint.workingDirectories\": [{ \"directory\": \"./\", \"changeProcessCWD\": true }],\n    \"typescript.tsserver.experimental.enableProjectDiagnostics\": false,\n    \"editor.codeActionsOnSave\": {\n        \"source.fixAll.eslint\": \"explicit\",\n        \"source.fixAll.stylelint\": \"explicit\",\n        \"source.organizeImports\": \"never\",\n        \"source.formatDocument\": \"explicit\"\n    },\n    \"css.validate\": true,\n    \"javascript.validate.enable\": false,\n    \"javascript.format.enable\": false,\n    \"typescript.format.enable\": false,\n    \"search.exclude\": {\n        \".git\": true,\n        \".eslintcache\": true,\n        \".erb/dll\": true,\n        \"release/{build,app/dist}\": true,\n        \"node_modules\": true,\n        \"npm-debug.log.*\": true,\n        \"test/**/__snapshots__\": true,\n        \"package-lock.json\": true,\n        \"*.{css,sass,scss}.d.ts\": true,\n        \"out/**/*\": true,\n        \"dist/**/*\": true\n    },\n    \"i18n-ally.localesPaths\": [\"src/i18n\", \"src/i18n/locales\"],\n    \"typescript.tsdk\": \"node_modules/typescript/lib\",\n    \"typescript.preferences.importModuleSpecifier\": \"non-relative\",\n    \"stylelint.config\": null,\n    \"stylelint.validate\": [\"css\", \"postcss\"],\n    \"typescript.updateImportsOnFileMove.enabled\": \"always\",\n    \"typescript.preferences.autoImportFileExcludePatterns\": [\n        \"@mantine/core\",\n        \"@mantine/modals\",\n        \"@mantine/dates\",\n        \"@mantine/hooks\",\n        \"@mantine/form\",\n        \"@radix-ui/react-context-menu\"\n    ],\n    \"[typescriptreact]\": { \"editor.defaultFormatter\": \"esbenp.prettier-vscode\" },\n    \"typescript.format.insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces\": true,\n    \"folderTemplates.structures\": [\n        {\n            \"name\": \"TypeScript Feature Component With CSS Modules\",\n            \"omitParentDirectory\": true,\n            \"structure\": [\n                {\n                    \"fileName\": \"<FTName | kebabcase>.tsx\",\n                    \"template\": \"Functional Component with CSS Modules\"\n                },\n                {\n                    \"fileName\": \"<FTName | kebabcase>.module.css\"\n                }\n            ]\n        }\n    ],\n    \"folderTemplates.fileTemplates\": {\n        \"Functional Component with CSS Modules\": [\n            \"import styles from './<FTName | kebabcase>.module.css';\",\n            \"\",\n            \"interface <FTName | pascalcase>Props {}\",\n            \"\",\n            \"export const <FTName | pascalcase> = ({}: <FTName | pascalcase>Props) => {\",\n            \"  return <div></div>;\",\n            \"};\"\n        ]\n    }\n}\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "# Changelog\n\nAll notable changes to this project will be documented in this file.\n\nThe format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n"
  },
  {
    "path": "Dockerfile",
    "content": "# --- Builder stage\nFROM node:23-alpine AS builder\nWORKDIR /app\n\n# Copy package.json first to cache node_modules\nCOPY package.json pnpm-lock.yaml .\n\nRUN npm install -g pnpm\n\nRUN pnpm install\n\n# Copy code and build with cached modules\nCOPY . .\nRUN pnpm run build:web\n\n# --- Production stage\nFROM nginxinc/nginx-unprivileged:alpine-slim\n\nCOPY --chown=nginx:nginx --from=builder /app/out/web /usr/share/nginx/html\nCOPY --chown=nginx:nginx ./settings.js.template /etc/nginx/templates/settings.js.template\nCOPY --chown=nginx:nginx ng.conf.template /etc/nginx/templates/default.conf.template\n\nENV SERVER_LOCK=false SERVER_NAME=\"\" SERVER_TYPE=\"\" SERVER_URL=\"\" REMOTE_URL=\"\"\nENV LEGACY_AUTHENTICATION=\"\" ANALYTICS_DISABLED=\"\" PUBLIC_PATH=\"/\"\n\nEXPOSE 9180\nCMD [\"nginx\", \"-g\", \"daemon off;\"]\n"
  },
  {
    "path": "LICENSE",
    "content": "                    GNU GENERAL PUBLIC LICENSE\n                       Version 3, 29 June 2007\n\n Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>\n Everyone is permitted to copy and distribute verbatim copies\n of this license document, but changing it is not allowed.\n\n                            Preamble\n\n  The GNU General Public License is a free, copyleft license for\nsoftware and other kinds of works.\n\n  The licenses for most software and other practical works are designed\nto take away your freedom to share and change the works.  By contrast,\nthe GNU General Public License is intended to guarantee your freedom to\nshare and change all versions of a program--to make sure it remains free\nsoftware for all its users.  We, the Free Software Foundation, use the\nGNU General Public License for most of our software; it applies also to\nany other work released this way by its authors.  You can apply it to\nyour programs, too.\n\n  When we speak of free software, we are referring to freedom, not\nprice.  Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthem if you wish), that you receive source code or can get it if you\nwant it, that you can change the software or use pieces of it in new\nfree programs, and that you know you can do these things.\n\n  To protect your rights, we need to prevent others from denying you\nthese rights or asking you to surrender the rights.  Therefore, you have\ncertain responsibilities if you distribute copies of the software, or if\nyou modify it: responsibilities to respect the freedom of others.\n\n  For example, if you distribute copies of such a program, whether\ngratis or for a fee, you must pass on to the recipients the same\nfreedoms that you received.  You must make sure that they, too, receive\nor can get the source code.  And you must show them these terms so they\nknow their rights.\n\n  Developers that use the GNU GPL protect your rights with two steps:\n(1) assert copyright on the software, and (2) offer you this License\ngiving you legal permission to copy, distribute and/or modify it.\n\n  For the developers' and authors' protection, the GPL clearly explains\nthat there is no warranty for this free software.  For both users' and\nauthors' sake, the GPL requires that modified versions be marked as\nchanged, so that their problems will not be attributed erroneously to\nauthors of previous versions.\n\n  Some devices are designed to deny users access to install or run\nmodified versions of the software inside them, although the manufacturer\ncan do so.  This is fundamentally incompatible with the aim of\nprotecting users' freedom to change the software.  The systematic\npattern of such abuse occurs in the area of products for individuals to\nuse, which is precisely where it is most unacceptable.  Therefore, we\nhave designed this version of the GPL to prohibit the practice for those\nproducts.  If such problems arise substantially in other domains, we\nstand ready to extend this provision to those domains in future versions\nof the GPL, as needed to protect the freedom of users.\n\n  Finally, every program is threatened constantly by software patents.\nStates should not allow patents to restrict development and use of\nsoftware on general-purpose computers, but in those that do, we wish to\navoid the special danger that patents applied to a free program could\nmake it effectively proprietary.  To prevent this, the GPL assures that\npatents cannot be used to render the program non-free.\n\n  The precise terms and conditions for copying, distribution and\nmodification follow.\n\n                       TERMS AND CONDITIONS\n\n  0. Definitions.\n\n  \"This License\" refers to version 3 of the GNU General Public License.\n\n  \"Copyright\" also means copyright-like laws that apply to other kinds of\nworks, such as semiconductor masks.\n\n  \"The Program\" refers to any copyrightable work licensed under this\nLicense.  Each licensee is addressed as \"you\".  \"Licensees\" and\n\"recipients\" may be individuals or organizations.\n\n  To \"modify\" a work means to copy from or adapt all or part of the work\nin a fashion requiring copyright permission, other than the making of an\nexact copy.  The resulting work is called a \"modified version\" of the\nearlier work or a work \"based on\" the earlier work.\n\n  A \"covered work\" means either the unmodified Program or a work based\non the Program.\n\n  To \"propagate\" a work means to do anything with it that, without\npermission, would make you directly or secondarily liable for\ninfringement under applicable copyright law, except executing it on a\ncomputer or modifying a private copy.  Propagation includes copying,\ndistribution (with or without modification), making available to the\npublic, and in some countries other activities as well.\n\n  To \"convey\" a work means any kind of propagation that enables other\nparties to make or receive copies.  Mere interaction with a user through\na computer network, with no transfer of a copy, is not conveying.\n\n  An interactive user interface displays \"Appropriate Legal Notices\"\nto the extent that it includes a convenient and prominently visible\nfeature that (1) displays an appropriate copyright notice, and (2)\ntells the user that there is no warranty for the work (except to the\nextent that warranties are provided), that licensees may convey the\nwork under this License, and how to view a copy of this License.  If\nthe interface presents a list of user commands or options, such as a\nmenu, a prominent item in the list meets this criterion.\n\n  1. Source Code.\n\n  The \"source code\" for a work means the preferred form of the work\nfor making modifications to it.  \"Object code\" means any non-source\nform of a work.\n\n  A \"Standard Interface\" means an interface that either is an official\nstandard defined by a recognized standards body, or, in the case of\ninterfaces specified for a particular programming language, one that\nis widely used among developers working in that language.\n\n  The \"System Libraries\" of an executable work include anything, other\nthan the work as a whole, that (a) is included in the normal form of\npackaging a Major Component, but which is not part of that Major\nComponent, and (b) serves only to enable use of the work with that\nMajor Component, or to implement a Standard Interface for which an\nimplementation is available to the public in source code form.  A\n\"Major Component\", in this context, means a major essential component\n(kernel, window system, and so on) of the specific operating system\n(if any) on which the executable work runs, or a compiler used to\nproduce the work, or an object code interpreter used to run it.\n\n  The \"Corresponding Source\" for a work in object code form means all\nthe source code needed to generate, install, and (for an executable\nwork) run the object code and to modify the work, including scripts to\ncontrol those activities.  However, it does not include the work's\nSystem Libraries, or general-purpose tools or generally available free\nprograms which are used unmodified in performing those activities but\nwhich are not part of the work.  For example, Corresponding Source\nincludes interface definition files associated with source files for\nthe work, and the source code for shared libraries and dynamically\nlinked subprograms that the work is specifically designed to require,\nsuch as by intimate data communication or control flow between those\nsubprograms and other parts of the work.\n\n  The Corresponding Source need not include anything that users\ncan regenerate automatically from other parts of the Corresponding\nSource.\n\n  The Corresponding Source for a work in source code form is that\nsame work.\n\n  2. Basic Permissions.\n\n  All rights granted under this License are granted for the term of\ncopyright on the Program, and are irrevocable provided the stated\nconditions are met.  This License explicitly affirms your unlimited\npermission to run the unmodified Program.  The output from running a\ncovered work is covered by this License only if the output, given its\ncontent, constitutes a covered work.  This License acknowledges your\nrights of fair use or other equivalent, as provided by copyright law.\n\n  You may make, run and propagate covered works that you do not\nconvey, without conditions so long as your license otherwise remains\nin force.  You may convey covered works to others for the sole purpose\nof having them make modifications exclusively for you, or provide you\nwith facilities for running those works, provided that you comply with\nthe terms of this License in conveying all material for which you do\nnot control copyright.  Those thus making or running the covered works\nfor you must do so exclusively on your behalf, under your direction\nand control, on terms that prohibit them from making any copies of\nyour copyrighted material outside their relationship with you.\n\n  Conveying under any other circumstances is permitted solely under\nthe conditions stated below.  Sublicensing is not allowed; section 10\nmakes it unnecessary.\n\n  3. Protecting Users' Legal Rights From Anti-Circumvention Law.\n\n  No covered work shall be deemed part of an effective technological\nmeasure under any applicable law fulfilling obligations under article\n11 of the WIPO copyright treaty adopted on 20 December 1996, or\nsimilar laws prohibiting or restricting circumvention of such\nmeasures.\n\n  When you convey a covered work, you waive any legal power to forbid\ncircumvention of technological measures to the extent such circumvention\nis effected by exercising rights under this License with respect to\nthe covered work, and you disclaim any intention to limit operation or\nmodification of the work as a means of enforcing, against the work's\nusers, your or third parties' legal rights to forbid circumvention of\ntechnological measures.\n\n  4. Conveying Verbatim Copies.\n\n  You may convey verbatim copies of the Program's source code as you\nreceive it, in any medium, provided that you conspicuously and\nappropriately publish on each copy an appropriate copyright notice;\nkeep intact all notices stating that this License and any\nnon-permissive terms added in accord with section 7 apply to the code;\nkeep intact all notices of the absence of any warranty; and give all\nrecipients a copy of this License along with the Program.\n\n  You may charge any price or no price for each copy that you convey,\nand you may offer support or warranty protection for a fee.\n\n  5. Conveying Modified Source Versions.\n\n  You may convey a work based on the Program, or the modifications to\nproduce it from the Program, in the form of source code under the\nterms of section 4, provided that you also meet all of these conditions:\n\n    a) The work must carry prominent notices stating that you modified\n    it, and giving a relevant date.\n\n    b) The work must carry prominent notices stating that it is\n    released under this License and any conditions added under section\n    7.  This requirement modifies the requirement in section 4 to\n    \"keep intact all notices\".\n\n    c) You must license the entire work, as a whole, under this\n    License to anyone who comes into possession of a copy.  This\n    License will therefore apply, along with any applicable section 7\n    additional terms, to the whole of the work, and all its parts,\n    regardless of how they are packaged.  This License gives no\n    permission to license the work in any other way, but it does not\n    invalidate such permission if you have separately received it.\n\n    d) If the work has interactive user interfaces, each must display\n    Appropriate Legal Notices; however, if the Program has interactive\n    interfaces that do not display Appropriate Legal Notices, your\n    work need not make them do so.\n\n  A compilation of a covered work with other separate and independent\nworks, which are not by their nature extensions of the covered work,\nand which are not combined with it such as to form a larger program,\nin or on a volume of a storage or distribution medium, is called an\n\"aggregate\" if the compilation and its resulting copyright are not\nused to limit the access or legal rights of the compilation's users\nbeyond what the individual works permit.  Inclusion of a covered work\nin an aggregate does not cause this License to apply to the other\nparts of the aggregate.\n\n  6. Conveying Non-Source Forms.\n\n  You may convey a covered work in object code form under the terms\nof sections 4 and 5, provided that you also convey the\nmachine-readable Corresponding Source under the terms of this License,\nin one of these ways:\n\n    a) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by the\n    Corresponding Source fixed on a durable physical medium\n    customarily used for software interchange.\n\n    b) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by a\n    written offer, valid for at least three years and valid for as\n    long as you offer spare parts or customer support for that product\n    model, to give anyone who possesses the object code either (1) a\n    copy of the Corresponding Source for all the software in the\n    product that is covered by this License, on a durable physical\n    medium customarily used for software interchange, for a price no\n    more than your reasonable cost of physically performing this\n    conveying of source, or (2) access to copy the\n    Corresponding Source from a network server at no charge.\n\n    c) Convey individual copies of the object code with a copy of the\n    written offer to provide the Corresponding Source.  This\n    alternative is allowed only occasionally and noncommercially, and\n    only if you received the object code with such an offer, in accord\n    with subsection 6b.\n\n    d) Convey the object code by offering access from a designated\n    place (gratis or for a charge), and offer equivalent access to the\n    Corresponding Source in the same way through the same place at no\n    further charge.  You need not require recipients to copy the\n    Corresponding Source along with the object code.  If the place to\n    copy the object code is a network server, the Corresponding Source\n    may be on a different server (operated by you or a third party)\n    that supports equivalent copying facilities, provided you maintain\n    clear directions next to the object code saying where to find the\n    Corresponding Source.  Regardless of what server hosts the\n    Corresponding Source, you remain obligated to ensure that it is\n    available for as long as needed to satisfy these requirements.\n\n    e) Convey the object code using peer-to-peer transmission, provided\n    you inform other peers where the object code and Corresponding\n    Source of the work are being offered to the general public at no\n    charge under subsection 6d.\n\n  A separable portion of the object code, whose source code is excluded\nfrom the Corresponding Source as a System Library, need not be\nincluded in conveying the object code work.\n\n  A \"User Product\" is either (1) a \"consumer product\", which means any\ntangible personal property which is normally used for personal, family,\nor household purposes, or (2) anything designed or sold for incorporation\ninto a dwelling.  In determining whether a product is a consumer product,\ndoubtful cases shall be resolved in favor of coverage.  For a particular\nproduct received by a particular user, \"normally used\" refers to a\ntypical or common use of that class of product, regardless of the status\nof the particular user or of the way in which the particular user\nactually uses, or expects or is expected to use, the product.  A product\nis a consumer product regardless of whether the product has substantial\ncommercial, industrial or non-consumer uses, unless such uses represent\nthe only significant mode of use of the product.\n\n  \"Installation Information\" for a User Product means any methods,\nprocedures, authorization keys, or other information required to install\nand execute modified versions of a covered work in that User Product from\na modified version of its Corresponding Source.  The information must\nsuffice to ensure that the continued functioning of the modified object\ncode is in no case prevented or interfered with solely because\nmodification has been made.\n\n  If you convey an object code work under this section in, or with, or\nspecifically for use in, a User Product, and the conveying occurs as\npart of a transaction in which the right of possession and use of the\nUser Product is transferred to the recipient in perpetuity or for a\nfixed term (regardless of how the transaction is characterized), the\nCorresponding Source conveyed under this section must be accompanied\nby the Installation Information.  But this requirement does not apply\nif neither you nor any third party retains the ability to install\nmodified object code on the User Product (for example, the work has\nbeen installed in ROM).\n\n  The requirement to provide Installation Information does not include a\nrequirement to continue to provide support service, warranty, or updates\nfor a work that has been modified or installed by the recipient, or for\nthe User Product in which it has been modified or installed.  Access to a\nnetwork may be denied when the modification itself materially and\nadversely affects the operation of the network or violates the rules and\nprotocols for communication across the network.\n\n  Corresponding Source conveyed, and Installation Information provided,\nin accord with this section must be in a format that is publicly\ndocumented (and with an implementation available to the public in\nsource code form), and must require no special password or key for\nunpacking, reading or copying.\n\n  7. Additional Terms.\n\n  \"Additional permissions\" are terms that supplement the terms of this\nLicense by making exceptions from one or more of its conditions.\nAdditional permissions that are applicable to the entire Program shall\nbe treated as though they were included in this License, to the extent\nthat they are valid under applicable law.  If additional permissions\napply only to part of the Program, that part may be used separately\nunder those permissions, but the entire Program remains governed by\nthis License without regard to the additional permissions.\n\n  When you convey a copy of a covered work, you may at your option\nremove any additional permissions from that copy, or from any part of\nit.  (Additional permissions may be written to require their own\nremoval in certain cases when you modify the work.)  You may place\nadditional permissions on material, added by you to a covered work,\nfor which you have or can give appropriate copyright permission.\n\n  Notwithstanding any other provision of this License, for material you\nadd to a covered work, you may (if authorized by the copyright holders of\nthat material) supplement the terms of this License with terms:\n\n    a) Disclaiming warranty or limiting liability differently from the\n    terms of sections 15 and 16 of this License; or\n\n    b) Requiring preservation of specified reasonable legal notices or\n    author attributions in that material or in the Appropriate Legal\n    Notices displayed by works containing it; or\n\n    c) Prohibiting misrepresentation of the origin of that material, or\n    requiring that modified versions of such material be marked in\n    reasonable ways as different from the original version; or\n\n    d) Limiting the use for publicity purposes of names of licensors or\n    authors of the material; or\n\n    e) Declining to grant rights under trademark law for use of some\n    trade names, trademarks, or service marks; or\n\n    f) Requiring indemnification of licensors and authors of that\n    material by anyone who conveys the material (or modified versions of\n    it) with contractual assumptions of liability to the recipient, for\n    any liability that these contractual assumptions directly impose on\n    those licensors and authors.\n\n  All other non-permissive additional terms are considered \"further\nrestrictions\" within the meaning of section 10.  If the Program as you\nreceived it, or any part of it, contains a notice stating that it is\ngoverned by this License along with a term that is a further\nrestriction, you may remove that term.  If a license document contains\na further restriction but permits relicensing or conveying under this\nLicense, you may add to a covered work material governed by the terms\nof that license document, provided that the further restriction does\nnot survive such relicensing or conveying.\n\n  If you add terms to a covered work in accord with this section, you\nmust place, in the relevant source files, a statement of the\nadditional terms that apply to those files, or a notice indicating\nwhere to find the applicable terms.\n\n  Additional terms, permissive or non-permissive, may be stated in the\nform of a separately written license, or stated as exceptions;\nthe above requirements apply either way.\n\n  8. Termination.\n\n  You may not propagate or modify a covered work except as expressly\nprovided under this License.  Any attempt otherwise to propagate or\nmodify it is void, and will automatically terminate your rights under\nthis License (including any patent licenses granted under the third\nparagraph of section 11).\n\n  However, if you cease all violation of this License, then your\nlicense from a particular copyright holder is reinstated (a)\nprovisionally, unless and until the copyright holder explicitly and\nfinally terminates your license, and (b) permanently, if the copyright\nholder fails to notify you of the violation by some reasonable means\nprior to 60 days after the cessation.\n\n  Moreover, your license from a particular copyright holder is\nreinstated permanently if the copyright holder notifies you of the\nviolation by some reasonable means, this is the first time you have\nreceived notice of violation of this License (for any work) from that\ncopyright holder, and you cure the violation prior to 30 days after\nyour receipt of the notice.\n\n  Termination of your rights under this section does not terminate the\nlicenses of parties who have received copies or rights from you under\nthis License.  If your rights have been terminated and not permanently\nreinstated, you do not qualify to receive new licenses for the same\nmaterial under section 10.\n\n  9. Acceptance Not Required for Having Copies.\n\n  You are not required to accept this License in order to receive or\nrun a copy of the Program.  Ancillary propagation of a covered work\noccurring solely as a consequence of using peer-to-peer transmission\nto receive a copy likewise does not require acceptance.  However,\nnothing other than this License grants you permission to propagate or\nmodify any covered work.  These actions infringe copyright if you do\nnot accept this License.  Therefore, by modifying or propagating a\ncovered work, you indicate your acceptance of this License to do so.\n\n  10. Automatic Licensing of Downstream Recipients.\n\n  Each time you convey a covered work, the recipient automatically\nreceives a license from the original licensors, to run, modify and\npropagate that work, subject to this License.  You are not responsible\nfor enforcing compliance by third parties with this License.\n\n  An \"entity transaction\" is a transaction transferring control of an\norganization, or substantially all assets of one, or subdividing an\norganization, or merging organizations.  If propagation of a covered\nwork results from an entity transaction, each party to that\ntransaction who receives a copy of the work also receives whatever\nlicenses to the work the party's predecessor in interest had or could\ngive under the previous paragraph, plus a right to possession of the\nCorresponding Source of the work from the predecessor in interest, if\nthe predecessor has it or can get it with reasonable efforts.\n\n  You may not impose any further restrictions on the exercise of the\nrights granted or affirmed under this License.  For example, you may\nnot impose a license fee, royalty, or other charge for exercise of\nrights granted under this License, and you may not initiate litigation\n(including a cross-claim or counterclaim in a lawsuit) alleging that\nany patent claim is infringed by making, using, selling, offering for\nsale, or importing the Program or any portion of it.\n\n  11. Patents.\n\n  A \"contributor\" is a copyright holder who authorizes use under this\nLicense of the Program or a work on which the Program is based.  The\nwork thus licensed is called the contributor's \"contributor version\".\n\n  A contributor's \"essential patent claims\" are all patent claims\nowned or controlled by the contributor, whether already acquired or\nhereafter acquired, that would be infringed by some manner, permitted\nby this License, of making, using, or selling its contributor version,\nbut do not include claims that would be infringed only as a\nconsequence of further modification of the contributor version.  For\npurposes of this definition, \"control\" includes the right to grant\npatent sublicenses in a manner consistent with the requirements of\nthis License.\n\n  Each contributor grants you a non-exclusive, worldwide, royalty-free\npatent license under the contributor's essential patent claims, to\nmake, use, sell, offer for sale, import and otherwise run, modify and\npropagate the contents of its contributor version.\n\n  In the following three paragraphs, a \"patent license\" is any express\nagreement or commitment, however denominated, not to enforce a patent\n(such as an express permission to practice a patent or covenant not to\nsue for patent infringement).  To \"grant\" such a patent license to a\nparty means to make such an agreement or commitment not to enforce a\npatent against the party.\n\n  If you convey a covered work, knowingly relying on a patent license,\nand the Corresponding Source of the work is not available for anyone\nto copy, free of charge and under the terms of this License, through a\npublicly available network server or other readily accessible means,\nthen you must either (1) cause the Corresponding Source to be so\navailable, or (2) arrange to deprive yourself of the benefit of the\npatent license for this particular work, or (3) arrange, in a manner\nconsistent with the requirements of this License, to extend the patent\nlicense to downstream recipients.  \"Knowingly relying\" means you have\nactual knowledge that, but for the patent license, your conveying the\ncovered work in a country, or your recipient's use of the covered work\nin a country, would infringe one or more identifiable patents in that\ncountry that you have reason to believe are valid.\n\n  If, pursuant to or in connection with a single transaction or\narrangement, you convey, or propagate by procuring conveyance of, a\ncovered work, and grant a patent license to some of the parties\nreceiving the covered work authorizing them to use, propagate, modify\nor convey a specific copy of the covered work, then the patent license\nyou grant is automatically extended to all recipients of the covered\nwork and works based on it.\n\n  A patent license is \"discriminatory\" if it does not include within\nthe scope of its coverage, prohibits the exercise of, or is\nconditioned on the non-exercise of one or more of the rights that are\nspecifically granted under this License.  You may not convey a covered\nwork if you are a party to an arrangement with a third party that is\nin the business of distributing software, under which you make payment\nto the third party based on the extent of your activity of conveying\nthe work, and under which the third party grants, to any of the\nparties who would receive the covered work from you, a discriminatory\npatent license (a) in connection with copies of the covered work\nconveyed by you (or copies made from those copies), or (b) primarily\nfor and in connection with specific products or compilations that\ncontain the covered work, unless you entered into that arrangement,\nor that patent license was granted, prior to 28 March 2007.\n\n  Nothing in this License shall be construed as excluding or limiting\nany implied license or other defenses to infringement that may\notherwise be available to you under applicable patent law.\n\n  12. No Surrender of Others' Freedom.\n\n  If conditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License.  If you cannot convey a\ncovered work so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you may\nnot convey it at all.  For example, if you agree to terms that obligate you\nto collect a royalty for further conveying from those to whom you convey\nthe Program, the only way you could satisfy both those terms and this\nLicense would be to refrain entirely from conveying the Program.\n\n  13. Use with the GNU Affero General Public License.\n\n  Notwithstanding any other provision of this License, you have\npermission to link or combine any covered work with a work licensed\nunder version 3 of the GNU Affero General Public License into a single\ncombined work, and to convey the resulting work.  The terms of this\nLicense will continue to apply to the part which is the covered work,\nbut the special requirements of the GNU Affero General Public License,\nsection 13, concerning interaction through a network will apply to the\ncombination as such.\n\n  14. Revised Versions of this License.\n\n  The Free Software Foundation may publish revised and/or new versions of\nthe GNU General Public License from time to time.  Such new versions will\nbe similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\n  Each version is given a distinguishing version number.  If the\nProgram specifies that a certain numbered version of the GNU General\nPublic License \"or any later version\" applies to it, you have the\noption of following the terms and conditions either of that numbered\nversion or of any later version published by the Free Software\nFoundation.  If the Program does not specify a version number of the\nGNU General Public License, you may choose any version ever published\nby the Free Software Foundation.\n\n  If the Program specifies that a proxy can decide which future\nversions of the GNU General Public License can be used, that proxy's\npublic statement of acceptance of a version permanently authorizes you\nto choose that version for the Program.\n\n  Later license versions may give you additional or different\npermissions.  However, no additional obligations are imposed on any\nauthor or copyright holder as a result of your choosing to follow a\nlater version.\n\n  15. Disclaimer of Warranty.\n\n  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY\nAPPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT\nHOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY\nOF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,\nTHE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\nPURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM\nIS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF\nALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n  16. Limitation of Liability.\n\n  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS\nTHE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY\nGENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE\nUSE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF\nDATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD\nPARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),\nEVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF\nSUCH DAMAGES.\n\n  17. Interpretation of Sections 15 and 16.\n\n  If the disclaimer of warranty and limitation of liability provided\nabove cannot be given local legal effect according to their terms,\nreviewing courts shall apply local law that most closely approximates\nan absolute waiver of all civil liability in connection with the\nProgram, unless a warranty or assumption of liability accompanies a\ncopy of the Program in return for a fee.\n\n                     END OF TERMS AND CONDITIONS\n\n            How to Apply These Terms to Your New Programs\n\n  If you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\n  To do so, attach the following notices to the program.  It is safest\nto attach them to the start of each source file to most effectively\nstate the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n    <one line to give the program's name and a brief idea of what it does.>\n    Copyright (C) <year>  <name of author>\n\n    This program is free software: you can redistribute it and/or modify\n    it under the terms of the GNU General Public License as published by\n    the Free Software Foundation, either version 3 of the License, or\n    (at your option) any later version.\n\n    This program is distributed in the hope that it will be useful,\n    but WITHOUT ANY WARRANTY; without even the implied warranty of\n    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n    GNU General Public License for more details.\n\n    You should have received a copy of the GNU General Public License\n    along with this program.  If not, see <http://www.gnu.org/licenses/>.\n\nAlso add information on how to contact you by electronic and paper mail.\n\n  If the program does terminal interaction, make it output a short\nnotice like this when it starts in an interactive mode:\n\n    <program>  Copyright (C) <year>  <name of author>\n    This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.\n    This is free software, and you are welcome to redistribute it\n    under certain conditions; type `show c' for details.\n\nThe hypothetical commands `show w' and `show c' should show the appropriate\nparts of the General Public License.  Of course, your program's commands\nmight be different; for a GUI interface, you would use an \"about box\".\n\n  You should also get your employer (if you work as a programmer) or school,\nif any, to sign a \"copyright disclaimer\" for the program, if necessary.\nFor more information on this, and how to apply and follow the GNU GPL, see\n<http://www.gnu.org/licenses/>.\n\n  The GNU General Public License does not permit incorporating your program\ninto proprietary programs.  If your program is a subroutine library, you\nmay consider it more useful to permit linking proprietary applications with\nthe library.  If this is what you want to do, use the GNU Lesser General\nPublic License instead of this License.  But first, please read\n<http://www.gnu.org/philosophy/why-not-lgpl.html>.\n"
  },
  {
    "path": "README.md",
    "content": "<img src=\"assets/icons/icon.png\" alt=\"logo\" title=\"feishin\" align=\"right\" height=\"60px\" width=\"60px\" />\n\n# Feishin\n\n  <p align=\"center\">\n    <a href=\"https://github.com/jeffvli/feishin/blob/main/LICENSE\">\n      <img src=\"https://img.shields.io/github/license/jeffvli/feishin?style=flat-square&color=brightgreen\"\n      alt=\"License\">\n    </a>\n      <a href=\"https://github.com/jeffvli/feishin/releases\">\n      <img src=\"https://img.shields.io/github/v/release/jeffvli/feishin?style=flat-square&color=blue\"\n      alt=\"Release\">\n    </a>\n    <a href=\"https://github.com/jeffvli/feishin/releases\">\n      <img src=\"https://img.shields.io/github/downloads/jeffvli/feishin/total?style=flat-square&color=orange\"\n      alt=\"Downloads\">\n    </a>\n  </p>\n  <p align=\"center\">\n    <a href=\"https://discord.gg/FVKpcMDy5f\">\n      <img src=\"https://img.shields.io/discord/922656312888811530?color=black&label=discord&logo=discord&logoColor=white\"\n      alt=\"Discord\">\n    </a>\n    <a href=\"https://matrix.to/#/#sonixd:matrix.org\">\n      <img src=\"https://img.shields.io/matrix/sonixd:matrix.org?color=black&label=matrix&logo=matrix&logoColor=white\"\n      alt=\"Matrix\">\n    </a>\n  </p>\n\n---\n\nRewrite of [Sonixd](https://github.com/jeffvli/sonixd).\n\n## Features\n\n- [x] MPV player backend\n- [x] Web player backend\n- [x] Modern UI\n- [x] Scrobble playback to your server\n- [x] Smart playlist editor (Navidrome)\n- [x] Synchronized and unsynchronized lyrics support\n- [ ] [Request a feature](https://github.com/jeffvli/feishin/issues) or [view taskboard](https://github.com/users/jeffvli/projects/5/views/1)\n\n## Screenshots\n\n<a href=\"./media/preview_full_screen_player.png\"><img src=\"./media/preview_full_screen_player.png\" width=\"49.5%\"/></a> <a href=\"./media/preview_album_artist_detail.png\"><img src=\"./media/preview_album_artist_detail.png\" width=\"49.5%\"/></a> <a href=\"./media/preview_album_detail.png\"><img src=\"./media/preview_album_detail.png\" width=\"49.5%\"/></a> <a href=\"./media/preview_smart_playlist.png\"><img src=\"./media/preview_smart_playlist.png\" width=\"49.5%\"/></a>\n\n## Getting Started\n\n### Desktop (recommended)\n\nDownload the [latest desktop client](https://github.com/jeffvli/feishin/releases). The desktop client is the recommended way to use Feishin. It supports both the MPV and web player backends, as well as includes built-in fetching for lyrics.\n\n#### macOS Notes\n\nIf you're using a device running macOS 12 (Monterey) or higher, [check here](https://github.com/jeffvli/feishin/issues/104#issuecomment-1553914730) for instructions on how to remove the app from quarantine.\n\nFor media keys to work, you will be prompted to allow Feishin to be a Trusted Accessibility Client. After allowing, you will need to restart Feishin for the privacy settings to take effect.\n\n#### Linux Notes\n\nWe provide a small install script to download the latest `.AppImage`, make it executable, and also download the icons required by Desktop Environments. Finally, it generates a `.desktop` file to add Feishin to your Application Launcher.\n\nSimply run the installer like this:\n\n```sh\ndir=/your/application/directory\ncurl 'https://raw.githubusercontent.com/jeffvli/feishin/refs/heads/development/install-feishin-appimage' | sh -s -- \"$dir\"\n```\n\nThe script also has an option to add launch arguments to run Feishin in native Wayland mode. Note that this is experimental in Electron and therefore not officially supported. If you want to use it, run this instead:\n\n```sh\ndir=/your/application/directory\ncurl 'https://raw.githubusercontent.com/jeffvli/feishin/refs/heads/development/install-feishin-appimage' | sh -s -- \"$dir\" wayland-native\n```\n\nIt also provides a simple uninstall routine, removing the downloaded files:\n\n```sh\ndir=/your/application/directory\ncurl 'https://raw.githubusercontent.com/jeffvli/feishin/refs/heads/development/install-feishin-appimage' | sh -s -- \"$dir\" remove\n```\n\nThe entry should show up in your Application Launcher immediately. If it does not, simply log out, wait 10 seconds, and log back in. Your Desktop Environment may alternatively provide a way to reload entries.\n\n### Web and Docker\n\nVisit [https://feishin.vercel.app](https://feishin.vercel.app) to use the hosted web version of Feishin. The web client only supports the web player backend.\n\nFeishin is also available as a Docker image. The images are hosted via `ghcr.io` and are available to view [here](https://github.com/jeffvli/feishin/pkgs/container/feishin). You can run the container using the following commands:\n\n```bash\n# Run the latest version\ndocker run --name feishin -p 9180:9180 ghcr.io/jeffvli/feishin:latest\n\n# Build the image locally\ndocker build -t feishin .\ndocker run --name feishin -p 9180:9180 feishin\n```\n\n#### Docker Compose\n\nTo install via Docker Compose, use the following snippet. This also works on Portainer.\n\n```yaml\nservices:\n    feishin:\n        container_name: feishin\n        image: 'ghcr.io/jeffvli/feishin:latest'\n        restart: unless-stopped\n        environment:\n            - SERVER_NAME=jellyfin # pre-defined server name\n            - SERVER_LOCK=true # When true AND name/type/url are set, only username/password can be toggled\n            - SERVER_TYPE=jellyfin # the allowed types are: jellyfin, navidrome, subsonic. These values are case insensitive\n            - SERVER_URL= # http://address:port or https://address:port\n            - REMOTE_URL= # http://address or https://address\n            - LEGACY_AUTHENTICATION=false # When SERVER_LOCK is true, sets the legacy (plaintext) authentication flag for Subsonic/OpenSubsonic servers\n            - ANALYTICS_DISABLED=true # Set to true to disable Umami analytics tracking\n        ports:\n            - 9180:9180\n            # Alternatively, to restrict to only localhost, - 127.0.0.1:9180:8190\n```\n\n### Configuration\n\n1. Upon startup you will be greeted with a prompt to select the path to your MPV binary. If you do not have MPV installed, you can download it [here](https://mpv.io/installation/) or install it using any package manager supported by your OS. After inputting the path, restart the app.\n\n2. After restarting the app, you will be prompted to select a server. Click the `Open menu` button and select `Manage servers`. Click the `Add server` button in the popup and fill out all applicable details. You will need to enter the full URL to your server, including the protocol and port if applicable (e.g. `https://navidrome.my-server.com` or `http://192.168.0.1:4533`).\n\n- **Navidrome** - For the best experience, select \"Save password\" when creating the server and configure the `SessionTimeout` setting in your Navidrome config to a larger value (e.g. 72h).\n    - **Linux users** - The default password store uses `libsecret`. `kwallet4/5/6` are also supported, but must be explicitly set in Settings > Window > Passwords/secret store.\n\n3. _Optional_ - If you want to host Feishin on a subpath (not `/`), then pass in the following environment variable: `PUBLIC_PATH=PATH`. For example, to host on `/feishin`, pass in `PUBLIC_PATH=/feishin`.\n\n4. _Optional_ - To hard code the server url, pass the following environment variables: `SERVER_NAME`, `SERVER_TYPE` (one of `jellyfin` or `navidrome` or `subsonic`), `SERVER_URL`. To prevent users from changing these settings, pass `SERVER_LOCK=true`. This can only be set if all three of the previous values are set. When `SERVER_LOCK=true`, you can also set `LEGACY_AUTHENTICATION=true` or `LEGACY_AUTHENTICATION=false` to configure the legacy authentication flag for the server (only applicable for Subsonic/OpenSubsonic servers).\n\n5. _Optional_ - If your server uses a separate public-facing URL than what integrating applications use internally to communicate with your server, such as a separate Navidrome `ShareURL`, set `REMOTE_URL` to said public-facing URL.\n \n6. _Optional_ - To disable Umami analytics tracking in the Docker/web version, set the environment variable `ANALYTICS_DISABLED=true`. When enabled, the analytics script will not be loaded and all tracking will be disabled.\n\n7. _Optional_ - App settings (theme, language, sidebar options, etc.) can be overridden with environment variables on first run. The variables use the `FS_` prefix (e.g. `FS_GENERAL_THEME=defaultDark`, `FS_GENERAL_LANGUAGE=de`). See [the settings environment variable documentation](docs/ENV_SETTINGS.md) for the full list.\n\n## FAQ\n\n### MPV is either not working or is rapidly switching between pause/play states\n\nFirst thing to do is check that your MPV binary path is correct. Navigate to the settings page and re-set the path and restart the app. If your issue still isn't resolved, try reinstalling MPV. Known working versions include `v0.35.x` and `v0.36.x`. `v0.34.x` is a known broken version.\n\n### What music servers does Feishin support?\n\nFeishin supports any music server that implements a [Navidrome](https://www.navidrome.org/), [Jellyfin](https://jellyfin.org/), or [OpenSubsonic compatible](https://opensubsonic.netlify.app/) API.\n\n- [Navidrome](https://github.com/navidrome/navidrome)\n- [Jellyfin](https://github.com/jellyfin/jellyfin)\n- [OpenSubsonic](https://opensubsonic.netlify.app/) compatible servers, such as...\n    - [Airsonic-Advanced](https://github.com/airsonic-advanced/airsonic-advanced)\n    - [Ampache](https://ampache.org)\n    - [Astiga](https://asti.ga/)\n    - [Funkwhale](https://www.funkwhale.audio/)\n    - [Gonic](https://github.com/sentriz/gonic)\n    - [LMS](https://github.com/epoupon/lms)\n    - [Nextcloud Music](https://apps.nextcloud.com/apps/music)\n    - [Supysonic](https://github.com/spl0k/supysonic)\n    - [Qm-Music](https://github.com/chenqimiao/qm-music)\n    - More (?)\n\n### I have the issue \"The SUID sandbox helper binary was found, but is not configured correctly\" on Linux\n\nThis happens when you have user (unprivileged) namespaces disabled (`sysctl kernel.unprivileged_userns_clone` returns 0). You can fix this by either enabling unprivileged namespaces, or by making the `chrome-sandbox` Setuid.\n\n```bash\nchmod 4755 chrome-sandbox\nsudo chown root:root chrome-sandbox\n```\n\nUbuntu 24.04 specifically introduced breaking changes that affect how namespaces work. Please see https://discourse.ubuntu.com/t/ubuntu-24-04-lts-noble-numbat-release-notes/39890#:~:text=security%20improvements%20 for possible fixes.\n\n## Development\n\nBuilt and tested using Node `v23.11.0`.\n\nThis project is built off of [electron-vite](https://github.com/alex8088/electron-vite)\n\n- `pnpm run dev` - Start the development server\n- `pnpm run dev:watch` - Start the development server in watch mode (for main / preload HMR)\n- `pnpm run start` - Starts the app in production preview mode\n- `pnpm run build` - Builds the app for desktop\n- `pnpm run build:electron` - Build the electron app (main, preload, and renderer)\n- `pnpm run build:remote` - Build the remote app (remote)\n- `pnpm run build:web` - Build the standalone web app (renderer)\n- `pnpm run package` - Package the project\n- `pnpm run package:dev` - Package the project for development locally\n- `pnpm run package:linux` - Package the project for Linux locally\n- `pnpm run package:mac` - Package the project for Mac locally\n- `pnpm run package:win` - Package the project for Windows locally\n- `pnpm run publish:linux` - Publish the project for Linux\n- `pnpm run publish:linux:beta` - Publish the project for Linux (beta channel)\n- `pnpm run publish:linux-arm64` - Publish the project for Linux ARM64\n- `pnpm run publish:linux-arm64:beta` - Publish the project for Linux ARM64 (beta channel)\n- `pnpm run publish:mac` - Publish the project for Mac\n- `pnpm run publish:mac:beta` - Publish the project for Mac (beta channel)\n- `pnpm run publish:win` - Publish the project for Windows\n- `pnpm run publish:win:beta` - Publish the project for Windows (beta channel)\n- `pnpm run typecheck` - Type check the project\n- `pnpm run typecheck:node` - Type check the project with tsconfig.node.json\n- `pnpm run typecheck:web` - Type check the project with tsconfig.web.json\n- `pnpm run lint` - Lint the project\n- `pnpm run lint:fix` - Lint the project and fix linting errors\n- `pnpm run i18next` - Generate i18n files\n\n## Translation\n\nThis project uses [Weblate](https://hosted.weblate.org/projects/feishin/) for translations. If you would like to contribute, please visit the link and submit a translation.\n\n## License\n\n[GNU General Public License v3.0 ©](https://github.com/jeffvli/feishin/blob/dev/LICENSE)\n"
  },
  {
    "path": "assets/assets.d.ts",
    "content": "type Styles = Record<string, string>;\n\ndeclare module '*.svg' {\n    const content: string;\n    export default content;\n}\n\ndeclare module '*.png' {\n    const content: string;\n    export default content;\n}\n\ndeclare module '*.jpg' {\n    const content: string;\n    export default content;\n}\n\ndeclare module '*.scss' {\n    const content: Styles;\n    export default content;\n}\n\ndeclare module '*.sass' {\n    const content: Styles;\n    export default content;\n}\n\ndeclare module '*.css' {\n    const content: Styles;\n    export default content;\n}\n"
  },
  {
    "path": "assets/entitlements.mac.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n  <dict>\n    <key>com.apple.security.cs.allow-jit</key>\n    <true/>\n    <key>com.apple.security.cs.allow-unsigned-executable-memory</key>\n    <true/>\n    <key>com.apple.security.cs.allow-dyld-environment-variables</key>\n    <true/>\n  </dict>\n</plist>\n"
  },
  {
    "path": "dev-app-update.yml",
    "content": "provider: generic\nurl: https://example.com/auto-updates\nupdaterCacheDirName: feishin-updater\n"
  },
  {
    "path": "docker-compose.yaml",
    "content": "services:\n    feishin:\n        container_name: feishin\n        image: \"ghcr.io/jeffvli/feishin:latest\"\n        restart: unless-stopped\n        environment:\n            - SERVER_NAME=jellyfin # pre-defined server name\n            - SERVER_LOCK=false # When true AND name/type/url are set, only username/password can be toggled\n            - SERVER_TYPE=jellyfin # the allowed types are: jellyfin, navidrome, subsonic. These values are case insensitive\n            - SERVER_URL=http://localhost:8096 # http://address:port or https://address:port\n            # - REMOTE_URL=http://share.localhost # Used for compatibility with external functionality, such as custom sharing URLs on Navidrome\n            - LEGACY_AUTHENTICATION=false # When SERVER_LOCK is true, sets the legacyauth flag for server authentication (true or false)\n            - ANALYTICS_DISABLED=false # Set to true to disable Umami analytics tracking\n        ports:\n            - 9180:9180\n            # Alternatively, to restrict to only localhost, - 127.0.0.1:9180:8190\n"
  },
  {
    "path": "docs/ENV_SETTINGS.md",
    "content": "# Environment variables for settings (web / Docker)\n\nThese variables override app settings **on first run** when no persisted settings exist. They are injected via `settings.js` (from `settings.js.template`) and only apply to the **web** build.\n\n**Format:** All values are strings; booleans use `true`/`false`, numbers are numeric strings. Leave unset or empty to use the default.\n\n---\n\n## General\n\n| Setting | Default | Env variable | Available values / Description |\n|-------------|---------|--------------|--------------------------------|\n| `general.accent` | `rgb(53, 116, 252)` | `FS_GENERAL_ACCENT` | CSS `rgb(r, g, b)` string (e.g. `rgb(53, 116, 252)`). Invalid values are ignored. |\n| `general.albumBackground` | `false` | `FS_GENERAL_ALBUM_BACKGROUND` | `true` / `false` — Show album background image. |\n| `general.albumBackgroundBlur` | `3` | `FS_GENERAL_ALBUM_BACKGROUND_BLUR` | Blur amount for album background (number). |\n| `general.artistBackground` | `true` | `FS_GENERAL_ARTIST_BACKGROUND` | `true` / `false` — Show artist background image. |\n| `general.artistBackgroundBlur` | `3` | `FS_GENERAL_ARTIST_BACKGROUND_BLUR` | Blur amount for artist background (number). |\n| `general.blurExplicitImages` | `false` | `FS_GENERAL_BLUR_EXPLICIT_IMAGES` | `true` / `false` — Blur explicit images. |\n| `general.combinedLyricsAndVisualizer` | `false` | `FS_GENERAL_COMBINED_LYRICS_AND_VISUALIZER` | `true` / `false` — Combine lyrics and visualizer panel. |\n| `general.enableGridMultiSelect` | `false` | `FS_GENERAL_ENABLE_GRID_MULTI_SELECT` | `true` / `false` — Enable multi-select in grid views. |\n| `general.externalLinks` | `true` | `FS_GENERAL_EXTERNAL_LINKS` | `true` / `false` — Show external links in UI. |\n| `general.followCurrentSong` | `true` | `FS_GENERAL_FOLLOW_CURRENT_SONG` | `true` / `false` — Follow current song in list. |\n| `general.followSystemTheme` | `false` | `FS_GENERAL_FOLLOW_SYSTEM_THEME` | `true` / `false` — Use OS light/dark preference. |\n| `general.homeFeature` | `true` | `FS_GENERAL_HOME_FEATURE` | `true` / `false` — Show home featured carousel. |\n| `general.homeFeatureStyle` | `single` | `FS_GENERAL_HOME_FEATURE_STYLE` | `multiple` / `single` — Home featured carousel style. |\n| `general.language` | `en` | `FS_GENERAL_LANGUAGE` | UI language code (e.g. `en`, `de`, `fr`). |\n| `general.theme` | `defaultDark` | `FS_GENERAL_THEME` | One of: `ayuDark`, `ayuLight`, `catppuccinLatte`, `catppuccinMocha`, `defaultDark`, `defaultLight`, `dracula`, `githubDark`, `githubLight`, `glassyDark`, `gruvboxDark`, `gruvboxLight`, `highContrastDark`, `highContrastLight`, `materialDark`, `materialLight`, `monokai`, `nightOwl`, `nord`, `oneDark`, `rosePine`, `rosePineDawn`, `rosePineMoon`, `shadesOfPurple`, `solarizedDark`, `solarizedLight`, `tokyoNight`, `vscodeDarkPlus`, `vscodeLightPlus`. |\n| `general.themeDark` | `defaultDark` | `FS_GENERAL_THEME_DARK` | Same as theme (used when system is dark). |\n| `general.themeLight` | `defaultLight` | `FS_GENERAL_THEME_LIGHT` | Same as theme (used when system is light). |\n| `general.lastfmApiKey` | *(empty)* | `FS_GENERAL_LASTFM_API_KEY` | Last.fm API key. |\n| `general.lastFM` | `true` | `FS_GENERAL_LAST_FM` | `true` / `false` — Enable Last.fm. |\n| `general.listenBrainz` | `true` | `FS_GENERAL_LISTEN_BRAINZ` | `true` / `false` — ListenBrainz links. |\n| `general.musicBrainz` | `true` | `FS_GENERAL_MUSIC_BRAINZ` | `true` / `false` — MusicBrainz links. |\n| `general.nativeAspectRatio` | `false` | `FS_GENERAL_NATIVE_ASPECT_RATIO` | `true` / `false` — Use native cover art aspect ratio. |\n| `general.pathReplace` | *(empty)* | `FS_GENERAL_PATH_REPLACE` | Path pattern to replace (e.g. server path in Docker). |\n| `general.pathReplaceWith` | *(empty)* | `FS_GENERAL_PATH_REPLACE_WITH` | Replacement path. |\n| `general.playerbarOpenDrawer` | `false` | `FS_GENERAL_PLAYERBAR_OPEN_DRAWER` | `true` / `false` — Open queue/lyrics as drawer from player bar. |\n| `general.primaryShade` | `6` | `FS_GENERAL_PRIMARY_SHADE` | Mantine primary shade 0–9 (number). |\n| `general.qobuz` | `true` | `FS_GENERAL_QOBUZ` | `true` / `false` — Qobuz links. |\n| `general.resume` | `true` | `FS_GENERAL_RESUME` | `true` / `false` — Resume playback on load. |\n| `general.showLyricsInSidebar` | `true` | `FS_GENERAL_SHOW_LYRICS_IN_SIDEBAR` | `true` / `false` — Show lyrics in sidebar. |\n| `general.showRatings` | `true` | `FS_GENERAL_SHOW_RATINGS` | `true` / `false` — Show star ratings. |\n| `general.showVisualizerInSidebar` | `true` | `FS_GENERAL_SHOW_VISUALIZER_IN_SIDEBAR` | `true` / `false` — Show visualizer in sidebar. |\n| `general.sidebarCollapsedNavigation` | `true` | `FS_GENERAL_SIDEBAR_COLLAPSED_NAVIGATION` | `true` / `false` — Start with collapsed sidebar nav. |\n| `general.sidebarCollapseShared` | `false` | `FS_GENERAL_SIDEBAR_COLLAPSE_SHARED` | `true` / `false` — Share sidebar collapse state. |\n| `general.sidebarPlaylistList` | `true` | `FS_GENERAL_SIDEBAR_PLAYLIST_LIST` | `true` / `false` — Show playlist list in sidebar. |\n| `general.sidebarPlaylistSorting` | `false` | `FS_GENERAL_SIDEBAR_PLAYLIST_SORTING` | `true` / `false` — Enable playlist sorting in sidebar. |\n| `general.sideQueueType` | `sideQueue` | `FS_GENERAL_SIDE_QUEUE_TYPE` | `sideDrawerQueue` / `sideQueue` — Side play queue style. |\n| `general.sideQueueLayout` | `horizontal` | `FS_GENERAL_SIDE_QUEUE_LAYOUT` | `horizontal` / `vertical` — Attached side queue layout orientation. |\n| `general.useThemeAccentColor` | `false` | `FS_GENERAL_USE_THEME_ACCENT_COLOR` | `true` / `false` — Use theme’s accent color instead of custom. |\n| `general.useThemePrimaryShade` | `true` | `FS_GENERAL_USE_THEME_PRIMARY_SHADE` | `true` / `false` — Use theme’s primary shade. |\n| `general.zoomFactor` | `100` | `FS_GENERAL_ZOOM_FACTOR` | UI zoom percentage (number). |\n\n---\n\n## Playback\n\n| Setting path | Default | Env variable | Available values / Description |\n|-------------|---------|--------------|--------------------------------|\n| `playback.mediaSession` | `false` | `FS_PLAYBACK_MEDIA_SESSION` | `true` / `false` — Media Session API (e.g. browser/media keys). |\n| `playback.webAudio` | `true` | `FS_PLAYBACK_WEB_AUDIO` | `true` / `false` — Use Web Audio for playback. |\n| `playback.audioFadeOnStatusChange` | `true` | `FS_PLAYBACK_AUDIO_FADE_ON_STATUS_CHANGE` | `true` / `false` — Fade on play/pause. |\n| `playback.preservePitch` | `true` | `FS_PLAYBACK_PRESERVE_PITCH` | `true` / `false` — Preserve pitch when changing speed. |\n| `playback.scrobble.enabled` | `true` | `FS_PLAYBACK_SCROBBLE_ENABLED` | `true` / `false` — Enable scrobbling. |\n| `playback.scrobble.notify` | `false` | `FS_PLAYBACK_SCROBBLE_NOTIFY` | `true` / `false` — Scrobble notifications. |\n| `playback.scrobble.scrobbleAtDuration` | `240` | `FS_PLAYBACK_SCROBBLE_AT_DURATION` | Seconds of playback before scrobble. |\n| `playback.scrobble.scrobbleAtPercentage` | `75` | `FS_PLAYBACK_SCROBBLE_AT_PERCENTAGE` | Percentage of track before scrobble. |\n| `playback.transcode.enabled` | `false` | `FS_PLAYBACK_TRANSCODE_ENABLED` | `true` / `false` — Enable transcoding. |\n\n---\n\n## Discord\n\n| Setting path | Default | Env variable | Available values / Description |\n|-------------|---------|--------------|--------------------------------|\n| `discord.enabled` | `false` | `FS_DISCORD_ENABLED` | `true` / `false` — Discord rich presence. |\n| `discord.clientId` | *(built-in)* | `FS_DISCORD_CLIENT_ID` | Custom Discord application ID. |\n| `discord.displayType` | `feishin` | `FS_DISCORD_DISPLAY_TYPE` | `artist` / `feishin` / `song`. |\n| `discord.linkType` | `none` | `FS_DISCORD_LINK_TYPE` | `last_fm` / `musicbrainz` / `musicbrainz_last_fm` / `none`. |\n| `discord.showAsListening` | `false` | `FS_DISCORD_SHOW_AS_LISTENING` | `true` / `false`. |\n| `discord.showPaused` | `true` | `FS_DISCORD_SHOW_PAUSED` | `true` / `false` — Show paused state. |\n| `discord.showServerImage` | `false` | `FS_DISCORD_SHOW_SERVER_IMAGE` | `true` / `false`. |\n| `discord.showStateIcon` | `true` | `FS_DISCORD_SHOW_STATE_ICON` | `true` / `false`. |\n\n---\n\n## Lyrics\n\n| Setting path | Default | Env variable | Available values / Description |\n|-------------|---------|--------------|--------------------------------|\n| `lyrics.fetch` | `true` | `FS_LYRICS_FETCH` | `true` / `false` — Fetch lyrics. |\n| `lyrics.follow` | `true` | `FS_LYRICS_FOLLOW` | `true` / `false` — Follow current line. |\n| `lyrics.delayMs` | `0` | `FS_LYRICS_DELAY_MS` | Sync delay in milliseconds. |\n| `lyrics.preferLocalLyrics` | `true` | `FS_LYRICS_PREFER_LOCAL` | `true` / `false` — Prefer local lyric files. |\n| `lyrics.showMatch` | `true` | `FS_LYRICS_SHOW_MATCH` | `true` / `false`. |\n| `lyrics.showProvider` | `true` | `FS_LYRICS_SHOW_PROVIDER` | `true` / `false`. |\n| `lyrics.enableAutoTranslation` | `false` | `FS_LYRICS_ENABLE_AUTO_TRANSLATION` | `true` / `false`. |\n| `lyrics.translationApiKey` | *(empty)* | `FS_LYRICS_TRANSLATION_API_KEY` | API key for lyric translation. |\n| `lyrics.translationTargetLanguage` | `en` | `FS_LYRICS_TRANSLATION_TARGET_LANGUAGE` | Target language code. |\n| `lyrics.alignment` | `center` | `FS_LYRICS_ALIGNMENT` | `center` / `left` / `right`. |\n\n---\n\n## Auto DJ\n\n| Setting path | Default | Env variable | Available values / Description |\n|-------------|---------|--------------|--------------------------------|\n| `autoDJ.enabled` | `false` | `FS_AUTO_DJ_ENABLED` | `true` / `false`. |\n| `autoDJ.itemCount` | `5` | `FS_AUTO_DJ_ITEM_COUNT` | Number of items to add. |\n| `autoDJ.timing` | `1` | `FS_AUTO_DJ_TIMING` | Timing value (number). |\n\n---\n\n## CSS\n\n| Setting path | Default | Env variable | Available values / Description |\n|-------------|---------|--------------|--------------------------------|\n| `css.content` | *(empty)* | `FS_CSS_CONTENT` | Custom CSS string (sanitized like in-app custom CSS). Set `FS_CSS_ENABLED=true` to apply. |\n| `css.enabled` | `false` | `FS_CSS_ENABLED` | `true` / `false` — Enable custom CSS. |\n\n---\n\n## Font\n\n| Setting path | Default | Env variable | Available values / Description |\n|-------------|---------|--------------|--------------------------------|\n| `font.type` | `builtIn` | `FS_FONT_TYPE` | `builtIn` / `system` / `custom`. |\n| `font.builtIn` | `Inter` | `FS_FONT_BUILT_IN` | Built-in font name. |\n| `font.system` | *(empty)* | `FS_FONT_SYSTEM` | System font name (when type is `system`). |\n"
  },
  {
    "path": "electron-builder-alpha.yml",
    "content": "appId: org.jeffvli.feishin\nproductName: Feishin\nartifactName: ${productName}-${version}-${os}-${arch}.${ext}\nelectronVersion: 39.4.0\ndirectories:\n    buildResources: assets\nfiles:\n    - 'out/**/*'\n    - 'package.json'\nextraResources:\n    - assets/**\nasarUnpack:\n    - resources/**\nwin:\n    target:\n        - target: zip\n          arch:\n              - x64\n              - arm64\n        - target: nsis\n          arch:\n              - x64\n              - arm64\n    icon: assets/icons/icon.ico\n\nnsis:\n    allowToChangeInstallationDirectory: true\n    oneClick: false\n    shortcutName: ${productName}\n    uninstallDisplayName: ${productName}\n    createDesktopShortcut: always\n\nmac:\n    target:\n        - target: dmg\n          arch:\n              - arm64\n              - x64\n        - target: zip\n          arch:\n              - arm64\n              - x64\n    icon: assets/icons/icon.icns\n    type: distribution\n    hardenedRuntime: false\n    identity: \"-\"\n    gatekeeperAssess: false\n    notarize: false\n\n\ndmg:\n    contents: [{ x: 130, y: 220 }, { x: 410, y: 220, type: link, path: /Applications }]\n\nlinux:\n    target:\n        - AppImage\n        - deb\n        - tar.xz\n    category: AudioVideo;Audio;Player\n    icon: assets/icons/icon.png\n    artifactName: ${productName}-${os}-${arch}.${ext}\n\ntoolsets:\n    appimage: \"1.0.2\"\n\nnpmRebuild: false\n\npublish:\n    provider: s3\n    bucket: feishin-nightly\n    channel: alpha\n    endpoint: https://065f090c64de2dc707dd70ac72db9669.r2.cloudflarestorage.com\n"
  },
  {
    "path": "electron-builder-beta.yml",
    "content": "appId: org.jeffvli.feishin\nproductName: Feishin\nartifactName: ${productName}-${version}-${os}-${arch}.${ext}\nelectronVersion: 39.4.0\ndirectories:\n    buildResources: assets\nfiles:\n    - 'out/**/*'\n    - 'package.json'\nextraResources:\n    - assets/**\nasarUnpack:\n    - resources/**\nwin:\n    target:\n        - target: zip\n          arch:\n              - x64\n              - arm64\n        - target: nsis\n          arch:\n              - x64\n              - arm64\n    icon: assets/icons/icon.ico\n\nnsis:\n    allowToChangeInstallationDirectory: true\n    oneClick: false\n    shortcutName: ${productName}\n    uninstallDisplayName: ${productName}\n    createDesktopShortcut: always\n\nmac:\n    target:\n        - target: dmg\n          arch:\n              - arm64\n              - x64\n        - target: zip\n          arch:\n              - arm64\n              - x64\n    icon: assets/icons/icon.icns\n    type: distribution\n    hardenedRuntime: false\n    identity: \"-\"\n    gatekeeperAssess: false\n    notarize: false\n\ndmg:\n    contents: [{ x: 130, y: 220 }, { x: 410, y: 220, type: link, path: /Applications }]\n\nlinux:\n    target:\n        - AppImage\n        - deb\n        - tar.xz\n    category: AudioVideo;Audio;Player\n    icon: assets/icons/icon.png\n    artifactName: ${productName}-${os}-${arch}.${ext}\n\ntoolsets:\n    appimage: \"1.0.2\"\n\nnpmRebuild: false\npublish:\n    provider: github\n    owner: jeffvli\n    repo: feishin\n    channel: beta\n    releaseType: draft\n"
  },
  {
    "path": "electron-builder.yml",
    "content": "appId: org.jeffvli.feishin\nproductName: Feishin\nartifactName: ${productName}-${version}-${os}-${arch}.${ext}\nelectronVersion: 39.4.0\ndirectories:\n    buildResources: assets\nfiles:\n    - 'out/**/*'\n    - 'package.json'\nextraResources:\n    - assets/**\nasarUnpack:\n    - resources/**\nwin:\n    target:\n        - target: zip\n          arch:\n              - x64\n              - arm64\n        - target: nsis\n          arch:\n              - x64\n              - arm64\n    icon: assets/icons/icon.ico\n\nnsis:\n    allowToChangeInstallationDirectory: true\n    oneClick: false\n    shortcutName: ${productName}\n    uninstallDisplayName: ${productName}\n    createDesktopShortcut: always\n\nmac:\n    target:\n        - target: dmg\n          arch:\n              - arm64\n              - x64\n        - target: zip\n          arch:\n              - arm64\n              - x64\n    icon: assets/icons/icon.icns\n    type: distribution\n    hardenedRuntime: false\n    identity: \"-\"\n    gatekeeperAssess: false\n    notarize: false\n\ndmg:\n    contents: [{ x: 130, y: 220 }, { x: 410, y: 220, type: link, path: /Applications }]\n\nlinux:\n    target:\n        - AppImage\n        - deb\n        - tar.xz\n    category: AudioVideo;Audio;Player\n    icon: assets/icons/icon.png\n    artifactName: ${productName}-${os}-${arch}.${ext}\n\ntoolsets:\n    appimage: \"1.0.2\"\n\nnpmRebuild: false\nafterAllArtifactBuild: scripts/after-all-artifact-build.mjs\npublish:\n    provider: github\n    owner: jeffvli\n    repo: feishin\n    channel: latest\n    releaseType: draft\n"
  },
  {
    "path": "electron.vite.config.ts",
    "content": "import react from '@vitejs/plugin-react';\nimport { externalizeDepsPlugin, UserConfig } from 'electron-vite';\nimport { resolve } from 'path';\nimport conditionalImportPlugin from 'vite-plugin-conditional-import';\nimport dynamicImportPlugin from 'vite-plugin-dynamic-import';\nimport { ViteEjsPlugin } from 'vite-plugin-ejs';\n\nconst currentOSEnv = process.platform;\nconst electronRendererTarget = 'chrome87';\n\nconst config: UserConfig = {\n    main: {\n        build: {\n            rollupOptions: {\n                external: ['source-map-support'],\n            },\n            sourcemap: true,\n        },\n        define: {\n            'import.meta.env.IS_LINUX': JSON.stringify(currentOSEnv === 'linux'),\n            'import.meta.env.IS_MACOS': JSON.stringify(currentOSEnv === 'darwin'),\n            'import.meta.env.IS_WIN': JSON.stringify(currentOSEnv === 'win32'),\n        },\n        plugins: [\n            externalizeDepsPlugin(),\n            dynamicImportPlugin(),\n            conditionalImportPlugin({\n                currentEnv: currentOSEnv,\n                envs: ['win32', 'linux', 'darwin'],\n            }),\n        ],\n        resolve: {\n            alias: {\n                '/@/main': resolve('src/main'),\n                '/@/shared': resolve('src/shared'),\n            },\n        },\n    },\n    preload: {\n        build: {\n            sourcemap: true,\n        },\n        plugins: [externalizeDepsPlugin()],\n        resolve: {\n            alias: {\n                '/@/preload': resolve('src/preload'),\n                '/@/shared': resolve('src/shared'),\n            },\n        },\n    },\n    renderer: {\n        build: {\n            cssMinify: 'esbuild',\n            minify: 'esbuild',\n            modulePreload: {\n                polyfill: false,\n            },\n            sourcemap: true,\n            target: electronRendererTarget,\n        },\n        css: {\n            modules: {\n                generateScopedName: 'fs-[name]-[local]',\n                localsConvention: 'camelCase',\n            },\n        },\n        plugins: [react(), ViteEjsPlugin({ web: false })],\n        resolve: {\n            alias: {\n                '/@/i18n': resolve('src/i18n'),\n                '/@/remote': resolve('src/remote'),\n                '/@/renderer': resolve('src/renderer'),\n                '/@/shared': resolve('src/shared'),\n            },\n        },\n    },\n};\n\nexport default config;\n"
  },
  {
    "path": "eslint.config.mjs",
    "content": "import eslintConfigPrettier from '@electron-toolkit/eslint-config-prettier';\nimport tseslint from '@electron-toolkit/eslint-config-ts';\nimport perfectionist from 'eslint-plugin-perfectionist';\nimport eslintPluginReact from 'eslint-plugin-react';\nimport eslintPluginReactHooks from 'eslint-plugin-react-hooks';\nimport eslintPluginReactRefresh from 'eslint-plugin-react-refresh';\n\nexport default tseslint.config(\n    { ignores: ['**/node_modules', '**/dist', '**/out'] },\n    tseslint.configs.recommended,\n    perfectionist.configs['recommended-natural'],\n    eslintPluginReact.configs.flat.recommended,\n    eslintPluginReact.configs.flat['jsx-runtime'],\n    {\n        settings: {\n            react: {\n                version: 'detect',\n            },\n        },\n    },\n    {\n        files: ['**/*.{ts,tsx}'],\n        plugins: {\n            'react-hooks': eslintPluginReactHooks,\n            'react-refresh': eslintPluginReactRefresh,\n        },\n        rules: {\n            ...eslintPluginReactHooks.configs.recommended.rules,\n            ...eslintPluginReactRefresh.configs.vite.rules,\n            '@typescript-eslint/explicit-function-return-type': 'off',\n            '@typescript-eslint/no-duplicate-enum-values': 'off',\n            '@typescript-eslint/no-explicit-any': 'off',\n            '@typescript-eslint/no-unused-vars': 'warn',\n            curly: ['error', 'all'],\n            indent: [\n                'error',\n                'tab',\n                {\n                    offsetTernaryExpressions: true,\n                    SwitchCase: 1,\n                },\n            ],\n            'no-unused-vars': 'off',\n            'no-use-before-define': 'off',\n            quotes: ['error', 'single'],\n            'react-hooks/refs': 'off',\n            'react-hooks/set-state-in-effect': 'off',\n            'react-refresh/only-export-components': 'off',\n            'react/display-name': 'off',\n            semi: ['error', 'always'],\n            'single-attribute-per-line': 'off',\n        },\n    },\n    eslintConfigPrettier,\n);\n"
  },
  {
    "path": "feishin.desktop.tmpl",
    "content": "[Desktop Entry]\nName=Feishin\nGenericName=Music player\nExec=${FEISHIN_DESKTOP_EXECUTABLE} ${FEISHIN_DESKTOP_ARGS}\nTryExec=${FEISHIN_DESKTOP_EXECUTABLE}\nTerminal=false\nType=Application\nIcon=org.jeffvli.feishin\nStartupWMClass=feishin\nSingleMainWindow=true\nCategories=AudioVideo;Audio;Player;Music;\nKeywords=Navidrome;Jellyfin;Subsonic;OpenSubsonic\nComment=A player for your self-hosted music server\n"
  },
  {
    "path": "install-feishin-appimage",
    "content": "#!/bin/sh\n\nset -eu\n\nif [ \"$#\" -lt 1 ]; then\n    echo \"Usage: $0 <installation-directory> <option>\"\n    echo \"Options:\"\n    echo \"  wayland-native   Enable native Wayland support\"\n    echo \"  remove           Remove Feishin AppImage and desktop entries\"\n    exit 1\nfi\n\ndir=\"$(readlink -f \"${1}\")\"\narg=\"${2:-\"\"}\"\narch=\"$(uname -m)\"\n\nif [ \"$arg\" != \"wayland-native\" ] && [ \"$arg\" != \"remove\" ] && [ \"$arg\" != \"\" ]; then\n    echo \"Invalid option: $arg\"\n    echo \"Valid options are: wayland-native, remove\"\n    exit 1\nfi\n\nif [ \"${arch}\" != \"x86_64\" ] && [ \"${arch}\" != \"aarch64\" ]; then\n\techo \"CPU architecture not recognised (not x86_64 or aarch64). Aborting.\"\n\texit 1\nfi\n\n# workaround if we're not renaming the artifact\nif [ \"${arch}\" = \"aarch64\" ]; then\n\tarch=\"arm64\"\nfi\n\nif [ ! -d \"${dir}\" ]; then\n\techo \"${dir} is not a directory or does not exist. Please provide an existing directory.\"\n\texit 1\nfi\n\nlocalShare=\"${XDG_DATA_HOME:-$HOME/.local/share}\"\nlocalShareIcons=\"${localShare}/icons/hicolor\"\n\nif [ \"${arg}\" = \"remove\" ]; then\n\trm -v \\\n\t\t\"${localShareIcons}/512x512/apps/org.jeffvli.feishin.png\" \\\n\t\t\"${localShareIcons}/256x256/apps/org.jeffvli.feishin.png\" \\\n\t\t\"${localShareIcons}/128x128/apps/org.jeffvli.feishin.png\" \\\n\t\t\"${localShareIcons}/64x64/apps/org.jeffvli.feishin.png\" \\\n\t\t\"${localShareIcons}/32x32/apps/org.jeffvli.feishin.png\" \\\n\t\t\"${localShare}/applications/org.jeffvli.feishin.desktop\" \\\n\t\t\"${dir}/Feishin-linux-${arch}.AppImage\"\n\texit 0\nfi\n\ncurl --fail -L --create-dirs --write-out '%{filename_effective}\\n' \\\n\t-o \"${dir}/Feishin-linux-${arch}.AppImage\" \"https://github.com/jeffvli/feishin/releases/latest/download/Feishin-linux-${arch}.AppImage\" \\\n\t-o \"${localShareIcons}/512x512/apps/org.jeffvli.feishin.png\" 'https://github.com/jeffvli/feishin/blob/development/assets/icons/512x512.png?raw=true' \\\n\t-o \"${localShareIcons}/256x256/apps/org.jeffvli.feishin.png\" 'https://github.com/jeffvli/feishin/blob/development/assets/icons/256x256.png?raw=true' \\\n\t-o \"${localShareIcons}/128x128/apps/org.jeffvli.feishin.png\" 'https://github.com/jeffvli/feishin/blob/development/assets/icons/128x128.png?raw=true' \\\n\t-o \"${localShareIcons}/64x64/apps/org.jeffvli.feishin.png\" 'https://github.com/jeffvli/feishin/blob/development/assets/icons/64x64.png?raw=true' \\\n\t-o \"${localShareIcons}/32x32/apps/org.jeffvli.feishin.png\" 'https://github.com/jeffvli/feishin/blob/development/assets/icons/32x32.png?raw=true'\nchmod -v u+x \"${dir}/Feishin-linux-${arch}.AppImage\"\n\nwaylandFlags=\"\"\nif [ \"${arg}\" = \"wayland-native\" ]; then\n\twaylandFlags=\"--enable-features=UseOzonePlatform,WaylandWindowDecorations --ozone-platform-hint=auto\"\nfi\n\n# this is for Debian-based kernels and ALT respectively\n# https://unix.stackexchange.com/a/303214/145722\nsandboxFlag=\"\"\nif [ \"$(sysctl kernel.unprivileged_userns_clone 2>/dev/null)\" = \"0\" ] \\\n\t|| [ \"$(sysctl kernel.userns_restrict 2>/dev/null)\" = \"1\" ]; then\n\tsandboxFlag=\"--no-sandbox\"\nfi\n\nmkdir -pv \"${localShare}/applications\"\n\nexport FEISHIN_DESKTOP_EXECUTABLE=\"${dir}/Feishin-linux-${arch}.AppImage\"\nexport FEISHIN_DESKTOP_ARGS=\"${sandboxFlag} ${waylandFlags}\"\ncurl --fail https://raw.githubusercontent.com/jeffvli/feishin/refs/heads/development/feishin.desktop.tmpl | envsubst > \"${localShare}/applications/org.jeffvli.feishin.desktop\"\n"
  },
  {
    "path": "ng.conf.template",
    "content": "server {\n  listen 9180;\n  listen [::]:9180;\n  sendfile on;\n  default_type application/octet-stream;\n\n  gzip on;\n  gzip_http_version 1.1;\n  gzip_disable      \"MSIE [1-6]\\.\";\n  gzip_min_length   256;\n  gzip_vary         on;\n  gzip_proxied      expired no-cache no-store private auth;\n  gzip_types        text/plain text/css application/json application/javascript application/x-javascript text/xml application/xml application/xml+rss text/javascript;\n  gzip_comp_level   9;\n\n  location ${PUBLIC_PATH} {\n    alias /usr/share/nginx/html/;\n    try_files $uri $uri/ /index.html =404;\n  }\n\n  location ${PUBLIC_PATH}settings.js {\n    alias /etc/nginx/conf.d/settings.js;\n    add_header Cache-Control \"no-store\";\n  }\n\n  location ${PUBLIC_PATH}/settings.js {\n    alias /etc/nginx/conf.d/settings.js;\n    add_header Cache-Control \"no-store\";\n  }\n}\n"
  },
  {
    "path": "org.jeffvli.feishin.metainfo.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<component type=\"desktop-application\">\n  <id>org.jeffvli.feishin</id>\n  <name>Feishin</name>\n  <summary>Jellyfin, Navidrome, and OpenSubsonic Compatible Music Player</summary>\n  <metadata_license>CC0-1.0</metadata_license>\n  <project_license>GPL-3.0-only</project_license>\n  <content_rating type=\"oars-1.1\"/>\n  <description>\n    <p>A modern, cross-platform music player for Jellyfin, Navidrome, and OpenSubsonic servers.</p>\n    <p>Features</p>\n    <ul>\n      <li>MPV player backend</li>\n      <li>Web player backend</li>\n      <li>Jellyfin server support</li>\n      <li>Navidrome server support</li>\n      <li>OpenSubsonic server support</li>\n      <li>Modern UI</li>\n      <li>Scrobble playback to your server</li>\n      <li>Smart playlist editor (Navidrome)</li>\n      <li>Synchronized and unsynchronized lyrics support</li>\n    </ul>\n  </description>\n  <developer id=\"org.jeffvli\">\n    <name>jeffvli</name>\n  </developer>\n  <launchable type=\"desktop-id\">org.jeffvli.feishin.desktop</launchable>\n  <url type=\"homepage\">https://github.com/jeffvli/feishin</url>\n  <screenshots>\n    <screenshot type=\"default\">\n      <caption>The main menu</caption>\n      <image type=\"source\">https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_home.png</image>\n    </screenshot>\n    <screenshot>\n      <caption>Browsing an album</caption>\n      <image type=\"source\">https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_album_detail.png</image>\n    </screenshot>\n    <screenshot>\n      <caption>Smart playlist creation</caption>\n      <image type=\"source\">https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_smart_playlist.png</image>\n    </screenshot>\n  </screenshots>\n  <categories>\n    <category>AudioVideo</category>\n    <category>Audio</category>\n    <category>Player</category>\n    <category>Music</category>\n  </categories>\n  <releases>\n    <release date=\"2025-10-13\" type=\"stable\" version=\"0.21.2\"></release>\n    <release date=\"2025-10-13\" type=\"stable\" version=\"0.21.1\"></release>\n    <release date=\"2025-10-13\" type=\"stable\" version=\"0.21.0\"></release>\n    <release date=\"2025-09-11\" type=\"stable\" version=\"0.20.1\"></release>\n    <release date=\"2025-09-07\" type=\"stable\" version=\"0.20.0\"></release>\n    <release date=\"2025-07-31\" type=\"stable\" version=\"0.19.0\"></release>\n    <release date=\"2025-07-08\" type=\"stable\" version=\"0.18.0\"></release>\n    <release date=\"2025-06-30\" type=\"stable\" version=\"0.17.0\"></release>\n    <release date=\"2025-06-26\" type=\"stable\" version=\"0.16.0\"></release>\n    <release date=\"2025-06-25\" type=\"stable\" version=\"0.15.1\"></release>\n    <release date=\"2025-06-25\" type=\"stable\" version=\"0.15.0\"></release>\n    <release date=\"2025-06-03\" type=\"stable\" version=\"0.14.0\"></release>\n    <release date=\"2025-05-26\" type=\"stable\" version=\"0.13.0\"></release>\n    <release date=\"2025-05-13\" type=\"stable\" version=\"0.12.7\"></release>\n    <release date=\"2025-05-08\" type=\"stable\" version=\"0.12.6\"></release>\n    <release date=\"2025-05-07\" type=\"stable\" version=\"0.12.5\"></release>\n    <release date=\"2025-03-10\" type=\"stable\" version=\"0.12.3\"></release>\n    <release date=\"2025-01-25\" type=\"stable\" version=\"0.12.2\"></release>\n    <release date=\"2024-11-20\" type=\"stable\" version=\"0.12.1\"></release>\n    <release date=\"2024-11-19\" type=\"stable\" version=\"0.12.0\"></release>\n    <release date=\"2024-10-15\" type=\"stable\" version=\"0.11.1\"></release>\n    <release date=\"2024-10-10\" type=\"stable\" version=\"0.11.0\"></release>\n    <release date=\"2024-09-29\" type=\"stable\" version=\"0.10.1\"></release>\n    <release date=\"2024-09-27\" type=\"stable\" version=\"0.10.0\"></release>\n    <release date=\"2024-09-11\" type=\"stable\" version=\"0.9.0\"></release>\n    <release date=\"2024-09-04\" type=\"stable\" version=\"0.8.1\"></release>\n    <release date=\"2024-09-03\" type=\"stable\" version=\"0.8.0\"></release>\n    <release date=\"2024-07-30\" type=\"stable\" version=\"0.7.3\"></release>\n    <release date=\"2024-07-30\" type=\"stable\" version=\"0.7.2\"></release>\n    <release date=\"2024-05-07\" type=\"stable\" version=\"0.7.1\"></release>\n    <release date=\"2024-05-07\" type=\"stable\" version=\"0.7.0\"></release>\n    <release date=\"2024-03-13\" type=\"stable\" version=\"0.6.1\"></release>\n    <release date=\"2024-03-06\" type=\"stable\" version=\"0.6.0\"></release>\n    <release date=\"2023-12-14\" type=\"stable\" version=\"0.5.3\"></release>\n    <release date=\"2023-11-18\" type=\"stable\" version=\"0.5.2\"></release>\n    <release date=\"2023-11-02\" type=\"stable\" version=\"0.5.1\"></release>\n    <release date=\"2023-10-31\" type=\"stable\" version=\"0.5.0\"></release>\n    <release date=\"2023-10-08\" type=\"stable\" version=\"0.4.1\"></release>\n    <release date=\"2023-09-25\" type=\"stable\" version=\"0.4.0\"></release>\n    <release date=\"2023-08-08\" type=\"stable\" version=\"0.3.0\"></release>\n    <release date=\"2023-06-14\" type=\"stable\" version=\"0.2.0\"></release>\n    <release date=\"2023-05-22\" type=\"stable\" version=\"0.1.1\"></release>\n    <release date=\"2023-05-22\" type=\"stable\" version=\"0.1.0\"></release>\n    <release date=\"2023-04-03\" type=\"development\" version=\"0.0.1-alpha6\"></release>\n    <release date=\"2023-02-09\" type=\"development\" version=\"0.0.1-alpha5\"></release>\n    <release date=\"2023-01-16\" type=\"development\" version=\"0.0.1-alpha4\"></release>\n    <release date=\"2023-01-03\" type=\"development\" version=\"0.0.1-alpha3\"></release>\n    <release date=\"2022-12-30\" type=\"development\" version=\"0.0.1-alpha2\"></release>\n    <release date=\"2022-11-21\" type=\"development\" version=\"0.0.1-alpha1\"></release>\n  </releases>\n</component>\n"
  },
  {
    "path": "package.json",
    "content": "{\n    \"name\": \"feishin\",\n    \"version\": \"1.9.0\",\n    \"description\": \"A modern self-hosted music player.\",\n    \"keywords\": [\n        \"subsonic\",\n        \"navidrome\",\n        \"jellyfin\",\n        \"react\",\n        \"electron\"\n    ],\n    \"homepage\": \"https://github.com/jeffvli/feishin\",\n    \"bugs\": {\n        \"url\": \"https://github.com/jeffvli/feishin/issues\"\n    },\n    \"license\": \"GPL-3.0\",\n    \"author\": {\n        \"name\": \"jeffvli\",\n        \"email\": \"feishin@users.noreply.github.com\",\n        \"url\": \"https://github.com/jeffvli/\"\n    },\n    \"main\": \"./out/main/index.js\",\n    \"scripts\": {\n        \"build\": \"pnpm run build:electron && pnpm run build:remote\",\n        \"build:electron\": \"electron-vite build\",\n        \"build:remote\": \"vite build --config remote.vite.config.ts\",\n        \"build:web\": \"vite build --config web.vite.config.ts\",\n        \"dev\": \"electron-vite dev\",\n        \"dev:remote\": \"vite dev --config remote.vite.config.ts\",\n        \"dev:watch\": \"electron-vite dev --watch\",\n        \"i18next\": \"i18next -c src/i18n/i18next-parser.config.js\",\n        \"postinstall\": \"electron-builder install-app-deps\",\n        \"lint\": \"pnpm run typecheck && pnpm run lint-code && pnpm run lint-styles\",\n        \"lint-code\": \"eslint --max-warnings=0 --cache .\",\n        \"lint-code:fix\": \"eslint --cache --fix .\",\n        \"lint-styles\": \"stylelint --max-warnings=0 'src/**/*.{css,scss}'\",\n        \"lint-styles:fix\": \"stylelint 'src/**/*.{css,scss}' --fix\",\n        \"lint:fix\": \"pnpm run lint-code:fix && pnpm run lint-styles:fix\",\n        \"package\": \"pnpm run build && electron-builder\",\n        \"package:dev\": \"pnpm run build && electron-builder --dir\",\n        \"package:linux\": \"pnpm run build && electron-builder --linux\",\n        \"package:linux-arm64:pr\": \"pnpm run build && electron-builder --linux --arm64 --publish never\",\n        \"package:linux:pr\": \"pnpm run build && electron-builder --linux --publish never\",\n        \"package:mac\": \"pnpm run build && electron-builder --mac\",\n        \"package:mac:pr\": \"pnpm run build && electron-builder --mac --publish never\",\n        \"package:win\": \"pnpm run build && electron-builder --win\",\n        \"package:win-arm64:pr\": \"pnpm run build && electron-builder --win --arm64 --publish never\",\n        \"package:win:pr\": \"pnpm run build && electron-builder --win --publish never\",\n        \"publish:linux\": \"pnpm run build && electron-builder --publish always --linux\",\n        \"publish:linux-arm64\": \"pnpm run build && electron-builder --publish always --linux --arm64\",\n        \"publish:linux-arm64:alpha\": \"pnpm run build && electron-builder --config electron-builder-alpha.yml --publish always --linux --arm64\",\n        \"publish:linux-arm64:beta\": \"pnpm run build && electron-builder --config electron-builder-beta.yml --publish always --linux --arm64\",\n        \"publish:linux:alpha\": \"pnpm run build && electron-builder --config electron-builder-alpha.yml --publish always --linux\",\n        \"publish:linux:beta\": \"pnpm run build && electron-builder --config electron-builder-beta.yml --publish always --linux\",\n        \"publish:mac\": \"pnpm run build && electron-builder --publish always --mac\",\n        \"publish:mac:alpha\": \"pnpm run build && electron-builder --config electron-builder-alpha.yml --publish always --mac\",\n        \"publish:mac:beta\": \"pnpm run build && electron-builder --config electron-builder-beta.yml --publish always --mac\",\n        \"publish:win\": \"pnpm run build && electron-builder --publish always --win\",\n        \"publish:win-arm64\": \"pnpm run build && electron-builder --publish always --win --arm64\",\n        \"publish:win-arm64:alpha\": \"pnpm run build && electron-builder --config electron-builder-alpha.yml --publish always --win --arm64\",\n        \"publish:win-arm64:beta\": \"pnpm run build && electron-builder --config electron-builder-beta.yml --publish always --win --arm64\",\n        \"publish:win:alpha\": \"pnpm run build && electron-builder --config electron-builder-alpha.yml --publish always --win\",\n        \"publish:win:beta\": \"pnpm run build && electron-builder --config electron-builder-beta.yml --publish always --win\",\n        \"start\": \"electron-vite preview\",\n        \"typecheck\": \"pnpm run typecheck:node && pnpm run typecheck:web\",\n        \"typecheck:node\": \"tsc --noEmit -p tsconfig.node.json --composite false\",\n        \"typecheck:web\": \"tsc --noEmit -p tsconfig.web.json --composite false\",\n        \"version\": \"pnpm version --no-git-tag-version\",\n        \"postversion\": \"node ./scripts/update-app-stream.mjs\"\n    },\n    \"dependencies\": {\n        \"@atlaskit/pragmatic-drag-and-drop\": \"1.7.7\",\n        \"@atlaskit/pragmatic-drag-and-drop-auto-scroll\": \"^2.1.2\",\n        \"@atlaskit/pragmatic-drag-and-drop-hitbox\": \"^1.1.0\",\n        \"@electron-toolkit/preload\": \"^3.0.1\",\n        \"@electron-toolkit/utils\": \"^4.0.0\",\n        \"@mantine/colors-generator\": \"^8.3.8\",\n        \"@mantine/core\": \"^8.3.8\",\n        \"@mantine/dates\": \"^8.3.8\",\n        \"@mantine/form\": \"^8.3.8\",\n        \"@mantine/hooks\": \"^8.3.8\",\n        \"@mantine/modals\": \"^8.3.8\",\n        \"@mantine/notifications\": \"^8.3.8\",\n        \"@radix-ui/react-context-menu\": \"^2.2.16\",\n        \"@tanstack/react-query\": \"^5.90.9\",\n        \"@tanstack/react-query-devtools\": \"^5.90.2\",\n        \"@tanstack/react-query-persist-client\": \"^5.90.11\",\n        \"@ts-rest/core\": \"^3.52.1\",\n        \"@wavesurfer/react\": \"^1.0.11\",\n        \"@xhayper/discord-rpc\": \"^1.3.0\",\n        \"audiomotion-analyzer\": \"^4.5.1\",\n        \"axios\": \"^1.13.5\",\n        \"butterchurn\": \"^3.0.0-beta.5\",\n        \"butterchurn-presets\": \"^3.0.0-beta.4\",\n        \"cheerio\": \"^1.1.2\",\n        \"clsx\": \"^2.1.1\",\n        \"cmdk\": \"^1.1.1\",\n        \"dayjs\": \"^1.11.19\",\n        \"dompurify\": \"^3.3.0\",\n        \"electron-debug\": \"^3.2.0\",\n        \"electron-localshortcut\": \"^3.2.1\",\n        \"electron-log\": \"^5.4.3\",\n        \"electron-store\": \"^8.2.0\",\n        \"electron-updater\": \"^6.6.2\",\n        \"fast-average-color\": \"^9.5.0\",\n        \"fast-xml-parser\": \"^5.3.6\",\n        \"format-duration\": \"^3.0.2\",\n        \"fuse.js\": \"^7.1.0\",\n        \"i18next\": \"^25.6.2\",\n        \"icecast-metadata-stats\": \"^0.1.12\",\n        \"idb-keyval\": \"^6.2.2\",\n        \"immer\": \"^10.2.0\",\n        \"is-electron\": \"^2.2.2\",\n        \"lodash\": \"^4.17.23\",\n        \"md5\": \"^2.3.0\",\n        \"motion\": \"^12.23.24\",\n        \"mpris-service\": \"^2.1.2\",\n        \"nanoid\": \"^3.3.11\",\n        \"node-mpv\": \"github:jeffvli/Node-MPV#32b4d64395289ad710c41d481d2707a7acfc228f\",\n        \"nuqs\": \"^2.7.1\",\n        \"overlayscrollbars\": \"^2.11.1\",\n        \"overlayscrollbars-react\": \"^0.5.6\",\n        \"qs\": \"^6.14.2\",\n        \"react\": \"^19.1.0\",\n        \"react-call\": \"^1.8.1\",\n        \"react-dom\": \"^19.1.0\",\n        \"react-error-boundary\": \"^5.0.0\",\n        \"react-i18next\": \"^16.3.3\",\n        \"react-icons\": \"^5.5.0\",\n        \"react-player\": \"^2.16.0\",\n        \"react-router\": \"^7.13.1\",\n        \"react-split-pane\": \"^3.0.4\",\n        \"react-virtualized-auto-sizer\": \"^1.0.26\",\n        \"react-window\": \"1.8.11\",\n        \"react-window-v2\": \"npm:react-window@^2.2.3\",\n        \"semver\": \"^7.5.4\",\n        \"string-to-color\": \"^2.2.2\",\n        \"wavesurfer.js\": \"^7.11.1\",\n        \"ws\": \"^8.18.2\",\n        \"zod\": \"^3.22.3\",\n        \"zustand\": \"^5.0.5\"\n    },\n    \"devDependencies\": {\n        \"@electron-toolkit/eslint-config-prettier\": \"^3.0.0\",\n        \"@electron-toolkit/eslint-config-ts\": \"^3.0.0\",\n        \"@electron-toolkit/tsconfig\": \"^2.0.0\",\n        \"@types/electron-localshortcut\": \"^3.1.0\",\n        \"@types/lodash\": \"^4.17.18\",\n        \"@types/md5\": \"^2.3.5\",\n        \"@types/node\": \"^24.10.1\",\n        \"@types/react\": \"^19.2.5\",\n        \"@types/react-dom\": \"^19.2.3\",\n        \"@types/react-window\": \"^1.8.8\",\n        \"@types/source-map-support\": \"^0.5.10\",\n        \"@types/ws\": \"^8.18.1\",\n        \"@vitejs/plugin-react\": \"^5.1.1\",\n        \"concurrently\": \"^9.2.1\",\n        \"cross-env\": \"^10.1.0\",\n        \"electron\": \"^39.4.0\",\n        \"electron-builder\": \"^26.8.2\",\n        \"electron-devtools-installer\": \"^4.0.0\",\n        \"electron-vite\": \"^4.0.1\",\n        \"eslint\": \"^9.24.0\",\n        \"eslint-plugin-perfectionist\": \"^4.13.0\",\n        \"eslint-plugin-prettier\": \"^5.4.0\",\n        \"eslint-plugin-react\": \"^7.37.5\",\n        \"eslint-plugin-react-hooks\": \"^7.0.1\",\n        \"eslint-plugin-react-refresh\": \"^0.4.24\",\n        \"i18next-parser\": \"^9.3.0\",\n        \"postcss-preset-mantine\": \"^1.18.0\",\n        \"postcss-simple-vars\": \"^7.0.1\",\n        \"prettier\": \"^3.6.2\",\n        \"prettier-plugin-packagejson\": \"^2.5.19\",\n        \"stylelint\": \"^16.25.0\",\n        \"stylelint-config-css-modules\": \"^4.5.1\",\n        \"stylelint-config-recess-order\": \"^7.4.0\",\n        \"stylelint-config-standard\": \"^39.0.1\",\n        \"typescript\": \"^5.8.3\",\n        \"vite\": \"^7.2.2\",\n        \"vite-plugin-conditional-import\": \"^0.1.7\",\n        \"vite-plugin-dynamic-import\": \"^1.6.0\",\n        \"vite-plugin-ejs\": \"^1.7.0\",\n        \"vite-plugin-pwa\": \"^1.1.0\"\n    },\n    \"pnpm\": {\n        \"onlyBuiltDependencies\": [\n            \"electron\",\n            \"esbuild\"\n        ]\n    },\n    \"productName\": \"feishin\"\n}\n"
  },
  {
    "path": "postcss.config.cjs",
    "content": "module.exports = {\n    plugins: {\n        'postcss-preset-mantine': {},\n        'postcss-simple-vars': {\n            variables: {\n                'mantine-breakpoint-2xl': '120em',\n                'mantine-breakpoint-3xl': '160em',\n                'mantine-breakpoint-lg': '75em',\n                'mantine-breakpoint-md': '62em',\n                'mantine-breakpoint-sm': '48em',\n                'mantine-breakpoint-xl': '88em',\n                'mantine-breakpoint-xs': '36em',\n            },\n        },\n    },\n};\n"
  },
  {
    "path": "remote.vite.config.ts",
    "content": "import react from '@vitejs/plugin-react';\nimport path from 'path';\nimport { defineConfig, normalizePath } from 'vite';\nimport { ViteEjsPlugin } from 'vite-plugin-ejs';\n\nimport { version } from './package.json';\n\nexport default defineConfig({\n    build: {\n        cssMinify: 'esbuild',\n        emptyOutDir: true,\n        minify: 'esbuild',\n        outDir: path.resolve(__dirname, './out/remote'),\n        rollupOptions: {\n            input: {\n                favicon: normalizePath(path.resolve(__dirname, './assets/icons/favicon.ico')),\n                index: normalizePath(path.resolve(__dirname, './src/remote/index.html')),\n                manifest: normalizePath(path.resolve(__dirname, './src/remote/manifest.json')),\n                remote: normalizePath(path.resolve(__dirname, './src/remote/index.tsx')),\n                worker: normalizePath(path.resolve(__dirname, './src/remote/service-worker.ts')),\n            },\n            output: {\n                assetFileNames: '[name].[ext]',\n                chunkFileNames: '[name].js',\n                entryFileNames: '[name].js',\n                sourcemapExcludeSources: false,\n            },\n        },\n        sourcemap: true,\n    },\n    css: {\n        modules: {\n            generateScopedName: 'fs-[name]-[local]',\n            localsConvention: 'camelCase',\n        },\n    },\n    plugins: [\n        react(),\n        ViteEjsPlugin({\n            prod: process.env.NODE_ENV === 'production',\n            root: normalizePath(path.resolve(__dirname, './src/remote')),\n            version,\n        }),\n    ],\n    resolve: {\n        alias: {\n            '/@/i18n': path.resolve(__dirname, './src/i18n'),\n            '/@/remote': path.resolve(__dirname, './src/remote'),\n            '/@/renderer': path.resolve(__dirname, './src/renderer'),\n            '/@/shared': path.resolve(__dirname, './src/shared'),\n        },\n    },\n    root: path.resolve(__dirname, './src/remote'),\n});\n"
  },
  {
    "path": "scripts/after-all-artifact-build.mjs",
    "content": "import { execSync } from 'child_process';\nimport path from 'path';\nimport { fileURLToPath } from 'url';\n\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = path.dirname(__filename);\n\n/**\n * Electron-builder afterAllArtifactBuild hook\n * Runs the app stream update script only for Linux builds\n * Returns the metainfo file path to be included in published artifacts\n */\n\n// This is not a typescript file, and is called by electron-builder, so we cannot use typescript features here.\n// eslint-disable-next-line @typescript-eslint/explicit-function-return-type\nexport default async function afterAllArtifactBuild(buildResult) {\n    // Check if this build includes Linux as a target\n    const isLinux = Array.from(buildResult.platformToTargets.keys()).some(\n        (platform) => platform.name === 'linux',\n    );\n\n    if (isLinux) {\n        const updateScriptPath = path.join(__dirname, 'update-app-stream.mjs');\n        const projectRoot = path.resolve(__dirname, '..');\n        const metainfoFile = path.resolve(projectRoot, 'org.jeffvli.feishin.metainfo.xml');\n\n        console.log('Running app stream update for Linux build...');\n\n        try {\n            execSync(`node ${updateScriptPath} --replace-if-version-missing`, {\n                cwd: projectRoot,\n                stdio: 'inherit',\n            });\n\n            // Return the metainfo file to be included in published artifacts\n            return [metainfoFile];\n        } catch (error) {\n            console.error('Failed to update app stream:', error.message);\n            throw error;\n        }\n    }\n\n    // Return empty array if not a Linux build\n    return [];\n}\n"
  },
  {
    "path": "scripts/update-app-stream.mjs",
    "content": "import { XMLBuilder, XMLParser } from 'fast-xml-parser';\nimport fs from 'fs';\nimport path from 'path';\n\nconst args = process.argv.slice(2);\n\n// Parse flags and positional arguments\nconst flags = args.filter((arg) => arg.startsWith('--'));\nconst positionalArgs = args.filter((arg) => !arg.startsWith('--'));\nconst replaceIfVersionMissing = flags.includes('--replace-if-version-missing');\n\nif (positionalArgs.length > 3) {\n    console.error(\n        'Usage: node update-app-stream.js [package-file] [date] [metainfo-file] [--replace-if-version-missing]',\n    );\n    process.exit(1);\n}\n\nconst packageFile = positionalArgs[0] || path.resolve(process.cwd(), 'package.json');\n\nconst packageContent = fs.readFileSync(packageFile, 'utf8');\nconst packageJson = JSON.parse(packageContent);\nconst version = packageJson.version;\n\nconst time = Math.floor((Date.parse(positionalArgs[1]) || Date.now()) / 1000);\nconst metainfoFile =\n    positionalArgs[2] || path.resolve(process.cwd(), 'org.jeffvli.feishin.metainfo.xml');\n\nconst parser = new XMLParser({ ignoreAttributes: false });\nconst metainfoContent = fs.readFileSync(metainfoFile, 'utf8');\nconst metainfo = parser.parse(metainfoContent);\n\nconst newRelease = {\n    '@_date': new Date(time * 1000).toISOString().split('T')[0],\n    '@_type': version.includes('-') ? 'development' : 'stable',\n    '@_version': version,\n};\n\nif (replaceIfVersionMissing) {\n    // Replace all releases with only the current version\n    metainfo.component.releases.release = [newRelease];\n} else {\n    // Default behavior: add new release if it doesn't exist\n    const releaseExists =\n        metainfo.component.releases.release.findIndex(\n            (release) => release['@_version'] === version,\n        ) !== -1;\n    if (!releaseExists) {\n        metainfo.component.releases.release.unshift(newRelease);\n    }\n}\n\nconst builder = new XMLBuilder({ format: true, ignoreAttributes: false, indentBy: '  ' });\nfs.writeFileSync(metainfoFile, builder.build(metainfo), 'utf8');\n\nconsole.log(`Updated ${metainfoFile} with version ${version}`);\n"
  },
  {
    "path": "settings.js.template",
    "content": "\"use strict\";\n\nwindow.SERVER_URL = \"${SERVER_URL}\";\nwindow.REMOTE_URL = \"${REMOTE_URL}\";\nwindow.SERVER_NAME = \"${SERVER_NAME}\";\nwindow.SERVER_TYPE = \"${SERVER_TYPE}\";\nwindow.SERVER_LOCK = \"${SERVER_LOCK}\";\nwindow.LEGACY_AUTHENTICATION = \"${LEGACY_AUTHENTICATION}\";\nwindow.ANALYTICS_DISABLED = \"${ANALYTICS_DISABLED}\";\n\nwindow.FS_GENERAL_ACCENT = \"${FS_GENERAL_ACCENT}\";\nwindow.FS_GENERAL_ALBUM_BACKGROUND = \"${FS_GENERAL_ALBUM_BACKGROUND}\";\nwindow.FS_GENERAL_ALBUM_BACKGROUND_BLUR = \"${FS_GENERAL_ALBUM_BACKGROUND_BLUR}\";\nwindow.FS_GENERAL_ARTIST_BACKGROUND = \"${FS_GENERAL_ARTIST_BACKGROUND}\";\nwindow.FS_GENERAL_ARTIST_BACKGROUND_BLUR = \"${FS_GENERAL_ARTIST_BACKGROUND_BLUR}\";\nwindow.FS_GENERAL_BLUR_EXPLICIT_IMAGES = \"${FS_GENERAL_BLUR_EXPLICIT_IMAGES}\";\nwindow.FS_GENERAL_COMBINED_LYRICS_AND_VISUALIZER = \"${FS_GENERAL_COMBINED_LYRICS_AND_VISUALIZER}\";\nwindow.FS_GENERAL_ENABLE_GRID_MULTI_SELECT = \"${FS_GENERAL_ENABLE_GRID_MULTI_SELECT}\";\nwindow.FS_GENERAL_EXTERNAL_LINKS = \"${FS_GENERAL_EXTERNAL_LINKS}\";\nwindow.FS_GENERAL_FOLLOW_CURRENT_SONG = \"${FS_GENERAL_FOLLOW_CURRENT_SONG}\";\nwindow.FS_GENERAL_FOLLOW_SYSTEM_THEME = \"${FS_GENERAL_FOLLOW_SYSTEM_THEME}\";\nwindow.FS_GENERAL_HOME_FEATURE = \"${FS_GENERAL_HOME_FEATURE}\";\nwindow.FS_GENERAL_HOME_FEATURE_STYLE = \"${FS_GENERAL_HOME_FEATURE_STYLE}\";\nwindow.FS_GENERAL_LANGUAGE = \"${FS_GENERAL_LANGUAGE}\";\nwindow.FS_GENERAL_LAST_FM = \"${FS_GENERAL_LAST_FM}\";\nwindow.FS_GENERAL_LASTFM_API_KEY = \"${FS_GENERAL_LASTFM_API_KEY}\";\nwindow.FS_GENERAL_LISTEN_BRAINZ = \"${FS_GENERAL_LISTEN_BRAINZ}\";\nwindow.FS_GENERAL_MUSIC_BRAINZ = \"${FS_GENERAL_MUSIC_BRAINZ}\";\nwindow.FS_GENERAL_NATIVE_ASPECT_RATIO = \"${FS_GENERAL_NATIVE_ASPECT_RATIO}\";\nwindow.FS_GENERAL_PATH_REPLACE = \"${FS_GENERAL_PATH_REPLACE}\";\nwindow.FS_GENERAL_PATH_REPLACE_WITH = \"${FS_GENERAL_PATH_REPLACE_WITH}\";\nwindow.FS_GENERAL_PLAYERBAR_OPEN_DRAWER = \"${FS_GENERAL_PLAYERBAR_OPEN_DRAWER}\";\nwindow.FS_GENERAL_PRIMARY_SHADE = \"${FS_GENERAL_PRIMARY_SHADE}\";\nwindow.FS_GENERAL_QOBUZ = \"${FS_GENERAL_QOBUZ}\";\nwindow.FS_GENERAL_RESUME = \"${FS_GENERAL_RESUME}\";\nwindow.FS_GENERAL_SHOW_LYRICS_IN_SIDEBAR = \"${FS_GENERAL_SHOW_LYRICS_IN_SIDEBAR}\";\nwindow.FS_GENERAL_SHOW_RATINGS = \"${FS_GENERAL_SHOW_RATINGS}\";\nwindow.FS_GENERAL_SHOW_VISUALIZER_IN_SIDEBAR = \"${FS_GENERAL_SHOW_VISUALIZER_IN_SIDEBAR}\";\nwindow.FS_GENERAL_SIDEBAR_COLLAPSED_NAVIGATION = \"${FS_GENERAL_SIDEBAR_COLLAPSED_NAVIGATION}\";\nwindow.FS_GENERAL_SIDEBAR_COLLAPSE_SHARED = \"${FS_GENERAL_SIDEBAR_COLLAPSE_SHARED}\";\nwindow.FS_GENERAL_SIDEBAR_PLAYLIST_LIST = \"${FS_GENERAL_SIDEBAR_PLAYLIST_LIST}\";\nwindow.FS_GENERAL_SIDEBAR_PLAYLIST_SORTING = \"${FS_GENERAL_SIDEBAR_PLAYLIST_SORTING}\";\nwindow.FS_GENERAL_SIDE_QUEUE_TYPE = \"${FS_GENERAL_SIDE_QUEUE_TYPE}\";\nwindow.FS_GENERAL_SIDE_QUEUE_LAYOUT = \"${FS_GENERAL_SIDE_QUEUE_LAYOUT}\";\nwindow.FS_GENERAL_THEME = \"${FS_GENERAL_THEME}\";\nwindow.FS_GENERAL_THEME_DARK = \"${FS_GENERAL_THEME_DARK}\";\nwindow.FS_GENERAL_THEME_LIGHT = \"${FS_GENERAL_THEME_LIGHT}\";\nwindow.FS_GENERAL_USE_THEME_ACCENT_COLOR = \"${FS_GENERAL_USE_THEME_ACCENT_COLOR}\";\nwindow.FS_GENERAL_USE_THEME_PRIMARY_SHADE = \"${FS_GENERAL_USE_THEME_PRIMARY_SHADE}\";\nwindow.FS_GENERAL_ZOOM_FACTOR = \"${FS_GENERAL_ZOOM_FACTOR}\";\n\nwindow.FS_PLAYBACK_MEDIA_SESSION = \"${FS_PLAYBACK_MEDIA_SESSION}\";\nwindow.FS_PLAYBACK_WEB_AUDIO = \"${FS_PLAYBACK_WEB_AUDIO}\";\nwindow.FS_PLAYBACK_AUDIO_FADE_ON_STATUS_CHANGE = \"${FS_PLAYBACK_AUDIO_FADE_ON_STATUS_CHANGE}\";\nwindow.FS_PLAYBACK_PRESERVE_PITCH = \"${FS_PLAYBACK_PRESERVE_PITCH}\";\nwindow.FS_PLAYBACK_SCROBBLE_ENABLED = \"${FS_PLAYBACK_SCROBBLE_ENABLED}\";\nwindow.FS_PLAYBACK_SCROBBLE_NOTIFY = \"${FS_PLAYBACK_SCROBBLE_NOTIFY}\";\nwindow.FS_PLAYBACK_SCROBBLE_AT_DURATION = \"${FS_PLAYBACK_SCROBBLE_AT_DURATION}\";\nwindow.FS_PLAYBACK_SCROBBLE_AT_PERCENTAGE = \"${FS_PLAYBACK_SCROBBLE_AT_PERCENTAGE}\";\nwindow.FS_PLAYBACK_TRANSCODE_ENABLED = \"${FS_PLAYBACK_TRANSCODE_ENABLED}\";\n\nwindow.FS_DISCORD_ENABLED = \"${FS_DISCORD_ENABLED}\";\nwindow.FS_DISCORD_CLIENT_ID = \"${FS_DISCORD_CLIENT_ID}\";\nwindow.FS_DISCORD_DISPLAY_TYPE = \"${FS_DISCORD_DISPLAY_TYPE}\";\nwindow.FS_DISCORD_LINK_TYPE = \"${FS_DISCORD_LINK_TYPE}\";\nwindow.FS_DISCORD_SHOW_AS_LISTENING = \"${FS_DISCORD_SHOW_AS_LISTENING}\";\nwindow.FS_DISCORD_SHOW_PAUSED = \"${FS_DISCORD_SHOW_PAUSED}\";\nwindow.FS_DISCORD_SHOW_SERVER_IMAGE = \"${FS_DISCORD_SHOW_SERVER_IMAGE}\";\nwindow.FS_DISCORD_SHOW_STATE_ICON = \"${FS_DISCORD_SHOW_STATE_ICON}\";\n\nwindow.FS_LYRICS_FETCH = \"${FS_LYRICS_FETCH}\";\nwindow.FS_LYRICS_FOLLOW = \"${FS_LYRICS_FOLLOW}\";\nwindow.FS_LYRICS_DELAY_MS = \"${FS_LYRICS_DELAY_MS}\";\nwindow.FS_LYRICS_PREFER_LOCAL = \"${FS_LYRICS_PREFER_LOCAL}\";\nwindow.FS_LYRICS_SHOW_MATCH = \"${FS_LYRICS_SHOW_MATCH}\";\nwindow.FS_LYRICS_SHOW_PROVIDER = \"${FS_LYRICS_SHOW_PROVIDER}\";\nwindow.FS_LYRICS_ENABLE_AUTO_TRANSLATION = \"${FS_LYRICS_ENABLE_AUTO_TRANSLATION}\";\nwindow.FS_LYRICS_TRANSLATION_API_KEY = \"${FS_LYRICS_TRANSLATION_API_KEY}\";\nwindow.FS_LYRICS_TRANSLATION_TARGET_LANGUAGE = \"${FS_LYRICS_TRANSLATION_TARGET_LANGUAGE}\";\nwindow.FS_LYRICS_ALIGNMENT = \"${FS_LYRICS_ALIGNMENT}\";\n\nwindow.FS_AUTO_DJ_ENABLED = \"${FS_AUTO_DJ_ENABLED}\";\nwindow.FS_AUTO_DJ_ITEM_COUNT = \"${FS_AUTO_DJ_ITEM_COUNT}\";\nwindow.FS_AUTO_DJ_TIMING = \"${FS_AUTO_DJ_TIMING}\";\n\nwindow.FS_CSS_CONTENT = \"${FS_CSS_CONTENT}\";\nwindow.FS_CSS_ENABLED = \"${FS_CSS_ENABLED}\";\nwindow.FS_FONT_TYPE = \"${FS_FONT_TYPE}\";\nwindow.FS_FONT_BUILT_IN = \"${FS_FONT_BUILT_IN}\";\nwindow.FS_FONT_SYSTEM = \"${FS_FONT_SYSTEM}\";\n"
  },
  {
    "path": "src/i18n/i18n.ts",
    "content": "import { PostProcessorModule, TOptions } from 'i18next';\nimport i18n from 'i18next';\nimport { initReactI18next } from 'react-i18next';\n\nimport ar from './locales/ar.json';\nimport ca from './locales/ca.json';\nimport cs from './locales/cs.json';\nimport de from './locales/de.json';\nimport en from './locales/en.json';\nimport es from './locales/es.json';\nimport eu from './locales/eu.json';\nimport fa from './locales/fa.json';\nimport fi from './locales/fi.json';\nimport fr from './locales/fr.json';\nimport hu from './locales/hu.json';\nimport id from './locales/id.json';\nimport it from './locales/it.json';\nimport ja from './locales/ja.json';\nimport ko from './locales/ko.json';\nimport nbNO from './locales/nb-NO.json';\nimport nl from './locales/nl.json';\nimport pl from './locales/pl.json';\nimport ptBr from './locales/pt-BR.json';\nimport pt from './locales/pt.json';\nimport ru from './locales/ru.json';\nimport sl from './locales/sl.json';\nimport sr from './locales/sr.json';\nimport sv from './locales/sv.json';\nimport ta from './locales/ta.json';\nimport tr from './locales/tr.json';\nimport zhHans from './locales/zh-Hans.json';\nimport zhHant from './locales/zh-Hant.json';\n\nconst resources = {\n    ar: { translation: ar },\n    ca: { translation: ca },\n    cs: { translation: cs },\n    de: { translation: de },\n    en: { translation: en },\n    es: { translation: es },\n    eu: { translation: eu },\n    fa: { translation: fa },\n    fi: { translation: fi },\n    fr: { translation: fr },\n    hu: { translation: hu },\n    id: { translation: id },\n    it: { translation: it },\n    ja: { translation: ja },\n    ko: { translation: ko },\n    'nb-NO': { translation: nbNO },\n    nl: { translation: nl },\n    pl: { translation: pl },\n    pt: { translation: pt },\n    'pt-BR': { translation: ptBr },\n    ru: { translation: ru },\n    sl: { translation: sl },\n    sr: { translation: sr },\n    sv: { translation: sv },\n    ta: { translation: ta },\n    tr: { translation: tr },\n    'zh-Hans': { translation: zhHans },\n    'zh-Hant': { translation: zhHant },\n};\n\nexport const languages = [\n    {\n        label: 'English',\n        value: 'en',\n    },\n    {\n        label: 'العربية',\n        value: 'ar',\n    },\n    {\n        label: 'Català',\n        value: 'ca',\n    },\n    {\n        label: 'Čeština',\n        value: 'cs',\n    },\n    {\n        label: 'Deutsch',\n        value: 'de',\n    },\n    {\n        label: 'Español',\n        value: 'es',\n    },\n    {\n        label: 'Basque',\n        value: 'eu',\n    },\n    {\n        label: 'Français',\n        value: 'fr',\n    },\n    {\n        label: 'Bahasa Indonesia',\n        value: 'id',\n    },\n    {\n        label: 'Suomeksi',\n        value: 'fi',\n    },\n    {\n        label: 'Magyar',\n        value: 'hu',\n    },\n    {\n        label: 'Italiano',\n        value: 'it',\n    },\n    {\n        label: '日本語',\n        value: 'ja',\n    },\n    {\n        label: '한국어',\n        value: 'ko',\n    },\n    {\n        label: 'Nederlands',\n        value: 'nl',\n    },\n    {\n        label: 'Norsk (Bokmål)',\n        value: 'nb-NO',\n    },\n    {\n        label: 'فارسی',\n        value: 'fa',\n    },\n    {\n        label: 'Português',\n        value: 'pt',\n    },\n    {\n        label: 'Português (Brasil)',\n        value: 'pt-BR',\n    },\n    {\n        label: 'Polski',\n        value: 'pl',\n    },\n    {\n        label: 'Русский',\n        value: 'ru',\n    },\n    {\n        label: 'Slovenščina',\n        value: 'sl',\n    },\n    {\n        label: 'Srpski',\n        value: 'sr',\n    },\n    {\n        label: 'Svenska',\n        value: 'sv',\n    },\n    {\n        label: 'Tamil',\n        value: 'ta',\n    },\n    {\n        label: 'Türkçe',\n        value: 'tr',\n    },\n    {\n        label: '简体中文',\n        value: 'zh-Hans',\n    },\n    {\n        label: '繁體中文',\n        value: 'zh-Hant',\n    },\n];\n\nconst lowerCasePostProcessor: PostProcessorModule = {\n    name: 'lowerCase',\n    process: (value: string) => {\n        return value.toLocaleLowerCase();\n    },\n    type: 'postProcessor',\n};\n\nconst upperCasePostProcessor: PostProcessorModule = {\n    name: 'upperCase',\n    process: (value: string) => {\n        return value.toLocaleUpperCase();\n    },\n    type: 'postProcessor',\n};\n\nconst titleCasePostProcessor: PostProcessorModule = {\n    name: 'titleCase',\n    process: (value: string) => {\n        return value.replace(/\\S\\S*/g, (txt) => {\n            return txt.charAt(0).toLocaleUpperCase() + txt.slice(1).toLowerCase();\n        });\n    },\n    type: 'postProcessor',\n};\n\nconst ignoreSentenceCaseLanguages = ['de'];\n\nconst sentenceCasePostProcessor: PostProcessorModule = {\n    name: 'sentenceCase',\n    process: (\n        value: string,\n        _key: string,\n        _options: TOptions<Record<string, string>>,\n        translator: any,\n    ) => {\n        const sentences = value.split('. ');\n\n        return sentences\n            .map((sentence) => {\n                return (\n                    sentence.charAt(0).toLocaleUpperCase() +\n                    (!ignoreSentenceCaseLanguages.includes(translator.language)\n                        ? sentence.slice(1).toLocaleLowerCase()\n                        : sentence.slice(1))\n                );\n            })\n            .join('. ');\n    },\n    type: 'postProcessor',\n};\ni18n.use(lowerCasePostProcessor)\n    .use(upperCasePostProcessor)\n    .use(titleCasePostProcessor)\n    .use(sentenceCasePostProcessor)\n    .use(initReactI18next) // passes i18n down to react-i18next\n    .init({\n        fallbackLng: 'en',\n        // language to use, more information here: https://www.i18next.com/overview/configuration-options#languages-namespaces-resources\n        // you can use the i18n.changeLanguage function to change the language manually: https://www.i18next.com/overview/api#changelanguage\n        // if you're using a language detector, do not define the lng option\n        interpolation: {\n            escapeValue: false, // react already safes from xss\n        },\n        resources,\n    });\n\nexport default i18n;\n"
  },
  {
    "path": "src/i18n/i18next-parser.config.js",
    "content": "// Reference: https://github.com/i18next/i18next-parser#options\n\nmodule.exports = {\n    contextSeparator: '_',\n    createOldCatalogs: true,\n    customValueTemplate: null,\n    defaultNamespace: 'translation',\n    defaultValue: function (locale, namespace, key) {\n        return key;\n    },\n    failOnUpdate: false,\n    failOnWarnings: false,\n    i18nextOptions: null,\n    indentation: 4,\n    input: [\n        '../renderer/components/**/*.{js,jsx,ts,tsx}',\n        '../renderer/features/**/*.{js,jsx,ts,tsx}',\n        '../renderer/layouts/**/*.{js,jsx,ts,tsx}',\n        '!../src/node_modules/**',\n        '!../src/**/*.prod.js',\n    ],\n    keepRemoved: false,\n    keySeparator: '.',\n    lexers: {\n        default: ['JavascriptLexer'],\n        handlebars: ['HandlebarsLexer'],\n        hbs: ['HandlebarsLexer'],\n        htm: ['HTMLLexer'],\n        html: ['HTMLLexer'],\n        js: ['JavascriptLexer'],\n        jsx: ['JsxLexer'],\n        mjs: ['JavascriptLexer'],\n        ts: ['JavascriptLexer'],\n        tsx: ['JsxLexer'],\n    },\n    lineEnding: 'auto',\n    locales: ['en'],\n    namespaceSeparator: false,\n    output: 'src/renderer/i18n/locales/$LOCALE.json',\n    pluralSeparator: '_',\n    resetDefaultValueLocale: 'en',\n    sort: true,\n    verbose: false,\n};\n"
  },
  {
    "path": "src/i18n/locales/ar.json",
    "content": "{\n    \"action\": {\n        \"addToFavorites\": \"إضافة الى $t(entity.favorite, {\\\"count\\\": 2})\",\n        \"addToPlaylist\": \"إضافة الى $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"clearQueue\": \"مسح قائمة الإنتظار\",\n        \"createPlaylist\": \"إنشاء $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"deletePlaylist\": \"حذف $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"deselectAll\": \"إلغاء تحديد الكل\",\n        \"editPlaylist\": \"تعديل $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"goToPage\": \"اذهب الى صفحة\",\n        \"moveToNext\": \"الذهاب الى التالي\",\n        \"moveToBottom\": \"الذهاب الى الأسفل\",\n        \"moveToTop\": \"الذهاب الى الأعلى\",\n        \"refresh\": \"$t(common.refresh)\",\n        \"removeFromFavorites\": \"حذف من $t(entity.favorite, {\\\"count\\\": 2})\",\n        \"removeFromPlaylist\": \"حذف من $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"removeFromQueue\": \"حذف من قائمة الإنتظار\",\n        \"setRating\": \"تحديد التقييم\",\n        \"toggleSmartPlaylistEditor\": \"تشغيل / إطفاء وضع التعديل لـ $t(entity.smartPlaylist)\",\n        \"viewPlaylists\": \"إظهار $t(entity.playlist, {\\\"count\\\": 2})\",\n        \"openIn\": {\n            \"lastfm\": \"فتح في Last.fm\",\n            \"musicbrainz\": \"فتح في MusicBrainz\"\n        },\n        \"addOrRemoveFromSelection\": \"إضافة أو إزالة من الإختيارات\",\n        \"selectRangeOfItems\": \"اختر مجموعة من العناصر\",\n        \"goToCurrent\": \"الانتقال إلى العنصر الحالي\",\n        \"createRadioStation\": \"يخلق $t(entity.radioStation, {\\\"count\\\": 1})\",\n        \"deleteRadioStation\": \"يمسح $t(entity.radioStation, {\\\"count\\\": 1})\",\n        \"selectAll\": \"تحديد الكل\"\n    },\n    \"common\": {\n        \"action_zero\": \"عملية\",\n        \"action_one\": \"عملية\",\n        \"action_two\": \"عمليتين\",\n        \"action_few\": \"عمليات\",\n        \"action_many\": \"عمليات\",\n        \"action_other\": \"عمليات\",\n        \"add\": \"إضافة\",\n        \"additionalParticipants\": \"مشاركين إضافيين\",\n        \"newVersion\": \"تم تثبيت تحديث جديد {{version}}\",\n        \"viewReleaseNotes\": \"عرض معلومات الإصدار\",\n        \"albumGain\": \"مستوى صوت الألبوم\",\n        \"albumPeak\": \"اعلى مستوى للألبوم\",\n        \"areYouSure\": \"هل أنت متأكد؟\",\n        \"ascending\": \"تصاعدي\",\n        \"backward\": \"خلف\",\n        \"biography\": \"سيرة\",\n        \"bitDepth\": \"عمق البت\",\n        \"bitrate\": \"معدل البت (البت ريت)\",\n        \"bpm\": \"نبضة في الدقيقة\",\n        \"cancel\": \"إلغاء\",\n        \"center\": \"منتصف\",\n        \"channel_zero\": \"قناة\",\n        \"channel_one\": \"قناة\",\n        \"channel_two\": \"قناتين\",\n        \"channel_few\": \"قنوات\",\n        \"channel_many\": \"قنوات\",\n        \"channel_other\": \"قنوات\",\n        \"clear\": \"مسح\",\n        \"close\": \"إغلاق\",\n        \"codec\": \"كوديك\",\n        \"collapse\": \"طي\",\n        \"comingSoon\": \"قريبًا…\",\n        \"configure\": \"تعديل\",\n        \"confirm\": \"تأكيد\",\n        \"create\": \"إنشاء\",\n        \"currentSong\": \"$t(entity.track, {\\\"count\\\": 1}) الحالي\",\n        \"decrease\": \"تنقيص\",\n        \"delete\": \"حذف\",\n        \"descending\": \"تنازلي\",\n        \"description\": \"وصف\",\n        \"disable\": \"تعطيل\",\n        \"disc\": \"قرص\",\n        \"dismiss\": \"إخفاء\",\n        \"duration\": \"مدة\",\n        \"edit\": \"تعديل\",\n        \"enable\": \"تفعيل\",\n        \"expand\": \"توسيع\",\n        \"favorite\": \"مفضلة\",\n        \"filter_zero\": \"فلتر\",\n        \"filter_one\": \"فلتر\",\n        \"filter_two\": \"فلاتر\",\n        \"filter_few\": \"فلاتر\",\n        \"filter_many\": \"فلاتر\",\n        \"filter_other\": \"فلاتر\",\n        \"filters\": \"فلاتر\",\n        \"forceRestartRequired\": \"اعد التشغيل لتطبيق التعديلات... اغلق التنبية لإعادة التشغيل\",\n        \"forward\": \"امام\",\n        \"gap\": \"فجوة\",\n        \"home\": \"الرئيسية\",\n        \"increase\": \"زيادة\",\n        \"left\": \"يسار\",\n        \"limit\": \"حد\",\n        \"manage\": \"إدارة\",\n        \"maximize\": \"تكبير\",\n        \"menu\": \"القائمة\",\n        \"minimize\": \"تصغير\",\n        \"modified\": \"تم تعديله\",\n        \"mbid\": \"معرف MusicBrainz\",\n        \"name\": \"إسم\",\n        \"no\": \"لا\",\n        \"none\": \"لا شي\",\n        \"noResultsFromQuery\": \"لا توجد نتائج\",\n        \"note\": \"ملاحظة\",\n        \"ok\": \"نعم\",\n        \"owner\": \"المالك\",\n        \"path\": \"المسار\",\n        \"playerMustBePaused\": \"يجب إيقاف المشغل\",\n        \"preview\": \"معاينة\",\n        \"previousSong\": \"$t(entity.track, {\\\"count\\\": 1}) السابق\",\n        \"quit\": \"خروج\",\n        \"random\": \"عشوائي\",\n        \"rating\": \"التقييم\",\n        \"refresh\": \"تحديث\",\n        \"reload\": \"تحديث\",\n        \"reset\": \"إعادة تعيين\",\n        \"resetToDefault\": \"إعادة تعيين الى الافتراضي\",\n        \"restartRequired\": \"يجب إعادة التشغيل\",\n        \"right\": \"يمين\",\n        \"sampleRate\": \"معدل العينة (sample rate)\",\n        \"save\": \"حفظ\",\n        \"saveAndReplace\": \"حفظ واستبدال\",\n        \"saveAs\": \"حفظ بإسم\",\n        \"search\": \"بحث\",\n        \"setting_zero\": \"إعداد\",\n        \"setting_one\": \"\",\n        \"setting_two\": \"\",\n        \"setting_few\": \"\",\n        \"setting_many\": \"\",\n        \"setting_other\": \"\",\n        \"share\": \"نشر\",\n        \"size\": \"حجم\",\n        \"sortOrder\": \"الترتيب\",\n        \"tags\": \"العلامات\",\n        \"title\": \"العنوان\",\n        \"trackNumber\": \"رقم المسار\",\n        \"trackGain\": \"مستوى صوت المسار\",\n        \"trackPeak\": \"اعلى مستوى للمسار\",\n        \"translation\": \"الترجمة\",\n        \"unknown\": \"غير معروف\",\n        \"version\": \"الإصدار\",\n        \"year\": \"السنة\",\n        \"yes\": \"نعم\"\n    },\n    \"entity\": {\n        \"album_zero\": \"الالبوم\",\n        \"album_one\": \"الالبوم\",\n        \"album_two\": \"الالبومين\",\n        \"album_few\": \"الالبومات\",\n        \"album_many\": \"الالبومات\",\n        \"album_other\": \"الالبومات\",\n        \"albumArtist_zero\": \"فنان الالبوم\",\n        \"albumArtist_one\": \"فنان الالبوم\",\n        \"albumArtist_two\": \"فنان الالبومين\",\n        \"albumArtist_few\": \"فنان الالبومات\",\n        \"albumArtist_many\": \"فنان الالبومات\",\n        \"albumArtist_other\": \"فنان الالبومات\"\n    }\n}\n"
  },
  {
    "path": "src/i18n/locales/ca.json",
    "content": "{\n    \"page\": {\n        \"sidebar\": {\n            \"myLibrary\": \"La meva llibreria\",\n            \"albumArtists\": \"$t(entity.albumArtist, {\\\"count\\\": 2})\",\n            \"albums\": \"$t(entity.album, {\\\"count\\\": 2})\",\n            \"artists\": \"$t(entity.artist, {\\\"count\\\": 2})\",\n            \"folders\": \"$t(entity.folder, {\\\"count\\\": 2})\",\n            \"genres\": \"$t(entity.genre, {\\\"count\\\": 2})\",\n            \"home\": \"$t(common.home)\",\n            \"playlists\": \"$t(entity.playlist, {\\\"count\\\": 2})\",\n            \"search\": \"$t(common.search)\",\n            \"settings\": \"$t(common.setting, {\\\"count\\\": 2})\",\n            \"tracks\": \"$t(entity.track, {\\\"count\\\": 2})\",\n            \"nowPlaying\": \"s'està reproduint\",\n            \"shared\": \"$t(entity.playlist, {\\\"count\\\": 2}) compartides\",\n            \"favorites\": \"$t(entity.favorite, {\\\"count\\\": 2})\",\n            \"radio\": \"$t(entity.radioStation, {\\\"count\\\": 2})\",\n            \"collections\": \"col·leccions\"\n        },\n        \"albumArtistDetail\": {\n            \"relatedArtists\": \"$t(entity.artist, {\\\"count\\\": 2}) similars\",\n            \"viewAllTracks\": \"mostra totes les $t(entity.track, {\\\"count\\\": 2})\",\n            \"about\": \"Sobre {{artist}}\",\n            \"appearsOn\": \"apareix a\",\n            \"recentReleases\": \"Llançaments recents\",\n            \"viewDiscography\": \"Mosta la discografia\",\n            \"topSongs\": \"millors cançons\",\n            \"topSongsFrom\": \"les millors cançons de {{title}}\",\n            \"viewAll\": \"mostra-ho tot\",\n            \"groupingTypeAll\": \"tots els tipus de llançaments\",\n            \"groupingTypePrimary\": \"tipus principals de llançament\",\n            \"favoriteSongs\": \"Cançons preferides\",\n            \"topSongsCommunity\": \"comunitat\",\n            \"topSongsPersonal\": \"personal\",\n            \"favoriteSongsFrom\": \"cançons preferides de {{title}}\"\n        },\n        \"albumArtistList\": {\n            \"title\": \"$t(entity.albumArtist, {\\\"count\\\": 2})\"\n        },\n        \"albumDetail\": {\n            \"moreFromArtist\": \"més d'aquest $t(entity.artist, {\\\"count\\\": 1})\",\n            \"moreFromGeneric\": \"més de {{item}}\",\n            \"released\": \"publicat\"\n        },\n        \"albumList\": {\n            \"title\": \"$t(entity.album, {\\\"count\\\": 2})\",\n            \"artistAlbums\": \"àlbums de {{artist}}\",\n            \"genreAlbums\": \"\\\"{{genre}}\\\" $t(entity.album, {\\\"count\\\": 2})\"\n        },\n        \"appMenu\": {\n            \"quit\": \"$t(common.quit)\",\n            \"settings\": \"$t(common.setting, {\\\"count\\\": 2})\",\n            \"goBack\": \"torna enrere\",\n            \"goForward\": \"avança\",\n            \"collapseSidebar\": \"replega la barra lateral\",\n            \"expandSidebar\": \"expandeix la barra lateral\",\n            \"manageServers\": \"gestionar servidors\",\n            \"selectServer\": \"seleccionar servidor\",\n            \"version\": \"versió {{version}}\",\n            \"openBrowserDevtools\": \"obre les eines de desenvolupament del navegador\",\n            \"privateModeOff\": \"desactiva el mode privat\",\n            \"privateModeOn\": \"activa el mode privat\",\n            \"commandPalette\": \"obre la paleta d'ordres\",\n            \"selectMusicFolder\": \"selecciona una carpeta de música\",\n            \"noMusicFolder\": \"no s'ha seleccionat cap carpeta de música\",\n            \"multipleMusicFolders\": \"{{count}} carpetes de música seleccionades\"\n        },\n        \"contextMenu\": {\n            \"addFavorite\": \"$t(action.addToFavorites)\",\n            \"addLast\": \"$t(player.addLast)\",\n            \"addNext\": \"$t(player.addNext)\",\n            \"addToFavorites\": \"$t(action.addToFavorites)\",\n            \"addToPlaylist\": \"$t(action.addToPlaylist)\",\n            \"createPlaylist\": \"$t(action.createPlaylist)\",\n            \"deletePlaylist\": \"$t(action.deletePlaylist)\",\n            \"deselectAll\": \"$t(action.deselectAll)\",\n            \"moveToNext\": \"$t(action.moveToNext)\",\n            \"moveToBottom\": \"$t(action.moveToBottom)\",\n            \"moveToTop\": \"$t(action.moveToTop)\",\n            \"play\": \"$t(player.play)\",\n            \"playSimilarSongs\": \"$t(player.playSimilarSongs)\",\n            \"removeFromFavorites\": \"$t(action.removeFromFavorites)\",\n            \"removeFromPlaylist\": \"$t(action.removeFromPlaylist)\",\n            \"removeFromQueue\": \"$t(action.removeFromQueue)\",\n            \"setRating\": \"$t(action.setRating)\",\n            \"playShuffled\": \"$t(player.shuffle)\",\n            \"download\": \"descarregar\",\n            \"showDetails\": \"informació\",\n            \"numberSelected\": \"{{count}} seleccionat\",\n            \"shareItem\": \"comparteix l'element\",\n            \"goToAlbumArtist\": \"Ves a $t(entity.albumArtist, {\\\"count\\\": 1})\",\n            \"goToAlbum\": \"ves a $t(entity.album, {\\\"count\\\": 1})\",\n            \"moveItems\": \"$t(action.moveItems)\",\n            \"goTo\": \"ves a\"\n        },\n        \"genreList\": {\n            \"title\": \"$t(entity.genre, {\\\"count\\\": 2})\",\n            \"showAlbums\": \"mostra $t(entity.album, {\\\"count\\\": 2}) de $t(entity.genre, {\\\"count\\\": 1})\",\n            \"showTracks\": \"mostra $t(entity.track, {\\\"count\\\": 2}) de $t(entity.genre, {\\\"count\\\": 1})\"\n        },\n        \"home\": {\n            \"title\": \"$t(common.home)\",\n            \"explore\": \"explora la teva biblioteca\",\n            \"newlyAdded\": \"afegits recentment\",\n            \"mostPlayed\": \"els més reproduïts\",\n            \"recentlyPlayed\": \"reproduït recentment\",\n            \"recentlyReleased\": \"estrenat fa poc\",\n            \"genres\": \"$t(entity.genre, {\\\"count\\\": 2})\"\n        },\n        \"playlistList\": {\n            \"title\": \"$t(entity.playlist, {\\\"count\\\": 2})\"\n        },\n        \"trackList\": {\n            \"title\": \"$t(entity.track, {\\\"count\\\": 2})\",\n            \"artistTracks\": \"pistes de {{artist}}\",\n            \"genreTracks\": \"$t(entity.track, {\\\"count\\\": 2}) \\\"{{genre}}\\\"\"\n        },\n        \"manageServers\": {\n            \"username\": \"nom d'usuari\",\n            \"title\": \"gestionar servidors\",\n            \"serverDetails\": \"detalls del servidor\",\n            \"editServerDetailsTooltip\": \"editar els detalls del servidor\",\n            \"removeServer\": \"eliminar el servidor\",\n            \"url\": \"URL\"\n        },\n        \"fullscreenPlayer\": {\n            \"config\": {\n                \"opacity\": \"opacitat\",\n                \"synchronized\": \"sincronitzat\",\n                \"unsynchronized\": \"no sincronitzat\",\n                \"useImageAspectRatio\": \"utilitza la relació d'aspecte de la imatge\",\n                \"dynamicBackground\": \"fons dinàmic\",\n                \"dynamicIsImage\": \"activar la imatge de fons\",\n                \"followCurrentLyric\": \"seguir la lletra actual\",\n                \"lyricAlignment\": \"alineació de la lletra\",\n                \"lyricSize\": \"tamany de la lletra\",\n                \"dynamicImageBlur\": \"mida del desenfocament de la imatge\",\n                \"lyricOffset\": \"demora de la lletra (ms)\",\n                \"showLyricMatch\": \"mosta coincidències de lletres\",\n                \"showLyricProvider\": \"mostra el proveïdor de la lletra\",\n                \"lyricGap\": \"espera entre lletres\"\n            },\n            \"lyrics\": \"lletres\",\n            \"visualizer\": \"visualitzador\",\n            \"noLyrics\": \"no s'ha trobat cap lletra\",\n            \"related\": \"relacionat\",\n            \"upNext\": \"a continuació\"\n        },\n        \"setting\": {\n            \"advanced\": \"avançat\",\n            \"generalTab\": \"general\",\n            \"hotkeysTab\": \"tecles d'accés ràpid\",\n            \"playbackTab\": \"reproducció\",\n            \"windowTab\": \"finestra\",\n            \"analytics\": \"analítiques\",\n            \"updates\": \"actualitza\",\n            \"cache\": \"memòria cau\",\n            \"application\": \"aplicació\",\n            \"queryBuilder\": \"constructor de consultes\",\n            \"theme\": \"tema\",\n            \"controls\": \"controls\",\n            \"sidebar\": \"barra lateral\",\n            \"remote\": \"remot\",\n            \"exportImport\": \"importa/exporta\",\n            \"scrobble\": \"scrobble\",\n            \"audio\": \"àudio\",\n            \"lyrics\": \"lletra\",\n            \"transcoding\": \"transcodificació\",\n            \"discord\": \"discord\",\n            \"logger\": \"registres\",\n            \"playerFilters\": \"filtres de reproducció\",\n            \"lyricsDisplay\": \"mostra la lletra\"\n        },\n        \"globalSearch\": {\n            \"commands\": {\n                \"goToPage\": \"anar a la pàgina\",\n                \"searchFor\": \"cerca {{query}}\",\n                \"serverCommands\": \"ordres del servidor\"\n            },\n            \"title\": \"ordres\"\n        },\n        \"itemDetail\": {\n            \"copyPath\": \"copia ruta al porta-retalls\",\n            \"copiedPath\": \"ruta copiada correctament\",\n            \"openFile\": \"mostra la pista al gestor d'arxius\"\n        },\n        \"playlist\": {\n            \"reorder\": \"el reordenament només s'activa quan s'ordena per id\"\n        },\n        \"favorites\": {\n            \"title\": \"$t(entity.favorite, {\\\"count\\\": 2})\"\n        },\n        \"folderList\": {\n            \"title\": \"$t(entity.folder, {\\\"count\\\": 2})\"\n        },\n        \"radioList\": {\n            \"title\": \"emissores de ràdio\"\n        },\n        \"windowBar\": {\n            \"paused\": \"(en pausa) \",\n            \"privateMode\": \"(mode privat)\"\n        },\n        \"collections\": {\n            \"overrideExisting\": \"sobreescriu existents\",\n            \"saveAsCollection\": \"desa com a col·lecció\"\n        },\n        \"releasenotes\": {\n            \"commitsSinceStable\": \"commits des de {{stable}}\",\n            \"noNewCommits\": \"no hi ha hagut commits en aquest període\",\n            \"noStableReleaseToCompare\": \"no hi ha actualitzacions disponibles amb les quals comparar\"\n        }\n    },\n    \"common\": {\n        \"home\": \"inici\",\n        \"year\": \"any\",\n        \"add\": \"afegir\",\n        \"ascending\": \"ascendent\",\n        \"biography\": \"biografia\",\n        \"bitrate\": \"taxa de bits\",\n        \"bpm\": \"bpm\",\n        \"cancel\": \"cancel·lar\",\n        \"center\": \"centrar\",\n        \"close\": \"tancar\",\n        \"codec\": \"còdec\",\n        \"configure\": \"configurar\",\n        \"confirm\": \"confirmar\",\n        \"create\": \"crear\",\n        \"decrease\": \"disminuir\",\n        \"delete\": \"eliminar\",\n        \"descending\": \"descendent\",\n        \"description\": \"descripció\",\n        \"disable\": \"desactivar\",\n        \"disc\": \"disc\",\n        \"dismiss\": \"descartar\",\n        \"duration\": \"duració\",\n        \"edit\": \"editar\",\n        \"enable\": \"activar\",\n        \"expand\": \"expandir\",\n        \"filters\": \"filtres\",\n        \"increase\": \"incrementar\",\n        \"left\": \"esquerra\",\n        \"maximize\": \"maximitzar\",\n        \"menu\": \"menú\",\n        \"minimize\": \"minimitzar\",\n        \"modified\": \"modificació\",\n        \"name\": \"nom\",\n        \"no\": \"no\",\n        \"none\": \"cap\",\n        \"note\": \"nota\",\n        \"ok\": \"bé\",\n        \"preview\": \"vista prèvia\",\n        \"quit\": \"sortir\",\n        \"random\": \"aleatori\",\n        \"rating\": \"valoració\",\n        \"reload\": \"torna a carregar\",\n        \"reset\": \"restablir\",\n        \"right\": \"dreta\",\n        \"save\": \"desar\",\n        \"search\": \"cercar\",\n        \"share\": \"compartir\",\n        \"size\": \"mida\",\n        \"sortOrder\": \"ordenar\",\n        \"tags\": \"etiquetes\",\n        \"title\": \"títol\",\n        \"translation\": \"traducció\",\n        \"unknown\": \"desconegut\",\n        \"version\": \"versió\",\n        \"yes\": \"sí\",\n        \"additionalParticipants\": \"participants addicionals\",\n        \"channel_one\": \"canal\",\n        \"channel_many\": \"canals\",\n        \"channel_other\": \"canals\",\n        \"filter_one\": \"filtre\",\n        \"filter_many\": \"filtres\",\n        \"filter_other\": \"filtres\",\n        \"saveAs\": \"desar com\",\n        \"action_one\": \"acció\",\n        \"action_many\": \"accions\",\n        \"action_other\": \"accions\",\n        \"newVersion\": \"s'ha instal·lat una nova versió ({{version}})\",\n        \"viewReleaseNotes\": \"veure les notes de la versió\",\n        \"currentSong\": \"$t(entity.track, {\\\"count\\\": 1}) actual\",\n        \"limit\": \"límit\",\n        \"previousSong\": \"$t(entity.track, {\\\"count\\\": 1}) anterior\",\n        \"trackNumber\": \"pista\",\n        \"albumGain\": \"guany de l'àlbum\",\n        \"albumPeak\": \"pic de l'àlbum\",\n        \"areYouSure\": \"estàs segur?\",\n        \"backward\": \"enrere\",\n        \"clear\": \"neteja\",\n        \"collapse\": \"col·lapsa\",\n        \"comingSoon\": \"aviat disponible…\",\n        \"favorite\": \"preferit\",\n        \"forceRestartRequired\": \"reinicia per aplicar els canvis… tanca la notificació per reiniciar\",\n        \"owner\": \"propietari\",\n        \"refresh\": \"actualitzar\",\n        \"resetToDefault\": \"restablir els valors predeterminats\",\n        \"saveAndReplace\": \"desar i substituir\",\n        \"bitDepth\": \"profunditat de bits\",\n        \"forward\": \"endavant\",\n        \"manage\": \"gestiona\",\n        \"mbid\": \"ID de MusicBrainz\",\n        \"noResultsFromQuery\": \"la petició no ha produït resultats\",\n        \"path\": \"ruta\",\n        \"playerMustBePaused\": \"cal pausar el reproductor\",\n        \"restartRequired\": \"cal reiniciar\",\n        \"sampleRate\": \"freqüència de mostreig\",\n        \"setting_one\": \"configuració\",\n        \"setting_many\": \"configuracions\",\n        \"setting_other\": \"configuracions\",\n        \"trackGain\": \"guany de pista\",\n        \"trackPeak\": \"pic de pista\",\n        \"gap\": \"espera\",\n        \"explicitStatus\": \"estat explícit\",\n        \"explicit\": \"explícit\",\n        \"clean\": \"net\",\n        \"private\": \"privat\",\n        \"public\": \"públic\",\n        \"recordLabel\": \"segell discogràfic\",\n        \"releaseType\": \"tipus de llançament\",\n        \"doNotShowAgain\": \"no tornis a mostrar això\",\n        \"view\": \"mostra\",\n        \"externalLinks\": \"enllaços externs\",\n        \"faster\": \"més ràpid\",\n        \"noFilters\": \"cap filtre configurat\",\n        \"slower\": \"més lent\",\n        \"sort\": \"ordre\",\n        \"gridRows\": \"files de la quadrícula\",\n        \"tableColumns\": \"columnes de la taula\",\n        \"itemsMore\": \"{{count}} més\",\n        \"countSelected\": \"{{count}} seleccionats\",\n        \"retry\": \"reintenta\",\n        \"example\": \"exemple\",\n        \"mood\": \"estat d'ànim\",\n        \"filter_single\": \"senzill\",\n        \"filter_multiple\": \"multi\",\n        \"rename\": \"reanomena\",\n        \"newVersionAvailable\": \"hi ha una nova versió disponible\"\n    },\n    \"entity\": {\n        \"album_one\": \"àlbum\",\n        \"album_many\": \"àlbums\",\n        \"album_other\": \"àlbums\",\n        \"albumWithCount_one\": \"{{count}} àlbum\",\n        \"albumWithCount_many\": \"{{count}} àlbums\",\n        \"albumWithCount_other\": \"{{count}} àlbums\",\n        \"albumArtist_one\": \"artista de l'àlbum\",\n        \"albumArtist_many\": \"artistes de l'àlbum\",\n        \"albumArtist_other\": \"artistes de l'àlbum\",\n        \"albumArtistCount_one\": \"{{count}} artista de l'àlbum\",\n        \"albumArtistCount_many\": \"{{count}} artistes de l'àlbum\",\n        \"albumArtistCount_other\": \"{{count}} artistes de l'àlbum\",\n        \"artist_one\": \"artista\",\n        \"artist_many\": \"artistes\",\n        \"artist_other\": \"artistes\",\n        \"artistWithCount_one\": \"{{count}} artista\",\n        \"artistWithCount_many\": \"{{count}} artistes\",\n        \"artistWithCount_other\": \"{{count}} artistes\",\n        \"playlist_one\": \"llista de reproducció\",\n        \"playlist_many\": \"llistes de reproducció\",\n        \"playlist_other\": \"llistes de reproducció\",\n        \"playlistWithCount_one\": \"{{count}} llista de reproducció\",\n        \"playlistWithCount_many\": \"{{count}} llistes de reproducció\",\n        \"playlistWithCount_other\": \"{{count}} llistes de reproducció\",\n        \"smartPlaylist\": \"$t(entity.playlist, {\\\"count\\\": 1}) intel·ligent\",\n        \"play_one\": \"{{count}} reproducció\",\n        \"play_many\": \"{{count}} reproduccions\",\n        \"play_other\": \"{{count}} reproduccions\",\n        \"folderWithCount_one\": \"{{count}} carpeta\",\n        \"folderWithCount_many\": \"{{count}} carpetes\",\n        \"folderWithCount_other\": \"{{count}} carpetes\",\n        \"genreWithCount_one\": \"{{count}} gènere\",\n        \"genreWithCount_many\": \"{{count}} gèneres\",\n        \"genreWithCount_other\": \"{{count}} gèneres\",\n        \"track_one\": \"pista\",\n        \"track_many\": \"pistes\",\n        \"track_other\": \"pistes\",\n        \"trackWithCount_one\": \"{{count}} pista\",\n        \"trackWithCount_many\": \"{{count}} pistes\",\n        \"trackWithCount_other\": \"{{count}} pistes\",\n        \"folder_one\": \"carpeta\",\n        \"folder_many\": \"carpetes\",\n        \"folder_other\": \"carpetes\",\n        \"genre_one\": \"gènere\",\n        \"genre_many\": \"gèneres\",\n        \"genre_other\": \"gèneres\",\n        \"song_one\": \"cançó\",\n        \"song_many\": \"cançons\",\n        \"song_other\": \"cançons\",\n        \"favorite_one\": \"preferit\",\n        \"favorite_many\": \"preferits\",\n        \"favorite_other\": \"preferits\",\n        \"radioStation_one\": \"emissora de ràdio\",\n        \"radioStation_many\": \"emissores de ràdio\",\n        \"radioStation_other\": \"emissores de ràdio\",\n        \"radioStationWithCount_one\": \"{{count}} emissora de ràdio\",\n        \"radioStationWithCount_many\": \"{{count}} emissores de ràdio\",\n        \"radioStationWithCount_other\": \"{{count}} emissores de ràdio\"\n    },\n    \"form\": {\n        \"addToPlaylist\": {\n            \"input_playlists\": \"$t(entity.playlist, {\\\"count\\\": 2})\",\n            \"title\": \"afegir a una $t(entity.playlist, {\\\"count\\\": 1})\",\n            \"input_skipDuplicates\": \"salta't els duplicats\",\n            \"success\": \"s'ha afegit $t(entity.trackWithCount, {\\\"count\\\": {{message}} }) a $t(entity.playlistWithCount, {\\\"count\\\": {{numOfPlaylists}} })\",\n            \"create\": \"crea $t(entity.playlist, {\\\"count\\\": 1}) {{playlist}}\",\n            \"searchOrCreate\": \"cerca $t(entity.playlist, {\\\"count\\\": 2}) o escriu per crear-ne una de nova\"\n        },\n        \"createPlaylist\": {\n            \"input_description\": \"$t(common.description)\",\n            \"input_name\": \"$t(common.name)\",\n            \"input_owner\": \"$t(common.owner)\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) s'ha creat amb èxit\",\n            \"title\": \"crea una $t(entity.playlist, {\\\"count\\\": 1})\",\n            \"input_public\": \"públic\"\n        },\n        \"deletePlaylist\": {\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) s'ha eliminat amb èxit\",\n            \"title\": \"elimina la $t(entity.playlist, {\\\"count\\\": 1})\",\n            \"input_confirm\": \"escriviu el nom de la $t(entity.playlist, {\\\"count\\\": 1}) per confirmar\"\n        },\n        \"editPlaylist\": {\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) s'ha actualitzat amb èxit\",\n            \"title\": \"editar la $t(entity.playlist, {\\\"count\\\": 1})\",\n            \"publicJellyfinNote\": \"Per algun motiu, Jellyfin no exposa si una llista de reproducció és pública o no. Si voleu que es mantingui pública, seleccioneu la següent entrada\",\n            \"editNote\": \"es recomana no editar manualment les llistes de reproducció grans. segur que accepteu el risc de perdre dades si sobreescriviu la llista de reproducció existent?\"\n        },\n        \"lyricSearch\": {\n            \"input_artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n            \"input_name\": \"$t(common.name)\",\n            \"title\": \"cerca de lletres\"\n        },\n        \"addServer\": {\n            \"input_password\": \"contrasenya\",\n            \"input_username\": \"nom d'usuari\",\n            \"error_savePassword\": \"hi ha hagut un error en intentar desar la contrasenya\",\n            \"ignoreCors\": \"ignora el cors ($t(common.restartRequired))\",\n            \"ignoreSsl\": \"ignora l'ssl ($t(common.restartRequired))\",\n            \"input_legacyAuthentication\": \"activa l'autenticació antiga\",\n            \"input_name\": \"nom del servidor\",\n            \"input_savePassword\": \"desa la contrasenya\",\n            \"input_url\": \"url\",\n            \"success\": \"servidor afegit correctament\",\n            \"title\": \"afegeix un servidor\",\n            \"input_preferInstantMix\": \"prefereix el mix instantani\",\n            \"input_preferInstantMixDescription\": \"utilitza només el mix instantani per obtenir cançons similars. útil si teniu complements que modifiquin aquest comportament\",\n            \"input_preferRemoteUrl\": \"prefereix l'url públic\",\n            \"input_remoteUrl\": \"url públic\",\n            \"input_remoteUrlPlaceholder\": \"opcional: url públic per característiques externes\"\n        },\n        \"shareItem\": {\n            \"description\": \"descripció\",\n            \"allowDownloading\": \"permetre descàrrega\",\n            \"setExpiration\": \"estableix expiració\",\n            \"success\": \"s'ha copiat l'enllaç de compartició al porta-retalls (o feu clic aquí per obrir-lo)\",\n            \"expireInvalid\": \"la data d'expiració ha de ser al futur\",\n            \"createFailed\": \"no s'ha pogut crear el recurs compartit (està habilitat, l'ús compartit?)\",\n            \"copyToClipboard\": \"Copiar al porta-retalls: Ctrl+C, Enter\",\n            \"successMustClick\": \"Compartició creada correctament. Feu clic aquí per obrir-la.\"\n        },\n        \"updateServer\": {\n            \"success\": \"s'ha actualitzat el servidor amb èxit\",\n            \"title\": \"actualitzar el servidor\"\n        },\n        \"queryEditor\": {\n            \"title\": \"editor de consultes\",\n            \"input_optionMatchAll\": \"coincidències totals\",\n            \"input_optionMatchAny\": \"coincidències parcials\",\n            \"addRuleGroup\": \"afegeix el grup de regles\",\n            \"removeRuleGroup\": \"elimina el grup de regles\",\n            \"resetToDefault\": \"reestableix als valors predeterminats\",\n            \"clearFilters\": \"neteja els filtres\"\n        },\n        \"privateMode\": {\n            \"enabled\": \"mode privat actiu; l'estat de reproducció ara està ocult d'integracions externes\",\n            \"disabled\": \"mode privat inactiu; l'estat de reproducció ara és visible per les integracions externes\",\n            \"title\": \"mode privat\"\n        },\n        \"largeFetchConfirmation\": {\n            \"title\": \"afegeix elements a la cua\",\n            \"description\": \"Aquesta acció afegirà tots els elements a la vista filtrada actual\"\n        },\n        \"shuffleAll\": {\n            \"title\": \"reprodueix a l'atzar\",\n            \"input_genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n            \"input_limit\": \"quantes cançons?\",\n            \"input_minYear\": \"de l'any\",\n            \"input_maxYear\": \"fins a l'any\",\n            \"input_played\": \"reprodueix el filtre\",\n            \"input_played_optionAll\": \"totes les pistes\",\n            \"input_played_optionUnplayed\": \"només les pistes sense reproduir\",\n            \"input_played_optionPlayed\": \"només les pistes reproduïdes\"\n        },\n        \"createRadioStation\": {\n            \"success\": \"emissora de ràdio creada amb èxit\",\n            \"title\": \"crea una emissora de ràdio\",\n            \"input_homepageUrl\": \"URL de la pàgina d'inici\",\n            \"input_name\": \"nom\",\n            \"input_streamUrl\": \"URL de transmissió\"\n        },\n        \"saveQueue\": {\n            \"success\": \"cua de reproducció desada al servidor\"\n        },\n        \"lyricsExport\": {\n            \"export\": \"exporta la lletra\",\n            \"input_synced\": \"exporta la lletra sincronitzada\",\n            \"input_offset\": \"$t(setting.lyricOffset)\"\n        }\n    },\n    \"action\": {\n        \"addToFavorites\": \"afegeix a $t(entity.favorite, {\\\"count\\\": 2})\",\n        \"addToPlaylist\": \"afegeix a $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"createPlaylist\": \"crea $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"deletePlaylist\": \"elimina la $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"editPlaylist\": \"edita $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"refresh\": \"$t(common.refresh)\",\n        \"removeFromFavorites\": \"elimina dels $t(entity.favorite, {\\\"count\\\": 2})\",\n        \"removeFromPlaylist\": \"elimina de $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"clearQueue\": \"buida la cua\",\n        \"removeFromQueue\": \"treure de la cua\",\n        \"goToPage\": \"anar a la pàgina\",\n        \"openIn\": {\n            \"lastfm\": \"Obrir a Last.fm\",\n            \"musicbrainz\": \"Obrir a MusicBrainz\"\n        },\n        \"deselectAll\": \"deselecciona-ho tot\",\n        \"viewPlaylists\": \"veure $t(entity.playlist, {\\\"count\\\": 2})\",\n        \"moveToNext\": \"passar al següent\",\n        \"moveToBottom\": \"anar al final\",\n        \"moveToTop\": \"anar al principi\",\n        \"setRating\": \"Qualifica\",\n        \"toggleSmartPlaylistEditor\": \"canvia l'editor $t(entity.smartPlaylist)\",\n        \"downloadStarted\": \"s'ha iniciat la descàrrega de {{count}} elements\",\n        \"moveUp\": \"mou amunt\",\n        \"moveDown\": \"mou avall\",\n        \"holdToMoveToTop\": \"mantingueu premut per moure al capdamunt\",\n        \"holdToMoveToBottom\": \"mantingueu premut per moure al capdavall\",\n        \"moveItems\": \"mou elements\",\n        \"shuffle\": \"mescla\",\n        \"shuffleAll\": \"mescla-ho tot\",\n        \"shuffleSelected\": \"mescla els seleccionats\",\n        \"viewMore\": \"mostra'n més\",\n        \"createRadioStation\": \"crea $t(entity.radioStation, {\\\"count\\\": 1})\",\n        \"deleteRadioStation\": \"elimina $t(entity.radioStation, {\\\"count\\\": 1})\",\n        \"addOrRemoveFromSelection\": \"afegeix o elimina de la selecció\",\n        \"selectRangeOfItems\": \"selecciona un interval d'elements\",\n        \"selectAll\": \"selecciona-ho tot\",\n        \"openApplicationDirectory\": \"obre el directori de l'aplicació\",\n        \"goToCurrent\": \"anar a l'element actual\"\n    },\n    \"setting\": {\n        \"language_description\": \"estableix l'idioma de l'aplicació ($t(common.restartRequired))\",\n        \"playButtonBehavior_optionAddLast\": \"$t(player.addLast)\",\n        \"playButtonBehavior_optionAddNext\": \"$t(player.addNext)\",\n        \"playButtonBehavior_optionPlay\": \"$t(player.play)\",\n        \"playButtonBehavior_optionPlayShuffled\": \"$t(player.shuffle)\",\n        \"replayGainMode_optionAlbum\": \"$t(entity.album, {\\\"count\\\": 1})\",\n        \"replayGainMode_optionNone\": \"$t(common.none)\",\n        \"replayGainMode_optionTrack\": \"$t(entity.track, {\\\"count\\\": 1})\",\n        \"font\": \"tipus de lletra\",\n        \"fontType\": \"selecció de tipus de lletra\",\n        \"fontType_optionBuiltIn\": \"tipus de lletra integrats\",\n        \"fontType_optionCustom\": \"tipus de lletra personalitzats\",\n        \"fontType_optionSystem\": \"tipus de lletra del sistema\",\n        \"disableLibraryUpdateOnStartup\": \"desactiva la comprovació de noves versions a l'inici\",\n        \"homeConfiguration\": \"configuració de la pàgina d'inici\",\n        \"sidebarConfiguration\": \"configuració de la barra lateral\",\n        \"contextMenu\": \"configuració del menú contextual (clic amb el botó dret)\",\n        \"hotkey_playbackNext\": \"pista següent\",\n        \"hotkey_playbackPrevious\": \"pista anterior\",\n        \"sidePlayQueueStyle_optionAttached\": \"unida\",\n        \"sidePlayQueueStyle_optionDetached\": \"separada\",\n        \"audioDevice\": \"dispositiu d'àudio\",\n        \"audioDevice_description\": \"seleccioneu el dispositiu d'àudio que voleu utilitzar per a la reproducció\",\n        \"audioPlayer\": \"reproductor d'àudio\",\n        \"audioPlayer_description\": \"seleccioneu el reproductor d'àudio que voleu utilitzar per a la reproducció\",\n        \"sidebarConfiguration_description\": \"selecciona els elements i l'ordre en què apareixen a la barra lateral\",\n        \"sidebarPlaylistList_description\": \"mostra o amaga les llistes de reproducció a la barra lateral\",\n        \"accentColor\": \"color de ressaltat\",\n        \"accentColor_description\": \"estableix el color de ressaltat de l'aplicació\",\n        \"useSystemTheme_description\": \"seguir la preferència de d'aspecte clar o fosc definida pel sistema\",\n        \"themeDark\": \"aspecte fosc\",\n        \"theme\": \"aspecte\",\n        \"themeLight\": \"aspecte clar\",\n        \"useSystemTheme\": \"utilitzar l'aspecte del sistema\",\n        \"discordUpdateInterval_description\": \"el temps en segons entre cada actualització (mínim 15 segons)\",\n        \"enableRemote\": \"activar el servidor de control remot\",\n        \"enableRemote_description\": \"el servidor de control remot permet que altres dispositius controlin l'aplicació\",\n        \"transcode_description\": \"permet la transcodificació a diferents formats\",\n        \"albumBackground\": \"imatge de fons de l'àlbum\",\n        \"albumBackground_description\": \"afegeix una imatge de fons per les pàgines d'àlbum amb caràtula\",\n        \"albumBackgroundBlur\": \"mida del desenfocament de la imatge de fons de l'àlbum\",\n        \"albumBackgroundBlur_description\": \"ajusa la quantitat de desenfocament que s'aplica a la imatge de fons de l'àlbum\",\n        \"applicationHotkeys\": \"tecles de drecera de l'aplicació\",\n        \"applicationHotkeys_description\": \"configura les tecles de drecera de l'aplicació. marca la casella per configurar-les com a derecres globals (només per ordinador)\",\n        \"artistConfiguration\": \"configuració de la pàgina de l'artista de l'àlbum\",\n        \"artistConfiguration_description\": \"configura quins elements es mostren i el seu ordre de la pàgina de l'artista de l'àlbum\",\n        \"audioExclusiveMode\": \"mode d'àudio exclusiu\",\n        \"audioExclusiveMode_description\": \"activa el mode d'àudio exclusiu. En aquest mode, el sistema normalment estarà bloquejat i només mpv podrà emetre àudio\",\n        \"buttonSize\": \"mida dels botons de la barra de reproducció\",\n        \"buttonSize_description\": \"la mida dels botons de la barra de reproducció\",\n        \"clearCache\": \"neteja la memòria del navegador\",\n        \"clearCache_description\": \"una \\\"neteja profunda\\\" del feishin. a més de netejar la memòria del feishin, buida la memòria del navegador (com les imatges desades i altres recursos). la configuració i les credencials del servidor es mantenen\",\n        \"clearQueryCache\": \"buida la memòria de feishin\",\n        \"clearQueryCache_description\": \"una neteja superficial de feishin. això refrescarà les llistes de reproducció, les metadades de les pistes i reestablirà les lletres desades. la configuració, les credencials del servidor i les imatges desades es mantindran\",\n        \"clearCacheSuccess\": \"memòria netejada correctament\",\n        \"contextMenu_description\": \"us permet amagar els elements que es mostren al menú quan fas clic dret sobre un element. els elements no seleccionats estaran amagats\",\n        \"crossfadeDuration\": \"duracció de la fosa encadenada\",\n        \"crossfadeDuration_description\": \"estableix la duració de l'efecte de fosa encadenada\",\n        \"crossfadeStyle_description\": \"selecciona l'estil de fosa encadenada que s'utilitzarà pel reproductor d'àudio\",\n        \"customCssEnable\": \"activa el css personalitzat\",\n        \"customCssEnable_description\": \"permet escriure CSS personalitzat\",\n        \"customCssNotice\": \"Atenció: tot i que hi ha un filtre (no es permet ni url() ni content:), l'ús de CSS personalitzat pot presentar riscs si canvieu la interfície\",\n        \"customCss\": \"css personalitzat\",\n        \"customCss_description\": \"contingut del css personalitzat. Nota: la propietat \\\"content\\\" i els urls remots no es permeten. A sota hi teniu una previsualització. Els camps addicionals que no establiu hi apareixin pel filtre\",\n        \"customFontPath\": \"ruta de font personalitzada\",\n        \"customFontPath_description\": \"estableix la ruta a una font personalitzada per utilitzar-la a l'aplicació\",\n        \"discordApplicationId\": \"id d'aplicació de {{discord}}\",\n        \"discordApplicationId_description\": \"l'id d'aplicació per l'estat d'activitat de {{discord}} (per defecte, {{defaultId}})\",\n        \"discordPausedStatus\": \"mostra l'estat d'activitat quan està en pausa\",\n        \"discordPausedStatus_description\": \"si està activat, l'estat es mostrarà quan el reproductor estigui pausat\",\n        \"discordIdleStatus\": \"mosta l'estat d'activitat quan està inactiu\",\n        \"discordIdleStatus_description\": \"si està activat, s'actualitzarà l'estat mentre el reproductor estigui inactiu\",\n        \"discordListening\": \"mosta l'estat com escoltant\",\n        \"discordListening_description\": \"mosta l'estat com escoltant en comptes de jugant\",\n        \"discordRichPresence_description\": \"activa l'estat de reproducció a l'activitat de {{discord}}. Les tecles d'imatge són: {{icon}}, {{playing}} i {{paused}}\",\n        \"discordServeImage\": \"serveix imatges de {{discord}} des del servidor\",\n        \"discordServeImage_description\": \"comparteix la caràtula per l'estat d'activitat de {{discord}} des del servidor; només disponible per Jellyfin i Navidrome. {{discord}} fa ser un bot per trobar les imatges, de manera que el vostre servidor ha de ser visible per l'internet públic\",\n        \"discordUpdateInterval\": \"interval d'actualització de l'estat d'activitat de {{discord}}\",\n        \"externalLinks\": \"mostra enllaços externs\",\n        \"externalLinks_description\": \"permet mostrar enllaços externs (Last.fm, MusicBrainz) a les pàgines d'artista/àlbum\",\n        \"exitToTray\": \"surt a la safata\",\n        \"exitToTray_description\": \"en sortir de l'aplicació, minimitza-la a la safa del sistema\",\n        \"followLyric\": \"segueix la lletra actual\",\n        \"followLyric_description\": \"desplaça la lletra a la posició de reproducció actual\",\n        \"preferLocalLyrics\": \"prefereix les lletres locals\",\n        \"preferLocalLyrics_description\": \"prefereix les lletres locals per sobre de les remotes, si estan disponibles\",\n        \"font_description\": \"estableix la font utilitzada a l'aplicació\",\n        \"fontType_description\": \"\\\"font incorporada\\\" selecciona una de les fonts proporcionades per feishin. \\\"font del sistema\\\" us permet seleccionar qualsevol font proporcionada pel sistema operatiu. \\\"personalitzada\\\" us permet proporcionar la vostra pròpia font\",\n        \"gaplessAudio\": \"àudio sense pauses\",\n        \"gaplessAudio_description\": \"estableix la configuració d'àudio sense pauses per mpv\",\n        \"gaplessAudio_optionWeak\": \"feble (recomanat)\",\n        \"globalMediaHotkeys\": \"tecles de drecera globals\",\n        \"globalMediaHotkeys_description\": \"activa o desactiva l'ús de les tecles multimèdia del sistema per controlar la reproducció\",\n        \"homeConfiguration_description\": \"configura quins objectes es mostren, i en quin ordre, a la pàgina d'inici\",\n        \"homeFeature\": \"carrusel de destacats d'inici\",\n        \"homeFeature_description\": \"controla si es mostra el gran carrusel d'elements destacats a la pàgina d'inici\",\n        \"hotkey_browserBack\": \"anar enrere\",\n        \"hotkey_browserForward\": \"anar endavant\",\n        \"hotkey_favoriteCurrentSong\": \"marca $t(common.currentSong) com a preferida\",\n        \"hotkey_favoritePreviousSong\": \"marca $t(common.previousSong) com a preferida\",\n        \"hotkey_globalSearch\": \"cerca global\",\n        \"hotkey_localSearch\": \"cerca a la pàgina\",\n        \"hotkey_playbackPause\": \"pausa\",\n        \"hotkey_playbackPlay\": \"reprodueix\",\n        \"hotkey_playbackPlayPause\": \"reprodueix / pausa\",\n        \"hotkey_playbackStop\": \"atura\",\n        \"hotkey_rate0\": \"neteja la qualificació\",\n        \"hotkey_rate1\": \"qualifica amb 1 estrella\",\n        \"hotkey_rate2\": \"qualifica amb 2 estrelles\",\n        \"hotkey_rate3\": \"qualifica amb 3 estrelles\",\n        \"hotkey_rate4\": \"qualifica amb 4 estrelles\",\n        \"hotkey_rate5\": \"qualifica amb 5 estrelles\",\n        \"hotkey_skipBackward\": \"salta enrere\",\n        \"hotkey_skipForward\": \"salta endavant\",\n        \"hotkey_toggleCurrentSongFavorite\": \"canvia si $t(common.currentSong) és preferida\",\n        \"hotkey_toggleFullScreenPlayer\": \"activa o desactiva el reproductor a pantalla completa\",\n        \"hotkey_togglePreviousSongFavorite\": \"canvia si $t(common.previousSong) és preferida\",\n        \"hotkey_toggleQueue\": \"activa o desactiva la cua\",\n        \"hotkey_toggleRepeat\": \"activa o desactiva la repetició\",\n        \"hotkey_toggleShuffle\": \"activa o desactiva la reproducció a l'atzar\",\n        \"hotkey_unfavoriteCurrentSong\": \"elimina $t(common.currentSong) dels preferits\",\n        \"hotkey_unfavoritePreviousSong\": \"elimina $t(common.previousSong) dels preferits\",\n        \"hotkey_volumeDown\": \"redueix el volum\",\n        \"hotkey_volumeMute\": \"silencia el volum\",\n        \"hotkey_volumeUp\": \"augmenta el volum\",\n        \"hotkey_zoomIn\": \"amplia\",\n        \"hotkey_zoomOut\": \"redueix\",\n        \"imageAspectRatio\": \"utilitza la relació d'aspecte predeterminada de la caràtula\",\n        \"imageAspectRatio_description\": \"si està activat, la caràtula es mostrarà amb la relació d'aspecte predeterminada. per caràtules que no siguin 1:1, l'espai restant estarà buit\",\n        \"lastfm\": \"mostra els enllaços last.fm\",\n        \"lastfm_description\": \"mosta enllaços a Last.fm a les pàgines d'artista/àlbum\",\n        \"lastfmApiKey\": \"clau d'API per {{lastfm}}\",\n        \"lastfmApiKey_description\": \"la clau d'API per {{lastfm}}. necessària per la caràtula\",\n        \"lyricFetch\": \"extreu la lletra d'internet\",\n        \"lyricFetch_description\": \"extreu la lletra de diverses fonts d'internet\",\n        \"lyricFetchProvider\": \"proveïdors de lletres\",\n        \"lyricFetchProvider_description\": \"selecciona els proveïdors de lletres\",\n        \"lyricOffset\": \"desfasament de la lletra (ms)\",\n        \"lyricOffset_description\": \"desplaça la lletra els mil·lisegons especificats\",\n        \"minimizeToTray\": \"minimitza a la safata\",\n        \"minimizeToTray_description\": \"minimitza l'aplicació a la safata del sistema\",\n        \"minimumScrobblePercentage\": \"duració mínima de l'scrobble (percentatge)\",\n        \"minimumScrobblePercentage_description\": \"el percentatge mínim de la cançó que cal reproduir abans d'activar l'scrobble\",\n        \"minimumScrobbleSeconds\": \"scrobble mínim (segons)\",\n        \"minimumScrobbleSeconds_description\": \"la duració mínima en segons durant la qual cal reproduir la cançó abans d'activar l'scrobble\",\n        \"mpvExecutablePath\": \"ruta de l'executable de l'mpv\",\n        \"mpvExecutablePath_description\": \"estableix la ruta de l'executable de l'mpv. si el deixeu buit, s'utilitzarà la ruta predeterminada\",\n        \"mpvExtraParameters_help\": \"un per línia\",\n        \"musicbrainz\": \"mostra els enllaços de MusicBrainz\",\n        \"musicbrainz_description\": \"mostra enllaços a les pàgines d'artista/àlbum a MusicBrainz si hi ha MusicBrainz ID\",\n        \"neteaseTranslation\": \"activeu les traduccions NetEase\",\n        \"neteaseTranslation_description\": \"Si ho activeu, cerca i mostra lletres traduïdes de NetEase si estan disponibles\",\n        \"passwordStore\": \"contrasenyes/emmagatzematge secret\",\n        \"passwordStore_description\": \"quina contrasenya/emmagatzematge secret s'utilitza. canvieu-ho si teniu problemes per desar contrasenyes\",\n        \"playbackStyle\": \"estil de reproducció\",\n        \"playbackStyle_description\": \"selecciona l'estil de reproducció a utilitzar pel reproductor d'àudio\",\n        \"playbackStyle_optionCrossFade\": \"fosa encadenada\",\n        \"playbackStyle_optionNormal\": \"normal\",\n        \"playButtonBehavior\": \"comportament del botó de reproducció\",\n        \"playButtonBehavior_description\": \"estableix el comportament predeterminat del botó de reproducció quan s'afegeixen cançons a la cua\",\n        \"playerbarOpenDrawer\": \"activa el reproductor en pantalla completa\",\n        \"playerbarOpenDrawer_description\": \"permet fer clic a la barra de reproducció per obrir el reproductor de pantalla completa\",\n        \"remotePassword\": \"contrasenya del servidor de control remot\",\n        \"remotePassword_description\": \"estableix la contrasenya pel servidor de control remot. Aquestes credencials es transfereixen de forma no segura per defecte, de manera que hauríeu d'utilitzar una contrasenya única no relacionada amb res més\",\n        \"remotePort\": \"port del servidor de control remot\",\n        \"remotePort_description\": \"estableix el port pel servidor de control remot\",\n        \"remoteUsername\": \"nom d'usuari pel servidor de control remot\",\n        \"remoteUsername_description\": \"estableix el nom d'usuari pel servidor de control remot. si tant el nom d'usuari com la contrasenya són buits, l'autenticació estarà desactivada\",\n        \"replayGainPreamp_description\": \"ajusta el guany del preamplificador aplicat als valors de {{ReplayGain}}\",\n        \"sampleRate\": \"ràtio de mostratge\",\n        \"sampleRate_description\": \"selecciona el ràtio de mostratge de sortida que s'ha d'utilitzar si la freqüència de mostratge seleccionada és diferent a la del mitjà actual. un valor inferior a 8000 utilitzarà la freqüència predeterminada\",\n        \"savePlayQueue\": \"desa la cua de reproducció\",\n        \"savePlayQueue_description\": \"desa la cua de reproducció quan l'aplicació es tanca i la restaura quan s'obre\",\n        \"scrobble\": \"scrobble\",\n        \"scrobble_description\": \"fa scrobble de les reproduccions al vostre servidor multimèdia\",\n        \"showSkipButton\": \"mostra els botons de saltar\",\n        \"showSkipButton_description\": \"mostra o amaga els botons de saltar a la barra de reproducció\",\n        \"showSkipButtons\": \"mostra els botons de saltar\",\n        \"showSkipButtons_description\": \"mostra o amaga els botons de saltar a la barra de reproducció\",\n        \"sidebarCollapsedNavigation\": \"navegació de la barra lateral (plegada)\",\n        \"sidebarCollapsedNavigation_description\": \"mostra o amaga la navegació a la barra lateral plegada\",\n        \"sidebarPlaylistList\": \"llista de reproducció lateral\",\n        \"sidePlayQueueStyle\": \"estil de la cua de reproducció lateral\",\n        \"sidePlayQueueStyle_description\": \"estableix l'estil de la cua de reproducció lateral\",\n        \"skipDuration\": \"interval de salt\",\n        \"skipDuration_description\": \"estableix l'interval de temps que se saltarà en fer servir els botons de saltar a la barra de reproducció\",\n        \"skipPlaylistPage\": \"salta la pàgina de la llista de reproducció\",\n        \"skipPlaylistPage_description\": \"en navegar a una llista de reproducció, obre la pàgina de cançons de la llista de reproducció en comptes de la pàgina predeterminada\",\n        \"startMinimized\": \"obre minimitzada\",\n        \"startMinimized_description\": \"obre l'aplicació a la safata del sistema\",\n        \"theme_description\": \"estableix el tema visual per l'aplicació\",\n        \"themeDark_description\": \"estableix el tema fosc per l'aplicació\",\n        \"themeLight_description\": \"estableix el tema clar per l'aplicació\",\n        \"transcodeBitrate\": \"taxa de bits per transcodificar\",\n        \"transcodeBitrate_description\": \"selecciona la taxa de bits per transcodificar. 0 significa deixar que el servidor triï\",\n        \"transcodeFormat\": \"format per transcodificar\",\n        \"transcodeFormat_description\": \"selecciona el format per transcodificar. deixeu-ho buit per deixar que el servidor decideixi\",\n        \"translationApiProvider\": \"proveïdor d'api de traducció\",\n        \"translationApiProvider_description\": \"proveïdor de l'api de traducció\",\n        \"translationApiKey\": \"clau de l'api de traducció\",\n        \"translationTargetLanguage\": \"llengua meta de traducció\",\n        \"translationTargetLanguage_description\": \"llengua meta per la traducció\",\n        \"trayEnabled\": \"mostra a la safata\",\n        \"trayEnabled_description\": \"mostra/oculta la icona/menú de la safata. si està desactivat, també desactiva la funcionalitat de minimitzar/sortir a la safata\",\n        \"volumeWheelStep\": \"increment de volum de la roda\",\n        \"volumeWheelStep_description\": \"la quantitat de volum a canviar quan utilitzeu la roda del ratolí sobre el controlador de volum\",\n        \"volumeWidth\": \"amplada del controlador de volum\",\n        \"volumeWidth_description\": \"l'amplada del controlador de volum\",\n        \"webAudio\": \"utilitza l'àudio web\",\n        \"webAudio_description\": \"utilitza l'àudio web. això habilita funcions avançades com Replaygain. desactiveu-ho si teniu una experiència diferent\",\n        \"replayGainClipping\": \"saturació de {{ReplayGain}}\",\n        \"replayGainClipping_description\": \"rebaixa automàticament el guany per evitar la saturació causada pel {{ReplayGain}}\",\n        \"replayGainFallback\": \"alternativa per {{ReplayGain}}\",\n        \"replayGainFallback_description\": \"guany en db que s'ha d'aplicar si el fitxer no té etiquetes de {{ReplayGain}}\",\n        \"replayGainMode\": \"mode de {{ReplayGain}}\",\n        \"replayGainMode_description\": \"ajuda el volum del guany segons els vlors de {{ReplayGain}} desats a les metadades del fitxer\",\n        \"replayGainPreamp\": \"preamplificador de {{ReplayGain}} (dB)\",\n        \"translationApiKey_description\": \"clau api per la traducció (només per serveis globals)\",\n        \"preservePitch\": \"mantén el to\",\n        \"preservePitch_description\": \"manté el to quan s'altera la velocitat de reproducció\",\n        \"windowBarStyle\": \"estil de la barra de la finestra\",\n        \"windowBarStyle_description\": \"selecciona l'estil de la barra de la finestra\",\n        \"zoom\": \"percentatge de zoom\",\n        \"zoom_description\": \"estableix el percentatge de zoom de l'aplicació\",\n        \"discordDisplayType\": \"tipus de pantalla d'activitat de {{discord}}\",\n        \"discordDisplayType_description\": \"canvia què escolteu al vostre estat\",\n        \"discordDisplayType_songname\": \"nom de la cançó\",\n        \"discordDisplayType_artistname\": \"nom de l'artista\",\n        \"hotkey_navigateHome\": \"ves a l'inici\",\n        \"preventSleepOnPlayback\": \"evitar entrar en repòs durant la reproducció\",\n        \"preventSleepOnPlayback_description\": \"evita que la pantalla s'adormi mentre la música es reprodueix\",\n        \"discordLinkType\": \"enllaços d'estat de {{discord}}\",\n        \"discordLinkType_description\": \"afegeix enllaços externs a {{lastfm}} o {{musicbrainz}} als camps de cançó i artista a l'estat d'activitat de {{discord}}. {{musicbrainz}} és el més precís, però requereix etiquetes i no proporciona enllaços d'artista, mentre que {{lastfm}} hauria de propocionar un enllaç sempre. no fa sol·licituds de xarxa addicionals\",\n        \"discordLinkType_none\": \"$t(common.none)\",\n        \"discordLinkType_mbz_lastfm\": \"{{musicbrainz}} amb {{lastfm}} com a alternativa\",\n        \"artistBackground\": \"imatge de fons de l'artista\",\n        \"artistBackground_description\": \"afegeix una imatge de fons per les pàgines d'artista amb l'art de l'artista\",\n        \"artistBackgroundBlur\": \"mida del desenfocament de la imatge de fons de l'artista\",\n        \"artistBackgroundBlur_description\": \"ajusta la quantitat de desenfocament aplicat a la imatge de fons de l'artista\",\n        \"releaseChannel_optionLatest\": \"última versió\",\n        \"releaseChannel_optionBeta\": \"beta\",\n        \"releaseChannel\": \"canal de versions\",\n        \"releaseChannel_description\": \"trieu entre versions estables i beta o alfa (diàries) per les actualitzacions automàtiques\",\n        \"mediaSession\": \"activa Media Session\",\n        \"mediaSession_description\": \"activa la integració amb Media Session per mostrar els controls multimèdia i les metadades a l'indicador de volum del sistema i la pantalla de bloqueig\",\n        \"crossfadeStyle\": \"estil de fosa encadenada\",\n        \"discordRichPresence\": \"estat d'activitat de {{discord}}\",\n        \"enableAutoTranslation_description\": \"activa la traducció automàtica en carregar la lletra\",\n        \"enableAutoTranslation\": \"activa la traducció automàtica\",\n        \"exportImportSettings_control_description\": \"expora i importa la configuració amb JSON\",\n        \"exportImportSettings_control_exportText\": \"expora la configuració\",\n        \"exportImportSettings_control_importText\": \"importa la configuració\",\n        \"exportImportSettings_control_title\": \"importa / exporta la configuració\",\n        \"exportImportSettings_destructiveWarning\": \"importar la configuració és una acció destructiva; reviseu el que es mostra a sobre abans de clicar \\\"importa\\\"!\",\n        \"exportImportSettings_importBtn\": \"importa la configuració\",\n        \"exportImportSettings_importModalTitle\": \"importa la configuració de feishin\",\n        \"exportImportSettings_importSuccess\": \"la configuració s'ha importat correctament!\",\n        \"exportImportSettings_notValidJSON\": \"el fitxer que heu seleccionat no és un JSON vàlid\",\n        \"exportImportSettings_offendingKeyError\": \"\\\"{{offendingKey}}\\\" és incorrecte: {{reason}}\",\n        \"language\": \"llengua\",\n        \"notify\": \"activa les notificacions de cançons\",\n        \"notify_description\": \"mostra notificacions quan la cançó actual canviï\",\n        \"transcode\": \"activa la transcodificació\",\n        \"autoDJ\": \"DJ automàtic\",\n        \"autoDJ_description\": \"afegeix cançons similars a la cua automàticament\",\n        \"autoDJ_itemCount\": \"número d'elements\",\n        \"autoDJ_itemCount_description\": \"el nombre d'elements que s'intenten afegir a la cua quan el DJ automàtic està activat\",\n        \"autoDJ_timing\": \"temps\",\n        \"autoDJ_timing_description\": \"el nombre de cançons que han de quedar a la cua per activar el DJ automàtic\",\n        \"analyticsDisable\": \"Desactiva les analítiques basades en l'ús\",\n        \"analyticsDisable_description\": \"S'envien dades d'ús anonimitzades al desenvolupador per ajudar a millorar l'aplicació\",\n        \"followCurrentSong_description\": \"desplaça automàticament la cua de reproducció a la cançó en reproducció\",\n        \"followCurrentSong\": \"segueix la cançó actual\",\n        \"logLevel\": \"nivell de registre\",\n        \"logLevel_description\": \"estableix el nivell mínim de registre que s'ha de mostrar. debug mostra tots els missatges, error mostra només els errors\",\n        \"logLevel_optionDebug\": \"debug\",\n        \"logLevel_optionError\": \"error\",\n        \"logLevel_optionInfo\": \"info\",\n        \"logLevel_optionWarn\": \"avís\",\n        \"playerFilters\": \"filtra les cançons de la cua\",\n        \"playerFilters_description\": \"evita afegir cançons a la cua segons els següents criteris\",\n        \"playerbarSlider\": \"barra lliscadora de reproducció\",\n        \"playerbarSlider_description\": \"la forma d'ona no es recomana si utilitzeu una connexió d'internet lenta o mesurada\",\n        \"playerbarSliderType_optionSlider\": \"lliscador\",\n        \"playerbarSliderType_optionWaveform\": \"forma d'ona\",\n        \"playerbarWaveformAlign\": \"alineament de la forma d'ona\",\n        \"playerbarWaveformAlign_optionTop\": \"superior\",\n        \"playerbarWaveformAlign_optionCenter\": \"centrat\",\n        \"playerbarWaveformAlign_optionBottom\": \"inferior\",\n        \"playerbarWaveformBarWidth\": \"amplada de la forma d'ona\",\n        \"playerbarWaveformGap\": \"buits de la forma d'ona\",\n        \"playerbarWaveformRadius\": \"radi de la forma d'ona\",\n        \"showLyricsInSidebar_description\": \"s'afegirà un tauler a la cua de reproducció que mostra la lletra\",\n        \"showLyricsInSidebar\": \"mostra la lletra a la barra lateral del reproductor\",\n        \"showVisualizerInSidebar_description\": \"s'afegirà un tauler a la barra lateral de reproducció que mostra el visualitzador\",\n        \"showVisualizerInSidebar\": \"mostra el visualitzador a la barra laterla de reproducció\",\n        \"audioFadeOnStatusChange\": \"fosa d'àudio en canviar d'estat\",\n        \"audioFadeOnStatusChange_description\": \"activa la fosa de sortida i entrada en reproduir o pausar\",\n        \"queryBuilder\": \"constructor de consultes\",\n        \"queryBuilderCustomFields_inputLabel\": \"discogràfica\",\n        \"queryBuilderCustomFields_inputTag\": \"etiqueta\",\n        \"queryBuilderCustomFields\": \"camps personalitzats\",\n        \"queryBuilderCustomFields_description\": \"afegeix camps personalitzats pel constructor de consultes\",\n        \"useThemeAccentColor\": \"fes servir el color d'accent del tema\",\n        \"useThemeAccentColor_description\": \"fes servir el color primari definit pel tema seleccionat en comptes del color d'accent personalitzat\",\n        \"artistRadioCount_description\": \"estableix el número de cançons per cercar per la ràdio d'artista i pista\",\n        \"artistRadioCount\": \"recompte de ràdios d'artista o pista\",\n        \"imageResolution\": \"resolució d'imatge\",\n        \"imageResolution_description\": \"la resolució per les imatges que s'utilitzen a l'aplicació. un valor de 0 equival a la resolució nativa de la imatge\",\n        \"imageResolution_optionTable\": \"taula\",\n        \"imageResolution_optionItemCard\": \"targeta d'element\",\n        \"imageResolution_optionSidebar\": \"tauler lateral\",\n        \"imageResolution_optionHeader\": \"encapçalament\",\n        \"imageResolution_optionFullScreenPlayer\": \"reproductor de pantalla completa\",\n        \"showRatings_description\": \"controla si es mostren les estrelles de valoració a la interfície\",\n        \"showRatings\": \"mostra la valoració d'estrelles\",\n        \"combinedLyricsAndVisualizer_description\": \"combina la lletra i el visualitzador en un sol tauler\",\n        \"combinedLyricsAndVisualizer\": \"combina la lletra i el visualitzador al tauler lateral del reproductor\",\n        \"artistReleaseTypeConfiguration\": \"configuració de tipus de llançament d'artista\",\n        \"artistReleaseTypeConfiguration_description\": \"configura quins llançaments es mostren, i en quin ordre, a la pàgina d'artista de l'àlbum\",\n        \"hotkey_listNavigateToPage\": \"navega per la llista fins a la pàgina de l'element\",\n        \"hotkey_listPlayDefault\": \"reprodueix llista\",\n        \"hotkey_listPlayLast\": \"reprodueix la llista al final\",\n        \"hotkey_listPlayNext\": \"reprodueix la llista a continuació\",\n        \"hotkey_listPlayNow\": \"reprodueix la llista ara\",\n        \"mpvExtraParameters\": \"paràmetres addicionals d'mpv\",\n        \"mpvExtraParameters_description\": \"arguments addicionals per l'mpv\",\n        \"pathReplace\": \"substitució de la ruta de l'arxiu\",\n        \"pathReplace_description\": \"substitueix la ruta d'arxiu predeterminada del servidor\",\n        \"pathReplace_optionRemovePrefix\": \"elimina el prefix\",\n        \"pathReplace_optionAddPrefix\": \"afegeix prefix\",\n        \"homeFeatureStyle_description\": \"controla l'estil del carrusel de destacats de l'inici\",\n        \"homeFeatureStyle\": \"estil del carrusel de destacats de l'inici\",\n        \"homeFeatureStyle_optionMultiple\": \"múltiple\",\n        \"homeFeatureStyle_optionSingle\": \"simple\",\n        \"enableGridMultiSelect\": \"activa la selecció múltiple de quadrícula\",\n        \"enableGridMultiSelect_description\": \"quan està activada, podeu seleccionar més d'un element en la vista de quadrícula; si feu clic en la imatge d'un element de la quadrícula, accedireu a la pàgina de l'element\",\n        \"sidebarPlaylistSorting_description\": \"permet ordenar manualment les llistes de reproducció a la barra lateral arrossegant amb el ratolí en comptes de seguir l'ordre predeterminat del servidor\",\n        \"sidebarPlaylistSorting\": \"ordenació de llistes de reproducció de la barra lateral\",\n        \"sidebarPlaylistListFilterRegex_description\": \"amaga les llistes de reproducció de la barra lateral que coincideixin amb aquesta expressió regular\",\n        \"sidebarPlaylistListFilterRegex_placeholder\": \"ex. ^Mescla diària.*\",\n        \"sidebarPlaylistListFilterRegex\": \"regex pel filtre de llistes\",\n        \"analyticsEnable\": \"envia analítiques basades en l'ús\",\n        \"analyticsEnable_description\": \"s'envien dades d'ús anonimitzades al desenvolupar per ajudar a millorar l'aplicació\",\n        \"automaticUpdates\": \"actualitzacions automàtiques\",\n        \"automaticUpdates_description\": \"cerca i instal·la actualitzacions automàticament\",\n        \"releaseChannel_optionAlpha\": \"alfa (diària)\",\n        \"blurExplicitImages\": \"desenfoca imatges explícites\",\n        \"blurExplicitImages_description\": \"les caràtules d'àlbums i cançons marcades com a explícites quedaran desenfocades\",\n        \"discordStateIcon\": \"mostra la icona de reproducció\",\n        \"discordStateIcon_description\": \"mostra una petita icona de reproducció a l'estat d'activitat. l'icona de pausa es mostra quan \\\"mostra l'estat d'activitat quan està en pausa\\\" està activat\",\n        \"autosave\": \"desa automàticament la cua de reproducció\",\n        \"autosave_description\": \"activa el desament automàtic de la cua de reproducció al teu servidor. això només és possible quan s'utilitza Navidrome/Subsonic i no es pot tenir una cua de reproducció mixta.\",\n        \"autosaveCount\": \"freqüència de desament de cua de reproducció automàtica\",\n        \"autosaveCount_description\": \"quants canvis de pista abans que es desi la cua. 1 (mínim) significa cada canvi de cançó\",\n        \"useThemePrimaryShade\": \"utilitza l'ombra primària del tema\",\n        \"useThemePrimaryShade_description\": \"utilitza el to primari definit al tema seleccionat per a les variants de color primari\",\n        \"primaryShade\": \"ombra primària\",\n        \"primaryShade_description\": \"substitueix el to primari (0–9) utilitzat per a botons, enllaços i altres elements de color primari\",\n        \"playerItemConfiguration_description\": \"configurar quins elements es mostren i en quin ordre al reproductor de pantalla completa\",\n        \"playerItemConfiguration\": \"configuració d'elements del jugador\"\n    },\n    \"table\": {\n        \"column\": {\n            \"albumCount\": \"$t(entity.album, {\\\"count\\\": 2})\",\n            \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n            \"channels\": \"$t(common.channel, {\\\"count\\\": 2})\",\n            \"codec\": \"$t(common.codec)\",\n            \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n            \"size\": \"$t(common.size)\",\n            \"songCount\": \"$t(entity.track, {\\\"count\\\": 2})\",\n            \"releaseYear\": \"any\",\n            \"playCount\": \"reproduccions\",\n            \"releaseDate\": \"data de llançament\",\n            \"album\": \"àlbum\",\n            \"albumArtist\": \"artista de l'àlbum\",\n            \"biography\": \"biografia\",\n            \"bitrate\": \"taxa de bits\",\n            \"bpm\": \"bpm\",\n            \"dateAdded\": \"data d'addició\",\n            \"discNumber\": \"disc\",\n            \"trackNumber\": \"pista\",\n            \"comment\": \"comentari\",\n            \"favorite\": \"preferit\",\n            \"lastPlayed\": \"última reproducció\",\n            \"path\": \"ruta\",\n            \"rating\": \"qualificació\",\n            \"title\": \"títol\",\n            \"bitDepth\": \"$t(common.bitDepth)\",\n            \"sampleRate\": \"$t(common.sampleRate)\",\n            \"owner\": \"propietari\"\n        },\n        \"config\": {\n            \"general\": {\n                \"gap\": \"$t(common.gap)\",\n                \"size\": \"$t(common.size)\",\n                \"autoFitColumns\": \"ajusta les columnes automàticament\",\n                \"followCurrentSong\": \"segueix la cançó actual\",\n                \"displayType\": \"tipus de visualització\",\n                \"itemGap\": \"espai entre elements (px)\",\n                \"itemSize\": \"mida dels elements (px)\",\n                \"tableColumns\": \"columnes de la taula\",\n                \"advancedSettings\": \"opcions avançades\",\n                \"autosize\": \"dimensions automàtiques\",\n                \"moveUp\": \"mou amunt\",\n                \"moveDown\": \"mou avall\",\n                \"pinToLeft\": \"ancora a l'esquerra\",\n                \"pinToRight\": \"ancora a la dreta\",\n                \"alignLeft\": \"alinea a l'esquerra\",\n                \"alignCenter\": \"alinea al centre\",\n                \"alignRight\": \"alinea a la dreta\",\n                \"itemsPerRow\": \"elements per fila\",\n                \"size_default\": \"predeterminat\",\n                \"size_compact\": \"compacte\",\n                \"size_large\": \"gran\",\n                \"pagination\": \"paginació\",\n                \"pagination_itemsPerPage\": \"elements per pàgina\",\n                \"pagination_infinite\": \"infinita\",\n                \"pagination_paginate\": \"paginada\",\n                \"alternateRowColors\": \"colors de fila alternants\",\n                \"horizontalBorders\": \"vores de fila\",\n                \"rowHoverHighlight\": \"ressalta en passar el cursor per la fila\",\n                \"verticalBorders\": \"vores de columna\",\n                \"showHeader\": \"mostra l'encapçalament\"\n            },\n            \"label\": {\n                \"actions\": \"$t(common.action, {\\\"count\\\": 2})\",\n                \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n                \"albumArtist\": \"$t(entity.albumArtist, {\\\"count\\\": 1})\",\n                \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n                \"biography\": \"$t(common.biography)\",\n                \"bitrate\": \"$t(common.bitrate)\",\n                \"bpm\": \"$t(common.bpm)\",\n                \"channels\": \"$t(common.channel, {\\\"count\\\": 2})\",\n                \"codec\": \"$t(common.codec)\",\n                \"duration\": \"$t(common.duration)\",\n                \"favorite\": \"$t(common.favorite)\",\n                \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n                \"note\": \"$t(common.note)\",\n                \"owner\": \"$t(common.owner)\",\n                \"path\": \"$t(common.path)\",\n                \"rating\": \"$t(common.rating)\",\n                \"size\": \"$t(common.size)\",\n                \"songCount\": \"$t(entity.track, {\\\"count\\\": 2})\",\n                \"title\": \"$t(common.title)\",\n                \"year\": \"$t(common.year)\",\n                \"playCount\": \"compte de reproduccions\",\n                \"releaseDate\": \"data de llançament\",\n                \"dateAdded\": \"data d'addició\",\n                \"trackNumber\": \"número de pista\",\n                \"discNumber\": \"número de disc\",\n                \"lastPlayed\": \"última reproducció\",\n                \"rowIndex\": \"índex de files\",\n                \"titleCombined\": \"$t(common.title) (combinat)\",\n                \"albumCount\": \"$t(entity.album, {\\\"count\\\": 2})\",\n                \"bitDepth\": \"$t(common.bitDepth)\",\n                \"genreBadge\": \"$t(entity.genre, {\\\"count\\\": 1}) (insígnies)\",\n                \"image\": \"imatge\",\n                \"sampleRate\": \"$t(common.sampleRate)\",\n                \"composer\": \"compositor\",\n                \"titleArtist\": \"$t(common.title) (artista)\",\n                \"albumGroup\": \"grup d'àlbums\"\n            },\n            \"view\": {\n                \"table\": \"taula\",\n                \"grid\": \"quadrícula\",\n                \"list\": \"llista\",\n                \"detail\": \"detall\"\n            }\n        }\n    },\n    \"filter\": {\n        \"fromYear\": \"des de l'any\",\n        \"releaseYear\": \"any de llançament\",\n        \"toYear\": \"fins a l'any\",\n        \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n        \"albumArtist\": \"$t(entity.albumArtist, {\\\"count\\\": 1})\",\n        \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n        \"biography\": \"biografia\",\n        \"bitrate\": \"taxa de bits\",\n        \"bpm\": \"bpm\",\n        \"channels\": \"$t(common.channel, {\\\"count\\\": 2})\",\n        \"comment\": \"comentari\",\n        \"disc\": \"disc\",\n        \"duration\": \"durada\",\n        \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n        \"id\": \"identificador\",\n        \"name\": \"nom\",\n        \"note\": \"nota\",\n        \"owner\": \"$t(common.owner)\",\n        \"random\": \"aleatori\",\n        \"rating\": \"valoració\",\n        \"search\": \"cercar\",\n        \"title\": \"títol\",\n        \"playCount\": \"compte de reproduccions\",\n        \"releaseDate\": \"data de llançament\",\n        \"mostPlayed\": \"els més reproduïts\",\n        \"dateAdded\": \"data d'addició\",\n        \"trackNumber\": \"pista\",\n        \"communityRating\": \"valoració de la comunitat\",\n        \"criticRating\": \"valoració dels crítics\",\n        \"recentlyAdded\": \"afegit recentment\",\n        \"recentlyPlayed\": \"reproduït recentment\",\n        \"recentlyUpdated\": \"actualitzat recentment\",\n        \"albumCount\": \"nombre de $t(entity.album, {\\\"count\\\": 2})\",\n        \"favorited\": \"preferits\",\n        \"isCompilation\": \"és una compilació\",\n        \"isFavorited\": \"és un preferit\",\n        \"isPublic\": \"és públic\",\n        \"isRated\": \"està qualificat\",\n        \"isRecentlyPlayed\": \"s'ha reproduït fa poc\",\n        \"lastPlayed\": \"última reproducció\",\n        \"path\": \"ruta\",\n        \"songCount\": \"nombre de cançons\",\n        \"explicitStatus\": \"$t(common.explicitStatus)\",\n        \"sortName\": \"ordena per nom\",\n        \"matchAnd\": \"i\",\n        \"matchOr\": \"o\"\n    },\n    \"player\": {\n        \"muted\": \"silenciat\",\n        \"repeat\": \"repetició d'una pista\",\n        \"skip\": \"saltar\",\n        \"stop\": \"parar\",\n        \"queue_clear\": \"buidar la cua\",\n        \"viewQueue\": \"veure la cua\",\n        \"playbackFetchInProgress\": \"carregant cançons…\",\n        \"playbackFetchNoResults\": \"no s'han trobat cançons\",\n        \"playbackSpeed\": \"velocitat de reproducció\",\n        \"playSimilarSongs\": \"reproduir cançons similars\",\n        \"repeat_off\": \"repetició desactivada\",\n        \"repeat_all\": \"repetició\",\n        \"shuffle\": \"reprodueix (mesclat)\",\n        \"shuffle_off\": \"reproducció aleatòria desactivada\",\n        \"addLast\": \"al final\",\n        \"addNext\": \"a continuació\",\n        \"favorite\": \"marcar com a preferida\",\n        \"mute\": \"silencia\",\n        \"next\": \"següent\",\n        \"play\": \"reprodueix\",\n        \"playbackFetchCancel\": \"està trigant bastant... tanqueu la notificació per cancel·lar\",\n        \"playRandom\": \"reproducció a l'atzar\",\n        \"previous\": \"anterior\",\n        \"queue_moveToBottom\": \"mou la selecció a l'inici\",\n        \"queue_moveToTop\": \"mou la selecció al final\",\n        \"queue_remove\": \"elimina la selecció\",\n        \"skip_back\": \"salta enrere\",\n        \"skip_forward\": \"salta endavant\",\n        \"toggleFullscreenPlayer\": \"activa el reproductor de pantalla completa\",\n        \"unfavorite\": \"elimina de preferits\",\n        \"pause\": \"pausa\",\n        \"addLastShuffled\": \"al final (mesclat)\",\n        \"addNextShuffled\": \"a continuació (mesclat)\",\n        \"holdToShuffle\": \"mantén premut per mesclar\",\n        \"lyrics\": \"lletra\",\n        \"restoreQueueFromServer\": \"restaura la cua del servidor\",\n        \"saveQueueToServer\": \"desa la cua al servidor\",\n        \"artistRadio\": \"ràdio de l'artista\",\n        \"trackRadio\": \"ràdio de la pista\",\n        \"sleepTimer\": \"temporitzador d'adormir\",\n        \"sleepTimer_endOfSong\": \"final de la cançó actual\",\n        \"sleepTimer_minutes\": \"{{count}} min\",\n        \"sleepTimer_hours\": \"{{count}} h\",\n        \"sleepTimer_custom\": \"personalitzat\",\n        \"sleepTimer_off\": \"apagat\",\n        \"sleepTimer_timeRemaining\": \"queden {{time}}\",\n        \"sleepTimer_setCustom\": \"configura el temporitzador\",\n        \"sleepTimer_cancel\": \"cancel·la el temporitzador\",\n        \"albumRadio\": \"ràdio d'àlbums\"\n    },\n    \"error\": {\n        \"credentialsRequired\": \"credencials requerides\",\n        \"genericError\": \"s'ha produït un error\",\n        \"invalidServer\": \"servidor no vàlid\",\n        \"localFontAccessDenied\": \"accés denegat als tipus de lletra locals\",\n        \"networkError\": \"s'ha produït un error de xarxa\",\n        \"openError\": \"no s'ha pogut obrir el fitxer\",\n        \"remotePortError\": \"s'ha produït un error en intentar configurar el port del servidor remot\",\n        \"serverNotSelectedError\": \"no s'ha seleccionat cap servidor\",\n        \"sessionExpiredError\": \"la sessió ha caducat\",\n        \"systemFontError\": \"s'ha produït un error en intentar obtenir els tipus de lletra del sistema\",\n        \"remoteEnableError\": \"s'ha produït un error en intentar $t(common.enable) el servidor remot\",\n        \"remotePortWarning\": \"reiniciar el servidor per aplicar el nou port\",\n        \"serverRequired\": \"servidor requerit\",\n        \"apiRouteError\": \"no es pot encaminar la sol·licitud\",\n        \"audioDeviceFetchError\": \"hi ha hagut un error en obtenir els dispositius d'àudio\",\n        \"authenticationFailed\": \"autenticació fallida\",\n        \"badAlbum\": \"esteu veient aquesta pàgina perquè aquesta cançó no és part de cap àlbum. aquest problema pot passar si teniu una cançó al nivell superior de la vostra carpeta de música. Jellyfin només agrupa pistes si són en una carpeta\",\n        \"badValue\": \"l'opció \\\"{{value}}\\\"és invàlida. aquest valor ja no existeix\",\n        \"loginRateError\": \"massa intents d'inici de sessió, intenteu-ho de nou d'aquí uns segons\",\n        \"mpvRequired\": \"Cal l'MPV\",\n        \"notificationDenied\": \"s'han negat els permisos per enviar notificacions. aquesta opció no té cap efecte\",\n        \"playbackError\": \"hi ha hagut un error en intentar reproduir el mitjà\",\n        \"remoteDisableError\": \"hi ha hagut un error en intentar $t(common.disable) el servidor remot\",\n        \"endpointNotImplementedError\": \"el punt final {{endpoint}} no està implementat per {{serverType}}\",\n        \"multipleServerSaveQueueError\": \"la cua de reproducció té una o més cançons que no són del servidor actual, cosa que no és compatible\",\n        \"saveQueueFailed\": \"error en desar la cua\",\n        \"settingsSyncError\": \"hi ha discrepàncies entre la configuració del renderitzador i el procés principal. reinicieu l'aplicació per aplicar els canvis\",\n        \"noNetwork\": \"servidor no disponible\",\n        \"noNetworkDescription\": \"no s'ha pogut connectar amb el servidor\",\n        \"invalidJson\": \"JSON invàlid\",\n        \"serverLockSingleServer\": \"només es permet un servidor quan el servidor està bloquejat\",\n        \"playbackPausedDueToError\": \"la reproducció s'ha pausat a causa d'un error\"\n    },\n    \"releaseType\": {\n        \"primary\": {\n            \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n            \"broadcast\": \"emissió\",\n            \"ep\": \"EP\",\n            \"other\": \"altres\",\n            \"single\": \"senzill\"\n        },\n        \"secondary\": {\n            \"audiobook\": \"audiollibre\",\n            \"audioDrama\": \"audioteatre\",\n            \"compilation\": \"compilació\",\n            \"djMix\": \"mescla de dj\",\n            \"demo\": \"demo\",\n            \"fieldRecording\": \"enregistrament de camp\",\n            \"interview\": \"entrevista\",\n            \"live\": \"en directe\",\n            \"mixtape\": \"recopilació\",\n            \"remix\": \"remix\",\n            \"soundtrack\": \"banda sonora\",\n            \"spokenWord\": \"àudio parlat\"\n        }\n    },\n    \"dragDropZone\": {\n        \"error_oneFileOnly\": \"Seleccioneu un sol fitxer\",\n        \"error_readingFile\": \"hi ha hagut un error en llegir el fitxer: {{errorMessage}}\",\n        \"mainText\": \"deixeu anar un fitxer aquí\"\n    },\n    \"filterOperator\": {\n        \"after\": \"és posterior\",\n        \"afterDate\": \"és posterior a (data)\",\n        \"before\": \"és anterior\",\n        \"beforeDate\": \"és anterior a (data)\",\n        \"contains\": \"conté\",\n        \"endsWith\": \"acaba en\",\n        \"inPlaylist\": \"és a\",\n        \"inTheLast\": \"és a l'últim\",\n        \"inTheRange\": \"és entre\",\n        \"inTheRangeDate\": \"és entre (data)\",\n        \"is\": \"és\",\n        \"isNot\": \"no és\",\n        \"isGreaterThan\": \"és més gran que\",\n        \"isLessThan\": \"és més petit que\",\n        \"matchesRegex\": \"coincideix amb l'expressió regular\",\n        \"notContains\": \"no conté\",\n        \"notInPlaylist\": \"no és a\",\n        \"notInTheLast\": \"no és a l'últim\",\n        \"startsWith\": \"comença amb\"\n    },\n    \"queryBuilder\": {\n        \"standardTags\": \"etiquetes estàndard\",\n        \"customTags\": \"etiquetes personalitzades\"\n    },\n    \"datetime\": {\n        \"minuteShort\": \"min\",\n        \"secondShort\": \"s\",\n        \"hourShort\": \"h\",\n        \"dayShort\": \"d\"\n    },\n    \"visualizer\": {\n        \"visualizerType\": \"tipus de visualitzador\",\n        \"cyclePresets\": \"opcions preconfigurades\",\n        \"cycleTime\": \"duració d'un cicle (segons)\",\n        \"includeAllPresets\": \"inclou totes les opcions predeterminades\",\n        \"ignoredPresets\": \"ignora les opcions predeterminades\",\n        \"selectedPresets\": \"opcions predeterminades seleccionades\",\n        \"randomizeNextPreset\": \"tria la següent opcions predeterminada a l'atzar\",\n        \"blendTime\": \"duració de la mescla\",\n        \"presets\": \"opcions predeterminades\",\n        \"selectPreset\": \"selecciona una opció predeterminada\",\n        \"applyPreset\": \"aplica l'opció predeterminada\",\n        \"saveAsPreset\": \"desa com a opció predeterminada\",\n        \"updatePreset\": \"actualitza l'opció predeterminada\",\n        \"copyConfiguration\": \"copia la configuració\",\n        \"pasteConfiguration\": \"enganxa la configuració\",\n        \"pasteConfigurationPlaceholder\": \"enganxa la configuració JSON aquí...\",\n        \"pasteFromClipboard\": \"enganxa des del portaretalls\",\n        \"applyConfiguration\": \"aplica la configuració\",\n        \"configCopied\": \"configuració copiada al portaretalls\",\n        \"configCopyFailed\": \"error en copiar la configuració\",\n        \"configPasted\": \"configuració aplicada correctament\",\n        \"configPasteFailed\": \"Error en aplicar la configuració. Reviseu-ne el format.\",\n        \"configPasteReadFailed\": \"Error en llegir del portaretalls\",\n        \"presetName\": \"Nom de l'opció predeterminada\",\n        \"presetNamePlaceholder\": \"Escriviu el nom de l'opció predeterminada\",\n        \"general\": \"General\",\n        \"mode\": \"Mode\",\n        \"mode1To8\": \"Mode 1 - 8\",\n        \"mode10\": \"Mode 10\",\n        \"barSpace\": \"Espai entre barres\",\n        \"lineWidth\": \"Amplitud de línia\",\n        \"fillAlpha\": \"Omplir alfa\",\n        \"channelLayout\": \"Disseny del canal\",\n        \"maxFPS\": \"FPS màxims\",\n        \"opacity\": \"Opacitat\",\n        \"customGradients\": \"Degradats personalitzats\",\n        \"addCustomGradient\": \"Afegeix un degradat personalitzat\",\n        \"gradientName\": \"Nom del degradat\",\n        \"gradientNamePlaceholder\": \"Nom del degradat\",\n        \"vertical\": \"Vertical\",\n        \"horizontal\": \"Horitzontal\",\n        \"colorStops\": \"Parades de color\",\n        \"addColor\": \"Afegeix el color\",\n        \"position\": \"Posició\",\n        \"level\": \"Nivell\",\n        \"remove\": \"Elimina\",\n        \"custom\": \"Personalitzat\",\n        \"builtIn\": \"Integrat\",\n        \"colors\": \"Colors\",\n        \"colorMode\": \"Mode de color\",\n        \"gradient\": \"Degradat\",\n        \"gradientLeft\": \"Esquerra del degradat\",\n        \"gradientRight\": \"Dreta del degradat\",\n        \"fft\": \"FFT\",\n        \"fftSize\": \"Mida del FFT\",\n        \"smoothing\": \"Suavitzador\",\n        \"frequencyRangeAndScaling\": \"Escala i rang de freqüència\",\n        \"minimumFrequency\": \"Freqüència mínima\",\n        \"maximumFrequency\": \"Freqüència màxima\",\n        \"frequencyScale\": \"Escala de freqüència\",\n        \"sensitivity\": \"Sensibilitat\",\n        \"weightingFilter\": \"Filtre de pes\",\n        \"minimumDecibels\": \"Decibels mínims\",\n        \"maximumDecibels\": \"Decibels màxims\",\n        \"linearAmplitude\": \"Amplitud lineal\",\n        \"linearBoost\": \"Augment lineal\",\n        \"peakBehavior\": \"Comportament del pic\",\n        \"showPeaks\": \"Mostra els pics\",\n        \"fadePeaks\": \"Pics de fosa\",\n        \"peakLine\": \"Línea del pic\",\n        \"gravity\": \"Gravetat\",\n        \"peakFadeTime\": \"Temps de fosa del pic (ms)\",\n        \"peakHoldTime\": \"Temps d'espera del pic (ms)\",\n        \"radialSpectrum\": \"Espectre radial\",\n        \"radial\": \"Radial\",\n        \"radialInvert\": \"Invertir el radial\",\n        \"spinSpeed\": \"Velocitat de gir\",\n        \"radius\": \"Radi\",\n        \"reflexMirror\": \"Mirall del reflex\",\n        \"reflexFit\": \"Ajustament del reflex\",\n        \"reflexRatio\": \"Proporció del reflex\",\n        \"reflexAlpha\": \"Alfa del reflex\",\n        \"reflexBrightness\": \"Brillantor del reflex\",\n        \"mirror\": \"Mirall\",\n        \"miscellaneousSettings\": \"Configuració miscel·lànea\",\n        \"alphaBars\": \"Barres alfa\",\n        \"ansiBands\": \"Bandes ANSI\",\n        \"ledBars\": \"Barres LED\",\n        \"trueLeds\": \"LEDs reals\",\n        \"lumiBars\": \"Barres Lumi\",\n        \"outlineBars\": \"Barres de vora\",\n        \"roundBars\": \"Barres arrodonides\",\n        \"lowResolution\": \"Baixa resolució\",\n        \"splitGradient\": \"Degradat dividit\",\n        \"showFPS\": \"Mostra els FPS\",\n        \"showScaleX\": \"Mostra l'escala X\",\n        \"noteLabels\": \"Etiquetes de nota\",\n        \"showScaleY\": \"Mostra l'escala Y\",\n        \"options\": {\n            \"colorMode\": {\n                \"gradient\": \"Degradat\",\n                \"barIndex\": \"Índex de barra\",\n                \"barLevel\": \"Nivell de barra\"\n            },\n            \"gradient\": {\n                \"classic\": \"Classic\",\n                \"prism\": \"Prisme\",\n                \"rainbow\": \"Arc de Sant Martí\",\n                \"steelblue\": \"Blau d'acer\",\n                \"orangered\": \"Vermell ataronjat\"\n            },\n            \"channelLayout\": {\n                \"single\": \"Únic\",\n                \"dualCombined\": \"Dual-Combinat\",\n                \"dualHorizontal\": \"Dual-Horitzontal\",\n                \"dualVertical\": \"Dual-Vertical\"\n            },\n            \"frequencyScale\": {\n                \"bark\": \"Escala Bark\",\n                \"linear\": \"Escala Lineal\",\n                \"log\": \"Escala logarítmica\",\n                \"mel\": \"Escala Mel\",\n                \"none\": \"Cap\"\n            },\n            \"weightingFilter\": {\n                \"none\": \"Cap\",\n                \"a\": \"A\",\n                \"b\": \"B\",\n                \"c\": \"C\",\n                \"d\": \"D\",\n                \"z\": \"Z\"\n            },\n            \"mode\": {\n                \"0\": \"[0] Freqüències discretes\",\n                \"1\": \"[1] 1/24a octava / 240 bandes\",\n                \"2\": \"[2] 1/12a octava / 120 bandes\",\n                \"3\": \"[3] 1/8a octava / 80 bandes\",\n                \"4\": \"[4] 1/6a octava / 60 bandes\",\n                \"5\": \"[5] 1/4a octava / 40 bandes\",\n                \"6\": \"[6] 1/3a octava / 30 bandes\",\n                \"7\": \"[7] Mitja octava / 20 bandes\",\n                \"8\": \"[8] Octava completa / 10 bandes\",\n                \"10\": \"[10] Línia / Gràfic d'àrea\"\n            }\n        },\n        \"pasteGradient\": \"enganxa degradat\",\n        \"pasteGradientPlaceholder\": \"enganxa el degradat JSON aquí...\"\n    }\n}\n"
  },
  {
    "path": "src/i18n/locales/cs.json",
    "content": "{\n    \"player\": {\n        \"repeat_all\": \"opakovat vše\",\n        \"stop\": \"zastavit\",\n        \"repeat\": \"opakovat\",\n        \"queue_remove\": \"odebrat vybrané\",\n        \"playRandom\": \"přehrát náhodně\",\n        \"skip\": \"přeskočit\",\n        \"previous\": \"předchozí\",\n        \"toggleFullscreenPlayer\": \"přepnout celoobrazovkový přehrávač\",\n        \"skip_back\": \"přeskočit dozadu\",\n        \"favorite\": \"oblíbené\",\n        \"next\": \"další\",\n        \"shuffle\": \"přehrát (náhodně)\",\n        \"playbackFetchNoResults\": \"nenalezeny žádné skladby\",\n        \"playbackFetchInProgress\": \"načítání skladeb…\",\n        \"addNext\": \"další\",\n        \"playbackSpeed\": \"rychlost přehrávání\",\n        \"playbackFetchCancel\": \"chvíli to trvá… zavřete oznámení pro zrušení akce\",\n        \"play\": \"přehrát\",\n        \"repeat_off\": \"opakování zakázáno\",\n        \"pause\": \"pozastavit\",\n        \"queue_clear\": \"vymazat frontu\",\n        \"muted\": \"ztlumeno\",\n        \"unfavorite\": \"odebrat z oblíbených\",\n        \"queue_moveToTop\": \"přesunout vybrané dolů\",\n        \"queue_moveToBottom\": \"přesunout vybrané nahoru\",\n        \"shuffle_off\": \"náhodně zakázáno\",\n        \"addLast\": \"poslední\",\n        \"mute\": \"ztlumit\",\n        \"skip_forward\": \"přeskočit dopředu\",\n        \"playSimilarSongs\": \"přehrát podobné skladby\",\n        \"viewQueue\": \"zobrazit frontu\",\n        \"addLastShuffled\": \"poslední (náhodně)\",\n        \"addNextShuffled\": \"další (náhodně)\",\n        \"holdToShuffle\": \"podržte pro zamíchání\",\n        \"lyrics\": \"texty\",\n        \"restoreQueueFromServer\": \"obnovit frontu ze serveru\",\n        \"saveQueueToServer\": \"uložit frontu na server\",\n        \"artistRadio\": \"rádio umělce\",\n        \"trackRadio\": \"rádio skladby\",\n        \"sleepTimer\": \"časovač spánku\",\n        \"sleepTimer_endOfSong\": \"konec aktuální skladby\",\n        \"sleepTimer_minutes\": \"{{count}} min.\",\n        \"sleepTimer_hours\": \"{{count}} hod.\",\n        \"sleepTimer_custom\": \"vlastní\",\n        \"sleepTimer_off\": \"vypnuto\",\n        \"sleepTimer_timeRemaining\": \"zbývá {{time}}\",\n        \"sleepTimer_setCustom\": \"nastavit časovač\",\n        \"sleepTimer_cancel\": \"zrušit časovač\",\n        \"albumRadio\": \"rádio alba\"\n    },\n    \"setting\": {\n        \"crossfadeStyle_description\": \"vyberte způsob prolnutí u přehrávače zvuku\",\n        \"remotePort_description\": \"nastavení portu pro server vzdáleného ovládání\",\n        \"hotkey_skipBackward\": \"přeskočení zpět\",\n        \"replayGainMode_description\": \"úprava zesílení hlasitosti podle hodnot {{ReplayGain}} uložených v metadatech souborů\",\n        \"volumeWheelStep_description\": \"počet procent, o které má být hlasitost posunuta při přejetí kolečkem myši na posuvníku hlasitosti\",\n        \"audioDevice_description\": \"vyberte zvukové zařízení k přehrávání\",\n        \"theme_description\": \"nastavení motivu použitého v aplikaci\",\n        \"hotkey_playbackPause\": \"pozastavení\",\n        \"replayGainFallback\": \"fallback {{ReplayGain}}\",\n        \"sidebarCollapsedNavigation_description\": \"zobrazit nebo skrýt navigaci ve sbaleném postranním panelu\",\n        \"hotkey_volumeUp\": \"zvýšení hlasitosti\",\n        \"skipDuration\": \"doba k přeskočení\",\n        \"discordIdleStatus_description\": \"při povolení bude upraven stav když je přehrávač nečinný\",\n        \"showSkipButtons\": \"zobrazit tlačítka k přeskočení\",\n        \"playButtonBehavior_optionPlay\": \"$t(player.play)\",\n        \"minimumScrobblePercentage\": \"minimální doba pro scrobblování (v procentech)\",\n        \"lyricFetch\": \"načtení textů z internetu\",\n        \"scrobble\": \"scrobblování\",\n        \"skipDuration_description\": \"nastavení doby k přeskočení při použití tlačítek k přeskočení na liště přehrávače\",\n        \"enableRemote_description\": \"povolí vzdálený ovládací server pro umožnění ostatním zařízením ovládat aplikaci\",\n        \"fontType_optionSystem\": \"systémové písmo\",\n        \"mpvExecutablePath_description\": \"nastavení cesty ke spustitelnému souboru mpv. pokud je prázdné, bude použita výchozí cesta\",\n        \"replayGainClipping_description\": \"Zabránění clippingu způsobenému funkcí {{ReplayGain}} automatickým snížením zesílení\",\n        \"replayGainPreamp\": \"před-zesílení {{ReplayGain}} (dB)\",\n        \"hotkey_favoriteCurrentSong\": \"oblíbit $t(common.currentSong)\",\n        \"sampleRate\": \"vzorkovací frekvence\",\n        \"sidePlayQueueStyle_optionAttached\": \"připojené\",\n        \"sidebarConfiguration\": \"nastavení postranního panelu\",\n        \"sampleRate_description\": \"vyberte výstupní vzorkovací frekvenci k použití, když je vybraná vzorkovací frekvence jiná, než ta u aktuálního média. hodnota nižší než 8000 použije výchozí frekvenci\",\n        \"replayGainMode_optionNone\": \"$t(common.none)\",\n        \"replayGainClipping\": \"clipping {{ReplayGain}}\",\n        \"hotkey_zoomIn\": \"přiblížení\",\n        \"scrobble_description\": \"scrobblovat přehrání na váš multimediální server\",\n        \"hotkey_browserForward\": \"vpřed v prohlížeči\",\n        \"audioExclusiveMode_description\": \"zapnout režim výhradního výstupu. V tomto režimu bude obvykle v systému schopný přehrávat zvuk pouze přehrávač mpv\",\n        \"discordUpdateInterval\": \"interval aktualizací {{discord}} rich presence\",\n        \"themeLight\": \"motiv (světlý)\",\n        \"fontType_optionBuiltIn\": \"vestavěné písmo\",\n        \"hotkey_playbackPlayPause\": \"přehrání / pozastavení\",\n        \"hotkey_rate1\": \"hodnocení 1 hvězdou\",\n        \"hotkey_skipForward\": \"přeskočení vpřed\",\n        \"disableLibraryUpdateOnStartup\": \"vypnout kontrolu nových verzí při spuštění\",\n        \"discordApplicationId_description\": \"id aplikace pro {{discord}} rich presence (výchozí je {{defaultId}})\",\n        \"sidePlayQueueStyle\": \"styl postranní fronty přehrávání\",\n        \"gaplessAudio\": \"zvuk bez mezer\",\n        \"playButtonBehavior_optionAddLast\": \"$t(player.addLast)\",\n        \"zoom\": \"procento přiblížení\",\n        \"minimizeToTray_description\": \"minimalizovat aplikaci do systémové lišty\",\n        \"hotkey_playbackPlay\": \"přehrání\",\n        \"hotkey_togglePreviousSongFavorite\": \"přepnutí oblíbení u $t(common.previousSong)\",\n        \"hotkey_volumeDown\": \"snížení hlasitosti\",\n        \"hotkey_unfavoritePreviousSong\": \"zrušení oblíbení u $t(common.previousSong)\",\n        \"audioPlayer_description\": \"vyberte zvukový přehrávač pro použití k přehrávání\",\n        \"globalMediaHotkeys\": \"globální klávesové zkratky médií\",\n        \"hotkey_globalSearch\": \"globální vyhledávání\",\n        \"gaplessAudio_description\": \"nastavení přehrávače mpv pro přehrávání bez mezer\",\n        \"remoteUsername_description\": \"nastavení uživatelského jména pro server vzdáleného ovládání. pokud je jméno i heslo prázdné, bude autentifikace zakázána\",\n        \"exitToTray_description\": \"ukončit aplikaci do systémové lišty\",\n        \"followLyric_description\": \"přesouvat texty s aktuální pozicí přehrávání\",\n        \"hotkey_favoritePreviousSong\": \"oblíbit $t(common.previousSong)\",\n        \"replayGainMode_optionAlbum\": \"$t(entity.album, {\\\"count\\\": 1})\",\n        \"lyricOffset\": \"odsazení textů (ms)\",\n        \"discordUpdateInterval_description\": \"čas v sekundách mezi každou aktualizací (minimálně 15 sekund)\",\n        \"fontType_optionCustom\": \"vlastní písmo\",\n        \"themeDark_description\": \"nastavit použití tmavého motivu v aplikaci\",\n        \"audioExclusiveMode\": \"režim výhradního výstupu\",\n        \"remotePassword\": \"heslo serveru pro vzdálené ovládání\",\n        \"lyricFetchProvider\": \"poskytovatelé textů\",\n        \"language_description\": \"nastavení jazyka aplikace ($t(common.restartRequired))\",\n        \"playbackStyle_optionCrossFade\": \"křížové prolnutí\",\n        \"hotkey_rate3\": \"hodnocení 3 hvězdami\",\n        \"font\": \"písmo\",\n        \"replayGainMode_optionTrack\": \"$t(entity.track, {\\\"count\\\": 1})\",\n        \"themeLight_description\": \"nastavit použití světlého motivu v aplikaci\",\n        \"hotkey_toggleFullScreenPlayer\": \"přepnutí přehrávače na celou obrazovku\",\n        \"hotkey_localSearch\": \"vyhledávání na stránce\",\n        \"hotkey_toggleQueue\": \"přepnutí fronty\",\n        \"zoom_description\": \"nastavte procento přiblížení aplikace\",\n        \"remotePassword_description\": \"nastavení hesla pro server vzdáleného ovládání. Tyto údaje jsou ve výchozím nastavení přenášeny nezabezpečeným spojením, takže doporučujeme použití unikátního hesla, na kterém vám nezáleží\",\n        \"hotkey_rate5\": \"hodnocení 5 hvězdami\",\n        \"hotkey_playbackPrevious\": \"předchozí skladba\",\n        \"showSkipButtons_description\": \"zobrazit nebo skrýt tlačítka k přeskočení na liště přehrávače\",\n        \"crossfadeDuration_description\": \"nastavte trvání efektu prolnutí\",\n        \"playbackStyle\": \"způsob přehrávání\",\n        \"hotkey_toggleShuffle\": \"přepnutí náhodného přehrávání\",\n        \"theme\": \"motiv\",\n        \"playbackStyle_description\": \"nastavení způsobu přehrávání pro přehrávač zvuku\",\n        \"discordRichPresence_description\": \"povolit stav přehrávání v {{discord}} rich presence. Klíče obrázků jsou: {{icon}}, {{playing}}, {{paused}}\",\n        \"mpvExecutablePath\": \"cesta ke spustitelnému souboru mpv\",\n        \"audioDevice\": \"zvukové zařízení\",\n        \"hotkey_rate2\": \"hodnocení 2 hvězdami\",\n        \"playButtonBehavior_description\": \"nastavení výchozího chování tlačítka přehrávání při přidávání skladeb do fronty\",\n        \"minimumScrobblePercentage_description\": \"minimální procento skladby, které musí být přehráno před jejím scrobblováním\",\n        \"exitToTray\": \"ukončit do lišty\",\n        \"hotkey_rate4\": \"hodnocení 4 hvězdami\",\n        \"enableRemote\": \"povolit vzdálený ovládací server\",\n        \"showSkipButton_description\": \"zobrazit nebo skrýt tlačítka k přeskočení na liště přehrávače\",\n        \"savePlayQueue\": \"uložit frontu přehrávání\",\n        \"minimumScrobbleSeconds_description\": \"minimální doba v sekundách, která musí být přehrána před scrobblováním skladby\",\n        \"skipPlaylistPage_description\": \"při navigaci na playlist přejít na stránku seznamu skladeb v playlistu namísto výchozí stránky\",\n        \"fontType_description\": \"vestavěné písmo vybere jedno z písem poskytovaných programem feishin. systémové písmo vám umožní vybrat si jakékoli písmo poskytované vaším operačním systémem. vlastní vám umožňuje použít vaše vlastní písmo\",\n        \"playButtonBehavior\": \"chování tlačítka přehrávání\",\n        \"volumeWheelStep\": \"krok kolečka hlasitosti\",\n        \"sidebarPlaylistList_description\": \"zobrazit nebo skrýt seznam playlistů v postranním panelu\",\n        \"accentColor\": \"barva\",\n        \"sidePlayQueueStyle_description\": \"nastavení stylu postranní fronty přehrávání\",\n        \"accentColor_description\": \"nastaví barvu aplikace\",\n        \"replayGainMode\": \"režim {{ReplayGain}}\",\n        \"playbackStyle_optionNormal\": \"normální\",\n        \"windowBarStyle\": \"styl záhlaví okna\",\n        \"replayGainFallback_description\": \"zesílení v db k použití, když nemá soubor žádné značky {{ReplayGain}}\",\n        \"replayGainPreamp_description\": \"úprava předběžného zesílení použitého na hodnoty {{ReplayGain}}\",\n        \"hotkey_toggleRepeat\": \"přepnutí opakování\",\n        \"lyricOffset_description\": \"odsazení textů o určité množství milisekund\",\n        \"sidebarConfiguration_description\": \"vyberte položky a pořadí, ve kterém budou v postranním panelu\",\n        \"fontType\": \"typ písma\",\n        \"remotePort\": \"port serveru vzdáleného ovládání\",\n        \"applicationHotkeys\": \"aplikační zkratky\",\n        \"hotkey_playbackNext\": \"další skladba\",\n        \"useSystemTheme_description\": \"následovat systémovou předvolbu světlého nebo tmavého motivu\",\n        \"playButtonBehavior_optionAddNext\": \"$t(player.addNext)\",\n        \"lyricFetch_description\": \"načtení textů z různých internetových zdrojů\",\n        \"lyricFetchProvider_description\": \"vyberte poskytovatele textů\",\n        \"globalMediaHotkeys_description\": \"zapnout nebo vypnout použití vašich systémových zkratek médií pro ovládání přehrávače\",\n        \"customFontPath\": \"vlastní cesta k písmům\",\n        \"followLyric\": \"zobrazit aktuální texty\",\n        \"crossfadeDuration\": \"trvání prolnutí\",\n        \"discordIdleStatus\": \"zobrazit stav nečinnosti v rich presence\",\n        \"sidePlayQueueStyle_optionDetached\": \"odpojené\",\n        \"audioPlayer\": \"zvukový přehrávač\",\n        \"hotkey_zoomOut\": \"oddálení\",\n        \"hotkey_unfavoriteCurrentSong\": \"zrušení oblíbení u $t(common.currentSong)\",\n        \"hotkey_rate0\": \"vymazání hodnocení\",\n        \"discordApplicationId\": \"id aplikace pro {{discord}}\",\n        \"applicationHotkeys_description\": \"nastavení klávesových zkratek aplikace. přepněte pole pro nastavení jako globální zkratku (pouze na počítači)\",\n        \"hotkey_volumeMute\": \"ztlumení\",\n        \"hotkey_toggleCurrentSongFavorite\": \"přepnutí oblíbení u $t(common.currentSong)\",\n        \"remoteUsername\": \"uživatelské jméno serveru vzdáleného ovládání\",\n        \"hotkey_browserBack\": \"zpět v prohlížeči\",\n        \"showSkipButton\": \"zobrazit tlačítka k přeskočení\",\n        \"sidebarPlaylistList\": \"postranní seznam playlistů\",\n        \"minimizeToTray\": \"minimalizovat do lišty\",\n        \"skipPlaylistPage\": \"přeskočit stránku playlistu\",\n        \"themeDark\": \"motiv (tmavý)\",\n        \"sidebarCollapsedNavigation\": \"postranní (sbalená) navigace\",\n        \"customFontPath_description\": \"nastavení cesty k vlastnímu písmu k využití v aplikaci\",\n        \"gaplessAudio_optionWeak\": \"slabý (doporučeno)\",\n        \"minimumScrobbleSeconds\": \"minimální scrobblování (v sekundách)\",\n        \"hotkey_playbackStop\": \"zastavení\",\n        \"windowBarStyle_description\": \"vyberte styl záhlaví okna\",\n        \"font_description\": \"nastavení písma použitého v aplikaci\",\n        \"savePlayQueue_description\": \"uložit frontu přehrávání, když je aplikace zavřena a obnovit ji při otevření aplikace\",\n        \"useSystemTheme\": \"použít systémový motiv\",\n        \"buttonSize\": \"velikost tlačítek lišty přehrávače\",\n        \"buttonSize_description\": \"velikost tlačítek na liště přehrávače\",\n        \"clearCache\": \"vymazat mezipaměť prohlížeče\",\n        \"clearCache_description\": \"„tvrdé pročištění“ aplikace feishin. kromě mezipaměti aplikace feishin vymaže i mezipaměť prohlížeče (uložené obrázky a další zdroje). přihlašovací údaje k serveru a nastavení nebudou ovlivněny\",\n        \"clearQueryCache\": \"vymazat mezipaměť aplikace feishin\",\n        \"clearQueryCache_description\": \"„lehké pročištění“ aplikace feishin. tímto obnovíte seznamy skladeb, metadata skladeb a resetujete uložené texty. nastavení, přihlašovací údaje k serveru a obrázky v mezipaměti nebudou ovlivněny\",\n        \"startMinimized\": \"spustit minimalizované\",\n        \"homeConfiguration_description\": \"nastavte, které položky a v jakém pořadí mají být zobrazeny na domovské stránce\",\n        \"passwordStore\": \"ukládání hesel / tajných klíčů\",\n        \"mpvExtraParameters_help\": \"jeden na řádek\",\n        \"homeConfiguration\": \"nastavení domovské stránky\",\n        \"externalLinks_description\": \"zapne zobrazování externích odkazů (Last.fm, MusicBrainz) na stránce umělce/alba\",\n        \"clearCacheSuccess\": \"mezipaměť úspěšně vymazána\",\n        \"externalLinks\": \"zobrazit externí odkazy\",\n        \"startMinimized_description\": \"spustit aplikaci do systémové lišty\",\n        \"passwordStore_description\": \"který způsob ukládání hesel / tajných klíčů použít. změňte tuto možnost, pokud máte problémy s ukládáním hesel\",\n        \"homeFeature\": \"carousel doporučení na domovské stránce\",\n        \"homeFeature_description\": \"ovládá, zda se má zobrazovat velký carousel s doporučenými alby na domovské stránce\",\n        \"imageAspectRatio\": \"použít nativní poměr stran obalů alb\",\n        \"imageAspectRatio_description\": \"pokud je povoleno, budou obaly alb zobrazeny s jejich nativním poměrem stran. u obalů, které nemají poměr 1:1, bude zbývající místo prázdné\",\n        \"volumeWidth\": \"šířka posuvníku hlasitosti\",\n        \"volumeWidth_description\": \"horizontální velikost posuvníku hlasitosti\",\n        \"discordListening\": \"zobrazit stav jako „Poslouchá“\",\n        \"discordListening_description\": \"zobrazit stav jako „Poslouchá“ namísto „Hraje“\",\n        \"contextMenu\": \"nastavení kontextové nabídky (kliknutí pravým)\",\n        \"contextMenu_description\": \"umožňuje skrýt položky, které se zobrazí v nabídce po kliknutí pravým tlačítkem myši na položku. položky, které nejsou zaškrtnuté, se skryjí\",\n        \"customCssEnable\": \"povolit vlastní css\",\n        \"customCssEnable_description\": \"umožnit psaní vlastního css\",\n        \"customCssNotice\": \"Varování: i když provádíme určitou sanitizaci (zakázáním url() a content:), může používání css stále představovat riziko změnami rozhraní\",\n        \"customCss_description\": \"vlastní css obsah. Upozornění: vlastnosti content a vzdálené url jsou zakázané. Níže je zobrazen náhled vašeho obsahu. Další pole, která jste nenastavili, jsou přítomna z důvodu sanitizace\",\n        \"customCss\": \"vlastní css\",\n        \"webAudio\": \"použít webový zvuk\",\n        \"webAudio_description\": \"použít webový zvuk. tím povolíte pokročilé funkce jako replaygain. zakažte, pokud se objeví problémy\",\n        \"transcode_description\": \"zapnout překódování do různých formátů\",\n        \"transcodeFormat_description\": \"vybere formát k překódování. pokud chcete nechat rozhodnout server, ponechte prázdné\",\n        \"transcodeFormat\": \"formát k překódování\",\n        \"transcodeBitrate\": \"datový tok k překódování\",\n        \"transcodeBitrate_description\": \"vybere datový tok k překódování. 0 znamená, že necháte server vybrat\",\n        \"albumBackground\": \"obrázek alba na pozadí\",\n        \"albumBackground_description\": \"přidá obrázek alba na pozadí pro stránky alba obsahující obrázky alba\",\n        \"albumBackgroundBlur\": \"velikost rozostření obrázku alba na pozadí\",\n        \"albumBackgroundBlur_description\": \"upraví množství rozostření použité na obrázek alba na pozadí\",\n        \"playerbarOpenDrawer\": \"lišta přehrávače jako přepínač celé obrazovky\",\n        \"playerbarOpenDrawer_description\": \"umožňuje kliknutí na lištu přehrávače pro otevření celoobrazovkového přehrávače\",\n        \"artistConfiguration\": \"nastavení stránky umělce alba\",\n        \"artistConfiguration_description\": \"nastavit, které položky na stránce umělce alba budou zobrazeny a v jakém pořadí\",\n        \"playButtonBehavior_optionPlayShuffled\": \"$t(player.shuffle)\",\n        \"trayEnabled\": \"zobrazit v oznamovací oblasti\",\n        \"trayEnabled_description\": \"zobrazit/skrýt ikonu/nabídku v oznamovací oblasti. pokud je zakázáno, vypne také minimalizaci/ukončení do oznamovací oblasti\",\n        \"translationApiProvider\": \"poskytovatel api překladů\",\n        \"translationApiProvider_description\": \"poskytovatel api pro překlady\",\n        \"translationApiKey\": \"klíč api překladů\",\n        \"translationApiKey_description\": \"klíč api pro překlady (pouze koncový bod globální služby)\",\n        \"translationTargetLanguage\": \"cílový jazyk překladu\",\n        \"translationTargetLanguage_description\": \"cílový jazyk pro překlad\",\n        \"lastfmApiKey\": \"klíč API {{lastfm}}\",\n        \"lastfmApiKey_description\": \"klíč API pro {{lastfm}}. vyžadováno pro obaly alb\",\n        \"discordServeImage\": \"načítat obrázky {{discord}} ze serveru\",\n        \"discordServeImage_description\": \"sdílet obaly alb pro {{discord}} rich presence ze samotného serveru, dostupné pouze pro Jellyfin a Navidrome. {{discord}} používá bota pro získávání obrázků, váš server tudíž musí být dosažitelný z veřejného internetu\",\n        \"lastfm\": \"zobrazit odkazy na last.fm\",\n        \"lastfm_description\": \"na stránkách umělců a alb zobrazit odkazy na Last.fm\",\n        \"musicbrainz\": \"zobrazit odkazy na MusicBrainz\",\n        \"musicbrainz_description\": \"na stránkách umělců a alb, kde existuje MusicBrainz ID, zobrazit odkazy na MusicBrainz\",\n        \"neteaseTranslation\": \"Povolit překlady NetEase\",\n        \"neteaseTranslation_description\": \"Pokud je povoleno, načte a zobrazí přeložené texty ze služby NetEase, pokud jsou dostupné\",\n        \"preferLocalLyrics\": \"preferovat místní texty\",\n        \"preferLocalLyrics_description\": \"preferovat místní texty před vzdálenými, pokud jsou dostupné\",\n        \"discordPausedStatus\": \"zobrazit stav při pozastavení\",\n        \"discordPausedStatus_description\": \"pokud je povoleno, bude při pozastavení přehrávače zobrazen stav\",\n        \"preservePitch\": \"zachovat výšku\",\n        \"preservePitch_description\": \"zachová výšku při úpravě rychlosti přehrávání\",\n        \"discordDisplayType\": \"typ zobrazení stavu {{discord}}\",\n        \"discordDisplayType_description\": \"změní, co posloucháte, ve vašem stavu\",\n        \"discordDisplayType_songname\": \"název skladby\",\n        \"discordDisplayType_artistname\": \"jména umělců\",\n        \"hotkey_navigateHome\": \"přejít domů\",\n        \"preventSleepOnPlayback\": \"zabránit uspání při přehrávání\",\n        \"preventSleepOnPlayback_description\": \"zabránit uspání displeje během přehrávání hudby\",\n        \"discordLinkType\": \"odkazy ve stavu na {{discord}}u\",\n        \"discordLinkType_description\": \"přidá externí odkazy na {{lastfm}} nebo {{musicbrainz}} do polí skladby a umělce ve stavu na službě {{discord}}. {{musicbrainz}} je nejpřesnější, ale vyžaduje značky a neposkytuje odkazy na umělce, zatímco {{lastfm}} by mělo vždy poskytnout odkaz. neprovádí žádné další síťové požadavky\",\n        \"discordLinkType_none\": \"$t(common.none)\",\n        \"discordLinkType_mbz_lastfm\": \"{{musicbrainz}} se zálohou na {{lastfm}}\",\n        \"artistBackground\": \"obrázek umělce na pozadí\",\n        \"artistBackground_description\": \"přidá obrázek na pozadí u stránek umělců\",\n        \"artistBackgroundBlur\": \"velikost rozostření obrázku umělce na pozadí\",\n        \"artistBackgroundBlur_description\": \"upraví velikost rozostření použitého na obrázek umělce na pozadí\",\n        \"releaseChannel_optionLatest\": \"nejnovější\",\n        \"releaseChannel_optionBeta\": \"beta\",\n        \"releaseChannel\": \"kanál vydání\",\n        \"releaseChannel_description\": \"vyberte si mezi stabilními, beta nebo alpha (nočními) vydáními pro automatické aktualizace\",\n        \"mediaSession\": \"povolit relaci médií\",\n        \"mediaSession_description\": \"povolí integraci do služby Media Session, což zobrazí ovládání a metadata médií v překrytí systémové hlasitosti a na zamykací obrazovce\",\n        \"exportImportSettings_control_description\": \"exportovat a importovat nastavení pomocí souboru JSON\",\n        \"exportImportSettings_control_exportText\": \"exportovat nastavení\",\n        \"exportImportSettings_control_importText\": \"importovat nastavení\",\n        \"exportImportSettings_control_title\": \"importovat / exportovat nastavení\",\n        \"exportImportSettings_destructiveWarning\": \"importování nastavení je destruktivní, přečtěte si prosím informace výše, než kliknete na tlačítko „importovat“!\",\n        \"exportImportSettings_importBtn\": \"importovat nastavení\",\n        \"exportImportSettings_importModalTitle\": \"importovat nastavení feishin\",\n        \"exportImportSettings_importSuccess\": \"nastavení byla úspěšně importována!\",\n        \"exportImportSettings_notValidJSON\": \"poskytnutý soubor není platným souborem JSON\",\n        \"exportImportSettings_offendingKeyError\": \"Řetězec „{{offendingKey}}“ je nesprávný – {{reason}}\",\n        \"crossfadeStyle\": \"styl prolnutí\",\n        \"discordRichPresence\": \"stav na {{discord}}u\",\n        \"enableAutoTranslation_description\": \"povolit automatický překlad při načtení textů\",\n        \"enableAutoTranslation\": \"povolit automatický překlad\",\n        \"language\": \"jazyk\",\n        \"notify\": \"povolit oznámení o skladbách\",\n        \"notify_description\": \"zobrazit oznámení při změně aktuální skladby\",\n        \"transcode\": \"povolit překódování\",\n        \"analyticsDisable\": \"Odhlásit se z analytiky používání aplikace\",\n        \"analyticsDisable_description\": \"Pro zlepšení aplikace jsou vývojáři odesílána anonymizovaná data o používání\",\n        \"playerbarSlider\": \"posuvník lišty přehrávače\",\n        \"playerbarSliderType_optionSlider\": \"posuvník\",\n        \"playerbarSliderType_optionWaveform\": \"vlnová křivka\",\n        \"playerbarWaveformAlign\": \"pozice vlnové křivky\",\n        \"playerbarWaveformAlign_optionTop\": \"nahoře\",\n        \"playerbarWaveformAlign_optionCenter\": \"uprostřed\",\n        \"playerbarWaveformAlign_optionBottom\": \"dole\",\n        \"playerbarWaveformBarWidth\": \"šířka sloupců vlnové křivky\",\n        \"playerbarWaveformGap\": \"mezera vlnové křivky\",\n        \"playerbarWaveformRadius\": \"poloměr vlnové křivky\",\n        \"showLyricsInSidebar_description\": \"do připojené fronty přehrávání bude přidán panel, který zobrazuje texty\",\n        \"showLyricsInSidebar\": \"zobrazit texty v postranní liště přehrávače\",\n        \"showVisualizerInSidebar_description\": \"do postranní lišty přehrávače bude přidán panel, který zobrazuje vizualizér\",\n        \"showVisualizerInSidebar\": \"zobrazit vizualizér v postranní liště přehrávače\",\n        \"queryBuilder\": \"sestavení dotazu\",\n        \"queryBuilderCustomFields_inputLabel\": \"štítek\",\n        \"queryBuilderCustomFields_inputTag\": \"značka\",\n        \"queryBuilderCustomFields\": \"vlastní pole\",\n        \"queryBuilderCustomFields_description\": \"přidat vlasntí pole k použití při sestavování dotazů\",\n        \"audioFadeOnStatusChange\": \"zeslabení zvuku při změně stavu\",\n        \"audioFadeOnStatusChange_description\": \"povolí postupné zeslabení a zesílení zvuku při změně stavu přehrávání/pozastavení\",\n        \"followCurrentSong_description\": \"automaticky posouvat frontu přehrávání na právě hrající skladbu\",\n        \"followCurrentSong\": \"následovat aktuální skladbu\",\n        \"playerFilters\": \"Filtrovat skladby z fronty\",\n        \"playerFilters_description\": \"vynechat skladby z přidání do fronty na základě následujících kritérií\",\n        \"playerbarSlider_description\": \"vlnová křivka není doporučena, pokud se nacházíte na pomalém nebo měřeném internetovém připojení\",\n        \"autoDJ\": \"automatický DJ\",\n        \"autoDJ_description\": \"automaticky přidávat podobné skladby do fronty\",\n        \"autoDJ_itemCount\": \"počet položek\",\n        \"autoDJ_itemCount_description\": \"počet položek, které se pokusíme přidat do fronty po povolení automatického DJ\",\n        \"autoDJ_timing\": \"časování\",\n        \"autoDJ_timing_description\": \"počet skladeb zbývajících ve frontě před spuštěním automatického DJ\",\n        \"logLevel\": \"úroveň protokolu\",\n        \"logLevel_description\": \"nastaví minimální úroveň protokolu k zobrazení. ladění zobrazuje vše, možnost chyba zobrazí pouze chyby\",\n        \"logLevel_optionDebug\": \"ladění\",\n        \"logLevel_optionError\": \"chyba\",\n        \"logLevel_optionInfo\": \"informace\",\n        \"logLevel_optionWarn\": \"varování\",\n        \"useThemeAccentColor\": \"použít barvu motivu\",\n        \"useThemeAccentColor_description\": \"použít primární barvu definovanou ve zvoleném motivu namísto vlastní barvy rozhraní\",\n        \"artistRadioCount_description\": \"nastaví počet skladeb, které načíst pro rádio umělce a rádio skladby\",\n        \"artistRadioCount\": \"počet skladeb pro rádio umělce/skladby\",\n        \"imageResolution\": \"rozlišení obrázků\",\n        \"imageResolution_description\": \"rozlišení obrázků používaných napříč aplikací. nastavení hodnoty 0 použije nativní rozlišení obrázku\",\n        \"imageResolution_optionTable\": \"tabulka\",\n        \"imageResolution_optionItemCard\": \"karta položky\",\n        \"imageResolution_optionSidebar\": \"postranní lišta\",\n        \"imageResolution_optionHeader\": \"záhlaví\",\n        \"imageResolution_optionFullScreenPlayer\": \"přehrávač na celé obrazovce\",\n        \"combinedLyricsAndVisualizer_description\": \"spojit texty a vizualizér do jednoho panelu\",\n        \"combinedLyricsAndVisualizer\": \"spojit texty a vizualizér v postranní liště přehrávače\",\n        \"showRatings_description\": \"ovládá, zda se funkce hodnocení pomocí hvězdiček objeví v rozhraní\",\n        \"showRatings\": \"zobrazit hodnocení pomocí hvězdiček\",\n        \"artistReleaseTypeConfiguration\": \"nastavení typu vydání umělce\",\n        \"artistReleaseTypeConfiguration_description\": \"nastavit, jaké typy vydání a v jakém pořadí jsou zobrazeny na stránce umělce alba\",\n        \"mpvExtraParameters\": \"extra parametry mpv\",\n        \"mpvExtraParameters_description\": \"další argumenty, které předat přehrávači mpv\",\n        \"hotkey_listNavigateToPage\": \"navigace na stránku položky v seznamu\",\n        \"hotkey_listPlayDefault\": \"přehrání v seznamu\",\n        \"hotkey_listPlayLast\": \"přehrání poslední položky v seznamu\",\n        \"hotkey_listPlayNext\": \"přehrání další položky v seznamu\",\n        \"hotkey_listPlayNow\": \"okamžité přehrání v seznamu\",\n        \"pathReplace\": \"nahrazení cesty k souborům\",\n        \"pathReplace_description\": \"nahradit výchozí cestu k souborům vašeho serveru\",\n        \"pathReplace_optionRemovePrefix\": \"odstranit předponu\",\n        \"pathReplace_optionAddPrefix\": \"přidat předponu\",\n        \"homeFeatureStyle_description\": \"ovládá styl doporučených skladeb na domovské stránce\",\n        \"homeFeatureStyle\": \"styl doporučených na domovské stránce\",\n        \"homeFeatureStyle_optionMultiple\": \"několik\",\n        \"homeFeatureStyle_optionSingle\": \"jeden\",\n        \"enableGridMultiSelect\": \"povolit vícenásobný výběr v mřížce\",\n        \"enableGridMultiSelect_description\": \"pokud je povoleno, umožňuje vybrat několik položek v zobrazení mřížky. pokud je zakázáno, kliknutím na obrázek položky mřížky přejdete na stránku položky\",\n        \"sidebarPlaylistSorting_description\": \"umožňuje ruční řazení seznamů skladeb v postranní liště pomocí přetažení namísto výchozího pořadí serveru\",\n        \"sidebarPlaylistSorting\": \"řazení seznamů skladeb v postranní liště\",\n        \"blurExplicitImages\": \"rozostřit explicitní obrázky\",\n        \"blurExplicitImages_description\": \"obaly alb a skladeb označené jako explicitní budou rozostřeny\",\n        \"sidebarPlaylistListFilterRegex_description\": \"v postranní liště skrýt seznamy skladeb, které odpovídají tomuto regulárnímu výrazu\",\n        \"sidebarPlaylistListFilterRegex_placeholder\": \"např. ^Denní mix.*\",\n        \"sidebarPlaylistListFilterRegex\": \"regulární výraz filtru seznamů skladeb\",\n        \"releaseChannel_optionAlpha\": \"alpha (noční)\",\n        \"analyticsEnable\": \"Posílat analytiku založenou na využití\",\n        \"analyticsEnable_description\": \"Anonymizovaná data o používání jsou odesílána vývojáři za účelem zlepšení aplikace\",\n        \"automaticUpdates\": \"Automatické aktualizace\",\n        \"automaticUpdates_description\": \"Kontrolovat a automaticky instalovat aktualizace\",\n        \"discordStateIcon\": \"zobrazit ikonu přehrávání\",\n        \"discordStateIcon_description\": \"zobrazit malou ikonu přehrávání ve stavu na Discordu. ikona pozastavení bude zobrazena vždy, když je povolena možnost „Zobrazit stav při pozastavení“\",\n        \"useThemePrimaryShade\": \"použít primární odstín motivu\",\n        \"useThemePrimaryShade_description\": \"použít primární odstín definovaný ve zvoleném motivu pro primární varianty barev\",\n        \"primaryShade\": \"primární odstín\",\n        \"primaryShade_description\": \"přepsat primární odstín (0–9) používaný pro tlačítka, odkazy a další prvky obarvené primární barvou\",\n        \"playerItemConfiguration_description\": \"nastavit, které položky budou zobrazeny a v jakém pořadí, v celoobrazovkovém přehrávači\",\n        \"playerItemConfiguration\": \"nastavení položek přehrávače\",\n        \"autosave\": \"automaticky ukládat frontu přehrávání\",\n        \"autosave_description\": \"zapnout automatické ukládání fronty přehrávání na server. toto je možné pouze při použití Navidrome/Subsonic a není možné mít kombinovanou frontu přehrávání.\",\n        \"autosaveCount\": \"četnost automatického ukládání fronty přehrávání\",\n        \"autosaveCount_description\": \"kolik změn skladeb se může provést před uložením fronty. 1 (minimum) znamená při každé změně skladby\",\n        \"spotify_description\": \"na stránkách umělců a alb zobrazit odkazy na Spotify\",\n        \"spotify\": \"zobrazit odkazy na Spotify\",\n        \"nativeSpotify_description\": \"otevřít v aplikaci Spotify namísto vašeho prohlížeče\",\n        \"nativeSpotify\": \"použít aplikaci Spotify\",\n        \"listenbrainz_description\": \"na stránkách umělců a alb zobrazit odkazy na ListenBrainz\",\n        \"listenbrainz\": \"zobrazit odkazy na ListenBrainz\",\n        \"qobuz_description\": \"na stránkách umělců a alb zobrazit odkazy na Qobuz\",\n        \"qobuz\": \"zobrazit odkazy na Qobuz\",\n        \"sidePlayQueueLayout\": \"rozložení postranní fronty přehrávání\",\n        \"sidePlayQueueLayout_description\": \"nastaví rozložení postranní lišty přehrávání\",\n        \"sidePlayQueueLayout_optionHorizontal\": \"na šířku\",\n        \"sidePlayQueueLayout_optionVertical\": \"na výšku\"\n    },\n    \"action\": {\n        \"editPlaylist\": \"upravit $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"goToPage\": \"přejít na stránku\",\n        \"moveToTop\": \"přesunout nahoru\",\n        \"clearQueue\": \"vymazat frontu\",\n        \"addToFavorites\": \"přidat do $t(entity.favorite, {\\\"count\\\": 2})\",\n        \"addToPlaylist\": \"přidat do $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"createPlaylist\": \"vytvořit $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"removeFromPlaylist\": \"odebrat z $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"viewPlaylists\": \"zobrazit $t(entity.playlist, {\\\"count\\\": 2})\",\n        \"refresh\": \"$t(common.refresh)\",\n        \"deletePlaylist\": \"odstranit $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"removeFromQueue\": \"odebrat z fronty\",\n        \"deselectAll\": \"zrušit výběr všeho\",\n        \"moveToBottom\": \"přesunout dolů\",\n        \"setRating\": \"nastavit hodnocení\",\n        \"toggleSmartPlaylistEditor\": \"přepnout editor $t(entity.smartPlaylist)\",\n        \"removeFromFavorites\": \"odebrat z $t(entity.favorite, {\\\"count\\\": 2})\",\n        \"openIn\": {\n            \"lastfm\": \"Otevřít v Last.fm\",\n            \"musicbrainz\": \"Otevřít v MusicBrainz\",\n            \"spotify\": \"Otevřít na Spotify\",\n            \"listenbrainz\": \"Otevřít ve službě ListenBrainz\",\n            \"qobuz\": \"Otevřít ve službě Qobuz\"\n        },\n        \"moveToNext\": \"přesunout na další\",\n        \"downloadStarted\": \"spuštěno stahování {{count}} položek\",\n        \"moveItems\": \"přesunout položky\",\n        \"shuffle\": \"náhodně\",\n        \"shuffleAll\": \"vše náhodně\",\n        \"shuffleSelected\": \"vybrané náhodně\",\n        \"viewMore\": \"zobrazit více\",\n        \"moveUp\": \"posunout nahoru\",\n        \"moveDown\": \"posunout dolů\",\n        \"holdToMoveToTop\": \"podržte pro přesunutí nahoru\",\n        \"holdToMoveToBottom\": \"podržte pro přesunutí dolů\",\n        \"createRadioStation\": \"vytvořit $t(entity.radioStation, {\\\"count\\\": 1})\",\n        \"deleteRadioStation\": \"odstranit $t(entity.radioStation, {\\\"count\\\": 1})\",\n        \"openApplicationDirectory\": \"otevřít adresář aplikace\",\n        \"addOrRemoveFromSelection\": \"přidat nebo odebrat z výběru\",\n        \"selectRangeOfItems\": \"vyberte rozsah položek\",\n        \"selectAll\": \"vybrat vše\",\n        \"goToCurrent\": \"přejít na aktuální položku\"\n    },\n    \"common\": {\n        \"backward\": \"zpátky\",\n        \"increase\": \"zvýčit\",\n        \"rating\": \"hodnocení\",\n        \"bpm\": \"bpm\",\n        \"refresh\": \"obnovit\",\n        \"unknown\": \"neznámý\",\n        \"areYouSure\": \"opravdu?\",\n        \"edit\": \"upravit\",\n        \"favorite\": \"oblíbený\",\n        \"left\": \"vlevo\",\n        \"save\": \"uložit\",\n        \"right\": \"vpravo\",\n        \"currentSong\": \"aktuální $t(entity.track, {\\\"count\\\": 1})\",\n        \"collapse\": \"sbalit\",\n        \"trackNumber\": \"stopa\",\n        \"descending\": \"sestupně\",\n        \"add\": \"přidat\",\n        \"gap\": \"mezera\",\n        \"ascending\": \"vzestupně\",\n        \"dismiss\": \"zavřít\",\n        \"year\": \"rok\",\n        \"manage\": \"správa\",\n        \"limit\": \"limit\",\n        \"minimize\": \"minimalizovat\",\n        \"modified\": \"upraveno\",\n        \"duration\": \"trvání\",\n        \"name\": \"název\",\n        \"maximize\": \"maximalizovat\",\n        \"decrease\": \"snížit\",\n        \"ok\": \"ok\",\n        \"description\": \"popis\",\n        \"configure\": \"nastavit\",\n        \"path\": \"cesta\",\n        \"center\": \"uprostřed\",\n        \"no\": \"ne\",\n        \"owner\": \"majitel\",\n        \"enable\": \"zapnout\",\n        \"clear\": \"vymazat\",\n        \"forward\": \"vpřed\",\n        \"delete\": \"odstranit\",\n        \"cancel\": \"zrušit\",\n        \"forceRestartRequired\": \"restartujte pro použití změn… zavřete oznámení pro restartování\",\n        \"setting_one\": \"nastavení\",\n        \"setting_few\": \"nastavení\",\n        \"setting_other\": \"nastavení\",\n        \"version\": \"verze\",\n        \"title\": \"název\",\n        \"filter_one\": \"filtr\",\n        \"filter_few\": \"filtry\",\n        \"filter_other\": \"filtrů\",\n        \"filters\": \"filtry\",\n        \"create\": \"vytvořit\",\n        \"bitrate\": \"datový tok\",\n        \"saveAndReplace\": \"uložit a nahradit\",\n        \"action_one\": \"akce\",\n        \"action_few\": \"akce\",\n        \"action_other\": \"akcí\",\n        \"playerMustBePaused\": \"přehrávač musí být pozastaven\",\n        \"confirm\": \"potvrdit\",\n        \"resetToDefault\": \"resetovat na výchozí\",\n        \"home\": \"domů\",\n        \"comingSoon\": \"již brzy…\",\n        \"reset\": \"resetovat\",\n        \"channel_one\": \"kanál\",\n        \"channel_few\": \"kanály\",\n        \"channel_other\": \"kanálů\",\n        \"disable\": \"vypnout\",\n        \"sortOrder\": \"pořadí\",\n        \"none\": \"žádný\",\n        \"menu\": \"nabídka\",\n        \"restartRequired\": \"vyžadován restart\",\n        \"previousSong\": \"předchozí $t(entity.track, {\\\"count\\\": 1})\",\n        \"noResultsFromQuery\": \"nebyly nalezeny žádné výsledky\",\n        \"quit\": \"ukončit\",\n        \"expand\": \"rozbalit\",\n        \"search\": \"hledat\",\n        \"saveAs\": \"uložit jako\",\n        \"disc\": \"disk\",\n        \"yes\": \"ano\",\n        \"random\": \"náhodně\",\n        \"size\": \"velikost\",\n        \"biography\": \"biografie\",\n        \"note\": \"poznámka\",\n        \"albumGain\": \"gain alba\",\n        \"albumPeak\": \"vrchol alba\",\n        \"close\": \"zavřít\",\n        \"mbid\": \"ID MusicBrainz\",\n        \"trackGain\": \"zisk (gain) skladby\",\n        \"reload\": \"znovu načíst\",\n        \"share\": \"sdílet\",\n        \"codec\": \"kodek\",\n        \"trackPeak\": \"vrchol skladby\",\n        \"preview\": \"náhled\",\n        \"translation\": \"překlad\",\n        \"additionalParticipants\": \"další přispívající\",\n        \"tags\": \"štítky\",\n        \"viewReleaseNotes\": \"zobrazit seznam změn\",\n        \"newVersion\": \"byla nainstalována nová verze ({{version}})\",\n        \"bitDepth\": \"bitová hloubka\",\n        \"sampleRate\": \"vzorkovací frekvence\",\n        \"explicitStatus\": \"stav explicitivity\",\n        \"explicit\": \"explicitní\",\n        \"clean\": \"čisté\",\n        \"private\": \"soukromý\",\n        \"public\": \"veřejný\",\n        \"recordLabel\": \"vydavatelství\",\n        \"releaseType\": \"typ vydání\",\n        \"doNotShowAgain\": \"již nezobrazovat\",\n        \"externalLinks\": \"externí odkazy\",\n        \"faster\": \"rychlejší\",\n        \"slower\": \"pomalejší\",\n        \"sort\": \"seřadit\",\n        \"gridRows\": \"řádky mřížky\",\n        \"tableColumns\": \"sloupce tabulky\",\n        \"itemsMore\": \"{{count}} dalších\",\n        \"noFilters\": \"nejsou nastaveny žádné filtry\",\n        \"view\": \"zobrazit\",\n        \"countSelected\": \"vybráno {{count}}\",\n        \"retry\": \"zkusit znovu\",\n        \"mood\": \"nálada\",\n        \"example\": \"příklad\",\n        \"filter_single\": \"jeden\",\n        \"filter_multiple\": \"několik\",\n        \"rename\": \"přejmenovat\",\n        \"newVersionAvailable\": \"je dostupná nová verze\",\n        \"numberOfResults\": \"{{numberOfResults}} výsledků\"\n    },\n    \"table\": {\n        \"config\": {\n            \"view\": {\n                \"table\": \"tabulka\",\n                \"list\": \"seznam\",\n                \"grid\": \"mřížka\",\n                \"detail\": \"podrobnosti\"\n            },\n            \"general\": {\n                \"displayType\": \"typ zobrazení\",\n                \"gap\": \"$t(common.gap)\",\n                \"tableColumns\": \"sloupce tabulky\",\n                \"autoFitColumns\": \"automaticky přizpůsobit sloupce\",\n                \"size\": \"$t(common.size)\",\n                \"itemGap\": \"mezera mezi položkami (px)\",\n                \"itemSize\": \"velikost položek (px)\",\n                \"followCurrentSong\": \"následovat aktuální skladbu\",\n                \"advancedSettings\": \"pokročilá nastavení\",\n                \"autosize\": \"automatická velikost\",\n                \"moveUp\": \"posunout nahoru\",\n                \"moveDown\": \"posunout dolů\",\n                \"pinToLeft\": \"připnout doleva\",\n                \"pinToRight\": \"připnout doprava\",\n                \"alignLeft\": \"zarovnat doleva\",\n                \"alignCenter\": \"zarovnat doprostřed\",\n                \"alignRight\": \"zarovat doprava\",\n                \"itemsPerRow\": \"položky na řádek\",\n                \"size_default\": \"výchozí\",\n                \"size_compact\": \"kompaktní\",\n                \"size_large\": \"velký\",\n                \"pagination\": \"stránkování\",\n                \"pagination_itemsPerPage\": \"položky na stránku\",\n                \"pagination_infinite\": \"nekonečno\",\n                \"pagination_paginate\": \"stránkované\",\n                \"alternateRowColors\": \"střídat barvy řádků\",\n                \"horizontalBorders\": \"okraje řádků\",\n                \"rowHoverHighlight\": \"zvýraznění řádku při přejetí myší\",\n                \"verticalBorders\": \"okraje sloupců\",\n                \"showHeader\": \"zobrazit záhlaví\"\n            },\n            \"label\": {\n                \"releaseDate\": \"datum vydání\",\n                \"title\": \"$t(common.title)\",\n                \"duration\": \"$t(common.duration)\",\n                \"titleCombined\": \"$t(common.title) (kombinovaný)\",\n                \"dateAdded\": \"datum přidání\",\n                \"size\": \"$t(common.size)\",\n                \"bpm\": \"$t(common.bpm)\",\n                \"lastPlayed\": \"naposledy přehráno\",\n                \"trackNumber\": \"číslo stopy\",\n                \"rowIndex\": \"index řádku\",\n                \"rating\": \"$t(common.rating)\",\n                \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n                \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n                \"note\": \"$t(common.note)\",\n                \"biography\": \"$t(common.biography)\",\n                \"owner\": \"$t(common.owner)\",\n                \"path\": \"$t(common.path)\",\n                \"channels\": \"$t(common.channel, {\\\"count\\\": 2})\",\n                \"playCount\": \"počet přehrání\",\n                \"bitrate\": \"$t(common.bitrate)\",\n                \"actions\": \"$t(common.action, {\\\"count\\\": 2})\",\n                \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n                \"discNumber\": \"číslo disku\",\n                \"favorite\": \"$t(common.favorite)\",\n                \"year\": \"$t(common.year)\",\n                \"albumArtist\": \"$t(entity.albumArtist, {\\\"count\\\": 1})\",\n                \"codec\": \"$t(common.codec)\",\n                \"songCount\": \"$t(entity.track, {\\\"count\\\": 2})\",\n                \"albumCount\": \"$t(entity.album, {\\\"count\\\": 2})\",\n                \"genreBadge\": \"$t(entity.genre, {\\\"count\\\": 1}) (značky)\",\n                \"image\": \"obrázek\",\n                \"bitDepth\": \"$t(common.bitDepth)\",\n                \"sampleRate\": \"$t(common.sampleRate)\",\n                \"composer\": \"skladatel\",\n                \"titleArtist\": \"$t(common.title) (umělec)\",\n                \"albumGroup\": \"skupina alb\"\n            }\n        },\n        \"column\": {\n            \"comment\": \"komentář\",\n            \"album\": \"album\",\n            \"rating\": \"hodnocení\",\n            \"favorite\": \"oblíbené\",\n            \"playCount\": \"přehrání\",\n            \"albumCount\": \"$t(entity.album, {\\\"count\\\": 2})\",\n            \"releaseYear\": \"rok\",\n            \"lastPlayed\": \"naposledy přehráno\",\n            \"biography\": \"biografie\",\n            \"releaseDate\": \"datum vydání\",\n            \"bitrate\": \"datový tok\",\n            \"title\": \"název\",\n            \"bpm\": \"bpm\",\n            \"dateAdded\": \"datum přidání\",\n            \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n            \"songCount\": \"$t(entity.track, {\\\"count\\\": 2})\",\n            \"trackNumber\": \"skladba\",\n            \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n            \"albumArtist\": \"umělec alba\",\n            \"path\": \"cesta\",\n            \"discNumber\": \"disk\",\n            \"channels\": \"$t(common.channel, {\\\"count\\\": 2})\",\n            \"size\": \"$t(common.size)\",\n            \"codec\": \"$t(common.codec)\",\n            \"owner\": \"majitel\",\n            \"bitDepth\": \"$t(common.bitDepth)\",\n            \"sampleRate\": \"$t(common.sampleRate)\"\n        }\n    },\n    \"error\": {\n        \"remotePortWarning\": \"restartujte server pro použití nového portu\",\n        \"systemFontError\": \"při pokusu o získání systémových písem se vyskytla chyba\",\n        \"playbackError\": \"při pokusu o přehrání médií se vyskytla chyba\",\n        \"endpointNotImplementedError\": \"endpoint {{endpoint}} není u serveru {{serverType}} implementován\",\n        \"remotePortError\": \"při pokusu o nastavení portu vzdáleného serveru se vyskytla chyba\",\n        \"serverRequired\": \"vyžadován server\",\n        \"authenticationFailed\": \"ověření selhalo\",\n        \"apiRouteError\": \"nepodařilo se přesměrovat žádost\",\n        \"genericError\": \"vyskytla se chyba\",\n        \"credentialsRequired\": \"vyžadovány údaje\",\n        \"sessionExpiredError\": \"vaše relace vypršela\",\n        \"remoteEnableError\": \"při pokusu $t(common.enable) vzdálený server se vyskytla chyba\",\n        \"localFontAccessDenied\": \"přístup k místním písmům zakázán\",\n        \"serverNotSelectedError\": \"není vybrán žádný server\",\n        \"remoteDisableError\": \"při pokusu $t(common.disable) vzdálený server se vyskytla chyba\",\n        \"mpvRequired\": \"vyžadován přehrávač MPV\",\n        \"audioDeviceFetchError\": \"při pokusu o přístup ke zvukovým zařízením se vyskytla chyba\",\n        \"invalidServer\": \"neplatný server\",\n        \"loginRateError\": \"příliš mnoho pokusů o přihlášení, zkuste to znovu za pár vteřin\",\n        \"badAlbum\": \"tuto stránku vidíte, protože tato skladba není součástí alba. tento problém může nastat, pokud máte skladbu na nejvyšší úrovni vaší složky s hudbou. Jellyfin seskupuje skladby pouze, pokud se nacházejí ve složce\",\n        \"networkError\": \"vyskytla se chyba sítě\",\n        \"openError\": \"nepodařilo se otevřít soubor\",\n        \"badValue\": \"neplatná možnost „{{value}}“. tato možnost již neexistuje\",\n        \"notificationDenied\": \"oprávnění k posílání oznámení byla zamítnuta. toto nastavení nemá žádný vliv\",\n        \"multipleServerSaveQueueError\": \"fronta přehrávání má jednu nebo více skladeb, které nejsou z aktuálního serveru. tato funkce není podporována\",\n        \"saveQueueFailed\": \"nepodařilo se uložit frontu\",\n        \"settingsSyncError\": \"byly zjištěny nesrovnalosti mezi nastavením v rendereru a hlavním procesem. restartujte aplikaci, aby se změny projevily\",\n        \"noNetwork\": \"server je nedostupný\",\n        \"noNetworkDescription\": \"k tomuto serveru se nepodařilo připojit\",\n        \"invalidJson\": \"neplatný JSON\",\n        \"serverLockSingleServer\": \"při uzamčení serveru je povolen pouze jeden server\",\n        \"playbackPausedDueToError\": \"přehrávání bylo pozastaveno z důvodu chyby\"\n    },\n    \"filter\": {\n        \"mostPlayed\": \"nejvíce přehráváno\",\n        \"comment\": \"komentář\",\n        \"playCount\": \"počet přehrání\",\n        \"recentlyUpdated\": \"nedávno upraveno\",\n        \"channels\": \"$t(common.channel, {\\\"count\\\": 2})\",\n        \"isCompilation\": \"je kompilace\",\n        \"recentlyPlayed\": \"nedávno přehráno\",\n        \"isRated\": \"je hodnoceno\",\n        \"owner\": \"$t(common.owner)\",\n        \"title\": \"název\",\n        \"rating\": \"hodnocení\",\n        \"search\": \"hledat\",\n        \"bitrate\": \"datový tok\",\n        \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n        \"recentlyAdded\": \"nedávno přidáno\",\n        \"note\": \"poznámka\",\n        \"name\": \"název\",\n        \"dateAdded\": \"datum přidání\",\n        \"releaseDate\": \"datum vydání\",\n        \"albumCount\": \"počet $t(entity.album, {\\\"count\\\": 2})\",\n        \"communityRating\": \"komunitní hodnocení\",\n        \"path\": \"cesta\",\n        \"favorited\": \"oblíbené\",\n        \"albumArtist\": \"$t(entity.albumArtist, {\\\"count\\\": 1})\",\n        \"isRecentlyPlayed\": \"je nedávno přehráno\",\n        \"isFavorited\": \"je oblíbené\",\n        \"bpm\": \"bpm\",\n        \"releaseYear\": \"rok vydání\",\n        \"id\": \"id\",\n        \"disc\": \"disk\",\n        \"biography\": \"biografie\",\n        \"songCount\": \"počet skladeb\",\n        \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n        \"duration\": \"trvání\",\n        \"isPublic\": \"je veřejné\",\n        \"random\": \"náhodně\",\n        \"lastPlayed\": \"naposledy přehráno\",\n        \"toYear\": \"do roku\",\n        \"fromYear\": \"z roku\",\n        \"criticRating\": \"hodnocení kritiků\",\n        \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n        \"trackNumber\": \"skladba\",\n        \"explicitStatus\": \"$t(common.explicitStatus)\",\n        \"sortName\": \"název v řazení\",\n        \"matchAnd\": \"a\",\n        \"matchOr\": \"nebo\"\n    },\n    \"page\": {\n        \"sidebar\": {\n            \"nowPlaying\": \"právě hraje\",\n            \"playlists\": \"$t(entity.playlist, {\\\"count\\\": 2})\",\n            \"search\": \"$t(common.search)\",\n            \"tracks\": \"$t(entity.track, {\\\"count\\\": 2})\",\n            \"albums\": \"$t(entity.album, {\\\"count\\\": 2})\",\n            \"genres\": \"$t(entity.genre, {\\\"count\\\": 2})\",\n            \"folders\": \"$t(entity.folder, {\\\"count\\\": 2})\",\n            \"settings\": \"$t(common.setting, {\\\"count\\\": 2})\",\n            \"home\": \"$t(common.home)\",\n            \"artists\": \"$t(entity.artist, {\\\"count\\\": 2})\",\n            \"albumArtists\": \"$t(entity.albumArtist, {\\\"count\\\": 2})\",\n            \"shared\": \"$t(entity.playlist, {\\\"count\\\": 2}) sdíleny\",\n            \"myLibrary\": \"moje knihovna\",\n            \"favorites\": \"$t(entity.favorite, {\\\"count\\\": 2})\",\n            \"radio\": \"$t(entity.radioStation, {\\\"count\\\": 2})\",\n            \"collections\": \"sbírky\"\n        },\n        \"fullscreenPlayer\": {\n            \"config\": {\n                \"showLyricMatch\": \"zobrazit shodu textů\",\n                \"dynamicBackground\": \"dynamické pozadí\",\n                \"synchronized\": \"synchronizováno\",\n                \"followCurrentLyric\": \"následovat aktuální text\",\n                \"opacity\": \"neprůhlednost\",\n                \"lyricSize\": \"velikost textů\",\n                \"showLyricProvider\": \"zobrazit poskytovatele textů\",\n                \"unsynchronized\": \"nesynchronizováno\",\n                \"lyricAlignment\": \"zarovnání textů\",\n                \"useImageAspectRatio\": \"použít poměr stran obrázku\",\n                \"lyricGap\": \"mezera textů\",\n                \"dynamicImageBlur\": \"velikost rozostření obrázku\",\n                \"dynamicIsImage\": \"povolit obrázek na pozadí\",\n                \"lyricOffset\": \"posunutí textů (ms)\"\n            },\n            \"upNext\": \"další\",\n            \"lyrics\": \"texty\",\n            \"related\": \"související\",\n            \"visualizer\": \"vizualizér\",\n            \"noLyrics\": \"nenalezeny žádné texty\"\n        },\n        \"appMenu\": {\n            \"selectServer\": \"vybrat server\",\n            \"version\": \"verze {{version}}\",\n            \"settings\": \"$t(common.setting, {\\\"count\\\": 2})\",\n            \"manageServers\": \"správce serverů\",\n            \"expandSidebar\": \"rozbalit postranní panel\",\n            \"collapseSidebar\": \"sbalit postranní panel\",\n            \"openBrowserDevtools\": \"otevřít vývojářské nástroje\",\n            \"quit\": \"$t(common.quit)\",\n            \"goBack\": \"přejít zpět\",\n            \"goForward\": \"přejít vpřed\",\n            \"privateModeOff\": \"vypnout soukromý režim\",\n            \"privateModeOn\": \"zapnout soukromý režim\",\n            \"selectMusicFolder\": \"vybrat složku s hudbou\",\n            \"noMusicFolder\": \"není vybrána žádná složka s hudbou\",\n            \"multipleMusicFolders\": \"Vybráno {{count}} složek s hudbou\",\n            \"commandPalette\": \"otevřít paletu příkazů\"\n        },\n        \"contextMenu\": {\n            \"addToPlaylist\": \"$t(action.addToPlaylist)\",\n            \"addToFavorites\": \"$t(action.addToFavorites)\",\n            \"setRating\": \"$t(action.setRating)\",\n            \"moveToTop\": \"$t(action.moveToTop)\",\n            \"deletePlaylist\": \"$t(action.deletePlaylist)\",\n            \"moveToBottom\": \"$t(action.moveToBottom)\",\n            \"createPlaylist\": \"$t(action.createPlaylist)\",\n            \"removeFromPlaylist\": \"$t(action.removeFromPlaylist)\",\n            \"removeFromFavorites\": \"$t(action.removeFromFavorites)\",\n            \"addNext\": \"$t(player.addNext)\",\n            \"deselectAll\": \"$t(action.deselectAll)\",\n            \"addLast\": \"$t(player.addLast)\",\n            \"addFavorite\": \"$t(action.addToFavorites)\",\n            \"play\": \"$t(player.play)\",\n            \"numberSelected\": \"vybráno {{count}}\",\n            \"removeFromQueue\": \"$t(action.removeFromQueue)\",\n            \"showDetails\": \"získat informace\",\n            \"shareItem\": \"sdílet položku\",\n            \"playSimilarSongs\": \"$t(player.playSimilarSongs)\",\n            \"download\": \"stáhnout\",\n            \"playShuffled\": \"$t(player.shuffle)\",\n            \"moveToNext\": \"$t(action.moveToNext)\",\n            \"goToAlbum\": \"přejít na $t(entity.album, {\\\"count\\\": 1})\",\n            \"goToAlbumArtist\": \"přejít na $t(entity.albumArtist, {\\\"count\\\": 1})\",\n            \"moveItems\": \"$t(action.moveItems)\",\n            \"goTo\": \"přejít na\"\n        },\n        \"home\": {\n            \"mostPlayed\": \"nejpřehrávanější\",\n            \"newlyAdded\": \"nově přidáno\",\n            \"title\": \"$t(common.home)\",\n            \"explore\": \"procházet z vaší knihovny\",\n            \"recentlyPlayed\": \"nedávno přehráno\",\n            \"recentlyReleased\": \"nedávno vydáno\",\n            \"genres\": \"$t(entity.genre, {\\\"count\\\": 2})\"\n        },\n        \"albumDetail\": {\n            \"moreFromArtist\": \"více od tohoto $t(entity.artist, {\\\"count\\\": 1})\",\n            \"moreFromGeneric\": \"více od {{item}}\",\n            \"released\": \"vydáno\"\n        },\n        \"setting\": {\n            \"playbackTab\": \"přehrávání\",\n            \"generalTab\": \"obecné\",\n            \"hotkeysTab\": \"klávesové zkratky\",\n            \"windowTab\": \"okno\",\n            \"advanced\": \"pokročilé\",\n            \"analytics\": \"analytika\",\n            \"updates\": \"aktualizace\",\n            \"cache\": \"mezipaměť\",\n            \"application\": \"aplikace\",\n            \"queryBuilder\": \"sestavení dotazu\",\n            \"theme\": \"motiv\",\n            \"controls\": \"ovládání\",\n            \"sidebar\": \"postranní lišta\",\n            \"remote\": \"vzdálené\",\n            \"exportImport\": \"import/export\",\n            \"scrobble\": \"scrobblování\",\n            \"audio\": \"zvuk\",\n            \"lyrics\": \"texty\",\n            \"transcoding\": \"překódování\",\n            \"discord\": \"discord\",\n            \"playerFilters\": \"filtry přehrávače\",\n            \"logger\": \"protokol\",\n            \"lyricsDisplay\": \"zobrazení textů\"\n        },\n        \"albumArtistList\": {\n            \"title\": \"$t(entity.albumArtist, {\\\"count\\\": 2})\"\n        },\n        \"genreList\": {\n            \"title\": \"$t(entity.genre, {\\\"count\\\": 2})\",\n            \"showTracks\": \"zobrazit $t(entity.track, {\\\"count\\\": 2}) s žánrem $t(entity.genre, {\\\"count\\\": 1})\",\n            \"showAlbums\": \"zobrazit $t(entity.album, {\\\"count\\\": 2}) s žánrem $t(entity.genre, {\\\"count\\\": 1})\"\n        },\n        \"trackList\": {\n            \"title\": \"$t(entity.track, {\\\"count\\\": 2})\",\n            \"artistTracks\": \"skladby od umělce {{artist}}\",\n            \"genreTracks\": \"$t(entity.track, {\\\"count\\\": 2}) s žánrem „{{genre}}“\"\n        },\n        \"globalSearch\": {\n            \"commands\": {\n                \"serverCommands\": \"příkazy serveru\",\n                \"goToPage\": \"přejít na stránku\",\n                \"searchFor\": \"hledání {{query}}\"\n            },\n            \"title\": \"příkazy\"\n        },\n        \"playlistList\": {\n            \"title\": \"$t(entity.playlist, {\\\"count\\\": 2})\"\n        },\n        \"albumList\": {\n            \"title\": \"$t(entity.album, {\\\"count\\\": 2})\",\n            \"artistAlbums\": \"alba od umělce {{artist}}\",\n            \"genreAlbums\": \"$t(entity.album, {\\\"count\\\": 2}) s žánrem „{{genre}}“\"\n        },\n        \"albumArtistDetail\": {\n            \"recentReleases\": \"nedávno vydáno\",\n            \"viewDiscography\": \"zobrazit diskografii\",\n            \"about\": \"O umělci {{artist}}\",\n            \"appearsOn\": \"také v\",\n            \"topSongs\": \"nejlepší skladby\",\n            \"topSongsFrom\": \"nejlepší skladby od umělce {{title}}\",\n            \"relatedArtists\": \"podobní $t(entity.artist, {\\\"count\\\": 2})\",\n            \"viewAllTracks\": \"zobrazit všechny $t(entity.track, {\\\"count\\\": 2})\",\n            \"viewAll\": \"zobrazit vše\",\n            \"groupingTypeAll\": \"všechny typy vydání\",\n            \"groupingTypePrimary\": \"primární typy vydání\",\n            \"favoriteSongs\": \"oblíbené skladby\",\n            \"topSongsCommunity\": \"komunita\",\n            \"topSongsPersonal\": \"osobní\",\n            \"favoriteSongsFrom\": \"oblíbené skladby od umělce {{title}}\"\n        },\n        \"itemDetail\": {\n            \"copiedPath\": \"cesta úspěšně zkopírována\",\n            \"copyPath\": \"kopírovat cestu do schránky\",\n            \"openFile\": \"zobrazit skladbu ve správci souborů\"\n        },\n        \"playlist\": {\n            \"reorder\": \"změna pořadí povolena pouze při řazení podle id\"\n        },\n        \"manageServers\": {\n            \"url\": \"URL\",\n            \"username\": \"uživatelské jméno\",\n            \"editServerDetailsTooltip\": \"upravit podrobnosti o serveru\",\n            \"removeServer\": \"odstranit server\",\n            \"serverDetails\": \"podrobnosti o serveru\",\n            \"title\": \"správa serverů\"\n        },\n        \"favorites\": {\n            \"title\": \"$t(entity.favorite, {\\\"count\\\": 2})\"\n        },\n        \"folderList\": {\n            \"title\": \"$t(entity.folder, {\\\"count\\\": 2})\"\n        },\n        \"radioList\": {\n            \"title\": \"stanice rádia\"\n        },\n        \"windowBar\": {\n            \"paused\": \"(Pozastaveno) \",\n            \"privateMode\": \"(Soukromý režim)\"\n        },\n        \"collections\": {\n            \"overrideExisting\": \"nahradit existující\",\n            \"saveAsCollection\": \"uložit jako sbírku\"\n        },\n        \"releasenotes\": {\n            \"commitsSinceStable\": \"revize od {{stable}}\",\n            \"noNewCommits\": \"žádné nové revize v tomto období\",\n            \"noStableReleaseToCompare\": \"není dostupné žádné stabilní vydání k porovnání\"\n        }\n    },\n    \"form\": {\n        \"deletePlaylist\": {\n            \"title\": \"odstranit $t(entity.playlist, {\\\"count\\\": 1})\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) úspěšně odstraněn\",\n            \"input_confirm\": \"pro potvrzení zadejte název $t(entity.playlist, {\\\"count\\\": 1})u\"\n        },\n        \"createPlaylist\": {\n            \"input_description\": \"$t(common.description)\",\n            \"title\": \"vytvořit $t(entity.playlist, {\\\"count\\\": 1})\",\n            \"input_public\": \"veřejné\",\n            \"input_name\": \"$t(common.name)\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) úspěšně vytvořen\",\n            \"input_owner\": \"$t(common.owner)\"\n        },\n        \"addServer\": {\n            \"title\": \"přidat server\",\n            \"input_username\": \"uživatelské jméno\",\n            \"input_url\": \"adresa url\",\n            \"input_password\": \"heslo\",\n            \"input_legacyAuthentication\": \"zapnout zastaralé ověřování\",\n            \"input_name\": \"název serveru\",\n            \"success\": \"server úspěšně přidán\",\n            \"input_savePassword\": \"uložit heslo\",\n            \"ignoreSsl\": \"ignorovat ssl $t(common.restartRequired)\",\n            \"ignoreCors\": \"ignorovat cors $t(common.restartRequired)\",\n            \"error_savePassword\": \"při ukládání hesla se vyskytla chyba\",\n            \"input_preferInstantMix\": \"preferovat instantní mix\",\n            \"input_preferInstantMixDescription\": \"pro získání podobných skladeb použít pouze instantní mix. užitečné, pokud máte doplňky, které upravují toto chování\",\n            \"input_preferRemoteUrl\": \"preferovat veřejnou adresu url\",\n            \"input_remoteUrl\": \"veřejná adresa url\",\n            \"input_remoteUrlPlaceholder\": \"volitelné: veřejná adresa url pro externí funkce\"\n        },\n        \"addToPlaylist\": {\n            \"success\": \"přidáno $t(entity.trackWithCount, {\\\"count\\\": {{message}} }) do $t(entity.playlistWithCount, {\\\"count\\\": {{numOfPlaylists}} })\",\n            \"title\": \"přidat do $t(entity.playlist, {\\\"count\\\": 1})\",\n            \"input_skipDuplicates\": \"přeskočit duplicity\",\n            \"input_playlists\": \"$t(entity.playlist, {\\\"count\\\": 2})\",\n            \"create\": \"vytvořit $t(entity.playlist, {\\\"count\\\": 1}) {{playlist}}\",\n            \"searchOrCreate\": \"vyhledejte $t(entity.playlist, {\\\"count\\\": 2}) nebo pište pro vytvoření nového\"\n        },\n        \"updateServer\": {\n            \"title\": \"upravit server\",\n            \"success\": \"server úspěšně upraven\"\n        },\n        \"queryEditor\": {\n            \"input_optionMatchAll\": \"shoda všeho\",\n            \"input_optionMatchAny\": \"shoda libovolného\",\n            \"title\": \"editor dotazů\",\n            \"addRuleGroup\": \"přidat skupinu pravidel\",\n            \"removeRuleGroup\": \"odstranit skupinu pravidel\",\n            \"resetToDefault\": \"resetovat na výchozí\",\n            \"clearFilters\": \"vymazat filtry\"\n        },\n        \"lyricSearch\": {\n            \"input_name\": \"$t(common.name)\",\n            \"input_artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n            \"title\": \"Hledat texty\"\n        },\n        \"editPlaylist\": {\n            \"title\": \"upravit $t(entity.playlist, {\\\"count\\\": 1})\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) úspěšně aktualizován\",\n            \"publicJellyfinNote\": \"Jellyfin z nějakého důvodu neukazuje, zda je seznam skladeb veřejný, nebo ne. Pokud si přejete, aby zůstal veřejný, zvolte prosím následující vstup\",\n            \"editNote\": \"ruční úpravy velkých seznamů skladeb nejsou doporučeny. opravdu přijímáte riziko ztráty dat, které může vzniknout přepsáním existujícího seznamu skladeb?\"\n        },\n        \"shareItem\": {\n            \"allowDownloading\": \"umožnit stahování\",\n            \"success\": \"odkaz ke sdílení zkopírován do schránky (klikněte sem pro otevření)\",\n            \"description\": \"popis\",\n            \"expireInvalid\": \"čas vypršení musí být v budoucnosti\",\n            \"setExpiration\": \"nastavit vypršení\",\n            \"createFailed\": \"nepodařilo se vytvořit sdílení (je sdílení povoleno?)\",\n            \"copyToClipboard\": \"Zkopírovat do schránky: Ctrl+C, Enter\",\n            \"successMustClick\": \"sdílení úspěšně vytvořeno. klikněte sem pro otevření\"\n        },\n        \"privateMode\": {\n            \"enabled\": \"soukromý režim povolen, stav přehrávání je nyní skryt před externími integracemi\",\n            \"disabled\": \"soukromý režim povolen, stav přehrávání je nyní viditelný pro externími integrace\",\n            \"title\": \"soukromý režim\"\n        },\n        \"largeFetchConfirmation\": {\n            \"title\": \"přidat položky do fronty\",\n            \"description\": \"Tato akce přidá všechny položky v aktuálně filtrovaném zobrazení\"\n        },\n        \"shuffleAll\": {\n            \"title\": \"přehrát náhodně\",\n            \"input_genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n            \"input_limit\": \"kolik skladeb?\",\n            \"input_minYear\": \"od roku\",\n            \"input_maxYear\": \"do roku\",\n            \"input_played\": \"přehrát filtr\",\n            \"input_played_optionAll\": \"všechny skladby\",\n            \"input_played_optionUnplayed\": \"pouze nepřehrané skladby\",\n            \"input_played_optionPlayed\": \"pouze přehrané skladby\"\n        },\n        \"saveQueue\": {\n            \"success\": \"fronta přehrávání uložena na server\"\n        },\n        \"createRadioStation\": {\n            \"success\": \"stanice rádia úspěšně vytvořena\",\n            \"title\": \"vytvořit stanici rádia\",\n            \"input_homepageUrl\": \"adresa domovské stránky\",\n            \"input_name\": \"název\",\n            \"input_streamUrl\": \"adresa streamu\"\n        },\n        \"lyricsExport\": {\n            \"export\": \"exportovat texty\",\n            \"input_synced\": \"exportovat synchronizované texty\",\n            \"input_offset\": \"$t(setting.lyricOffset)\"\n        }\n    },\n    \"entity\": {\n        \"genre_one\": \"žánr\",\n        \"genre_few\": \"žánry\",\n        \"genre_other\": \"žánry\",\n        \"playlistWithCount_one\": \"{{count}} playlist\",\n        \"playlistWithCount_few\": \"{{count}} playlisty\",\n        \"playlistWithCount_other\": \"{{count}} playlistů\",\n        \"playlist_one\": \"playlist\",\n        \"playlist_few\": \"playlisty\",\n        \"playlist_other\": \"playlisty\",\n        \"artist_one\": \"umělec\",\n        \"artist_few\": \"umělci\",\n        \"artist_other\": \"umělci\",\n        \"folderWithCount_one\": \"{{count}} složka\",\n        \"folderWithCount_few\": \"{{count}} složky\",\n        \"folderWithCount_other\": \"{{count}} složek\",\n        \"albumArtist_one\": \"umělec alba\",\n        \"albumArtist_few\": \"umělci alb\",\n        \"albumArtist_other\": \"umělci alb\",\n        \"track_one\": \"skladba\",\n        \"track_few\": \"skladby\",\n        \"track_other\": \"skladby\",\n        \"albumArtistCount_one\": \"{{count}} umělec alba\",\n        \"albumArtistCount_few\": \"{{count}} umělci alba\",\n        \"albumArtistCount_other\": \"{{count}} umělců alba\",\n        \"albumWithCount_one\": \"{{count}} album\",\n        \"albumWithCount_few\": \"{{count}} alba\",\n        \"albumWithCount_other\": \"{{count}} alb\",\n        \"favorite_one\": \"oblíbený\",\n        \"favorite_few\": \"oblíbené\",\n        \"favorite_other\": \"oblíbené\",\n        \"artistWithCount_one\": \"{{count}} umělec\",\n        \"artistWithCount_few\": \"{{count}} umělci\",\n        \"artistWithCount_other\": \"{{count}} umělců\",\n        \"folder_one\": \"složka\",\n        \"folder_few\": \"složky\",\n        \"folder_other\": \"složky\",\n        \"smartPlaylist\": \"chytrý $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"album_one\": \"album\",\n        \"album_few\": \"alba\",\n        \"album_other\": \"alba\",\n        \"genreWithCount_one\": \"{{count}} žánr\",\n        \"genreWithCount_few\": \"{{count}} žánry\",\n        \"genreWithCount_other\": \"{{count}} žánrů\",\n        \"trackWithCount_one\": \"{{count}} skladba\",\n        \"trackWithCount_few\": \"{{count}} skladby\",\n        \"trackWithCount_other\": \"{{count}} skladeb\",\n        \"play_one\": \"{{count}} přehrání\",\n        \"play_few\": \"{{count}} přehrání\",\n        \"play_other\": \"{{count}} přehrání\",\n        \"song_one\": \"píseň\",\n        \"song_few\": \"písničky\",\n        \"song_other\": \"písní\",\n        \"radioStation_one\": \"stanice rádia\",\n        \"radioStation_few\": \"stanice rádia\",\n        \"radioStation_other\": \"stanice rádia\",\n        \"radioStationWithCount_one\": \"{{count}} stanice rádia\",\n        \"radioStationWithCount_few\": \"{{count}} stanice rádia\",\n        \"radioStationWithCount_other\": \"{{count}} stanic rádia\"\n    },\n    \"dragDropZone\": {\n        \"error_oneFileOnly\": \"Vyberte prosím pouze 1 soubor\",\n        \"error_readingFile\": \"během čtení souboru došlo k chybě: {{errorMessage}}\",\n        \"mainText\": \"přesuňte soubor sem\"\n    },\n    \"releaseType\": {\n        \"primary\": {\n            \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n            \"broadcast\": \"vysílání\",\n            \"ep\": \"ep\",\n            \"other\": \"jiné\",\n            \"single\": \"singl\"\n        },\n        \"secondary\": {\n            \"audiobook\": \"audiokniha\",\n            \"audioDrama\": \"rozhlasová hra\",\n            \"compilation\": \"kolekce\",\n            \"djMix\": \"dj mix\",\n            \"demo\": \"demo\",\n            \"fieldRecording\": \"field recording\",\n            \"interview\": \"rozhovor\",\n            \"live\": \"živě\",\n            \"mixtape\": \"mixtape\",\n            \"remix\": \"remix\",\n            \"soundtrack\": \"soundtrack\",\n            \"spokenWord\": \"mluvené slovo\"\n        }\n    },\n    \"queryBuilder\": {\n        \"standardTags\": \"standardní značky\",\n        \"customTags\": \"vlastní značky\"\n    },\n    \"filterOperator\": {\n        \"after\": \"je po\",\n        \"afterDate\": \"je po (datum)\",\n        \"before\": \"je před\",\n        \"beforeDate\": \"je před (datum)\",\n        \"contains\": \"obsahuje\",\n        \"endsWith\": \"končí na\",\n        \"inPlaylist\": \"je v\",\n        \"inTheLast\": \"je v posledním\",\n        \"inTheRange\": \"je v rozsahu\",\n        \"inTheRangeDate\": \"je v rozsahu (datum)\",\n        \"is\": \"je\",\n        \"isNot\": \"není\",\n        \"isGreaterThan\": \"je větší než\",\n        \"isLessThan\": \"je menší než\",\n        \"matchesRegex\": \"odpovídá regulárnímu výrazu\",\n        \"notContains\": \"neobsahuje\",\n        \"notInPlaylist\": \"není v\",\n        \"notInTheLast\": \"není v posledním\",\n        \"startsWith\": \"začíná na\"\n    },\n    \"datetime\": {\n        \"minuteShort\": \"min.\",\n        \"secondShort\": \"s\",\n        \"hourShort\": \"h.\",\n        \"dayShort\": \"d.\"\n    },\n    \"visualizer\": {\n        \"visualizerType\": \"Typ vizualizéru\",\n        \"cyclePresets\": \"Cyklicky procházet předvolby\",\n        \"cycleTime\": \"Čas cyklování (sekundy)\",\n        \"includeAllPresets\": \"Zahrnout všechny předvolby\",\n        \"ignoredPresets\": \"Ignorované předvolby\",\n        \"selectedPresets\": \"Vybrané předvolby\",\n        \"randomizeNextPreset\": \"Náhodně vybrat další předvolbu\",\n        \"blendTime\": \"Čas prolnutí\",\n        \"presets\": \"Předvolby\",\n        \"selectPreset\": \"Vybrat předvolbu\",\n        \"applyPreset\": \"Použít předvolbu\",\n        \"saveAsPreset\": \"Uložit jako předvolbu\",\n        \"updatePreset\": \"Aktualizovat předvolbu\",\n        \"copyConfiguration\": \"Kopírovat konfiguraci\",\n        \"pasteConfiguration\": \"Vložit konfiguraci\",\n        \"pasteConfigurationPlaceholder\": \"Sem vložte konfiguraci JSON…\",\n        \"pasteFromClipboard\": \"Vložit ze schránky\",\n        \"applyConfiguration\": \"Použít konfiguraci\",\n        \"configCopied\": \"Konfigurace zkopírována do schránky\",\n        \"configCopyFailed\": \"Nepodařilo se zkopírovat konfiguraci\",\n        \"configPasted\": \"Konfigurace úspěšně použita\",\n        \"configPasteFailed\": \"Nepodařilo se použít konfiguraci. Zkontrolujte prosím formát.\",\n        \"configPasteReadFailed\": \"Nepodařilo se přečíst schránku\",\n        \"presetName\": \"Název předvolby\",\n        \"presetNamePlaceholder\": \"Zadejte název předvolby\",\n        \"general\": \"Obecné\",\n        \"mode\": \"Režim\",\n        \"mode1To8\": \"Režim 1–8\",\n        \"mode10\": \"Režim 10\",\n        \"barSpace\": \"Mezera mezi sloupci\",\n        \"lineWidth\": \"Šířka linky\",\n        \"fillAlpha\": \"Vyplnit alfu\",\n        \"channelLayout\": \"Rozložení kanálů\",\n        \"maxFPS\": \"Max. počet snímků za sekundu\",\n        \"opacity\": \"Neprůhlednost\",\n        \"customGradients\": \"Vlastní přechody\",\n        \"addCustomGradient\": \"Přidat vlastní přechod\",\n        \"gradientName\": \"Název přechodu\",\n        \"gradientNamePlaceholder\": \"Název přechodu\",\n        \"vertical\": \"Vertikální\",\n        \"horizontal\": \"Horizontální\",\n        \"colorStops\": \"Ukončení barev\",\n        \"addColor\": \"Přidat barvu\",\n        \"position\": \"Pozice\",\n        \"level\": \"Úroveň\",\n        \"remove\": \"Odstranit\",\n        \"custom\": \"Vlastní\",\n        \"builtIn\": \"Vestavěné\",\n        \"colors\": \"Barvy\",\n        \"colorMode\": \"Režim barev\",\n        \"gradient\": \"Přechod\",\n        \"gradientLeft\": \"Přechod zleva\",\n        \"gradientRight\": \"Přechod zprava\",\n        \"fft\": \"FFT\",\n        \"fftSize\": \"Velikost FFT\",\n        \"smoothing\": \"Vyhlazování\",\n        \"frequencyRangeAndScaling\": \"Rozsah a škálování frekvencí\",\n        \"minimumFrequency\": \"Minimální frekvence\",\n        \"maximumFrequency\": \"Maximální frekvence\",\n        \"frequencyScale\": \"Škála frekvence\",\n        \"sensitivity\": \"Citlivost\",\n        \"weightingFilter\": \"Filtr váhy\",\n        \"minimumDecibels\": \"Minimální decibely\",\n        \"maximumDecibels\": \"Maximální decibely\",\n        \"linearAmplitude\": \"Lineární amplituda\",\n        \"linearBoost\": \"Lineární zesílení\",\n        \"peakBehavior\": \"Chování ve špičce\",\n        \"showPeaks\": \"Zobrazit špičky\",\n        \"fadePeaks\": \"Prolnout špičky\",\n        \"peakLine\": \"Linka špiček\",\n        \"gravity\": \"Gravitace\",\n        \"peakFadeTime\": \"Čas pádu ze špičky (ms)\",\n        \"peakHoldTime\": \"Čas udržení na špičce (ms)\",\n        \"radialSpectrum\": \"Kruhové spektrum\",\n        \"radial\": \"Kruhové\",\n        \"radialInvert\": \"Kruhové invertované\",\n        \"spinSpeed\": \"Rychlost rotace\",\n        \"radius\": \"Poloměr\",\n        \"reflexMirror\": \"Reflexní zrcadlení\",\n        \"reflexFit\": \"Reflexní vyplnění\",\n        \"reflexRatio\": \"Reflexní poměr\",\n        \"reflexAlpha\": \"Reflexní alfa\",\n        \"reflexBrightness\": \"Reflexní jas\",\n        \"mirror\": \"Zrcadlení\",\n        \"miscellaneousSettings\": \"Různá nastavení\",\n        \"alphaBars\": \"Alfa sloupce\",\n        \"ansiBands\": \"ANSI sloupce\",\n        \"ledBars\": \"LED sloupce\",\n        \"trueLeds\": \"Pravé LED\",\n        \"lumiBars\": \"Lumi sloupce\",\n        \"outlineBars\": \"Obrysové sloupce\",\n        \"roundBars\": \"Zaoblené sloupce\",\n        \"lowResolution\": \"Nízké rozlišení\",\n        \"splitGradient\": \"Přechod rozdělení\",\n        \"showFPS\": \"Zobrazit FPS\",\n        \"showScaleX\": \"Zobrazit osu X\",\n        \"noteLabels\": \"Štítky not\",\n        \"showScaleY\": \"Zobrazit osu Y\",\n        \"options\": {\n            \"colorMode\": {\n                \"gradient\": \"Přechod\",\n                \"barIndex\": \"Index sloupce\",\n                \"barLevel\": \"Úroveň sloupce\"\n            },\n            \"gradient\": {\n                \"classic\": \"Klasický\",\n                \"prism\": \"Prism\",\n                \"rainbow\": \"Duha\",\n                \"steelblue\": \"Ocelově modrá\",\n                \"orangered\": \"Oranžová\"\n            },\n            \"channelLayout\": {\n                \"single\": \"Jeden\",\n                \"dualCombined\": \"Duální kombinované\",\n                \"dualHorizontal\": \"Duální horizontální\",\n                \"dualVertical\": \"Duální vertikální\"\n            },\n            \"frequencyScale\": {\n                \"bark\": \"Barkova stupnice\",\n                \"linear\": \"Lineární stupnice\",\n                \"log\": \"Logaritmická stupnice\",\n                \"mel\": \"Melová stupnice\",\n                \"none\": \"Žádný\"\n            },\n            \"weightingFilter\": {\n                \"none\": \"Žádný\",\n                \"a\": \"A\",\n                \"b\": \"B\",\n                \"c\": \"C\",\n                \"d\": \"D\",\n                \"z\": \"Z\"\n            },\n            \"mode\": {\n                \"0\": \"[0] Diskrétní frekvence\",\n                \"1\": \"[1] 1/24 oktávy / 240 pásem\",\n                \"2\": \"[2] 1/12 oktávy / 120 pásem\",\n                \"3\": \"[3] 1/8 oktávy / 80 pásem\",\n                \"4\": \"[4] 1/6 oktávy / 60 pásem\",\n                \"5\": \"[5] 1/4 oktávy / 40 pásem\",\n                \"6\": \"[6] 1/3 oktávy / 30 pásem\",\n                \"7\": \"[7] Polovina oktávy / 20 pásem\",\n                \"8\": \"[8] Celá oktáva / 10 pásem\",\n                \"10\": \"[10] Linka / Graf oblasti\"\n            }\n        },\n        \"pasteGradient\": \"Vložit přechod\",\n        \"pasteGradientPlaceholder\": \"Sem vložte JSON přechodu…\"\n    }\n}\n"
  },
  {
    "path": "src/i18n/locales/da.json",
    "content": "{\n    \"action\": {\n        \"addToFavorites\": \"tilføj til $t(entity.favorite, {\\\"count\\\": 2})\",\n        \"addToPlaylist\": \"tilføj til $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"addOrRemoveFromSelection\": \"tilføj eller fjern fra markering\",\n        \"selectAll\": \"markér alt\",\n        \"deletePlaylist\": \"slet $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"selectRangeOfItems\": \"markér et udvalg af elementer\",\n        \"clearQueue\": \"ryd kø\",\n        \"createPlaylist\": \"opret $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"createRadioStation\": \"opret $t(entity.radioStation, {\\\"count\\\": 1})\",\n        \"deleteRadioStation\": \"slet $t(entity.radioStation, {\\\"count\\\": 1})\",\n        \"deselectAll\": \"fjern markering\",\n        \"downloadStarted\": \"download af {{count}} elementer startet\",\n        \"editPlaylist\": \"rediger $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"goToPage\": \"gå til side\",\n        \"moveDown\": \"flyt ned\",\n        \"moveToNext\": \"flyt til næste\",\n        \"moveUp\": \"flyt op\",\n        \"holdToMoveToTop\": \"hold for at flytte til toppen\",\n        \"moveToBottom\": \"flyt til bunden\",\n        \"moveToTop\": \"flyt til toppen\",\n        \"holdToMoveToBottom\": \"hold for at flytte til bunden\",\n        \"shuffleSelected\": \"bland markerede\",\n        \"moveItems\": \"flyt elementer\",\n        \"shuffle\": \"bland\",\n        \"shuffleAll\": \"bland alle\",\n        \"removeFromFavorites\": \"fjern fra $t(entity.favorite, {\\\"count\\\": 2})\",\n        \"setRating\": \"sæt bedømmelse\",\n        \"toggleSmartPlaylistEditor\": \"vis/skjul $t(entity.smartPlaylist)-editor\",\n        \"removeFromQueue\": \"fjern fra kø\",\n        \"refresh\": \"$t(common.refresh)\",\n        \"removeFromPlaylist\": \"fjern fra $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"viewMore\": \"vis mere\",\n        \"openApplicationDirectory\": \"åbn programmappe\",\n        \"viewPlaylists\": \"vis $t(entity.playlist, {\\\"count\\\": 2})\",\n        \"openIn\": {\n            \"lastfm\": \"Åbn i Last.fm\",\n            \"musicbrainz\": \"Åbn i MusicBrainz\"\n        }\n    },\n    \"common\": {\n        \"countSelected\": \"{{count}} markeret\",\n        \"explicitStatus\": \"eksplicit status\",\n        \"action_one\": \"handling\",\n        \"action_other\": \"handlinger\",\n        \"albumPeak\": \"albumspids\",\n        \"add\": \"tilføj\",\n        \"additionalParticipants\": \"yderligere medvirkende\",\n        \"areYouSure\": \"er du sikker?\",\n        \"newVersion\": \"en ny version er blevet installeret ({{version}})\",\n        \"viewReleaseNotes\": \"vis udgivelsesnoter\",\n        \"albumGain\": \"albumforstærkning\",\n        \"backward\": \"tilbage\",\n        \"ascending\": \"stigende\",\n        \"bitDepth\": \"bitdybde\",\n        \"biography\": \"biografi\",\n        \"bpm\": \"bpm\",\n        \"bitrate\": \"bitrate\",\n        \"cancel\": \"annuller\",\n        \"center\": \"center\",\n        \"clear\": \"ryd\",\n        \"channel_one\": \"kanal\",\n        \"channel_other\": \"kanaler\",\n        \"close\": \"luk\",\n        \"codec\": \"codec\",\n        \"collapse\": \"fold sammen\",\n        \"comingSoon\": \"kommer snart…\",\n        \"configure\": \"konfigurer\",\n        \"confirm\": \"bekræft\",\n        \"create\": \"opret\",\n        \"currentSong\": \"nuværende $t(entity.track, {\\\"count\\\": 1})\",\n        \"decrease\": \"formindsk\",\n        \"delete\": \"slet\",\n        \"descending\": \"faldende\",\n        \"disc\": \"disk\",\n        \"description\": \"beskrivelse\",\n        \"disable\": \"deaktiver\",\n        \"dismiss\": \"afvis\",\n        \"doNotShowAgain\": \"vis ikke dette igen\",\n        \"duration\": \"varighed\",\n        \"view\": \"vis\",\n        \"edit\": \"rediger\",\n        \"enable\": \"aktiver\",\n        \"expand\": \"fold ud\",\n        \"example\": \"eksempel\",\n        \"externalLinks\": \"eksterne links\",\n        \"faster\": \"hurtigere\",\n        \"favorite\": \"favorit\",\n        \"filter_one\": \"filter\",\n        \"filter_other\": \"filtre\",\n        \"filters\": \"filtre\",\n        \"filter_single\": \"enkelt\",\n        \"filter_multiple\": \"flere\",\n        \"forceRestartRequired\": \"genstart for at anvende ændringer… luk notifikationen for at genstarte\",\n        \"forward\": \"frem\",\n        \"gap\": \"mellemrum\",\n        \"home\": \"hjem\",\n        \"increase\": \"forøg\",\n        \"left\": \"venstre\",\n        \"limit\": \"grænse\",\n        \"manage\": \"administrer\",\n        \"maximize\": \"maksimer\",\n        \"menu\": \"menu\",\n        \"minimize\": \"minimer\",\n        \"modified\": \"ændret\",\n        \"mbid\": \"MusicBrainz-ID\",\n        \"mood\": \"stemning\",\n        \"name\": \"navn\",\n        \"no\": \"nej\",\n        \"none\": \"ingen\",\n        \"noResultsFromQuery\": \"søgningen gav ingen resultater\",\n        \"noFilters\": \"ingen filtre konfigureret\",\n        \"note\": \"note\",\n        \"ok\": \"ok\",\n        \"owner\": \"ejer\",\n        \"path\": \"sti\",\n        \"playerMustBePaused\": \"afspilleren skal være sat på pause\",\n        \"preview\": \"forhåndsvisning\",\n        \"previousSong\": \"forrige $t(entity.track, {\\\"count\\\": 1})\",\n        \"private\": \"privat\",\n        \"public\": \"offentlig\",\n        \"quit\": \"afslut\",\n        \"random\": \"tiltilfældig\",\n        \"rating\": \"bedømmelse\",\n        \"retry\": \"prøv igen\",\n        \"recordLabel\": \"pladeselskab\",\n        \"releaseType\": \"udgivelsestype\",\n        \"refresh\": \"opdater\",\n        \"reload\": \"genindlæs\",\n        \"rename\": \"omdøb\",\n        \"reset\": \"nulstil\",\n        \"resetToDefault\": \"nulstil til standard\",\n        \"restartRequired\": \"genstart påkrævet\",\n        \"right\": \"højre\",\n        \"sampleRate\": \"samplerate\",\n        \"save\": \"gem\",\n        \"saveAndReplace\": \"gem og erstat\",\n        \"saveAs\": \"gem som\",\n        \"search\": \"søg\",\n        \"setting_one\": \"indstilling\",\n        \"setting_other\": \"indstillinger\",\n        \"slower\": \"langsommere\",\n        \"share\": \"del\",\n        \"size\": \"størrelse\",\n        \"sort\": \"sorter\",\n        \"sortOrder\": \"rækkefølge\",\n        \"tags\": \"tags\",\n        \"title\": \"titel\",\n        \"trackNumber\": \"spor\",\n        \"trackGain\": \"sporforstærkning\",\n        \"trackPeak\": \"sporspids\",\n        \"translation\": \"oversættelse\",\n        \"unknown\": \"ukendt\",\n        \"version\": \"version\",\n        \"year\": \"år\",\n        \"yes\": \"ja\",\n        \"explicit\": \"eksplicit\",\n        \"clean\": \"ren\",\n        \"gridRows\": \"gitterrækker\",\n        \"tableColumns\": \"tabelkolonner\",\n        \"itemsMore\": \"{{count}} mere\"\n    },\n    \"entity\": {\n        \"album_one\": \"album\",\n        \"album_other\": \"albummer\",\n        \"albumArtist_one\": \"albumkunstner\",\n        \"albumArtist_other\": \"albumkunstnere\",\n        \"albumArtistCount_one\": \"{{count}} albumkunstner\",\n        \"albumArtistCount_other\": \"{{count}} albumkunstnere\",\n        \"albumWithCount_one\": \"{{count}} album\",\n        \"albumWithCount_other\": \"{{count}} albummer\",\n        \"radioStation_one\": \"radiostation\",\n        \"radioStation_other\": \"radiostationer\",\n        \"radioStationWithCount_one\": \"{{count}} radiostation\",\n        \"radioStationWithCount_other\": \"{{count}} radiostationer\",\n        \"artist_one\": \"kunstner\",\n        \"artist_other\": \"kunstnere\",\n        \"artistWithCount_one\": \"{{count}} kunstner\",\n        \"artistWithCount_other\": \"{{count}} kunstnere\",\n        \"favorite_one\": \"favorit\",\n        \"favorite_other\": \"favoritter\",\n        \"folder_one\": \"mappe\",\n        \"folder_other\": \"mapper\",\n        \"folderWithCount_one\": \"{{count}} mappe\",\n        \"folderWithCount_other\": \"{{count}} mapper\",\n        \"genre_one\": \"genre\",\n        \"genre_other\": \"genrer\",\n        \"genreWithCount_one\": \"{{count}} genre\",\n        \"genreWithCount_other\": \"{{count}} genrer\",\n        \"playlist_one\": \"playliste\",\n        \"playlist_other\": \"playlister\",\n        \"play_one\": \"{{count}} afspilning\",\n        \"play_other\": \"{{count}} afspilninger\",\n        \"playlistWithCount_one\": \"{{count}} playliste\",\n        \"playlistWithCount_other\": \"{{count}} playlister\",\n        \"smartPlaylist\": \"smart $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"track_one\": \"nummer\",\n        \"track_other\": \"numre\",\n        \"song_one\": \"sang\",\n        \"song_other\": \"sange\",\n        \"trackWithCount_one\": \"{{count}} nummer\",\n        \"trackWithCount_other\": \"{{count}} numre\"\n    },\n    \"error\": {\n        \"apiRouteError\": \"kunne ikke dirigere forespørgslen\",\n        \"audioDeviceFetchError\": \"der opstod en fejl under hentning af lydenheder\",\n        \"badValue\": \"ugyldig indstilling \\\"{{value}}\\\". denne værdi eksisterer ikke længere\",\n        \"authenticationFailed\": \"godkendelse mislykkedes\",\n        \"badAlbum\": \"du ser denne side, fordi denne sang ikke er en del af et album. du ser sandsynligvis denne fejl, hvis du har en sang i rodmappen af din musikmappe. Jellyfin grupperer kun numre, hvis de er i en mappe\",\n        \"credentialsRequired\": \"loginoplysninger påkrævet\",\n        \"endpointNotImplementedError\": \"endpointet {{endpoint}} er ikke implementeret for {{serverType}}\",\n        \"genericError\": \"der opstod en fejl\",\n        \"invalidServer\": \"ugyldig server\",\n        \"localFontAccessDenied\": \"adgang til lokale skrifttyper nægtet\",\n        \"loginRateError\": \"for mange loginforsøg, prøv venligst igen om et par sekunder\",\n        \"mpvRequired\": \"MPV påkrævet\",\n        \"multipleServerSaveQueueError\": \"afspilningskøen indeholder et eller flere numre, der ikke er fra den aktuelle server. dette understøttes ikke\",\n        \"networkError\": \"der opstod en netværksfejl\",\n        \"noNetwork\": \"server utilgængelig\",\n        \"noNetworkDescription\": \"kunne ikke oprette forbindelse til denne server\",\n        \"notificationDenied\": \"tilladelser til notifikationer blev nægtet. denne indstilling har ingen effekt\",\n        \"openError\": \"kunne ikke åbne filen\",\n        \"playbackError\": \"der opstod en fejl under afspilning af mediet\",\n        \"remoteDisableError\": \"der opstod en fejl under forsøg på at $t(common.disable) fjernserveren\",\n        \"remoteEnableError\": \"der opstod en fejl under forsøg på at $t(common.enable) fjernserveren\",\n        \"remotePortError\": \"der opstod en fejl under forsøg på at sætte fjernserverens port\",\n        \"remotePortWarning\": \"genstart serveren for at anvende den nye port\",\n        \"saveQueueFailed\": \"kunne ikke gemme kø\",\n        \"serverNotSelectedError\": \"ingen server valgt\",\n        \"serverRequired\": \"server påkrævet\",\n        \"sessionExpiredError\": \"din session er udløbet\",\n        \"systemFontError\": \"der opstod en fejl under hentning af systemskrifttyper\",\n        \"settingsSyncError\": \"der blev fundet uoverensstemmelser mellem indstillingerne i rendereren og hovedprocessen. genstart programmet for at anvende ændringerne\"\n    },\n    \"filter\": {\n        \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n        \"albumArtist\": \"$t(entity.albumArtist, {\\\"count\\\": 1})\",\n        \"albumCount\": \"$t(entity.album, {\\\"count\\\": 2}) antal\",\n        \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n        \"biography\": \"biografi\",\n        \"bitrate\": \"bitrate\",\n        \"bpm\": \"bpm\",\n        \"channels\": \"$t(common.channel, {\\\"count\\\": 2})\",\n        \"comment\": \"kommentar\",\n        \"communityRating\": \"fællesskabsbedømmelse\",\n        \"criticRating\": \"anmelderbedømmelse\",\n        \"dateAdded\": \"tilføjelsesdato\",\n        \"disc\": \"disk\",\n        \"duration\": \"varighed\",\n        \"favorited\": \"favoriseret\",\n        \"fromYear\": \"fra år\",\n        \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n        \"id\": \"id\",\n        \"isCompilation\": \"er samling\",\n        \"isFavorited\": \"er favoriseret\",\n        \"isPublic\": \"er offentlig\",\n        \"isRated\": \"er bedømt\",\n        \"isRecentlyPlayed\": \"er afspillet for nylig\",\n        \"recentlyPlayed\": \"nyligt afspillet\",\n        \"recentlyUpdated\": \"nyligt opdateret\",\n        \"lastPlayed\": \"sidst afspillet\",\n        \"mostPlayed\": \"mest afspillet\",\n        \"name\": \"navn\",\n        \"note\": \"note\",\n        \"owner\": \"$t(common.owner)\",\n        \"path\": \"sti\",\n        \"playCount\": \"afspilninger\",\n        \"random\": \"tilfældig\",\n        \"rating\": \"bedømmelse\",\n        \"recentlyAdded\": \"nyligt tilføjet\",\n        \"releaseDate\": \"udgivelsesdato\",\n        \"releaseYear\": \"udgivelsesår\",\n        \"search\": \"søg\",\n        \"songCount\": \"antal sange\",\n        \"sortName\": \"sorteringsnavn\",\n        \"title\": \"titel\",\n        \"toYear\": \"til år\",\n        \"trackNumber\": \"spor\",\n        \"explicitStatus\": \"$t(common.explicitStatus)\"\n    },\n    \"datetime\": {\n        \"minuteShort\": \"m\",\n        \"secondShort\": \"s\",\n        \"hourShort\": \"t\",\n        \"dayShort\": \"d\"\n    },\n    \"filterOperator\": {\n        \"after\": \"er efter\",\n        \"afterDate\": \"er efter (dato)\",\n        \"before\": \"er før\",\n        \"beforeDate\": \"er før (dato)\",\n        \"contains\": \"indeholder\",\n        \"endsWith\": \"slutter med\",\n        \"inPlaylist\": \"er i\",\n        \"inTheLast\": \"er inden for de seneste\",\n        \"inTheRange\": \"er i intervallet\",\n        \"inTheRangeDate\": \"er i intervallet (dato)\",\n        \"is\": \"er\",\n        \"isNot\": \"er ikke\",\n        \"isGreaterThan\": \"er større end\",\n        \"isLessThan\": \"er mindre end\",\n        \"matchesRegex\": \"matcher regex\",\n        \"notContains\": \"indeholder ikke\",\n        \"notInPlaylist\": \"er ikke i\",\n        \"notInTheLast\": \"er ikke inden for de seneste\",\n        \"startsWith\": \"starter med\"\n    },\n    \"form\": {\n        \"addServer\": {\n            \"error_savePassword\": \"der opstod en fejl under forsøg på at gemme adgangskoden\",\n            \"ignoreCors\": \"ignorer cors ($t(common.restartRequired))\",\n            \"ignoreSsl\": \"ignorer ssl ($t(common.restartRequired))\",\n            \"input_legacyAuthentication\": \"aktiver ældre godkendelse\",\n            \"input_name\": \"servernavn\",\n            \"input_password\": \"adgangskode\",\n            \"input_preferInstantMix\": \"foretræk øjeblikkeligt mix\",\n            \"input_preferInstantMixDescription\": \"brug kun øjeblikkeligt mix til at finde lignende sange. nyttigt hvis du har plugins der ændrer denne adfærd\",\n            \"input_preferRemoteUrl\": \"foretræk offentlig url\",\n            \"input_remoteUrl\": \"offentlig url\",\n            \"input_remoteUrlPlaceholder\": \"valgfrit: offentlig url til eksterne funktioner\",\n            \"input_savePassword\": \"gem adgangskode\",\n            \"input_url\": \"url\",\n            \"input_username\": \"brugernavn\",\n            \"success\": \"server tilføjet\",\n            \"title\": \"tilføj server\"\n        },\n        \"largeFetchConfirmation\": {\n            \"title\": \"tilføj elementer til køen\",\n            \"description\": \"Denne handling tilføjer alle elementer fra den aktuelle filtrerede visning\"\n        },\n        \"addToPlaylist\": {\n            \"create\": \"opret $t(entity.playlist, {\\\"count\\\": 1}) {{playlist}}\",\n            \"input_playlists\": \"$t(entity.playlist, {\\\"count\\\": 2})\",\n            \"input_skipDuplicates\": \"spring dubletter over\",\n            \"searchOrCreate\": \"søg i $t(entity.playlist, {\\\"count\\\": 2}) eller skriv for at oprette en ny\",\n            \"success\": \"tilføjede $t(entity.trackWithCount, {\\\"count\\\": {{message}} }) til $t(entity.playlistWithCount, {\\\"count\\\": {{numOfPlaylists}} })\",\n            \"title\": \"tilføj til $t(entity.playlist, {\\\"count\\\": 1})\"\n        },\n        \"createPlaylist\": {\n            \"input_description\": \"$t(common.description)\",\n            \"input_name\": \"$t(common.name)\",\n            \"input_owner\": \"$t(common.owner)\",\n            \"input_public\": \"offentlig\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) oprettet\",\n            \"title\": \"opret $t(entity.playlist, {\\\"count\\\": 1})\"\n        },\n        \"createRadioStation\": {\n            \"success\": \"radiostation oprettet\",\n            \"title\": \"opret radiostation\",\n            \"input_homepageUrl\": \"hjemmeside-url\",\n            \"input_name\": \"navn\",\n            \"input_streamUrl\": \"stream-url\"\n        },\n        \"deletePlaylist\": {\n            \"input_confirm\": \"skriv navnet på $t(entity.playlist, {\\\"count\\\": 1}) for at bekræfte\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) slettet\",\n            \"title\": \"slet $t(entity.playlist, {\\\"count\\\": 1})\"\n        },\n        \"editPlaylist\": {\n            \"publicJellyfinNote\": \"Jellyfin viser af en eller anden grund ikke, om en playliste er offentlig eller ej. Hvis du ønsker, at den forbliver offentlig, skal du have følgende felt markeret\",\n            \"editNote\": \"manuelle ændringer anbefales ikke for store playlister. er du sikker på, at du accepterer risikoen for datatab ved at overskrive den eksisterende playliste?\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) opdateret\",\n            \"title\": \"rediger $t(entity.playlist, {\\\"count\\\": 1})\"\n        },\n        \"lyricsExport\": {\n            \"export\": \"eksporter sangtekster\",\n            \"input_synced\": \"eksporter synkroniserede sangtekster\",\n            \"input_offset\": \"$t(setting.lyricOffset)\"\n        },\n        \"lyricSearch\": {\n            \"input_artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n            \"input_name\": \"$t(common.name)\",\n            \"title\": \"søg efter sangtekst\"\n        },\n        \"queryEditor\": {\n            \"title\": \"forespørgselseditor\",\n            \"input_optionMatchAll\": \"match alle\",\n            \"input_optionMatchAny\": \"match enhver\",\n            \"addRuleGroup\": \"tilføj regelgruppe\",\n            \"removeRuleGroup\": \"fjern regelgruppe\",\n            \"resetToDefault\": \"nulstil til standard\",\n            \"clearFilters\": \"ryd filtre\"\n        },\n        \"saveQueue\": {\n            \"success\": \"afspilningskø gemt på serveren\"\n        },\n        \"shareItem\": {\n            \"allowDownloading\": \"tillad download\",\n            \"description\": \"beskrivelse\",\n            \"setExpiration\": \"sæt udløbsdato\",\n            \"success\": \"delingslink kopieret til udklipsholder (eller klik her for at åbne)\",\n            \"expireInvalid\": \"udløbsdatoen skal være i fremtiden\",\n            \"createFailed\": \"kunne ikke oprette deling (er deling aktiveret?)\"\n        },\n        \"shuffleAll\": {\n            \"title\": \"afspil tilfældigt\",\n            \"input_genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n            \"input_limit\": \"hvor mange sange?\",\n            \"input_minYear\": \"fra år\",\n            \"input_maxYear\": \"til år\",\n            \"input_played\": \"afspilningsfilter\",\n            \"input_played_optionAll\": \"alle numre\",\n            \"input_played_optionUnplayed\": \"kun uafspillede numre\",\n            \"input_played_optionPlayed\": \"kun afspillede numre\"\n        },\n        \"updateServer\": {\n            \"success\": \"server opdateret\",\n            \"title\": \"opdater server\"\n        },\n        \"privateMode\": {\n            \"enabled\": \"privat tilstand aktiveret, afspilningsstatus er nu skjult for eksterne integrationer\",\n            \"disabled\": \"privat tilstand deaktiveret, afspilningsstatus er nu synlig for aktiverede eksterne integrationer\",\n            \"title\": \"privat tilstand\"\n        }\n    },\n    \"page\": {\n        \"albumArtistDetail\": {\n            \"about\": \"Om {{artist}}\",\n            \"appearsOn\": \"optræder på\",\n            \"favoriteSongs\": \"favoritnumre\",\n            \"groupingTypeAll\": \"alle udgivelsestyper\",\n            \"groupingTypePrimary\": \"primære udgivelsestyper\",\n            \"recentReleases\": \"nylige udgivelser\",\n            \"viewDiscography\": \"vis diskografi\",\n            \"relatedArtists\": \"relaterede $t(entity.artist, {\\\"count\\\": 2})\",\n            \"topSongs\": \"populære sange\",\n            \"topSongsCommunity\": \"fællesskab\",\n            \"topSongsFrom\": \"populære sange fra {{title}}\",\n            \"topSongsPersonal\": \"personlig\",\n            \"viewAllTracks\": \"vis alle $t(entity.track, {\\\"count\\\": 2})\",\n            \"viewAll\": \"vis alle\",\n            \"favoriteSongsFrom\": \"favoritnumre fra {{title}}\"\n        },\n        \"albumArtistList\": {\n            \"title\": \"$t(entity.albumArtist, {\\\"count\\\": 2})\"\n        },\n        \"albumDetail\": {\n            \"moreFromArtist\": \"mere fra denne $t(entity.artist, {\\\"count\\\": 1})\",\n            \"moreFromGeneric\": \"mere fra {{item}}\",\n            \"released\": \"udgivet\"\n        },\n        \"albumList\": {\n            \"artistAlbums\": \"album af {{artist}}\",\n            \"genreAlbums\": \"\\\"{{genre}}\\\"-$t(entity.album, {\\\"count\\\": 2})\",\n            \"title\": \"$t(entity.album, {\\\"count\\\": 2})\"\n        },\n        \"radioList\": {\n            \"title\": \"radiostationer\"\n        },\n        \"releasenotes\": {\n            \"commitsSinceStable\": \"commits siden {{stable}}\",\n            \"noNewCommits\": \"ingen nye commits i dette interval\",\n            \"noStableReleaseToCompare\": \"ingen stabil udgivelse at sammenligne med\"\n        },\n        \"favorites\": {\n            \"title\": \"$t(entity.favorite, {\\\"count\\\": 2})\"\n        },\n        \"windowBar\": {\n            \"paused\": \"(Pauset) \",\n            \"privateMode\": \"(Privat tilstand)\"\n        },\n        \"appMenu\": {\n            \"collapseSidebar\": \"fold sidebjælke sammen\",\n            \"commandPalette\": \"åbn kommandopalet\",\n            \"expandSidebar\": \"fold sidebjælke ud\",\n            \"goBack\": \"gå tilbage\",\n            \"goForward\": \"gå frem\",\n            \"manageServers\": \"administrer servere\",\n            \"privateModeOff\": \"slå privat tilstand fra\",\n            \"privateModeOn\": \"slå privat tilstand til\",\n            \"openBrowserDevtools\": \"åbn browserudviklingsværktøjer\",\n            \"quit\": \"$t(common.quit)\",\n            \"selectServer\": \"vælg server\",\n            \"selectMusicFolder\": \"vælg musikmappe\",\n            \"noMusicFolder\": \"ingen musikmappe valgt\",\n            \"multipleMusicFolders\": \"{{count}} musikmapper valgt\",\n            \"settings\": \"$t(common.setting, {\\\"count\\\": 2})\",\n            \"version\": \"version {{version}}\"\n        },\n        \"manageServers\": {\n            \"title\": \"administrer servere\",\n            \"serverDetails\": \"serverdetaljer\",\n            \"url\": \"URL\",\n            \"username\": \"brugernavn\",\n            \"editServerDetailsTooltip\": \"rediger serverdetaljer\",\n            \"removeServer\": \"fjern server\"\n        },\n        \"contextMenu\": {\n            \"addFavorite\": \"$t(action.addToFavorites)\",\n            \"addLast\": \"$t(player.addLast)\",\n            \"addNext\": \"$t(player.addNext)\",\n            \"addToFavorites\": \"$t(action.addToFavorites)\",\n            \"addToPlaylist\": \"$t(action.addToPlaylist)\",\n            \"createPlaylist\": \"$t(action.createPlaylist)\",\n            \"deletePlaylist\": \"$t(action.deletePlaylist)\",\n            \"deselectAll\": \"$t(action.deselectAll)\",\n            \"download\": \"download\",\n            \"moveItems\": \"$t(action.moveItems)\",\n            \"moveToNext\": \"$t(action.moveToNext)\",\n            \"moveToBottom\": \"$t(action.moveToBottom)\",\n            \"moveToTop\": \"$t(action.moveToTop)\",\n            \"numberSelected\": \"{{count}} markeret\",\n            \"play\": \"$t(player.play)\",\n            \"playSimilarSongs\": \"$t(player.playSimilarSongs)\",\n            \"removeFromFavorites\": \"$t(action.removeFromFavorites)\",\n            \"removeFromPlaylist\": \"$t(action.removeFromPlaylist)\",\n            \"removeFromQueue\": \"$t(action.removeFromQueue)\",\n            \"setRating\": \"$t(action.setRating)\",\n            \"playShuffled\": \"$t(player.shuffle)\",\n            \"shareItem\": \"del element\",\n            \"goTo\": \"gå til\",\n            \"goToAlbum\": \"gå til $t(entity.album, {\\\"count\\\": 1})\",\n            \"goToAlbumArtist\": \"gå til $t(entity.albumArtist, {\\\"count\\\": 1})\",\n            \"showDetails\": \"vis detaljer\"\n        },\n        \"fullscreenPlayer\": {\n            \"config\": {\n                \"dynamicBackground\": \"dynamisk baggrund\",\n                \"dynamicImageBlur\": \"billedsløringsstørrelse\",\n                \"dynamicIsImage\": \"aktiver baggrundsbillede\",\n                \"followCurrentLyric\": \"følg aktuel sangtekstlinje\",\n                \"lyricAlignment\": \"sangtekstjustering\",\n                \"lyricOffset\": \"sangteksterforskydning (ms)\",\n                \"lyricGap\": \"sangtekstmellemrum\",\n                \"lyricSize\": \"sangtekststørrelse\",\n                \"opacity\": \"gennemsigtighed\",\n                \"showLyricMatch\": \"vis sangtekstmatch\",\n                \"showLyricProvider\": \"vis sangtekstudbyder\",\n                \"synchronized\": \"synkroniseret\",\n                \"unsynchronized\": \"usynkroniseret\",\n                \"useImageAspectRatio\": \"brug billedets størrelsesforholdsangtekster\"\n            },\n            \"lyrics\": \"sangtekster\",\n            \"related\": \"relateret\",\n            \"upNext\": \"næste\",\n            \"visualizer\": \"visualisering\",\n            \"noLyrics\": \"ingen sangtekster fundet\"\n        },\n        \"genreList\": {\n            \"showAlbums\": \"vis $t(entity.genre, {\\\"count\\\": 1})-$t(entity.album, {\\\"count\\\": 2})\",\n            \"showTracks\": \"vis $t(entity.genre, {\\\"count\\\": 1})-$t(entity.track, {\\\"count\\\": 2})\",\n            \"title\": \"$t(entity.genre, {\\\"count\\\": 2})\"\n        },\n        \"folderList\": {\n            \"title\": \"$t(entity.folder, {\\\"count\\\": 2})\"\n        },\n        \"globalSearch\": {\n            \"commands\": {\n                \"goToPage\": \"gå til side\",\n                \"searchFor\": \"søg efter {{query}}\",\n                \"serverCommands\": \"serverkommandoer\"\n            },\n            \"title\": \"kommandoer\"\n        },\n        \"home\": {\n            \"explore\": \"udforsk fra dit bibliotek\",\n            \"genres\": \"$t(entity.genre, {\\\"count\\\": 2})\",\n            \"mostPlayed\": \"mest afspillet\",\n            \"newlyAdded\": \"nyligt tilføjede udgivelser\",\n            \"recentlyPlayed\": \"senest afspillet\",\n            \"recentlyReleased\": \"senest udgivet\",\n            \"title\": \"$t(common.home)\"\n        },\n        \"itemDetail\": {\n            \"copyPath\": \"kopiér sti til udklipsholder\",\n            \"copiedPath\": \"sti kopieret\",\n            \"openFile\": \"vis nummer i filhåndtering\"\n        },\n        \"playlist\": {\n            \"reorder\": \"ændring af rækkefølge er kun mulig ved sortering efter id\"\n        },\n        \"playlistList\": {\n            \"title\": \"$t(entity.playlist, {\\\"count\\\": 2})\"\n        },\n        \"collections\": {\n            \"overrideExisting\": \"overskriv eksisterende\",\n            \"saveAsCollection\": \"gem som samling\"\n        },\n        \"setting\": {\n            \"advanced\": \"avanceret\",\n            \"analytics\": \"analyse\",\n            \"generalTab\": \"generelt\",\n            \"hotkeysTab\": \"genvejstaster\",\n            \"playbackTab\": \"afspilning\",\n            \"windowTab\": \"vindue\",\n            \"updates\": \"opdatering\",\n            \"cache\": \"cache\",\n            \"application\": \"program\",\n            \"queryBuilder\": \"forespørgselsbygger\",\n            \"theme\": \"tema\",\n            \"controls\": \"kontroller\",\n            \"sidebar\": \"sidebjælke\",\n            \"remote\": \"fjernbetjening\",\n            \"exportImport\": \"import/eksport\",\n            \"scrobble\": \"scrobble\",\n            \"audio\": \"lyd\",\n            \"lyrics\": \"sangtekster\",\n            \"lyricsDisplay\": \"sangtekstvisning\",\n            \"transcoding\": \"transkodning\",\n            \"discord\": \"Discord\",\n            \"logger\": \"logning\",\n            \"playerFilters\": \"afspillerfiltre\"\n        },\n        \"sidebar\": {\n            \"albumArtists\": \"$t(entity.albumArtist, {\\\"count\\\": 2})\",\n            \"albums\": \"$t(entity.album, {\\\"count\\\": 2})\",\n            \"collections\": \"samlinger\",\n            \"artists\": \"$t(entity.artist, {\\\"count\\\": 2})\",\n            \"favorites\": \"$t(entity.favorite, {\\\"count\\\": 2})\",\n            \"folders\": \"$t(entity.folder, {\\\"count\\\": 2})\",\n            \"genres\": \"$t(entity.genre, {\\\"count\\\": 2})\",\n            \"home\": \"$t(common.home)\",\n            \"radio\": \"$t(entity.radioStation, {\\\"count\\\": 2})\",\n            \"myLibrary\": \"mit bibliotek\",\n            \"nowPlaying\": \"spiller nu\",\n            \"playlists\": \"$t(entity.playlist, {\\\"count\\\": 2})\",\n            \"search\": \"$t(common.search)\",\n            \"settings\": \"$t(common.setting, {\\\"count\\\": 2})\",\n            \"shared\": \"delte $t(entity.playlist, {\\\"count\\\": 2})\",\n            \"tracks\": \"$t(entity.track, {\\\"count\\\": 2})\"\n        },\n        \"trackList\": {\n            \"artistTracks\": \"numre af {{artist}}\",\n            \"genreTracks\": \"\\\"{{genre}}\\\"-$t(entity.track, {\\\"count\\\": 2})\",\n            \"title\": \"$t(entity.track, {\\\"count\\\": 2})\"\n        }\n    },\n    \"player\": {\n        \"addLast\": \"sidst\",\n        \"addNext\": \"næste\",\n        \"addLastShuffled\": \"sidst (blandet)\",\n        \"addNextShuffled\": \"næste (blandet)\",\n        \"artistRadio\": \"kunstnerradio\",\n        \"holdToShuffle\": \"hold for at blande\",\n        \"favorite\": \"favorit\",\n        \"lyrics\": \"sangtekster\",\n        \"mute\": \"slå lyd fra\",\n        \"muted\": \"lyd slået fra\",\n        \"next\": \"næste\",\n        \"play\": \"afspil\",\n        \"playbackFetchCancel\": \"det tager lidt tid… luk notifikationen for at annullere\",\n        \"playbackFetchInProgress\": \"indlæser sange…\",\n        \"playbackFetchNoResults\": \"ingen sange fundet\",\n        \"playbackSpeed\": \"afspilningshastighed\",\n        \"playRandom\": \"afspil tilfældigt\",\n        \"playSimilarSongs\": \"afspil lignende sange\",\n        \"previous\": \"forrige\",\n        \"queue_clear\": \"ryd kø\",\n        \"queue_moveToBottom\": \"flyt markerede til toppen\",\n        \"queue_moveToTop\": \"flyt markerede til bunden\",\n        \"queue_remove\": \"fjern markerede\",\n        \"repeat\": \"gentag\",\n        \"repeat_all\": \"gentag alle\",\n        \"repeat_off\": \"gentagelse deaktiveret\",\n        \"restoreQueueFromServer\": \"gendan kø fra server\",\n        \"saveQueueToServer\": \"gem kø på server\",\n        \"shuffle\": \"afspil (blandet)\",\n        \"shuffle_off\": \"blanding deaktiveret\",\n        \"skip\": \"spring over\",\n        \"skip_back\": \"spring tilbage\",\n        \"skip_forward\": \"spring frem\",\n        \"stop\": \"stop\",\n        \"toggleFullscreenPlayer\": \"vis/skjul fuldskærmsafspiller\",\n        \"trackRadio\": \"nummerradio\",\n        \"unfavorite\": \"fjern fra favoritter\",\n        \"pause\": \"pause\",\n        \"viewQueue\": \"vis kø\"\n    },\n    \"queryBuilder\": {\n        \"standardTags\": \"standardtags\",\n        \"customTags\": \"brugerdefinerede tags\"\n    },\n    \"releaseType\": {\n        \"primary\": {\n            \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n            \"broadcast\": \"udsendelse\",\n            \"ep\": \"ep\",\n            \"other\": \"andet\",\n            \"single\": \"single\"\n        },\n        \"secondary\": {\n            \"audiobook\": \"lydbog\",\n            \"audioDrama\": \"hørespil\",\n            \"compilation\": \"samling\",\n            \"djMix\": \"dj-mix\",\n            \"demo\": \"demo\",\n            \"fieldRecording\": \"feltoptagelse\",\n            \"interview\": \"interview\",\n            \"live\": \"live\",\n            \"mixtape\": \"mixtape\",\n            \"remix\": \"remix\",\n            \"soundtrack\": \"soundtrack\",\n            \"spokenWord\": \"oplæsning\"\n        }\n    },\n    \"setting\": {\n        \"autoDJ\": \"auto-DJ\",\n        \"autoDJ_description\": \"tilføj automatisk lignende sange til køen\",\n        \"autoDJ_itemCount\": \"antal elementer\",\n        \"autoDJ_itemCount_description\": \"antallet af elementer der forsøges tilføjet til køen, når auto-DJ er aktiveret\",\n        \"autoDJ_timing\": \"tidspunkt\",\n        \"autoDJ_timing_description\": \"antallet af resterende sange i køen, før auto-DJ aktiveres\",\n        \"accentColor_description\": \"angiver accentfarven for programmet\",\n        \"accentColor\": \"accentfarve\",\n        \"useThemeAccentColor\": \"brug temaets accentfarve\",\n        \"useThemeAccentColor_description\": \"brug den primære farve defineret i det valgte tema i stedet for den brugerdefinerede accentfarve\",\n        \"albumBackground_description\": \"tilføjer et baggrundsbillede med albumcoveret på albumsider\",\n        \"albumBackground\": \"albumbaggrundsbillede\",\n        \"albumBackgroundBlur_description\": \"justerer mængden af sløring på albumbaggrundsbilledet\",\n        \"albumBackgroundBlur\": \"sløringsstørrelse for albumbaggrund\",\n        \"analyticsDisable\": \"Fravælg brugsbaseret analyse\",\n        \"analyticsDisable_description\": \"Anonymiserede brugsdata sendes til udvikleren for at forbedre programmet\",\n        \"applicationHotkeys_description\": \"konfigurer programmets genvejstaster. slå afkrydsningsfeltet til for at gøre det til en global genvejstast (kun desktop)\",\n        \"applicationHotkeys\": \"programmets genvejstaster\",\n        \"artistBackground\": \"kunstnerbaggrundsbillede\",\n        \"artistBackground_description\": \"tilføjer et baggrundsbillede med kunstnerbilledet på kunstnersider\",\n        \"artistBackgroundBlur\": \"sløringsstørrelse for kunstnerbaggrund\",\n        \"artistBackgroundBlur_description\": \"justerer mængden af sløring på kunstnerbaggrundsbilledet\",\n        \"artistConfiguration\": \"konfiguration af albumkunstnerside\",\n        \"artistConfiguration_description\": \"konfigurer hvilke elementer der vises, og i hvilken rækkefølge, på albumkunstnersiden\",\n        \"artistReleaseTypeConfiguration\": \"konfiguration af kunstners udgivelsestyper\",\n        \"artistReleaseTypeConfiguration_description\": \"konfigurer hvilke udgivelsestyper der vises, og i hvilken rækkefølge, på albumkunstnersiden\",\n        \"audioDevice_description\": \"vælg den lydenhed der skal bruges til afspilning (kun webafspiller)\",\n        \"audioDevice\": \"lydenhed\",\n        \"audioExclusiveMode_description\": \"aktiver eksklusiv udgangstilstand. I denne tilstand er systemet normalt låst, og kun mpv kan sende lyd\",\n        \"audioExclusiveMode\": \"eksklusiv lydtilstand\",\n        \"audioPlayer_description\": \"vælg den lydafspiller der skal bruges til afspilning\",\n        \"audioPlayer\": \"lydafspiller\",\n        \"buttonSize_description\": \"størrelsen på knapperne i afspillerbjælken\",\n        \"buttonSize\": \"knapstørrelse i afspillerbjælke\",\n        \"clearCache\": \"ryd browsercache\",\n        \"clearCache_description\": \"en 'hård rydning' af feishin. udover at rydde feishins cache, tømmes browserens cache (gemte billeder og andre ressourcer). serveroplysninger og indstillinger bevares\",\n        \"clearCacheSuccess\": \"cache ryddet\",\n        \"clearQueryCache_description\": \"en 'blød rydning' af feishin. dette vil opdatere playlister, nummermetadata og nulstille gemte sangtekster. indstillinger, serveroplysninger og cachede billeder bevares\",\n        \"clearQueryCache\": \"ryd feishin-cache\",\n        \"contextMenu_description\": \"giver dig mulighed for at skjule elementer i højrekliksmenuen. elementer der ikke er markeret, vil blive skjult\",\n        \"contextMenu\": \"konfiguration af kontekstmenu (højreklik)\",\n        \"crossfadeDuration_description\": \"angiver varigheden af crossfade-effekten\",\n        \"crossfadeDuration\": \"crossfade-varighed\",\n        \"crossfadeStyle\": \"crossfade-stil\",\n        \"customFontPath\": \"sti til brugerdefineret skrifttype\",\n        \"customCssEnable\": \"aktiver brugerdefineret css\",\n        \"customCss\": \"brugerdefineret css\",\n        \"customCss_description\": \"brugerdefineret css-indhold. Bemærk: content- og remote url-egenskaber er ikke tilladt. En forhåndsvisning af dit indhold vises nedenfor. Yderligere felter, du ikke har angivet, er til stede på grund af sanitering\",\n        \"crossfadeStyle_description\": \"vælg den crossfade-stil der skal bruges til lydafspilleren\",\n        \"customCssEnable_description\": \"tillad skrivning af brugerdefineret css\",\n        \"releaseChannel_optionAlpha\": \"alpha (nightly)\",\n        \"customCssNotice\": \"Advarsel: selvom der er nogen sanitering (url() og content: er ikke tilladt), kan brug af brugerdefineret css stadig udgøre risici ved at ændre brugergrænsefladen\",\n        \"releaseChannel_optionBeta\": \"beta\",\n        \"releaseChannel_optionLatest\": \"seneste\",\n        \"releaseChannel\": \"udgivelseskanal\",\n        \"releaseChannel_description\": \"vælg mellem stabile, beta- eller alpha-udgivelser (nightly) til automatiske opdateringer\",\n        \"disableLibraryUpdateOnStartup\": \"deaktiver kontrol for nye versioner ved opstart\",\n        \"discordApplicationId_description\": \"program-id'et til {{discord}} Rich Presence (standard er {{defaultId}})\",\n        \"discordApplicationId\": \"{{discord}}-program-id\",\n        \"discordDisplayType_artistname\": \"kunstnernavn(e)\",\n        \"discordDisplayType_description\": \"ændrer hvad du lytter til i din status\",\n        \"discordDisplayType_songname\": \"sangnavn\",\n        \"discordDisplayType\": \"{{discord}} Presence-visningstype\",\n        \"discordIdleStatus_description\": \"opdater status, når afspilleren er inaktiv, når dette er aktiveret\",\n        \"discordIdleStatus\": \"vis Rich Presence-inaktivstatus\",\n        \"discordLinkType_description\": \"tilføjer eksterne links til {{lastfm}} eller {{musicbrainz}} i sang- og kunstnerfelterne i {{discord}} Rich Presence. {{musicbrainz}} er mest præcis, men kræver tags og giver ikke kunstnerlinks, mens {{lastfm}} som regel altid giver et link. foretager ingen ekstra netværksanmodninger\",\n        \"discordLinkType_mbz_lastfm\": \"{{musicbrainz}} med {{lastfm}}-fallback\",\n        \"discordLinkType_none\": \"$t(common.none)\",\n        \"discordLinkType\": \"{{discord}} Presence-links\",\n        \"discordListening_description\": \"vis status som 'lytter' i stedet for 'spiller'\",\n        \"discordListening\": \"vis status som lytter\",\n        \"discordPausedStatus_description\": \"status vises, når afspilleren er sat på pause, når dette er aktiveret\",\n        \"discordPausedStatus\": \"vis Rich Presence ved pause\",\n        \"discordRichPresence\": \"{{discord}} Rich Presence\",\n        \"discordRichPresence_description\": \"aktiver afspilningsstatus i {{discord}} Rich Presence. Billednøgler er: {{icon}}, {{playing}} og {{paused}}\",\n        \"discordServeImage\": \"server {{discord}}-billeder fra serveren\",\n        \"discordServeImage_description\": \"del coverart til {{discord}} Rich Presence fra selve serveren, kun tilgængelig for Jellyfin og Navidrome. {{discord}} bruger en bot til at hente billeder, så din server skal være tilgængelig fra det offentlige internet\",\n        \"discordUpdateInterval\": \"{{discord}} Rich Presence-opdateringsinterval\",\n        \"discordUpdateInterval_description\": \"tiden i sekunder mellem hver opdatering (minimum 15 sekunder)\",\n        \"enableAutoTranslation_description\": \"aktiver oversættelse automatisk, når sangtekster indlæses\",\n        \"enableAutoTranslation\": \"aktiver automatisk oversættelse\",\n        \"enableRemote_description\": \"aktiverer fjernbetjeningsserveren, så andre enheder kan styre programmet\",\n        \"enableRemote\": \"aktiver fjernbetjeningsserver\",\n        \"exitToTray_description\": \"afslut programmet til systembakken\",\n        \"exitToTray\": \"afslut til systembakke\",\n        \"exportImportSettings_control_description\": \"eksporter og importer indstillinger via JSON\",\n        \"exportImportSettings_control_exportText\": \"eksporter indstillinger\",\n        \"exportImportSettings_control_importText\": \"importer indstillinger\",\n        \"exportImportSettings_control_title\": \"import / eksport af indstillinger\",\n        \"exportImportSettings_destructiveWarning\": \"import af indstillinger er destruktiv, gennemgå venligst ovenstående, før du klikker på \\\"importer\\\" nedenfor!\",\n        \"exportImportSettings_importBtn\": \"importer indstillinger\",\n        \"exportImportSettings_importModalTitle\": \"importer feishin-indstillinger\",\n        \"exportImportSettings_importSuccess\": \"indstillinger importeret!\",\n        \"exportImportSettings_notValidJSON\": \"den valgte fil er ikke gyldig JSON\",\n        \"exportImportSettings_offendingKeyError\": \"\\\"{{offendingKey}}\\\" er forkert – {{reason}}\",\n        \"externalLinks_description\": \"aktiverer visning af eksterne links (Last.fm, MusicBrainz) på kunstner-/albumsider\",\n        \"externalLinks\": \"vis eksterne links\",\n        \"followCurrentSong_description\": \"rul automatisk afspilningskøen til den sang, der afspilles\",\n        \"followCurrentSong\": \"følg aktuel sang\",\n        \"followLyric_description\": \"rul sangteksten til den aktuelle afspilningsposition\",\n        \"followLyric\": \"følg aktuel sangtekstlinje\",\n        \"font_description\": \"angiver skrifttypen, der skal bruges i programmet\",\n        \"font\": \"skrifttype\",\n        \"fontType_description\": \"indbygget skrifttype vælger en af skrifttyperne fra feishin. systemskrifttype lader dig vælge en skrifttype fra dit styresystem. brugerdefineret lader dig angive din egen skrifttype\",\n        \"fontType_optionBuiltIn\": \"indbygget skrifttype\",\n        \"fontType_optionCustom\": \"brugerdefineret skrifttype\",\n        \"fontType_optionSystem\": \"systemskrifttype\",\n        \"fontType\": \"skrifttypetype\",\n        \"gaplessAudio_description\": \"angiver indstillingen for uafbrudt lyd til mpv\",\n        \"gaplessAudio_optionWeak\": \"svag (anbefalet)\",\n        \"gaplessAudio\": \"uafbrudt lyd\",\n        \"globalMediaHotkeys_description\": \"aktiver eller deaktiver brugen af systemets mediegenvejstaster til at styre afspilningen\",\n        \"globalMediaHotkeys\": \"globale mediegenvejstaster\",\n        \"homeConfiguration_description\": \"konfigurer hvilke elementer der vises, og i hvilken rækkefølge, på startsiden\",\n        \"homeConfiguration\": \"konfiguration af startside\",\n        \"homeFeature_description\": \"styrer om den store udvalgte karrusel vises på startsiden\",\n        \"homeFeature\": \"startsidens udvalgte karrusel\",\n        \"homeFeatureStyle_description\": \"styrer stilen på startsidens udvalgte karrusel\",\n        \"homeFeatureStyle\": \"stil for startsidens udvalgte karrusel\",\n        \"homeFeatureStyle_optionMultiple\": \"flere\",\n        \"homeFeatureStyle_optionSingle\": \"enkelt\",\n        \"hotkey_browserBack\": \"browser tilbage\",\n        \"hotkey_browserForward\": \"browser frem\",\n        \"hotkey_favoriteCurrentSong\": \"favoriser $t(common.currentSong)\",\n        \"hotkey_favoritePreviousSong\": \"favoriser $t(common.previousSong)\",\n        \"hotkey_globalSearch\": \"global søgning\",\n        \"hotkey_localSearch\": \"søg på siden\",\n        \"hotkey_listNavigateToPage\": \"naviger til elementside fra liste\",\n        \"hotkey_listPlayDefault\": \"afspil fra liste\",\n        \"hotkey_listPlayLast\": \"afspil sidst fra liste\",\n        \"hotkey_listPlayNext\": \"afspil næste fra liste\",\n        \"hotkey_listPlayNow\": \"afspil nu fra liste\",\n        \"hotkey_navigateHome\": \"naviger til hjem\",\n        \"hotkey_playbackNext\": \"næste nummer\",\n        \"hotkey_playbackPause\": \"pause\",\n        \"hotkey_playbackPlay\": \"afspil\",\n        \"hotkey_playbackPlayPause\": \"afspil / pause\",\n        \"hotkey_playbackPrevious\": \"forrige nummer\",\n        \"hotkey_playbackStop\": \"stop\",\n        \"hotkey_rate0\": \"ryd bedømmelse\",\n        \"hotkey_rate1\": \"bedømmelse 1 stjerne\",\n        \"hotkey_rate2\": \"bedømmelse 2 stjerner\",\n        \"hotkey_rate3\": \"bedømmelse 3 stjerner\",\n        \"hotkey_rate4\": \"bedømmelse 4 stjerner\",\n        \"hotkey_rate5\": \"bedømmelse 5 stjerner\",\n        \"hotkey_skipBackward\": \"spring tilbage\",\n        \"hotkey_skipForward\": \"spring frem\",\n        \"hotkey_toggleCurrentSongFavorite\": \"slå favorit til/fra for $t(common.currentSong)\",\n        \"hotkey_toggleFullScreenPlayer\": \"slå fuldskærmsafspiller til/fra\",\n        \"hotkey_togglePreviousSongFavorite\": \"slå favorit til/fra for $t(common.previousSong)\",\n        \"hotkey_toggleQueue\": \"vis/skjul kø\",\n        \"hotkey_toggleRepeat\": \"slå gentagelse til/fra\",\n        \"hotkey_toggleShuffle\": \"slå blanding til/fra\",\n        \"hotkey_unfavoriteCurrentSong\": \"fjern favorit for $t(common.currentSong)\",\n        \"hotkey_unfavoritePreviousSong\": \"fjern favorit for $t(common.previousSong)\",\n        \"hotkey_volumeDown\": \"lydstyrke ned\",\n        \"hotkey_volumeMute\": \"slå lyd fra\",\n        \"hotkey_volumeUp\": \"lydstyrke op\",\n        \"hotkey_zoomIn\": \"zoom ind\",\n        \"hotkey_zoomOut\": \"zoom ud\",\n        \"imageAspectRatio_description\": \"hvis aktiveret, vises coverart med dets originale størrelsesforhold. For billeder der ikke er 1:1, vil den resterende plads være tom\",\n        \"imageAspectRatio\": \"brug originalt størrelsesforhold for coverart\",\n        \"language\": \"sprog\",\n        \"language_description\": \"angiver sproget for programmet ($t(common.restartRequired))\",\n        \"lastfm_description\": \"vis links til Last.fm på kunstner-/albumsider\",\n        \"lastfm\": \"vis Last.fm-links\",\n        \"lastfmApiKey_description\": \"API-nøglen til {{lastfm}}. Påkrævet til coverart\",\n        \"lastfmApiKey\": \"{{lastfm}}-API-nøgle\",\n        \"lyricFetch_description\": \"hent sangtekster fra forskellige internetkilder\",\n        \"lyricFetch\": \"hent sangtekster fra internettet\",\n        \"lyricFetchProvider_description\": \"vælg udbyderne til at hente sangtekster fra\",\n        \"lyricFetchProvider\": \"udbydere til sangteksthentning\",\n        \"lyricOffset_description\": \"forskyd sangteksten med det angivne antal millisekunder\",\n        \"lyricOffset\": \"sangtekstforskydning (ms)\",\n        \"logLevel\": \"logniveau\",\n        \"logLevel_description\": \"angiver det mindste logniveau, der vises. debug viser alle logfiler, error viser kun fejl\",\n        \"logLevel_optionDebug\": \"debug\",\n        \"logLevel_optionError\": \"fejl\",\n        \"logLevel_optionInfo\": \"info\",\n        \"logLevel_optionWarn\": \"advarsel\",\n        \"minimizeToTray_description\": \"minimer programmet til systembakken\",\n        \"minimizeToTray\": \"minimer til systembakke\",\n        \"minimumScrobblePercentage_description\": \"den mindste procentdel af sangen, der skal afspilles, før den scrobbles\",\n        \"minimumScrobblePercentage\": \"minimum scrobble-varighed (procent)\",\n        \"minimumScrobbleSeconds_description\": \"den mindste varighed i sekunder af sangen, der skal afspilles, før den scrobbles\",\n        \"minimumScrobbleSeconds\": \"minimum scrobble (sekunder)\",\n        \"mpvExecutablePath_description\": \"angiver stien til mpv-programfilen. Hvis den er tom, bruges standardstien\",\n        \"mpvExecutablePath\": \"sti til mpv-programfil\",\n        \"mpvExtraParameters\": \"ekstra mpv-parametre\",\n        \"mpvExtraParameters_description\": \"ekstra argumenter, der sendes til mpv\",\n        \"mpvExtraParameters_help\": \"én pr. linje\",\n        \"musicbrainz_description\": \"vis links til MusicBrainz på kunstner-/albumsider, hvor MusicBrainz-ID findes\",\n        \"musicbrainz\": \"vis MusicBrainz-links\",\n        \"neteaseTranslation_description\": \"Når aktiveret, hentes og vises oversatte sangtekster fra NetEase, hvis tilgængelige\",\n        \"neteaseTranslation\": \"Aktiver NetEase-oversættelser\",\n        \"notify\": \"aktiver sangnotifikationer\",\n        \"notify_description\": \"vis notifikationer ved sangskift\",\n        \"pathReplace\": \"udskiftning af filsti\",\n        \"pathReplace_description\": \"erstat din servers standardfilsti\",\n        \"pathReplace_optionRemovePrefix\": \"fjern præfiks\",\n        \"pathReplace_optionAddPrefix\": \"tilføj præfiks\",\n        \"passwordStore_description\": \"hvilken adgangskode-/hemmelighedsbutik der skal bruges. Skift dette, hvis du har problemer med at gemme adgangskoder\",\n        \"passwordStore\": \"adgangskode-/hemmelighedsbutik\",\n        \"playerFilters\": \"Filtrer sange fra køen\",\n        \"playerFilters_description\": \"udeluk sange fra køen baseret på følgende kriterier\",\n        \"playbackStyle_description\": \"vælg den afspilningsstil, der skal bruges til lydafspilleren\",\n        \"playbackStyle_optionCrossFade\": \"crossfade\",\n        \"playbackStyle_optionNormal\": \"normal\",\n        \"playbackStyle\": \"afspilningsstil\",\n        \"playButtonBehavior_description\": \"angiver standardadfærden for afspilknappen, når sange tilføjes til køen\",\n        \"playButtonBehavior_optionAddLast\": \"$t(player.addLast)\",\n        \"playButtonBehavior_optionAddNext\": \"$t(player.addNext)\",\n        \"playButtonBehavior_optionPlay\": \"$t(player.play)\",\n        \"playButtonBehavior_optionPlayShuffled\": \"$t(player.shuffle)\",\n        \"playButtonBehavior\": \"afspilknappens adfærd\",\n        \"artistRadioCount_description\": \"angiver antallet af sange, der hentes til kunstnerradio og nummerradio\",\n        \"artistRadioCount\": \"antal for kunstner-/nummerradio\",\n        \"imageResolution\": \"billedopløsning\",\n        \"imageResolution_description\": \"opløsningen for billederne, der bruges i programmet. En værdi på 0 bruger billedets originale opløsning\",\n        \"imageResolution_optionTable\": \"tabel\",\n        \"imageResolution_optionItemCard\": \"elementkort\",\n        \"imageResolution_optionSidebar\": \"sidebjælke\",\n        \"imageResolution_optionHeader\": \"overskrift\",\n        \"imageResolution_optionFullScreenPlayer\": \"fuldskærmsafspiller\",\n        \"playerbarOpenDrawer_description\": \"gør det muligt at klikke på afspillerbjælken for at åbne fuldskærmsafspilleren\",\n        \"playerbarOpenDrawer\": \"afspillerbjælke fuldskærmsskift\",\n        \"playerbarSlider\": \"afspillerbjælkens skyder\",\n        \"playerbarSlider_description\": \"bølgeformen anbefales ikke ved langsom eller forbrugsmålt internetforbindelse\",\n        \"playerbarSliderType_optionSlider\": \"skyder\",\n        \"playerbarSliderType_optionWaveform\": \"bølgeform\",\n        \"playerbarWaveformAlign\": \"bølgeformjustering\",\n        \"playerbarWaveformAlign_optionTop\": \"top\",\n        \"playerbarWaveformAlign_optionCenter\": \"center\",\n        \"playerbarWaveformAlign_optionBottom\": \"bund\",\n        \"playerbarWaveformBarWidth\": \"bølgeform-søjlebredde\",\n        \"playerbarWaveformGap\": \"bølgeform-mellemrum\",\n        \"playerbarWaveformRadius\": \"bølgeform-radius\",\n        \"preferLocalLyrics_description\": \"foretræk lokale sangtekster frem for fjern-sangtekster, når de er tilgængelige\",\n        \"preferLocalLyrics\": \"foretræk lokale sangtekster\",\n        \"showLyricsInSidebar_description\": \"et panel tilføjes til den tilknyttede afspilningskø, der viser sangteksterne\",\n        \"showLyricsInSidebar\": \"vis sangtekster i afspillerens sidebjælke\",\n        \"showRatings_description\": \"styrer om stjernebedømmelsesfunktionen vises i grænsefladen\",\n        \"showRatings\": \"vis stjernebedømmelser\",\n        \"blurExplicitImages\": \"slør eksplicit indhold\",\n        \"blurExplicitImages_description\": \"album- og sangcover, der er markeret som eksplicit, vil blive sløret\",\n        \"enableGridMultiSelect\": \"aktiver gitterflervalg\",\n        \"enableGridMultiSelect_description\": \"når aktiveret, kan du markere flere elementer i gittervisninger. Når deaktiveret navigerer klik på gitterbilleder til elementsiden\",\n        \"showVisualizerInSidebar_description\": \"et panel tilføjes til afspillerens sidebjælke, der viser visualiseringen\",\n        \"showVisualizerInSidebar\": \"vis visualisering i afspillerens sidebjælke\",\n        \"combinedLyricsAndVisualizer_description\": \"kombinér sangtekster og visualisering i samme panel\",\n        \"combinedLyricsAndVisualizer\": \"kombinér sangtekster og visualisering i afspillerens sidebjælke\",\n        \"preservePitch_description\": \"bevar tonehøjde ved ændring af afspilningshastighed\",\n        \"preservePitch\": \"bevar tonehøjde\",\n        \"audioFadeOnStatusChange\": \"lydfading ved statusændring\",\n        \"audioFadeOnStatusChange_description\": \"aktiverer ind- og udfading, når afspilnings-/pausestatus ændres\",\n        \"preventSleepOnPlayback_description\": \"forhindrer skærmen i at gå i dvale under musikafspilning\",\n        \"preventSleepOnPlayback\": \"forhindre dvale under afspilning\",\n        \"remotePassword_description\": \"angiver adgangskoden til fjernbetjeningsserveren. Disse oplysninger overføres som standard usikkert, så brug en unik adgangskode, du ikke er bekymret for\",\n        \"remotePassword\": \"adgangskode til fjernbetjeningsserver\",\n        \"remotePort_description\": \"angiver porten til fjernbetjeningsserveren\",\n        \"remotePort\": \"port til fjernbetjeningsserver\",\n        \"remoteUsername_description\": \"angiver brugernavnet til fjernbetjeningsserveren. Hvis både brugernavn og adgangskode er tomme, deaktiveres godkendelse\",\n        \"remoteUsername\": \"brugernavn til fjernbetjeningsserver\",\n        \"replayGainClipping_description\": \"Forhindrer klipning forårsaget af {{ReplayGain}} ved automatisk at sænke forstærkningen\",\n        \"replayGainClipping\": \"{{ReplayGain}}-klipning\",\n        \"replayGainFallback_description\": \"forstærkning i dB, der anvendes, hvis filen ikke har {{ReplayGain}}-tags\",\n        \"replayGainFallback\": \"{{ReplayGain}}-fallback\",\n        \"replayGainMode_description\": \"juster lydstyrkeforstærkning i henhold til {{ReplayGain}}-værdier gemt i filens metadata\",\n        \"replayGainMode_optionAlbum\": \"$t(entity.album, {\\\"count\\\": 1})\",\n        \"replayGainMode_optionNone\": \"$t(common.none)\",\n        \"replayGainMode_optionTrack\": \"$t(entity.track, {\\\"count\\\": 1})\",\n        \"replayGainMode\": \"{{ReplayGain}}-tilstand\",\n        \"replayGainPreamp_description\": \"juster forforstærkerforstærkningen, der anvendes på {{ReplayGain}}-værdierne\",\n        \"replayGainPreamp\": \"{{ReplayGain}}-forforstærker (dB)\",\n        \"sampleRate_description\": \"vælg udgangens samplerate, der skal bruges, hvis den valgte samplefrekvens er anderledes end det aktuelle medies. En værdi mindre end 8000 bruger standardfrekvensen\",\n        \"sampleRate\": \"samplerate\",\n        \"savePlayQueue_description\": \"gem afspilningskøen, når programmet lukkes, og gendan den, når programmet åbnes\",\n        \"savePlayQueue\": \"gem afspilningskø\",\n        \"scrobble_description\": \"scrobble afspilninger til din medieserver\",\n        \"scrobble\": \"scrobble\",\n        \"showSkipButton_description\": \"vis eller skjul spring over-knapperne i afspillerbjælken\",\n        \"showSkipButton\": \"vis spring over-knapper\",\n        \"showSkipButtons_description\": \"vis eller skjul spring over-knapperne i afspillerbjælken\",\n        \"showSkipButtons\": \"vis spring over-knapper\",\n        \"sidebarCollapsedNavigation_description\": \"vis eller skjul navigationen i den foldede sidebjælke\",\n        \"sidebarCollapsedNavigation\": \"sidebjælke (foldet) navigation\",\n        \"sidebarConfiguration_description\": \"vælg elementerne og rækkefølgen, de vises i sidebjælken\",\n        \"sidebarConfiguration\": \"konfiguration af sidebjælke\",\n        \"sidebarPlaylistList_description\": \"vis eller skjul playlistelisten i sidebjælken\",\n        \"sidebarPlaylistList\": \"playlisteliste i sidebjælke\",\n        \"sidebarPlaylistSorting_description\": \"tillader manuel sortering af playlister i sidebjælken med træk og slip i stedet for serverens standardrækkefølge\",\n        \"sidebarPlaylistSorting\": \"playlistesortering i sidebjælke\",\n        \"sidebarPlaylistListFilterRegex_description\": \"skjul playlister i sidebjælken, der matcher dette regulære udtryk\",\n        \"sidebarPlaylistListFilterRegex_placeholder\": \"f.eks. ^Dagligt Mix.*\",\n        \"sidebarPlaylistListFilterRegex\": \"playlistefilter (regex)\",\n        \"sidePlayQueueStyle_description\": \"angiver stilen for sideafspilningskøen\",\n        \"sidePlayQueueStyle_optionAttached\": \"tilknyttet\",\n        \"sidePlayQueueStyle_optionDetached\": \"frakoblet\",\n        \"mediaSession_description\": \"aktiverer Media Session-integration, som viser mediekontroller og metadata i systemets lydstyrkeoverlay og låseskærm\",\n        \"mediaSession\": \"aktiver mediesession\",\n        \"sidePlayQueueStyle\": \"stil for sideafspilningskø\",\n        \"skipDuration_description\": \"angiver den varighed, der springes over, når spring over-knapperne i afspillerbjælken bruges\",\n        \"skipDuration\": \"spring over-varighed\",\n        \"skipPlaylistPage_description\": \"gå direkte til playlistens sangliste i stedet for standardsiden, når du navigerer til en playliste\",\n        \"skipPlaylistPage\": \"spring playlisteside over\",\n        \"startMinimized_description\": \"start programmet i systembakken\",\n        \"startMinimized\": \"start minimeret\",\n        \"theme_description\": \"angiver det tema, der skal bruges i programmet\",\n        \"theme\": \"tema\",\n        \"themeDark_description\": \"angiver det mørke tema, der skal bruges i programmet\",\n        \"themeDark\": \"tema (mørkt)\",\n        \"themeLight_description\": \"angiver det lyse tema, der skal bruges i programmet\",\n        \"themeLight\": \"tema (lyst)\",\n        \"transcode\": \"aktiver transkodning\",\n        \"transcode_description\": \"aktiverer transkodning til forskellige formater\",\n        \"transcodeBitrate_description\": \"vælger den bitrate, der transkodes til. 0 betyder, at serveren vælger\",\n        \"transcodeBitrate\": \"bitrate til transkodning\",\n        \"transcodeFormat_description\": \"vælger det format, der transkodes til. Lad stå tomt for at lade serveren bestemme\",\n        \"transcodeFormat\": \"format til transkodning\",\n        \"translationApiKey_description\": \"api-nøgle til oversættelse (kun globalt tjenesteendpoint)\",\n        \"translationApiKey\": \"oversættelses-api-nøgle\",\n        \"translationApiProvider_description\": \"api-udbyder til oversættelse\",\n        \"translationApiProvider\": \"oversættelses-api-udbyder\",\n        \"translationTargetLanguage_description\": \"målsprog til oversættelse\",\n        \"translationTargetLanguage\": \"oversættelsesmålsprog\",\n        \"trayEnabled_description\": \"vis/skjul systembakkeikon/-menu. Hvis deaktiveret, deaktiveres minimer/afslut til systembakke også\",\n        \"trayEnabled\": \"vis systembakke\",\n        \"useSystemTheme_description\": \"følg systemets foretrukne lys- eller mørketilstand\",\n        \"useSystemTheme\": \"brug systemtema\",\n        \"volumeWheelStep_description\": \"mængden af lydstyrke, der ændres, når du scroller med musehjulet på lydstyrkeskyderen\",\n        \"volumeWheelStep\": \"mushjulstrin for lydstyrke\",\n        \"volumeWidth_description\": \"bredden af lydstyrkeskyderen\",\n        \"volumeWidth\": \"bredde på lydstyrkeskyder\",\n        \"webAudio_description\": \"brug web-audio. dette aktiverer avancerede funktioner som ReplayGain. Deaktiver, hvis du oplever problemer\",\n        \"webAudio\": \"brug web-audio\",\n        \"windowBarStyle_description\": \"vælg stilen på vinduesbjælken\",\n        \"windowBarStyle\": \"vinduesbjælkestil\",\n        \"zoom_description\": \"angiver zoomprocenten for programmet\",\n        \"zoom\": \"zoomprocent\",\n        \"queryBuilder\": \"forespørgselsbygger\",\n        \"queryBuilderCustomFields_inputLabel\": \"etiket\",\n        \"queryBuilderCustomFields_inputTag\": \"tag\",\n        \"queryBuilderCustomFields\": \"brugerdefinerede felter\",\n        \"queryBuilderCustomFields_description\": \"tilføj brugerdefinerede felter til brug i forespørgselsbyggere\",\n        \"customFontPath_description\": \"angiver stien til den brugerdefinerede skrifttype, der skal bruges i programmet\"\n    },\n    \"table\": {\n        \"column\": {\n            \"album\": \"album\",\n            \"albumArtist\": \"albumkunstner\",\n            \"albumCount\": \"$t(entity.album, {\\\"count\\\": 2})\",\n            \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n            \"biography\": \"biografi\",\n            \"bitDepth\": \"$t(common.bitDepth)\",\n            \"bitrate\": \"bitrate\",\n            \"bpm\": \"bpm\",\n            \"channels\": \"$t(common.channel, {\\\"count\\\": 2})\",\n            \"codec\": \"$t(common.codec)\",\n            \"comment\": \"kommentar\",\n            \"dateAdded\": \"tilføjelsesdato\",\n            \"discNumber\": \"disk\",\n            \"favorite\": \"favorit\",\n            \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n            \"lastPlayed\": \"sidst afspillet\",\n            \"path\": \"sti\",\n            \"playCount\": \"afspilninger\",\n            \"rating\": \"bedømmelse\",\n            \"releaseDate\": \"udgivelsesdato\",\n            \"releaseYear\": \"år\",\n            \"sampleRate\": \"$t(common.sampleRate)\",\n            \"size\": \"$t(common.size)\",\n            \"songCount\": \"$t(entity.track, {\\\"count\\\": 2})\",\n            \"title\": \"titel\",\n            \"trackNumber\": \"spor\",\n            \"owner\": \"ejer\"\n        },\n        \"config\": {\n            \"general\": {\n                \"advancedSettings\": \"avancerede indstillinger\",\n                \"autoFitColumns\": \"tilpas kolonner automatisk\",\n                \"autosize\": \"automatisk størrelse\",\n                \"moveUp\": \"flyt op\",\n                \"moveDown\": \"flyt ned\",\n                \"pinToLeft\": \"fastgør til venstre\",\n                \"pinToRight\": \"fastgør til højre\",\n                \"alignLeft\": \"venstrejuster\",\n                \"alignCenter\": \"centrerjuster\",\n                \"alignRight\": \"højrejuster\",\n                \"followCurrentSong\": \"følg aktuel sang\",\n                \"displayType\": \"visningstype\",\n                \"gap\": \"$t(common.gap)\",\n                \"itemGap\": \"elementmellemrum (px)\",\n                \"itemSize\": \"elementstørrelse (px)\",\n                \"itemsPerRow\": \"elementer pr. række\",\n                \"size\": \"$t(common.size)\",\n                \"size_default\": \"standard\",\n                \"size_compact\": \"kompakt\",\n                \"size_large\": \"stor\",\n                \"tableColumns\": \"tabelkolonner\",\n                \"pagination\": \"sideinddeling\",\n                \"pagination_itemsPerPage\": \"elementer pr. side\",\n                \"pagination_infinite\": \"uendelig\",\n                \"pagination_paginate\": \"sideinddelt\",\n                \"alternateRowColors\": \"vekslende rækkefarver\",\n                \"horizontalBorders\": \"rækkekanter\",\n                \"rowHoverHighlight\": \"fremhæv række ved svæv\",\n                \"showHeader\": \"vis overskrift\",\n                \"verticalBorders\": \"kolonnekanter\"\n            },\n            \"label\": {\n                \"actions\": \"$t(common.action, {\\\"count\\\": 2})\",\n                \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n                \"albumCount\": \"$t(entity.album, {\\\"count\\\": 2})\",\n                \"albumArtist\": \"$t(entity.albumArtist, {\\\"count\\\": 1})\",\n                \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n                \"biography\": \"$t(common.biography)\",\n                \"bitDepth\": \"$t(common.bitDepth)\",\n                \"bitrate\": \"$t(common.bitrate)\",\n                \"bpm\": \"$t(common.bpm)\",\n                \"channels\": \"$t(common.channel, {\\\"count\\\": 2})\",\n                \"codec\": \"$t(common.codec)\",\n                \"composer\": \"komponist\",\n                \"dateAdded\": \"tilføjelsesdato\",\n                \"discNumber\": \"disknummer\",\n                \"duration\": \"$t(common.duration)\",\n                \"favorite\": \"$t(common.favorite)\",\n                \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n                \"genreBadge\": \"$t(entity.genre, {\\\"count\\\": 1}) (badges)\",\n                \"image\": \"billede\",\n                \"lastPlayed\": \"sidst afspillet\",\n                \"note\": \"$t(common.note)\",\n                \"owner\": \"$t(common.owner)\",\n                \"path\": \"$t(common.path)\",\n                \"playCount\": \"afspilninger\",\n                \"rating\": \"$t(common.rating)\",\n                \"releaseDate\": \"udgivelsesdato\",\n                \"rowIndex\": \"rækkeindeks\",\n                \"sampleRate\": \"$t(common.sampleRate)\",\n                \"size\": \"$t(common.size)\",\n                \"songCount\": \"$t(entity.track, {\\\"count\\\": 2})\",\n                \"title\": \"$t(common.title)\",\n                \"titleArtist\": \"$t(common.title) (kunstner)\",\n                \"titleCombined\": \"$t(common.title) (kombineret)\",\n                \"trackNumber\": \"spornummer\",\n                \"year\": \"$t(common.year)\"\n            },\n            \"view\": {\n                \"grid\": \"gitter\",\n                \"list\": \"liste\",\n                \"table\": \"tabel\"\n            }\n        }\n    },\n    \"dragDropZone\": {\n        \"error_oneFileOnly\": \"Vælg venligst kun 1 fil\",\n        \"error_readingFile\": \"der opstod et problem med at læse filen: {{errorMessage}}\",\n        \"mainText\": \"slip en fil her\"\n    },\n    \"visualizer\": {\n        \"visualizerType\": \"Visualiseringstype\",\n        \"cyclePresets\": \"Skift forudindstillinger\",\n        \"cycleTime\": \"Skiftetid (sekunder)\",\n        \"includeAllPresets\": \"Inkludér alle forudindstillinger\",\n        \"ignoredPresets\": \"Ignorerede forudindstillinger\",\n        \"selectedPresets\": \"Valgte forudindstillinger\",\n        \"randomizeNextPreset\": \"Tilfældig næste forudindstilling\",\n        \"blendTime\": \"Blandingstid\",\n        \"presets\": \"Forudindstillinger\",\n        \"selectPreset\": \"Vælg forudindstilling\",\n        \"applyPreset\": \"Anvend forudindstilling\",\n        \"saveAsPreset\": \"Gem som forudindstilling\",\n        \"updatePreset\": \"Opdater forudindstilling\",\n        \"copyConfiguration\": \"Kopiér konfiguration\",\n        \"pasteConfiguration\": \"Indsæt konfiguration\",\n        \"pasteConfigurationPlaceholder\": \"Indsæt JSON-konfiguration her...\",\n        \"pasteFromClipboard\": \"Indsæt fra udklipsholder\",\n        \"applyConfiguration\": \"Anvend konfiguration\",\n        \"configCopied\": \"Konfiguration kopieret til udklipsholder\",\n        \"configCopyFailed\": \"Kunne ikke kopiere konfiguration\",\n        \"configPasted\": \"Konfiguration anvendt\",\n        \"configPasteFailed\": \"Kunne ikke anvende konfiguration. Kontrollér formatet.\",\n        \"configPasteReadFailed\": \"Kunne ikke læse fra udklipsholder\",\n        \"presetName\": \"Navn på forudindstilling\",\n        \"presetNamePlaceholder\": \"Indtast navn på forudindstilling\",\n        \"general\": \"Generelt\",\n        \"mode\": \"Tilstand\",\n        \"mode1To8\": \"Tilstand 1–8\",\n        \"mode10\": \"Tilstand 10\",\n        \"barSpace\": \"Søjlemellemrum\",\n        \"lineWidth\": \"Linjebredde\",\n        \"fillAlpha\": \"Fyldningsgennemsigtighed\",\n        \"channelLayout\": \"Kanallayout\",\n        \"maxFPS\": \"Maks. FPS\",\n        \"opacity\": \"Gennemsigtighed\",\n        \"customGradients\": \"Brugerdefinerede gradienter\",\n        \"addCustomGradient\": \"Tilføj brugerdefineret gradient\",\n        \"gradientName\": \"Gradientnavn\",\n        \"gradientNamePlaceholder\": \"Gradientnavn\",\n        \"vertical\": \"Lodret\",\n        \"horizontal\": \"Vandret\",\n        \"colorStops\": \"Farvestop\",\n        \"addColor\": \"Tilføj farve\",\n        \"position\": \"Position\",\n        \"level\": \"Niveau\",\n        \"remove\": \"Fjern\",\n        \"pasteGradient\": \"Indsæt gradient\",\n        \"pasteGradientPlaceholder\": \"Indsæt gradient-JSON her...\",\n        \"custom\": \"Brugerdefineret\",\n        \"builtIn\": \"Indbygget\",\n        \"colors\": \"Farver\",\n        \"colorMode\": \"Farvetilstand\",\n        \"gradient\": \"Gradient\",\n        \"gradientLeft\": \"Gradient venstre\",\n        \"gradientRight\": \"Gradient højre\",\n        \"fft\": \"FFT\",\n        \"fftSize\": \"FFT-størrelse\",\n        \"smoothing\": \"Udjævning\",\n        \"frequencyRangeAndScaling\": \"Frekvensområde og skalering\",\n        \"minimumFrequency\": \"Minimumsfrekvens\",\n        \"maximumFrequency\": \"Maksimumsfrekvens\",\n        \"frequencyScale\": \"Frekvensskala\",\n        \"sensitivity\": \"Følsomhed\",\n        \"weightingFilter\": \"Vægtningsfilter\",\n        \"minimumDecibels\": \"Minimum decibel\",\n        \"maximumDecibels\": \"Maksimum decibel\",\n        \"linearAmplitude\": \"Lineær amplitude\",\n        \"linearBoost\": \"Lineær forstærkning\",\n        \"peakBehavior\": \"Spidsadfærd\",\n        \"showPeaks\": \"Vis spidser\",\n        \"fadePeaks\": \"Spidsfading\",\n        \"peakLine\": \"Spidslinje\",\n        \"gravity\": \"Tyngdekraft\",\n        \"peakFadeTime\": \"Spidsudtoningstid (ms)\",\n        \"peakHoldTime\": \"Spidsholdetid (ms)\",\n        \"radialSpectrum\": \"Radialt spektrum\",\n        \"radial\": \"Radial\",\n        \"radialInvert\": \"Radial inverteret\",\n        \"spinSpeed\": \"Rotationshastighed\",\n        \"radius\": \"Radius\",\n        \"reflexMirror\": \"Refleksspejl\",\n        \"reflexFit\": \"Reflekstilpasning\",\n        \"reflexRatio\": \"Refleksforhold\",\n        \"reflexAlpha\": \"Refleksgennemsigtighed\",\n        \"reflexBrightness\": \"Reflekslysstyrke\",\n        \"mirror\": \"Spejling\",\n        \"miscellaneousSettings\": \"Diverse indstillinger\",\n        \"alphaBars\": \"Alfa-søjler\",\n        \"ansiBands\": \"ANSI-bånd\",\n        \"ledBars\": \"LED-søjler\",\n        \"trueLeds\": \"Ægte LED'er\",\n        \"lumiBars\": \"Lumi-søjler\",\n        \"outlineBars\": \"Kontur-søjler\",\n        \"roundBars\": \"Runde søjler\",\n        \"lowResolution\": \"Lav opløsning\",\n        \"splitGradient\": \"Delt gradient\",\n        \"showFPS\": \"Vis FPS\",\n        \"showScaleX\": \"Vis X-skala\",\n        \"noteLabels\": \"Nodeetiketter\",\n        \"showScaleY\": \"Vis Y-skala\",\n        \"options\": {\n            \"mode\": {\n                \"0\": \"[0] Diskrete frekvenser\",\n                \"1\": \"[1] 1/24 oktav / 240 bånd\",\n                \"2\": \"[2] 1/12 oktav / 120 bånd\",\n                \"3\": \"[3] 1/8 oktav / 80 bånd\",\n                \"4\": \"[4] 1/6 oktav / 60 bånd\",\n                \"5\": \"[5] 1/4 oktav / 40 bånd\",\n                \"6\": \"[6] 1/3 oktav / 30 bånd\",\n                \"7\": \"[7] Halv oktav / 20 bånd\",\n                \"8\": \"[8] Hel oktav / 10 bånd\",\n                \"10\": \"[10] Linje- / områdegraf\"\n            },\n            \"colorMode\": {\n                \"gradient\": \"Gradient\",\n                \"barIndex\": \"Søjleindeks\",\n                \"barLevel\": \"Søjleniveau\"\n            },\n            \"gradient\": {\n                \"classic\": \"Klassisk\",\n                \"prism\": \"Prisme\",\n                \"rainbow\": \"Regnbue\",\n                \"steelblue\": \"Stålblå\",\n                \"orangered\": \"Orangerød\"\n            },\n            \"channelLayout\": {\n                \"single\": \"Enkelt\",\n                \"dualCombined\": \"Dobbelt-kombineret\",\n                \"dualHorizontal\": \"Dobbelt-vandret\",\n                \"dualVertical\": \"Dobbelt-lodret\"\n            },\n            \"frequencyScale\": {\n                \"none\": \"Ingen\",\n                \"bark\": \"Bark-skala\",\n                \"linear\": \"Lineær skala\",\n                \"log\": \"Logaritmisk skala\",\n                \"mel\": \"Mel-skala\"\n            },\n            \"weightingFilter\": {\n                \"none\": \"Ingen\",\n                \"a\": \"A\",\n                \"b\": \"B\",\n                \"c\": \"C\",\n                \"d\": \"D\",\n                \"z\": \"Z\"\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "src/i18n/locales/de.json",
    "content": "{\n    \"action\": {\n        \"editPlaylist\": \"$t(entity.playlist, {\\\"count\\\": 1}) bearbeiten\",\n        \"clearQueue\": \"Wiedergabeliste leeren\",\n        \"addToFavorites\": \"Zu $t(entity.favorite, {\\\"count\\\": 2}) hinzufügen\",\n        \"addToPlaylist\": \"Zu $t(entity.playlist, {\\\"count\\\": 1}) hinzufügen\",\n        \"createPlaylist\": \"$t(entity.playlist, {\\\"count\\\": 1}) erstellen\",\n        \"deletePlaylist\": \"$t(entity.playlist, {\\\"count\\\": 1}) löschen\",\n        \"deselectAll\": \"Alle abwählen\",\n        \"goToPage\": \"Zu Seite gehen\",\n        \"moveToTop\": \"Als erstes\",\n        \"moveToBottom\": \"Als letztes\",\n        \"removeFromPlaylist\": \"Aus $t(entity.playlist, {\\\"count\\\": 1}) entfernen\",\n        \"viewPlaylists\": \"$t(entity.playlist, {\\\"count\\\": 2}) anzeigen\",\n        \"refresh\": \"$t(common.refresh)\",\n        \"removeFromQueue\": \"Aus Wiedergabeliste entfernen\",\n        \"setRating\": \"Bewerten\",\n        \"toggleSmartPlaylistEditor\": \"Editor für $t(entity.smartPlaylist) ein-/ausblenden\",\n        \"removeFromFavorites\": \"Aus $t(entity.favorite, {\\\"count\\\": 2}) entfernen\",\n        \"openIn\": {\n            \"lastfm\": \"Auf Last.fm öffnen\",\n            \"musicbrainz\": \"Auf MusicBrainz öffnen\"\n        },\n        \"moveToNext\": \"Als nächstes\",\n        \"downloadStarted\": \"Download von {{count}} Elementen gestartet\",\n        \"moveItems\": \"Elemente verschieben\",\n        \"shuffle\": \"Zufällig wiedergeben\",\n        \"shuffleAll\": \"Alle zufällig wiedergeben\",\n        \"shuffleSelected\": \"Ausgewählte zufällig wiedergeben\",\n        \"viewMore\": \"Mehr zeigen\",\n        \"moveUp\": \"Nach oben bewegen\",\n        \"moveDown\": \"Nach unten bewegen\",\n        \"createRadioStation\": \"$t(entity.radioStation, {\\\"count\\\": 1}) erstellen\",\n        \"deleteRadioStation\": \"$t(entity.radioStation, {\\\"count\\\": 1}) löschen\",\n        \"selectAll\": \"alle auswählen\",\n        \"openApplicationDirectory\": \"Anwendungsverzeichnis öffnen\",\n        \"addOrRemoveFromSelection\": \"Zur Auswahl hinzufügen oder entfernen\",\n        \"selectRangeOfItems\": \"Wählen sie eine Reihe von Elementen\",\n        \"holdToMoveToTop\": \"Halten um nach oben zu bewegen\",\n        \"holdToMoveToBottom\": \"Halten um nach unten zu bewegen\",\n        \"goToCurrent\": \"Zu aktuellem Eintrag wechseln\"\n    },\n    \"common\": {\n        \"backward\": \"zurück\",\n        \"increase\": \"erhöhen\",\n        \"rating\": \"Wertung\",\n        \"bpm\": \"bpm\",\n        \"refresh\": \"Aktualisieren\",\n        \"unknown\": \"Unbekannt\",\n        \"areYouSure\": \"Bist Du sicher?\",\n        \"edit\": \"Bearbeiten\",\n        \"favorite\": \"Favorit\",\n        \"left\": \"Linksbündig\",\n        \"save\": \"Speichern\",\n        \"right\": \"Rechtsbündig\",\n        \"currentSong\": \"Aktueller $t(entity.track, {\\\"count\\\": 1})\",\n        \"collapse\": \"Verkleinern\",\n        \"trackNumber\": \"Track\",\n        \"descending\": \"absteigend\",\n        \"add\": \"Hinzufügen\",\n        \"gap\": \"Lücke\",\n        \"ascending\": \"aufsteigend\",\n        \"dismiss\": \"Verwerfen\",\n        \"year\": \"Jahr\",\n        \"manage\": \"Verwalten\",\n        \"limit\": \"Limit\",\n        \"minimize\": \"minimieren\",\n        \"modified\": \"geändert\",\n        \"duration\": \"Laufzeit\",\n        \"name\": \"Name\",\n        \"maximize\": \"maximieren\",\n        \"decrease\": \"verringern\",\n        \"ok\": \"okay\",\n        \"description\": \"Beschreibung\",\n        \"configure\": \"Konfigurieren\",\n        \"path\": \"Pfad\",\n        \"center\": \"Zentriert\",\n        \"no\": \"Nein\",\n        \"owner\": \"Eigentümer\",\n        \"enable\": \"Aktivieren\",\n        \"clear\": \"Leeren\",\n        \"forward\": \"vorwärts\",\n        \"delete\": \"Löschen\",\n        \"cancel\": \"Abbrechen\",\n        \"forceRestartRequired\": \"Neustarten um die Änderungen zu übernehmen... Schließe die Benachrichtigung zum Neustarten\",\n        \"setting\": \"Einstellungen\",\n        \"setting_one\": \"Einstellung\",\n        \"setting_other\": \"Einstellungen\",\n        \"version\": \"Version\",\n        \"title\": \"Titel\",\n        \"filter_one\": \"Filter\",\n        \"filter_other\": \"Filter\",\n        \"filters\": \"Filter\",\n        \"create\": \"Erstellen\",\n        \"bitrate\": \"Bitrate\",\n        \"saveAndReplace\": \"Speichern und Ersetzen\",\n        \"action_one\": \"Aktion\",\n        \"action_other\": \"Aktionen\",\n        \"playerMustBePaused\": \"Player muss pausiert sein\",\n        \"confirm\": \"Bestätigen\",\n        \"resetToDefault\": \"Auf Standard zurücksetzen\",\n        \"home\": \"Home\",\n        \"comingSoon\": \"Kommt bald…\",\n        \"reset\": \"zurücksetzen\",\n        \"channel_one\": \"Kanal\",\n        \"channel_other\": \"Kanäle\",\n        \"disable\": \"Deaktivieren\",\n        \"sortOrder\": \"Reihenfolge\",\n        \"none\": \"keine\",\n        \"menu\": \"Menü\",\n        \"restartRequired\": \"(Neustart benötigt)\",\n        \"previousSong\": \"vorheriger $t(entity.track, {\\\"count\\\": 1})\",\n        \"noResultsFromQuery\": \"Die Abfrage brachte keine Ergebnisse\",\n        \"quit\": \"verlassen\",\n        \"expand\": \"Vergrößern\",\n        \"search\": \"Suchen\",\n        \"saveAs\": \"Speichern unter\",\n        \"disc\": \"CD\",\n        \"yes\": \"Ja\",\n        \"random\": \"zufällig\",\n        \"size\": \"Größe\",\n        \"biography\": \"Biografie\",\n        \"note\": \"Hinweis\",\n        \"preview\": \"Vorschau\",\n        \"reload\": \"Neu Laden\",\n        \"mbid\": \"MusicBrainz ID\",\n        \"close\": \"schließen\",\n        \"share\": \"Teilen\",\n        \"translation\": \"Übersetzung\",\n        \"trackGain\": \"Track Gain\",\n        \"trackPeak\": \"Track Peak\",\n        \"codec\": \"Codec\",\n        \"albumPeak\": \"Album-Spitzenpegel\",\n        \"albumGain\": \"Album Gain\",\n        \"tags\": \"tags\",\n        \"viewReleaseNotes\": \"Veröffentlichungsnotizen anzeigen\",\n        \"newVersion\": \"eine neue Version wurde installiert ({{version}})\",\n        \"bitDepth\": \"Bittiefe\",\n        \"sampleRate\": \"Abtastrate\",\n        \"additionalParticipants\": \"weitere Beteiligte\",\n        \"explicitStatus\": \"Anstößig-Status\",\n        \"doNotShowAgain\": \"Nicht wieder zeigen\",\n        \"explicit\": \"Anstößig\",\n        \"gridRows\": \"Rasterzeilen\",\n        \"tableColumns\": \"Tabellenspalten\",\n        \"itemsMore\": \"{{count}} weitere\",\n        \"externalLinks\": \"externe Links\",\n        \"faster\": \"schneller\",\n        \"noFilters\": \"Keine Filter konfiguriert\",\n        \"private\": \"privat\",\n        \"public\": \"öffentlich\",\n        \"sort\": \"sortieren\",\n        \"clean\": \"Jugendfrei\",\n        \"recordLabel\": \"Plattenlabel\",\n        \"slower\": \"langsamer\",\n        \"releaseType\": \"Veröffentlichungsformat\",\n        \"view\": \"Betrachten\",\n        \"countSelected\": \"{{count}} ausgewählt\",\n        \"mood\": \"Stimmung\",\n        \"example\": \"Beispiel\",\n        \"rename\": \"Umbenennen\",\n        \"filter_single\": \"einzeln\",\n        \"filter_multiple\": \"mehrfach\",\n        \"retry\": \"Wiederholen\",\n        \"newVersionAvailable\": \"Eine neue Version ist verfügbar\"\n    },\n    \"error\": {\n        \"remotePortWarning\": \"Starten Sie den Server neu, um den neuen Port anzuwenden\",\n        \"systemFontError\": \"Beim Versuch, Systemschriftarten abzurufen, ist ein Fehler aufgetreten\",\n        \"playbackError\": \"Beim Versuch, das Medium abzuspielen, ist ein Fehler aufgetreten\",\n        \"endpointNotImplementedError\": \"Endgerät {{endpoint}} ist nicht für {{serverType}} implementiert\",\n        \"remotePortError\": \"Beim Versuch, den Remote-Server-Port festzulegen, ist ein Fehler aufgetreten\",\n        \"serverRequired\": \"Server benötigt\",\n        \"authenticationFailed\": \"Authentifizierung fehlgeschlagen\",\n        \"apiRouteError\": \"Anforderung kann nicht weitergeleitet werden\",\n        \"genericError\": \"Ein Fehler ist aufgetreten\",\n        \"credentialsRequired\": \"Anmeldeinformationen erforderlich\",\n        \"sessionExpiredError\": \"Deine Sitzung ist abgelaufen\",\n        \"remoteEnableError\": \"Beim Versuch, den Remote-Server mit $t(common.enable), ist ein Fehler aufgetreten\",\n        \"localFontAccessDenied\": \"Zugriff auf lokale Schriftarten verweigert\",\n        \"serverNotSelectedError\": \"Kein Server ausgewählt\",\n        \"remoteDisableError\": \"Beim Versuch, den Remote-Server mit $t(common.disable), ist ein Fehler aufgetreten\",\n        \"mpvRequired\": \"MPV benötigt\",\n        \"audioDeviceFetchError\": \"Beim Versuch, Audiogeräte abzurufen, ist ein Fehler aufgetreten\",\n        \"invalidServer\": \"Ungültiger Server\",\n        \"loginRateError\": \"Zu viele Anmeldeversuche, bitte versuche es in einigen Sekunden erneut\",\n        \"badAlbum\": \"sie sehen diese Seite, weil dieses Lied nicht Teil eines Albums ist. Dieses Problem tritt meist auf, wenn sich ein Lied im Überordner befindet. Jellyfin gruppiert Tracks nur, wenn diese sich innerhalb eines Ordners befinden\",\n        \"networkError\": \"ein Netzwerkfehler ist aufgetreten\",\n        \"openError\": \"datei kann nicht geöffnet werden\",\n        \"badValue\": \"ungültige option \\\"{{value}}\\\". Dieser Wert existiert nicht mehr\",\n        \"notificationDenied\": \"Berechtigungen über Benachrichtigungen wurden verweigert. Diese Einstellung hat keinen Effekt\",\n        \"saveQueueFailed\": \"Wiedergabeliste konnte nicht gespeichert werden\",\n        \"multipleServerSaveQueueError\": \"die Wiedergabeliste enthält einen oder mehrere Titel, die nicht vom aktuellen Server stammen. dies wird nicht unterstützt\",\n        \"noNetwork\": \"Server nicht verfügbar\",\n        \"noNetworkDescription\": \"Verbindung zum Server konnte nicht hergestellt werden\",\n        \"invalidJson\": \"JSON ungültig\",\n        \"serverLockSingleServer\": \"Nur ein Server ist erlaubt, wenn der Server gesperrt ist\",\n        \"settingsSyncError\": \"Es wurden Unstimmigkeiten zwischen den Einstellungen im Renderer und dem Hauptprozess gefunden. Starte die Anwendung neu, um die Änderungen zu übernehmen\",\n        \"playbackPausedDueToError\": \"Die Wiedergabe wurde aufgrund eines Fehlers pausiert\"\n    },\n    \"filter\": {\n        \"mostPlayed\": \"Meistgespielt\",\n        \"comment\": \"Kommentar\",\n        \"playCount\": \"Anzahl abgespielt\",\n        \"recentlyUpdated\": \"kürzlich aktualisiert\",\n        \"isCompilation\": \"ist Zusammenstellung\",\n        \"recentlyPlayed\": \"kürzlich gespielt\",\n        \"isRated\": \"ist bewertet\",\n        \"title\": \"Titel\",\n        \"rating\": \"Bewertung\",\n        \"search\": \"Suche\",\n        \"bitrate\": \"Bitrate\",\n        \"recentlyAdded\": \"kürzlich hinzugefügt\",\n        \"note\": \"Hinweis\",\n        \"name\": \"Name\",\n        \"dateAdded\": \"Datum hinzugefügt\",\n        \"releaseDate\": \"Veröffentlichungsdatum\",\n        \"albumCount\": \"$t(entity.album, {\\\"count\\\": 2}) Anzahl\",\n        \"communityRating\": \"Community-Wertung\",\n        \"path\": \"Pfad\",\n        \"favorited\": \"favorisiert\",\n        \"albumArtist\": \"$t(entity.albumArtist, {\\\"count\\\": 1})\",\n        \"isRecentlyPlayed\": \"wurde kürzlich gespielt\",\n        \"isFavorited\": \"wird favorisiert\",\n        \"bpm\": \"bpm\",\n        \"releaseYear\": \"Erscheinungsjahr\",\n        \"id\": \"ID\",\n        \"disc\": \"Disk\",\n        \"biography\": \"Biografie\",\n        \"songCount\": \"Anzahl Lieder\",\n        \"duration\": \"Dauer\",\n        \"isPublic\": \"ist öffentlich\",\n        \"random\": \"zufällig\",\n        \"lastPlayed\": \"Zuletzt gespielt\",\n        \"toYear\": \"bis Jahr\",\n        \"fromYear\": \"ab Jahr\",\n        \"criticRating\": \"Kritikerbewertung\",\n        \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n        \"trackNumber\": \"Track\",\n        \"channels\": \"$t(common.channel_other)\",\n        \"owner\": \"$t(common.owner)\",\n        \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n        \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n        \"explicitStatus\": \"$t(common.explicitStatus)\",\n        \"matchAnd\": \"und\",\n        \"matchOr\": \"oder\"\n    },\n    \"form\": {\n        \"deletePlaylist\": {\n            \"title\": \"$t(entity.playlist, {\\\"count\\\": 1}) löschen\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) erfolgreich gelöscht\",\n            \"input_confirm\": \"Geben Sie zur Bestätigung den Namen von $t(entity.playlist, {\\\"count\\\": 1}) ein\"\n        },\n        \"createPlaylist\": {\n            \"input_description\": \"$t(common.description)\",\n            \"title\": \"$t(entity.playlist, {\\\"count\\\": 1}) erstellen\",\n            \"input_public\": \"öffentlich\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) erfolgreich erstellt\",\n            \"input_name\": \"$t(common.name)\",\n            \"input_owner\": \"$t(common.owner)\"\n        },\n        \"addServer\": {\n            \"title\": \"Server hinzufügen\",\n            \"input_username\": \"Benutzername\",\n            \"input_url\": \"URL\",\n            \"input_password\": \"Passwort\",\n            \"input_legacyAuthentication\": \"Alte Authentifizierung verwenden\",\n            \"input_name\": \"Servername\",\n            \"success\": \"Server erfolgreich hinzugefügt\",\n            \"input_savePassword\": \"Passwort speichern\",\n            \"ignoreSsl\": \"SSL ignorieren $t(common.restartRequired)\",\n            \"ignoreCors\": \"CORS ignorieren $t(common.restartRequired)\",\n            \"error_savePassword\": \"Beim Speichern des Passworts ist ein Fehler aufgetreten\",\n            \"input_preferInstantMix\": \"Instant-Mix bevorzugen\",\n            \"input_preferInstantMixDescription\": \"nur Instant-Mix verwenden, um ähnliche Songs zu erhalten. Nützlich bei Verwendung von Plugins, die in dieses Verhalten eingreifen\",\n            \"input_preferRemoteUrl\": \"öffentliche URL bevorzugen\",\n            \"input_remoteUrl\": \"Öffentliche URL\",\n            \"input_remoteUrlPlaceholder\": \"Optional: öffentliche URL für externe Funktionen\"\n        },\n        \"addToPlaylist\": {\n            \"success\": \"$t(entity.trackWithCount, {\\\"count\\\": {{message}} }) zu $t(entity.playlistWithCount, {\\\"count\\\": {{numOfPlaylists}} }) hinzugefügt\",\n            \"title\": \"Zu $t(entity.playlist, {\\\"count\\\": 1}) hinzufügen\",\n            \"input_skipDuplicates\": \"Duplikate überspringen\",\n            \"input_playlists\": \"$t(entity.playlist, {\\\"count\\\": 2})\",\n            \"create\": \"$t(entity.playlist, {\\\"count\\\": 1}) {{playlist}} erstellen\",\n            \"searchOrCreate\": \"Nach $t(entity.playlist, {\\\"count\\\": 2}) suchen oder Namen eingeben, um eine neue zu erstellen\"\n        },\n        \"updateServer\": {\n            \"title\": \"Server aktualisieren\",\n            \"success\": \"Server erfolgreich aktualisiert\"\n        },\n        \"queryEditor\": {\n            \"input_optionMatchAll\": \"Treffer Alle\",\n            \"input_optionMatchAny\": \"Treffer Einige\",\n            \"title\": \"query bearbeiten\",\n            \"clearFilters\": \"Filter löschen\",\n            \"addRuleGroup\": \"Regelgruppe hinzufügen\",\n            \"removeRuleGroup\": \"Regelgruppe entfernen\",\n            \"resetToDefault\": \"auf Standard zurücksetzen\"\n        },\n        \"editPlaylist\": {\n            \"title\": \"Bearbeite $t(entity.playlist, {\\\"count\\\": 1})\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) erfolgreich aktualisiert\",\n            \"publicJellyfinNote\": \"Jellyfin legt aus irgendwelchen Gründen nicht offen ob eine Wiedergabeliste öffentlich ist oder nicht. Wenn du möchtest, dass sie öffentlich bleibt, wähle bitte diese Option aus\",\n            \"editNote\": \"Manuelles Bearbeiten wird für große Wiedergabelisten nicht empfohlen. Bist Du sicher, dass Du die aktuelle Wiedergabeliste unter dem Risiko von Datenverlust überschrieben möchtest?\"\n        },\n        \"lyricSearch\": {\n            \"title\": \"Songtext Suche\",\n            \"input_name\": \"$t(common.name)\",\n            \"input_artist\": \"$t(entity.artist, {\\\"count\\\": 1})\"\n        },\n        \"shareItem\": {\n            \"description\": \"Beschreibung\",\n            \"setExpiration\": \"Ablaufdatum setzen\",\n            \"expireInvalid\": \"Ablaufdatum muss in der Zukunft liegen\",\n            \"allowDownloading\": \"Herunterladen zulassen\",\n            \"success\": \"Link in die Zwischenablage kopiert (oder hier klicken, um zu öffnen)\",\n            \"createFailed\": \"fehler beim Teilen (Ist Teilen aktiviert?)\",\n            \"copyToClipboard\": \"In Zwischenablage kopieren: Strg+C, Enter\",\n            \"successMustClick\": \"Freigabe erfolgreich erstellt. Hier klicken um diese zu öffnen\"\n        },\n        \"privateMode\": {\n            \"enabled\": \"Privatmodus aktiviert, Wiedergabe-Status wird externen Quellen nicht preisgegeben\",\n            \"disabled\": \"Privatmodus deaktiviert, Wiedergabe-Status wird externen Quellen preisgegeben\",\n            \"title\": \"Privatmodus\"\n        },\n        \"largeFetchConfirmation\": {\n            \"title\": \"Elemente der Wiedergabeliste hinzufügen\",\n            \"description\": \"Diese Aktion fügt alle Elemente in der aktuell gefilterten Ansicht hinzu\"\n        },\n        \"shuffleAll\": {\n            \"title\": \"Zufallswiedergabe\",\n            \"input_genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n            \"input_limit\": \"Wie viele Songs?\",\n            \"input_minYear\": \"ab Jahr\",\n            \"input_maxYear\": \"bis Jahr\",\n            \"input_played_optionAll\": \"alle Tracks\",\n            \"input_played_optionUnplayed\": \"nur nicht gespielte Tracks\",\n            \"input_played_optionPlayed\": \"nur gespielte Tracks\",\n            \"input_played\": \"Wiedergabefilter\"\n        },\n        \"saveQueue\": {\n            \"success\": \"Wiedergabeliste auf Server gespeichert\"\n        },\n        \"createRadioStation\": {\n            \"success\": \"Radiosender erfolgreich erstellt\",\n            \"title\": \"Radiosender erstellen\",\n            \"input_homepageUrl\": \"Homepage URL\",\n            \"input_name\": \"Name\",\n            \"input_streamUrl\": \"Stream URL\"\n        },\n        \"lyricsExport\": {\n            \"input_offset\": \"$t(setting.lyricOffset)\",\n            \"export\": \"Songtexte exportieren\",\n            \"input_synced\": \"Synchronisierte Songtexte exportieren\"\n        }\n    },\n    \"entity\": {\n        \"genre_one\": \"Genre\",\n        \"genre_other\": \"Genres\",\n        \"playlistWithCount_one\": \"{{count}} Wiedergabeliste\",\n        \"playlistWithCount_other\": \"{{count}} Wiedergabelisten\",\n        \"playlist_one\": \"Wiedergabeliste\",\n        \"playlist_other\": \"Wiedergabelisten\",\n        \"artist_one\": \"Interpret\",\n        \"artist_other\": \"Interpreten\",\n        \"folderWithCount_one\": \"{{count}} Verzeichnis\",\n        \"folderWithCount_other\": \"{{count}} Verzeichnisse\",\n        \"albumArtist_one\": \"Albuminterpret\",\n        \"albumArtist_other\": \"Albuminterpreten\",\n        \"track_one\": \"Track\",\n        \"track_other\": \"Tracks\",\n        \"albumArtistCount_one\": \"{{count}} Albuminterpret\",\n        \"albumArtistCount_other\": \"{{count}} Albuminterpreten\",\n        \"albumWithCount_one\": \"{{count}} Album\",\n        \"albumWithCount_other\": \"{{count}} Alben\",\n        \"favorite_one\": \"Favorit\",\n        \"favorite_other\": \"Favoriten\",\n        \"artistWithCount_one\": \"{{count}} Interpret\",\n        \"artistWithCount_other\": \"{{count}} Interpreten\",\n        \"folder_one\": \"Verzeichnis\",\n        \"folder_other\": \"Verzeichnisse\",\n        \"album_one\": \"Album\",\n        \"album_other\": \"Alben\",\n        \"genreWithCount_one\": \"{{count}} Genre\",\n        \"genreWithCount_other\": \"{{count}} Genres\",\n        \"trackWithCount_one\": \"{{count}} Track\",\n        \"trackWithCount_other\": \"{{count}} Tracks\",\n        \"smartPlaylist\": \"Intelligente $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"play_one\": \"{{count}} Wiedergabe\",\n        \"play_other\": \"{{count}} Wiedergaben\",\n        \"song_one\": \"Lied\",\n        \"song_other\": \"Lieder\",\n        \"radioStation_one\": \"Radiosender\",\n        \"radioStation_other\": \"Radiosender\",\n        \"radioStationWithCount_one\": \"{{count}} Radiosender\",\n        \"radioStationWithCount_other\": \"{{count}} Radiosender\"\n    },\n    \"table\": {\n        \"config\": {\n            \"view\": {\n                \"table\": \"Tabelle\",\n                \"grid\": \"Raster\",\n                \"list\": \"Liste\",\n                \"detail\": \"Detail\"\n            },\n            \"general\": {\n                \"tableColumns\": \"Tabellenspalten\",\n                \"gap\": \"$t(common.gap)\",\n                \"size\": \"$t(common.size)\",\n                \"displayType\": \"Anzeigestil\",\n                \"autoFitColumns\": \"automatisch Spalten einpassen\",\n                \"size_default\": \"Standard\",\n                \"followCurrentSong\": \"aktuellem Titel folgen\",\n                \"advancedSettings\": \"erweiterte Einstellungen\",\n                \"autosize\": \"automatische Größe\",\n                \"alignLeft\": \"linksbündig\",\n                \"alignCenter\": \"mittig\",\n                \"alignRight\": \"rechtsbündig\",\n                \"size_compact\": \"kompakt\",\n                \"size_large\": \"groß\",\n                \"pagination\": \"Seitenzahlen\",\n                \"pagination_itemsPerPage\": \"Elemente pro Seite\",\n                \"pagination_infinite\": \"unendlich\",\n                \"moveUp\": \"Nach oben bewegen\",\n                \"moveDown\": \"Nach unten bewegen\",\n                \"pinToLeft\": \"links anheften\",\n                \"pinToRight\": \"rechts anheften\",\n                \"itemGap\": \"Item Abstand (px)\",\n                \"itemSize\": \"Item Größe (px)\",\n                \"itemsPerRow\": \"Items pro Zeile\",\n                \"pagination_paginate\": \"paginiert\",\n                \"alternateRowColors\": \"Zeilenfarben abwechseln\",\n                \"horizontalBorders\": \"Zeilenbegrenzungen\",\n                \"rowHoverHighlight\": \"Zeilenhervorhebungen beim hovern\",\n                \"showHeader\": \"Spaltenüberschrift anzeigen\",\n                \"verticalBorders\": \"Spaltenbegrenzungen\"\n            },\n            \"label\": {\n                \"dateAdded\": \"Hinzugefügt am\",\n                \"lastPlayed\": \"zuletzt gespielt\",\n                \"rowIndex\": \"Reihenindex\",\n                \"trackNumber\": \"Tracknummer\",\n                \"biography\": \"$t(common.biography)\",\n                \"bitrate\": \"$t(common.bitrate)\",\n                \"albumArtist\": \"$t(entity.albumArtist, {\\\"count\\\": 1})\",\n                \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n                \"favorite\": \"$t(common.favorite)\",\n                \"actions\": \"$t(common.action_other)\",\n                \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n                \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n                \"size\": \"$t(common.size)\",\n                \"bpm\": \"$t(common.bpm)\",\n                \"titleCombined\": \"$t(common.title) (kombiniert)\",\n                \"channels\": \"$t(common.channel_other)\",\n                \"duration\": \"$t(common.duration)\",\n                \"note\": \"$t(common.note)\",\n                \"owner\": \"$t(common.owner)\",\n                \"path\": \"$t(common.path)\",\n                \"rating\": \"$t(common.rating)\",\n                \"releaseDate\": \"Veröffentlichungsdatum\",\n                \"title\": \"$t(common.title)\",\n                \"year\": \"$t(common.year)\",\n                \"discNumber\": \"disk-Nummer\",\n                \"playCount\": \"Wiedergaben\",\n                \"albumCount\": \"$t(entity.album, {\\\"count\\\": 2})\",\n                \"bitDepth\": \"$t(common.bitDepth)\",\n                \"codec\": \"$t(common.codec)\",\n                \"image\": \"Bild\",\n                \"sampleRate\": \"$t(common.sampleRate)\",\n                \"songCount\": \"$t(entity.track, {\\\"count\\\": 2})\",\n                \"genreBadge\": \"$t(entity.genre, {\\\"count\\\": 1}) (Abzeichen)\",\n                \"composer\": \"Komponist\",\n                \"titleArtist\": \"$t(common.title) (Interpret)\"\n            }\n        },\n        \"column\": {\n            \"releaseYear\": \"Jahr\",\n            \"biography\": \"Biografie\",\n            \"releaseDate\": \"Veröffentlichungsdatum\",\n            \"bitrate\": \"Bitrate\",\n            \"title\": \"Titel\",\n            \"path\": \"Pfad\",\n            \"album\": \"Album\",\n            \"albumArtist\": \"Albenkünstler\",\n            \"bpm\": \"bpm\",\n            \"favorite\": \"Favorit\",\n            \"lastPlayed\": \"zuletzt gespielt\",\n            \"rating\": \"Bewertung\",\n            \"albumCount\": \"$t(entity.album, {\\\"count\\\": 2})\",\n            \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n            \"channels\": \"$t(common.channel_other)\",\n            \"comment\": \"Kommentar\",\n            \"dateAdded\": \"hinzugefügt am\",\n            \"playCount\": \"Abgespielt\",\n            \"discNumber\": \"Disk\",\n            \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n            \"songCount\": \"$t(entity.track, {\\\"count\\\": 2})\",\n            \"trackNumber\": \"titel\",\n            \"size\": \"$t(common.size)\",\n            \"bitDepth\": \"$t(common.bitDepth)\",\n            \"codec\": \"$t(common.codec)\",\n            \"sampleRate\": \"$t(common.sampleRate)\",\n            \"owner\": \"Besitzer\"\n        }\n    },\n    \"page\": {\n        \"fullscreenPlayer\": {\n            \"config\": {\n                \"showLyricMatch\": \"Textübereinstimmung anzeigen\",\n                \"dynamicBackground\": \"Dynamischer Hintergrund\",\n                \"synchronized\": \"synchronisiert\",\n                \"followCurrentLyric\": \"dem Songtext folgen\",\n                \"opacity\": \"Deckkraft\",\n                \"lyricSize\": \"Songtext-Größe\",\n                \"showLyricProvider\": \"Songtext-Anbieter anzeigen\",\n                \"unsynchronized\": \"nicht synchronisiert\",\n                \"lyricAlignment\": \"Songtext-Ausrichtung\",\n                \"useImageAspectRatio\": \"Bildseitenverhältnis verwenden\",\n                \"lyricGap\": \"Songtext-Lücke\",\n                \"dynamicIsImage\": \"Hintergrundbild aktivieren\",\n                \"dynamicImageBlur\": \"Größe der Bildunschärfe\",\n                \"lyricOffset\": \"Zeitversatz des Songtextes (ms)\"\n            },\n            \"upNext\": \"als nächstes\",\n            \"lyrics\": \"Songtexte\",\n            \"related\": \"Ähnliche\",\n            \"noLyrics\": \"Songtext nicht gefunden\",\n            \"visualizer\": \"visualizer\"\n        },\n        \"appMenu\": {\n            \"selectServer\": \"Server auswählen\",\n            \"version\": \"Version {{version}}\",\n            \"manageServers\": \"Server verwalten\",\n            \"expandSidebar\": \"Seitenleiste erweitern\",\n            \"collapseSidebar\": \"Seitenleiste einklappen\",\n            \"openBrowserDevtools\": \"Browser-Entwicklungswerkzeuge öffnen\",\n            \"goBack\": \"Gehe zurück\",\n            \"goForward\": \"Gehe vorwärts\",\n            \"settings\": \"$t(common.setting, {\\\"count\\\": 2})\",\n            \"quit\": \"$t(common.quit)\",\n            \"privateModeOff\": \"Privatmodus deaktivieren\",\n            \"privateModeOn\": \"Privatmodus aktivieren\",\n            \"commandPalette\": \"Kommandopalette öffnen\",\n            \"selectMusicFolder\": \"Musikordner wählen\",\n            \"noMusicFolder\": \"kein Musikordner gewählt\",\n            \"multipleMusicFolders\": \"{{count}} Musikordner ausgewählt\"\n        },\n        \"home\": {\n            \"mostPlayed\": \"Meistgespielt\",\n            \"newlyAdded\": \"Neu hinzugefügte Veröffentlichungen\",\n            \"explore\": \"Entdecke deine Bibliothek\",\n            \"recentlyPlayed\": \"Kürzlich gespielt\",\n            \"title\": \"$t(common.home)\",\n            \"recentlyReleased\": \"kürzlich veröffentlicht\",\n            \"genres\": \"$t(entity.genre, {\\\"count\\\": 2})\"\n        },\n        \"albumDetail\": {\n            \"moreFromArtist\": \"mehr von diesem $t(entity.artist, {\\\"count\\\": 1})\",\n            \"moreFromGeneric\": \"Mehr von {{item}}\",\n            \"released\": \"erschienen\"\n        },\n        \"globalSearch\": {\n            \"commands\": {\n                \"serverCommands\": \"Serverbefehle\",\n                \"goToPage\": \"Gehe zur Seite\",\n                \"searchFor\": \"Nach {{query}} suchen\"\n            },\n            \"title\": \"Befehle\"\n        },\n        \"contextMenu\": {\n            \"numberSelected\": \"{{count}} ausgewählt\",\n            \"addToPlaylist\": \"$t(action.addToPlaylist)\",\n            \"addToFavorites\": \"$t(action.addToFavorites)\",\n            \"setRating\": \"$t(action.setRating)\",\n            \"moveToTop\": \"$t(action.moveToTop)\",\n            \"deletePlaylist\": \"$t(action.deletePlaylist)\",\n            \"moveToBottom\": \"$t(action.moveToBottom)\",\n            \"createPlaylist\": \"$t(action.createPlaylist)\",\n            \"removeFromPlaylist\": \"$t(action.removeFromPlaylist)\",\n            \"removeFromFavorites\": \"$t(action.removeFromFavorites)\",\n            \"addNext\": \"$t(player.addNext)\",\n            \"deselectAll\": \"$t(action.deselectAll)\",\n            \"addLast\": \"$t(player.addLast)\",\n            \"addFavorite\": \"$t(action.addToFavorites)\",\n            \"play\": \"$t(player.play)\",\n            \"removeFromQueue\": \"$t(action.removeFromQueue)\",\n            \"playShuffled\": \"$t(player.shuffle)\",\n            \"download\": \"Download\",\n            \"playSimilarSongs\": \"$t(player.playSimilarSongs)\",\n            \"moveToNext\": \"$t(action.moveToNext)\",\n            \"shareItem\": \"teilen\",\n            \"showDetails\": \"Informationen\",\n            \"goToAlbum\": \"zu $t(entity.album, {\\\"count\\\": 1}) gehen\",\n            \"goToAlbumArtist\": \"zu $t(entity.albumArtist, {\\\"count\\\": 1}) gehen\",\n            \"moveItems\": \"$t(action.moveItems)\",\n            \"goTo\": \"Gehe zu\"\n        },\n        \"sidebar\": {\n            \"nowPlaying\": \"läuft gerade\",\n            \"playlists\": \"$t(entity.playlist, {\\\"count\\\": 2})\",\n            \"search\": \"$t(common.search)\",\n            \"tracks\": \"$t(entity.track, {\\\"count\\\": 2})\",\n            \"albums\": \"$t(entity.album, {\\\"count\\\": 2})\",\n            \"genres\": \"$t(entity.genre, {\\\"count\\\": 2})\",\n            \"folders\": \"$t(entity.folder, {\\\"count\\\": 2})\",\n            \"settings\": \"$t(common.setting, {\\\"count\\\": 2})\",\n            \"home\": \"$t(common.home)\",\n            \"artists\": \"$t(entity.artist, {\\\"count\\\": 2})\",\n            \"albumArtists\": \"$t(entity.albumArtist, {\\\"count\\\": 2})\",\n            \"shared\": \"$t(entity.playlist, {\\\"count\\\": 2}) geteilt\",\n            \"myLibrary\": \"meine bibliothek\",\n            \"favorites\": \"$t(entity.favorite, {\\\"count\\\": 2})\",\n            \"radio\": \"$t(entity.radioStation, {\\\"count\\\": 2})\",\n            \"collections\": \"Sammlungen\"\n        },\n        \"setting\": {\n            \"playbackTab\": \"Wiedergabe\",\n            \"generalTab\": \"Allgemein\",\n            \"hotkeysTab\": \"Kurzbefehle\",\n            \"windowTab\": \"Fenster\",\n            \"advanced\": \"Erweitert\",\n            \"discord\": \"Discord\",\n            \"exportImport\": \"Importieren/Exportieren\",\n            \"analytics\": \"Analyse\",\n            \"updates\": \"Update\",\n            \"cache\": \"Cache\",\n            \"application\": \"App\",\n            \"queryBuilder\": \"Abfrage-Editor\",\n            \"theme\": \"Erscheinungsbild\",\n            \"controls\": \"Steuerelemente\",\n            \"sidebar\": \"Seitenleiste\",\n            \"scrobble\": \"Scrobbeln\",\n            \"audio\": \"Audio\",\n            \"lyrics\": \"Songtexte\",\n            \"transcoding\": \"Transcoding\",\n            \"logger\": \"Logger\",\n            \"playerFilters\": \"Player-Filter\",\n            \"remote\": \"Fernsteuerung\",\n            \"lyricsDisplay\": \"Songtexte Anzeige\"\n        },\n        \"albumArtistList\": {\n            \"title\": \"$t(entity.albumArtist, {\\\"count\\\": 2})\"\n        },\n        \"genreList\": {\n            \"title\": \"$t(entity.genre, {\\\"count\\\": 2})\",\n            \"showTracks\": \"$t(entity.genre, {\\\"count\\\": 1}) $t(entity.track, {\\\"count\\\": 2}) anzeigen\",\n            \"showAlbums\": \"$t(entity.genre, {\\\"count\\\": 1}) $t(entity.album, {\\\"count\\\": 2}) anzeigen\"\n        },\n        \"trackList\": {\n            \"title\": \"$t(entity.track, {\\\"count\\\": 2})\",\n            \"artistTracks\": \"Tracks von {{artist}}\",\n            \"genreTracks\": \"\\\"{{genre}}\\\" $t(entity.track, {\\\"count\\\": 2})\"\n        },\n        \"playlistList\": {\n            \"title\": \"$t(entity.playlist, {\\\"count\\\": 2})\"\n        },\n        \"albumList\": {\n            \"title\": \"$t(entity.album, {\\\"count\\\": 2})\",\n            \"artistAlbums\": \"Alben von {{artist}}\",\n            \"genreAlbums\": \"\\\"{{genre}}\\\" $t(entity.album, {\\\"count\\\": 2})\"\n        },\n        \"albumArtistDetail\": {\n            \"about\": \"Über {{artist}}\",\n            \"appearsOn\": \"erscheint auf\",\n            \"recentReleases\": \"Kürzliche Veröffentlichungen\",\n            \"viewDiscography\": \"Diskographie ansehen\",\n            \"viewAllTracks\": \"Alle $t(entity.track, {\\\"count\\\": 2}) ansehen\",\n            \"topSongsFrom\": \"Toplieder von {{title}}\",\n            \"viewAll\": \"Alles ansehen\",\n            \"topSongs\": \"Toplieder\",\n            \"relatedArtists\": \"ähnliche $t(entity.artist, {\\\"count\\\": 2})\",\n            \"groupingTypeAll\": \"alle Veröffentlichungsformate\",\n            \"groupingTypePrimary\": \"primäre Veröffentlichungsformate\",\n            \"favoriteSongs\": \"Lieblingssongs\",\n            \"favoriteSongsFrom\": \"Liebslingssongs von {{title}}\",\n            \"topSongsCommunity\": \"Community\",\n            \"topSongsPersonal\": \"Persönlich\"\n        },\n        \"manageServers\": {\n            \"title\": \"Servers verwalten\",\n            \"editServerDetailsTooltip\": \"Serverdetails editieren\",\n            \"removeServer\": \"Server entfernen\",\n            \"url\": \"URL\",\n            \"serverDetails\": \"Serverdetails\",\n            \"username\": \"Benutzername\"\n        },\n        \"itemDetail\": {\n            \"copyPath\": \"Pfad in Zwischenablage kopieren\",\n            \"copiedPath\": \"Pfad erfolgreich kopiert\",\n            \"openFile\": \"Track im Dateiexplorer anzeigen\"\n        },\n        \"favorites\": {\n            \"title\": \"$t(entity.favorite, {\\\"count\\\": 2})\"\n        },\n        \"folderList\": {\n            \"title\": \"$t(entity.folder, {\\\"count\\\": 2})\"\n        },\n        \"playlist\": {\n            \"reorder\": \"Neuanordnung nur bei Sortierung nach ID möglich\"\n        },\n        \"radioList\": {\n            \"title\": \"Radiosender\"\n        },\n        \"windowBar\": {\n            \"paused\": \"(Pausiert) \",\n            \"privateMode\": \"(Privater Modus)\"\n        },\n        \"collections\": {\n            \"saveAsCollection\": \"Als Sammlung speichern\",\n            \"overrideExisting\": \"Bestehende überschreiben\"\n        },\n        \"releasenotes\": {\n            \"commitsSinceStable\": \"Commits seit {{stable}}\",\n            \"noStableReleaseToCompare\": \"Kein stable Relase zum vergleichen verfügbar\"\n        }\n    },\n    \"player\": {\n        \"next\": \"nächster\",\n        \"addNext\": \"als Nächstes\",\n        \"play\": \"Abspielen\",\n        \"muted\": \"stummgeschaltet\",\n        \"addLast\": \"als Letztes\",\n        \"mute\": \"Stumm\",\n        \"playRandom\": \"Zufällige Wiedergabe\",\n        \"previous\": \"Vorheriger\",\n        \"favorite\": \"Favorit\",\n        \"playbackFetchNoResults\": \"keine Lieder gefunden\",\n        \"playbackFetchInProgress\": \"lieder werden geladen…\",\n        \"playbackSpeed\": \"Wiedergabegeschwindigkeit\",\n        \"playbackFetchCancel\": \"Das dauert eine Weile. Schließen Sie die Benachrichtigung, um den Vorgang abzubrechen\",\n        \"queue_clear\": \"Wiedergabeliste bereinigen\",\n        \"repeat_all\": \"Alle wiederholen\",\n        \"repeat\": \"Wiederholen\",\n        \"queue_remove\": \"Ausgewählte entfernen\",\n        \"shuffle\": \"Wiedergabe (zufällig)\",\n        \"repeat_off\": \"nicht wiederholen\",\n        \"queue_moveToTop\": \"Ausgewählte nach unten verschieben\",\n        \"queue_moveToBottom\": \"Ausgewählte nach oben verschieben\",\n        \"shuffle_off\": \"Zufallswiedergabe deaktiviert\",\n        \"stop\": \"stopp\",\n        \"toggleFullscreenPlayer\": \"Vollbildmodus\",\n        \"skip_back\": \"zurückspulen\",\n        \"pause\": \"Pause\",\n        \"unfavorite\": \"Aus Favoriten entfernen\",\n        \"skip_forward\": \"vorspulen\",\n        \"skip\": \"Überspringen\",\n        \"playSimilarSongs\": \"Ähnliche Lieder abspielen\",\n        \"viewQueue\": \"Wiedergabeliste anzeigen\",\n        \"addLastShuffled\": \"als Letztes (zufällige Wiedergabe)\",\n        \"addNextShuffled\": \"als Nächstes (zufällige Wiedergabe)\",\n        \"holdToShuffle\": \"Halten für Zufallswiedergabe\",\n        \"restoreQueueFromServer\": \"Wiedergabeliste von Server wiederherstellen\",\n        \"saveQueueToServer\": \"Wiedergabeliste auf Server speichern\",\n        \"lyrics\": \"Songtexte\",\n        \"artistRadio\": \"Künstler Radio\",\n        \"sleepTimer_endOfSong\": \"Ende des aktuellen Liedes\",\n        \"sleepTimer_off\": \"aus\",\n        \"sleepTimer_timeRemaining\": \"{{time}} verbleibend\",\n        \"sleepTimer_cancel\": \"Timer abbrechen\",\n        \"sleepTimer_setCustom\": \"Timer stellen\",\n        \"sleepTimer\": \"Sleep Timer\",\n        \"sleepTimer_custom\": \"Benutzerdefiniert\",\n        \"sleepTimer_hours\": \"{{count}} std\",\n        \"sleepTimer_minutes\": \"{{count}} min\",\n        \"trackRadio\": \"Song Radio\",\n        \"albumRadio\": \"Album Radio\"\n    },\n    \"setting\": {\n        \"audioDevice_description\": \"wählen Sie das Audiogerät aus, das für die Wiedergabe verwendet werden soll (nur Webplayer)\",\n        \"audioExclusiveMode\": \"Audio-Exklusivmodus\",\n        \"audioDevice\": \"Audiogerät\",\n        \"accentColor\": \"Akzentfarbe\",\n        \"accentColor_description\": \"Legt die Akzentfarbe für die Anwendung fest\",\n        \"applicationHotkeys\": \"anwendungs Kurzbefehle\",\n        \"applicationHotkeys_description\": \"konfiguriere die Tastenkombinationen der Anwendung. Setze einen Haken, um die Tastenkombination global zu verwenden (nur für die Desktopanwendung)\",\n        \"crossfadeStyle_description\": \"Wählen Sie Art des Überblendungseffekts aus, welcher für den Audioplayer verwendet werden soll\",\n        \"discordIdleStatus_description\": \"Status aktualisieren, während die Wiedergabe pausiert ist\",\n        \"audioExclusiveMode_description\": \"Aktivieren Sie den exklusiven Ausgabemodus. In diesem Modus ist das System normalerweise gesperrt und nur MPV ist in der Lage Audio ausgeben\",\n        \"disableLibraryUpdateOnStartup\": \"beim Start nicht nach neuen Versionen suchen\",\n        \"discordApplicationId_description\": \"die Application-ID für {{discord}} Rich Presence (Standard: {{defaultId}})\",\n        \"audioPlayer_description\": \"Wählen Sie den Audioplayer aus, der für die Wiedergabe verwendet werden soll\",\n        \"crossfadeDuration_description\": \"Legt die Dauer der Überblendung fest\",\n        \"customFontPath\": \"Benutzerdefinierter Pfad für Schriftarten\",\n        \"crossfadeDuration\": \"Dauer der Überblendung\",\n        \"discordIdleStatus\": \"rich presence status im Leerlauf\",\n        \"audioPlayer\": \"Audio-Player\",\n        \"discordApplicationId\": \"{{discord}} Anwendungs ID\",\n        \"customFontPath_description\": \"Legt den Pfad zur benutzerdefinierten Schriftart fest, welche für die Anwendung verwendet werden soll\",\n        \"remotePort_description\": \"Legt den Port des Fernsteuerungsserver fest\",\n        \"hotkey_skipBackward\": \"rückwärts springen\",\n        \"replayGainMode_description\": \"Passen Sie die Lautstärkeverstärkung entsprechend den in den Dateimetadaten gespeicherten {{ReplayGain}}-Werten an\",\n        \"volumeWheelStep_description\": \"Die Lautstärkeänderung beim Scrollen mit dem Mausrad auf dem Lautstärkeregler\",\n        \"theme_description\": \"Legt das für die Anwendung zu verwendende Erscheinungsbild fest\",\n        \"hotkey_playbackPause\": \"Pause\",\n        \"sidebarCollapsedNavigation_description\": \"Zeigt die Navigation in der minimierten Seitenleiste an oder verbirgt sie\",\n        \"hotkey_volumeUp\": \"Lauter\",\n        \"skipDuration\": \"Sprungdauer\",\n        \"showSkipButtons\": \"Schaltflächen zum Überspringen anzeigen\",\n        \"playButtonBehavior_optionPlay\": \"$t(player.play)\",\n        \"minimumScrobblePercentage\": \"Minimum Scrobble-Dauer (Prozentsatz)\",\n        \"lyricFetch\": \"Songtexte aus dem Internet abrufen\",\n        \"scrobble\": \"scrobbel\",\n        \"skipDuration_description\": \"Legt die zu überspringende Dauer fest, wenn die Überspringen-Schaltflächen in der Player-Leiste verwendet werden\",\n        \"mpvExecutablePath_description\": \"Legt den Pfad zur ausführbaren MPV-Datei fest. Wenn leer gelassen, wird der Standardpfad verwendet\",\n        \"replayGainClipping_description\": \"Verhindern Sie durch {{ReplayGain}} verursachtes Clipping, indem Sie die Verstärkung automatisch verringern\",\n        \"replayGainPreamp\": \"{{ReplayGain}} Vorverstärker (db)\",\n        \"hotkey_favoriteCurrentSong\": \"Favorit $t(common.currentSong)\",\n        \"sampleRate\": \"Abtastrate\",\n        \"sidePlayQueueStyle_optionAttached\": \"angefügt\",\n        \"sidebarConfiguration\": \"Seitenleistenkonfiguration\",\n        \"sampleRate_description\": \"Wähle die auszugebende Abtastrate aus, wenn sich die ausgewählte Abtastfrequenz von der des aktuellen Mediums unterscheidet. Ein Wert unter 8000 wird die Standardfrequenz verwenden\",\n        \"replayGainMode_optionNone\": \"$t(common.none)\",\n        \"hotkey_zoomIn\": \"Hineinzoomen\",\n        \"scrobble_description\": \"Scrobble wird auf Ihrem Medienserver abgespielt\",\n        \"hotkey_browserForward\": \"browser vor\",\n        \"hotkey_playbackPlayPause\": \"Wiedergabe / Pause\",\n        \"hotkey_rate1\": \"Bewertung 1 Stern\",\n        \"hotkey_skipForward\": \"vorwärts springen\",\n        \"playButtonBehavior_optionAddLast\": \"$t(player.addLast)\",\n        \"minimizeToTray_description\": \"Minimieren der Anwendung in die Taskleiste\",\n        \"hotkey_playbackPlay\": \"Wiedergabe\",\n        \"hotkey_volumeDown\": \"Leiser\",\n        \"hotkey_unfavoritePreviousSong\": \"$t(common.previousSong) aus Favoriten entfernen\",\n        \"globalMediaHotkeys\": \"globale Medien Kurzbefehle\",\n        \"hotkey_globalSearch\": \"Globale Suche\",\n        \"gaplessAudio_description\": \"Legt die lückenlose Audioeinstellung für MPV fest\",\n        \"remoteUsername_description\": \"Legt den Benutzernamen für den Fernsteuerungsserver fest. Wenn sowohl Benutzername als auch Passwort leer sind, wird die Authentifizierung deaktiviert\",\n        \"hotkey_favoritePreviousSong\": \"Favorit $t(common.previousSong)\",\n        \"replayGainMode_optionAlbum\": \"$t(entity.album, {\\\"count\\\": 1})\",\n        \"lyricOffset\": \"Zeitversatz des Songtextes (ms)\",\n        \"themeDark_description\": \"Legt das Erscheinungsbild für den dunklen Modus fest\",\n        \"remotePassword\": \"Passwort des Fernsteuerungsservers\",\n        \"lyricFetchProvider\": \"Anbieter, von denen Songtexte abgerufen werden können\",\n        \"language_description\": \"Legt die Sprache für die Anwendung fest $t(common.restartRequired)\",\n        \"playbackStyle_optionCrossFade\": \"Überblendung\",\n        \"hotkey_rate3\": \"Bewertung 3 Sterne\",\n        \"replayGainMode_optionTrack\": \"$t(entity.track, {\\\"count\\\": 1})\",\n        \"themeLight_description\": \"Legt das Erscheinungsbild für den hellen Modus fest\",\n        \"hotkey_toggleFullScreenPlayer\": \"Vollbildmodus umschalten\",\n        \"hotkey_localSearch\": \"Suche auf Seite\",\n        \"hotkey_toggleQueue\": \"Wiedergabeliste umschalten\",\n        \"remotePassword_description\": \"Legt das Passwort für den Fernsteuerungsserver fest. Diese Anmeldeinformationen werden standardmäßig unsicher übertragen, daher sollten Sie ein Passwort verwenden, das Ihnen egal ist\",\n        \"hotkey_rate5\": \"Bewertung 5 Sterne\",\n        \"hotkey_playbackPrevious\": \"Vorheriger Track\",\n        \"showSkipButtons_description\": \"Ein- oder Ausblenden der Überspringen-Schaltflächen in der Player-Leiste\",\n        \"playbackStyle\": \"Wiedergabestil\",\n        \"hotkey_toggleShuffle\": \"Zufallswiedergabe umschalten\",\n        \"theme\": \"Erscheinungsbild\",\n        \"playbackStyle_description\": \"Wählen Sie den Wiedergabestil aus, der für den Audioplayer verwendet werden soll\",\n        \"mpvExecutablePath\": \"Pfad der ausführbaren MPV-Datei\",\n        \"hotkey_rate2\": \"Bewertung 2 Sterne\",\n        \"playButtonBehavior_description\": \"legt das Standardverhalten des Wiedergabe-Buttons fest, wenn Lieder zur Wiedergabeliste hinzugefügt werden\",\n        \"minimumScrobblePercentage_description\": \"die Mindestdauer in Prozent, welche das Lied gespielt werden muss, bevor dieses gescrobbelt wird\",\n        \"hotkey_rate4\": \"Bewertung 4 Sterne\",\n        \"showSkipButton_description\": \"Ein- oder Ausblenden der Überspringen-Schaltflächen in der Player-Leiste\",\n        \"savePlayQueue\": \"Wiedergabeliste speichern\",\n        \"minimumScrobbleSeconds_description\": \"die Mindestdauer in Sekunden, welche das Lied gespielt werden muss, bevor dieses gescrobbelt wird\",\n        \"skipPlaylistPage_description\": \"Gehe beim Navigieren zu einer Wiedergabeliste zu deren Titelseite und nicht zur Standardseite\",\n        \"fontType_description\": \"Die integrierte Schriftart wählt eine der von feishin bereitgestellten Schriftarten aus. Mit der Systemschriftart können Sie jede von Ihrem Betriebssystem bereitgestellte Schriftart auswählen. Benutzerdefiniert erlaubt es eine eigene Schriftart bereitzustellen\",\n        \"playButtonBehavior\": \"Verhalten der Wiedergabetaste\",\n        \"volumeWheelStep\": \"Lautstärkeänderung mit Mausrad\",\n        \"sidebarPlaylistList_description\": \"Ein- oder Ausblenden der Wiedergabelisten in der Seitenleiste\",\n        \"sidebarPlaylistSorting_description\": \"sortiere Wiedergabelisten in der Seitenleiste per Drag & Drop anstelle der standardmäßigen Serverreihenfolge\",\n        \"sidePlayQueueStyle_description\": \"legt den Stil der Wiedergabeliste in der Seitenleiste fest\",\n        \"replayGainMode\": \"{{ReplayGain}} Modus\",\n        \"playbackStyle_optionNormal\": \"Normal\",\n        \"windowBarStyle\": \"Fensterleistenstil\",\n        \"replayGainFallback_description\": \"Verstärkung in db, die angewendet werden soll, wenn die Datei keine {{ReplayGain}}-Tags hat\",\n        \"replayGainPreamp_description\": \"Passen Sie die Vorverstärkerverstärkung an, die auf die {{ReplayGain}}-Werte angewendet wird\",\n        \"hotkey_toggleRepeat\": \"Wiederholung umschalten\",\n        \"lyricOffset_description\": \"Versetzen Sie den Songtext um die angegebene Anzahl von Millisekunden\",\n        \"sidebarConfiguration_description\": \"Wählen Sie die Elemente und die Reihenfolge aus, in der sie in der Seitenleiste angezeigt werden\",\n        \"remotePort\": \"Port des Fernsteuerungsserver\",\n        \"hotkey_playbackNext\": \"Nächster Track\",\n        \"useSystemTheme_description\": \"Folgt dem hellen oder dunklen Erscheinungsbild des Systems\",\n        \"playButtonBehavior_optionAddNext\": \"$t(player.addNext)\",\n        \"lyricFetch_description\": \"Songtexte aus verschiedenen Internetquellen abrufen\",\n        \"lyricFetchProvider_description\": \"Wähle den Anbieter zum Abrufen von Songtexten aus\",\n        \"globalMediaHotkeys_description\": \"aktivieren oder deaktivieren Sie die Verwendung der Medien-Kurzbefehle Ihres Systems zur Steuerung der Wiedergabe\",\n        \"hotkey_zoomOut\": \"Herauszoomen\",\n        \"hotkey_unfavoriteCurrentSong\": \"$t(common.currentSong) aus Favoriten entfernen\",\n        \"hotkey_rate0\": \"Bewertung löschen\",\n        \"hotkey_volumeMute\": \"Lautstärke stumm\",\n        \"remoteUsername\": \"Benutzername des Fernsteuerungsserver\",\n        \"hotkey_browserBack\": \"Browser zurück\",\n        \"showSkipButton\": \"Schaltflächen zum Überspringen anzeigen\",\n        \"sidebarPlaylistList\": \"Wiedergabelisten in Seitenleiste\",\n        \"sidebarPlaylistSorting\": \"Wiedergabelisten-Sortierung in der Seitenleiste\",\n        \"minimizeToTray\": \"Zur Taskleiste minimieren\",\n        \"skipPlaylistPage\": \"Wiedergabeliste-Seite überspringen\",\n        \"themeDark\": \"Erscheinungsbild (dunkel)\",\n        \"sidebarCollapsedNavigation\": \"Navigation in der Seitenleiste (komprimiert)\",\n        \"gaplessAudio_optionWeak\": \"schwach (empfohlen)\",\n        \"minimumScrobbleSeconds\": \"Minimum Scrobble-Dauer (Sekunden)\",\n        \"hotkey_playbackStop\": \"Stoppen\",\n        \"savePlayQueue_description\": \"speichert die Wiedergabeliste beim Schließen der Anwendung, und stellt diese wieder her, wenn die Anwendung geöffnet wird\",\n        \"useSystemTheme\": \"Nach Erscheinungsbild des Systems richten\",\n        \"enableRemote_description\": \"Aktiviert den Server für die Fernsteuerung, damit andere Geräte die Anwendung steuern können\",\n        \"fontType_optionSystem\": \"System Schriftart\",\n        \"discordUpdateInterval\": \"{{discord}} Rich Presence Aktualisierungsintervall\",\n        \"fontType_optionBuiltIn\": \"eingebaute Schriftart\",\n        \"gaplessAudio\": \"unterbrechungsfreie Wiedergabe\",\n        \"exitToTray_description\": \"die Anwendung beim Schließen in die Taskleiste minimieren\",\n        \"followLyric_description\": \"der Songtext bewegt sich mit der Wiedergabeposition\",\n        \"discordUpdateInterval_description\": \"Zeit in Sekunden zwischen Aktualisierungen (min. 15 Sekunden)\",\n        \"fontType_optionCustom\": \"benutzerdefinierte Schriftart\",\n        \"font\": \"Schriftart\",\n        \"exitToTray\": \"In die Taskleiste minimieren\",\n        \"enableRemote\": \"Server für Fernsteuerung aktivieren\",\n        \"fontType\": \"schriftartenquelle\",\n        \"followLyric\": \"aktuellen songtext synchronisieren\",\n        \"font_description\": \"wähle die Schriftart für die Anwendung\",\n        \"themeLight\": \"Erscheinungsbild (hell)\",\n        \"sidePlayQueueStyle_optionDetached\": \"lösgelöst\",\n        \"windowBarStyle_description\": \"Legt das Erscheinungsbild des Fensterrahmens fest\",\n        \"hotkey_toggleCurrentSongFavorite\": \"$t(common.currentSong) zu Favoriten hinzufügen\",\n        \"clearQueryCache_description\": \"\\\"Weiches\\\" Zurücksetzen. Dies wird Wiedergabelisten, Musik-Metadaten und gespeicherte Songtexte zurücksetzen, Zugangsinformationen und zwischengespeicherte Bilder werden behalten\",\n        \"discordRichPresence_description\": \"Aktiviert den Wiedergabestatus in {{discord}} Rich Presence. Angezeigte Bilder sind: {{icon}}, {{playing}}, und {{paused}}\",\n        \"clearCache\": \"Browser-Zwischenspeicher löschen\",\n        \"clearQueryCache\": \"feishins Zwischenspeicher leeren\",\n        \"clearCache_description\": \"Hartes Zurücksetzen. Neben feishins Zwischenspeicher wird auch der des Browsers gelöscht (Bilder und andere Daten). Zugangsinformationen und Einstellungen werden behalten\",\n        \"sidePlayQueueStyle\": \"Stil der Wiedergabeliste in der Seitenleiste\",\n        \"zoom_description\": \"Setzt den Zoom (in %) für das Programm\",\n        \"zoom\": \"Zoom\",\n        \"albumBackground\": \"Album Hintergrund\",\n        \"customCss\": \"Benutzerdefiniertes CSS\",\n        \"homeConfiguration\": \"Startseite Konfiguration\",\n        \"lastfmApiKey\": \"{{lastfm}} API-Schlüssel\",\n        \"lastfmApiKey_description\": \"Der API-Schlüssel für {{lastfm}}. wird für Albumcover benötigt\",\n        \"discordListening\": \"Status als hört zu anzeigen\",\n        \"discordListening_description\": \"Status als hört zu statt als spielt anzeigen\",\n        \"lastfm\": \"zeige last.fm links\",\n        \"lastfm_description\": \"zeige links zu Last.fm auf dem Künstler/Album-Seiten\",\n        \"musicbrainz\": \"Zeige MusicBrainz links\",\n        \"customCssEnable\": \"benutzerdefiniertes CSS aktivieren\",\n        \"albumBackground_description\": \"fügt ein Hintergrundbild für die Albumseiten hinzu, welche das Albumcover zeigen\",\n        \"albumBackgroundBlur\": \"Größe der Album-Bildunschärfe\",\n        \"albumBackgroundBlur_description\": \"passt die Stärke der Unschärfe an, welche auf das Hintergrundbild des Albums angewandt wird\",\n        \"clearCacheSuccess\": \"Cache erfolgreich geleert\",\n        \"contextMenu\": \"Kontextmenü-Einstellungen (Rechtsklick)\",\n        \"customCssEnable_description\": \"erlaubt das Hinzufügen von benutzerdefiniertem CSS\",\n        \"artistBackground\": \"Künstler Hintergrundbild\",\n        \"artistBackground_description\": \"fügt ein Hintergrundbild für die Künstlerseite hinzu\",\n        \"artistConfiguration\": \"künstler Albumseite Konfiguration\",\n        \"buttonSize\": \"spielerleisten-Knopfgröße\",\n        \"buttonSize_description\": \"die Größe der Spieler-Knöpfe\",\n        \"hotkey_togglePreviousSongFavorite\": \"wähle $t(common.previousSong) als Favorit aus\",\n        \"replayGainFallback\": \"{{ReplayGain}} Alternative\",\n        \"replayGainClipping\": \"{{ReplayGain}} Clipping\",\n        \"exportImportSettings_control_description\": \"Einstellungen mit JSON exportieren und importieren\",\n        \"exportImportSettings_control_exportText\": \"Einstellungen exportieren\",\n        \"exportImportSettings_control_importText\": \"Einstellungen importieren\",\n        \"exportImportSettings_control_title\": \"Einstellungen importieren / exportieren\",\n        \"exportImportSettings_importBtn\": \"Einstellungen importieren\",\n        \"exportImportSettings_importModalTitle\": \"Feishin Einstellungen importieren\",\n        \"exportImportSettings_importSuccess\": \"Einstellungen wurden erfolgreich importiert!\",\n        \"exportImportSettings_notValidJSON\": \"Die Datei ist kein gültiges JSON\",\n        \"exportImportSettings_offendingKeyError\": \"\\\"{{offendingKey}}\\\" ist falsch - {{reason}}\",\n        \"language\": \"Sprache\",\n        \"imageAspectRatio\": \"Original Seitenverhältnis des Albumcovers verwenden\",\n        \"analyticsDisable\": \"Keine nutzungsbasierte Analyse\",\n        \"analyticsDisable_description\": \"Anonymisierte Nutzungsdaten werden an den Entwickler geschickt, um die Anwendung zu verbessern\",\n        \"logLevel_optionDebug\": \"Debug\",\n        \"logLevel_description\": \"legt die Protokollstufe fest. \\\"Debug\\\" zeigt alle Protokollierungen an. \\\"Fehler\\\" zeigt nur Fehler an\",\n        \"logLevel\": \"Protokolllevel\",\n        \"logLevel_optionError\": \"Fehler\",\n        \"logLevel_optionInfo\": \"Info\",\n        \"logLevel_optionWarn\": \"Warnung\",\n        \"autoDJ_description\": \"füge automatisch ähnliche Lieder der Wiedergabeliste hinzu\",\n        \"autoDJ\": \"Auto DJ\",\n        \"autoDJ_itemCount\": \"Anzahl\",\n        \"autoDJ_itemCount_description\": \"die Anzahl der Lieder, die bei aktiviertem Auto DJ zur Wiedergabeliste hinzugefügt werden sollen\",\n        \"autoDJ_timing_description\": \"die Anzahl der Lieder, die sich noch in der Wiedergabeliste befinden, bevor Auto DJ ausgelöst wird\",\n        \"autoDJ_timing\": \"Timing\",\n        \"discordDisplayType\": \"{{discord}} Presence Darstellungsart\",\n        \"discordLinkType_mbz_lastfm\": \"{{musicbrainz}} mit {{lastfm}} als Ersatz\",\n        \"discordLinkType_none\": \"$t(common.none)\",\n        \"discordLinkType\": \"{{discord}} Presence Links\",\n        \"discordPausedStatus_description\": \"Wenn aktiviert, wird der Status auch angezeigt, falls die Wiedergabe pausiert\",\n        \"discordPausedStatus\": \"Zeige Rich Presence bei Pause\",\n        \"discordRichPresence\": \"{{discord}} Rich Presence\",\n        \"discordServeImage\": \"Bilder für {{discord}} vom Server beziehen\",\n        \"discordServeImage_description\": \"Bezieht die Coverbilder für {{discord}} Rich Presence vom Server selbst. Nur verfügbar für Jellyfin und Navidrome. Damit der Bot von {{discord}} die Coverbilder abrufen kann, muss der Server öffentlich erreichbar sein\",\n        \"enableAutoTranslation\": \"Automatische Übersetzung aktivieren\",\n        \"externalLinks\": \"Externe Links anzeigen\",\n        \"externalLinks_description\": \"Aktiviert die Anzeige externer Links (Last.fm, MusicBrainz) auf Artist/Album Seiten\",\n        \"musicbrainz_description\": \"Zeige Links zu MusicBrainz auf Artist/Album Seite, falls MusicBrainz ID vorhanden\",\n        \"neteaseTranslation_description\": \"Wenn aktiviert, werden Songtextübersetzungen von NetEase abgerufen und angezeigt, sofern verfügbar\",\n        \"neteaseTranslation\": \"NetEase Übersetzungen aktivieren\",\n        \"notify\": \"Benachrichtigungen aktivieren\",\n        \"notify_description\": \"Zeigt Benachrichtigungen beim Titelwechsel\",\n        \"playerFilters\": \"Lieder der Wiedergabeliste filtern\",\n        \"playButtonBehavior_optionPlayShuffled\": \"$t(player.shuffle)\",\n        \"volumeWidth_description\": \"Die Breite des Lautstärkereglers\",\n        \"volumeWidth\": \"Lautstärkereglerbreite\",\n        \"webAudio_description\": \"Web-Audio verwenden. Dies ermöglicht erweiterte Funktionen wie ReplayGain. Deaktiviere die Option, falls bei der Wiedergabe Probleme auftreten\",\n        \"webAudio\": \"Web-Audio verwenden\",\n        \"trayEnabled\": \"Info-Symbol anzeigen\",\n        \"transcode\": \"Transkodierung aktivieren\",\n        \"transcode_description\": \"Aktiviert die Umwandlung in verschiedene Formate\",\n        \"transcodeBitrate_description\": \"Legt die Bitrate für die Umwandlung fest. Bei 0 wird die Wahl dem Server überlassen\",\n        \"transcodeBitrate\": \"Bitrate für Umwandlung\",\n        \"transcodeFormat_description\": \"Legt das Format für die Umwandlung fest. Leer lassen, um den Server entscheiden zu lassen\",\n        \"transcodeFormat\": \"Format für Umwandlung\",\n        \"startMinimized_description\": \"Startet die Anwendung im Info-Bereich\",\n        \"startMinimized\": \"Im Info-Bereich starten\",\n        \"mediaSession_description\": \"Aktiviert die Windows Media Session-Integration, zeigt Mediensteuerelemente und Metadaten im Systemlautstärke-Overlay und auf dem Sperrbildschirm an (nur Windows)\",\n        \"mediaSession\": \"Media Session aktivieren\",\n        \"artistBackgroundBlur\": \"Unschärfegrad für Künstlerhintergründe\",\n        \"artistBackgroundBlur_description\": \"Legt den Grad der Unschärfe fest, der auf das Hintergrundbild des Künstlers angewendet wird\",\n        \"artistConfiguration_description\": \"Legt fest, welche Elemente auf der Albumkünstlerseite angezeigt werden und in welcher Reihenfolge\",\n        \"contextMenu_description\": \"Legt die Einträge fest, die im Rechtsklick-Menü angezeigt werden sollen. Abgewählte Einträge werden ausgeblendet\",\n        \"crossfadeStyle\": \"Art der Überblende\",\n        \"customCss_description\": \"Benutzerdefinierter CSS-Inhalt. Hinweis: Inhalte und Remote-URLs sind nicht zulässige Eigenschaften. Unten siehst du eine Vorschau deines Inhalts. Aufgrund von Bereinigung werden womöglich zusätzliche, nicht von dir definierte Felder angezeigt\",\n        \"customCssNotice\": \"Warnung: Obwohl eine gewisse Bereinigung erfolgt (url() und content: sind nicht zulässig), kann die Verwendung von benutzerdefiniertem CSS dennoch Risiken mit sich bringen, da dadurch die Benutzeroberfläche verändert wird\",\n        \"releaseChannel_optionBeta\": \"Beta\",\n        \"releaseChannel_optionLatest\": \"Stabil\",\n        \"releaseChannel\": \"Veröffentlichungskanal\",\n        \"releaseChannel_description\": \"Zwischen stabilen und beta Veröffentlichungen für automatische Aktualisierungen wählen\",\n        \"discordDisplayType_artistname\": \"Künstlername(n)\",\n        \"discordDisplayType_description\": \"Ändert den aktuellen Titel im Zuhör-Status\",\n        \"discordDisplayType_songname\": \"Songtitel\",\n        \"discordLinkType_description\": \"Fügt externe Links zu {{lastfm}} oder {{musicbrainz}} zu Song- und Künstlerfeldern in {{discord}} Rich Presence hinzu. {{musicbrainz}} ist am genauesten, erfordert jedoch Tags und bietet keine Künstlerlinks, während {{lastfm}} immer einen Link bereitstellen sollte. Verursacht keine zusätzlichen Netzwerkabfragen\",\n        \"enableAutoTranslation_description\": \"Automatische Übersetzung von Songtexten aktivieren\",\n        \"exportImportSettings_destructiveWarning\": \"Das Importieren von Einstellungen ist irreversibel. Bitte lies die Hinweise oben sorgfältig durch, bevor du auf \\\"Importieren\\\" klickst!\",\n        \"followCurrentSong\": \"aktuellem Titel folgen\",\n        \"followCurrentSong_description\": \"die Wiedergabeliste scrollt automatisch zum aktuellen Titel\",\n        \"playerFilters_description\": \"verhindert, dass Titel anhand der folgenden Kriterien zur Wiedergabeliste hinzugefügt werden\",\n        \"preferLocalLyrics_description\": \"lokale Songtexte gegenüber externen Quellen bevorzugen (sofern verfügbar)\",\n        \"preferLocalLyrics\": \"Priorisiere lokale Songtexte\",\n        \"showLyricsInSidebar_description\": \"ein Bereich, in dem Songtexte angezeigt werden, wird der Wiedergabeliste hinzugefügt\",\n        \"showLyricsInSidebar\": \"zeige Songtexte in der Player-Seitenleiste\",\n        \"homeFeature_description\": \"steuert, ob das große Featured-Karussell auf der Startseite angezeigt wird\",\n        \"homeFeature\": \"Feature-Karussell\",\n        \"playerbarWaveformAlign_optionTop\": \"Oben\",\n        \"playerbarWaveformAlign_optionCenter\": \"Mitte\",\n        \"playerbarWaveformAlign_optionBottom\": \"Unten\",\n        \"translationApiKey_description\": \"API-Schlüssel für Übersetzungen (nur globale Service-Endpunkte)\",\n        \"translationApiKey\": \"API-Schlüssel für Übersetzungen\",\n        \"translationApiProvider_description\": \"API-Anbieter für Übersetzungen\",\n        \"translationApiProvider\": \"API-Anbieter für Übersetzungen\",\n        \"hotkey_navigateHome\": \"zurück zur Startseite\",\n        \"translationTargetLanguage_description\": \"die gewünschte Sprache der Übersetzung\",\n        \"translationTargetLanguage\": \"Zielsprache der Übersetzung\",\n        \"queryBuilderCustomFields\": \"benutzerdefiniertes Feld\",\n        \"queryBuilderCustomFields_inputTag\": \"Tag\",\n        \"homeFeatureStyle_optionMultiple\": \"mehrere\",\n        \"imageResolution\": \"Bildauflösung\",\n        \"imageResolution_optionTable\": \"Tabelle\",\n        \"imageResolution_optionSidebar\": \"Seitenleiste\",\n        \"preservePitch\": \"Tonhöhe erhalten\",\n        \"analyticsEnable\": \"Nutzungsbasierte Analyse senden\",\n        \"automaticUpdates\": \"Automatische Updates\",\n        \"automaticUpdates_description\": \"Updates automatisch suchen und installieren\",\n        \"releaseChannel_optionAlpha\": \"Alpha (nightly)\",\n        \"useThemeAccentColor\": \"Akzentfarbe des Themas nutzen\",\n        \"analyticsEnable_description\": \"Anonymisierte Nutzungsdaten werden an den Entwickler gesendet, um die Anwendung zu verbessern\",\n        \"artistReleaseTypeConfiguration_description\": \"Konfigurieren, welche Release-Typen und in welcher Reihenfolge diese auf der Album-Künstlerseite angezeigt werden\",\n        \"homeConfiguration_description\": \"Konfigurieren, welche Elemente und in welcher Reihenfolge diese auf der Startseite angezeigt werden\",\n        \"passwordStore_description\": \"Verwendeter Passwort/Geheimnis Speicher. Sollten Probleme beim Speichern von Passwörtern auftreten, wähle eine andere Methode\",\n        \"passwordStore\": \"Passwort/Geheimnis Speicher\",\n        \"audioFadeOnStatusChange_description\": \"ermöglicht Ein- und Ausblenden, wenn sich der Wiedergabe-/Pause-Status ändert\",\n        \"audioFadeOnStatusChange\": \"Audio Ein-/Ausblenden bei Statusveränderung\",\n        \"showRatings_description\": \"Aktiviere die Anzeige einer Bewertung in Sternen\",\n        \"showRatings\": \"Zeige Sternebewertungen\",\n        \"blurExplicitImages\": \"Explizite Bilder unkenntlich machen\",\n        \"blurExplicitImages_description\": \"Album- und Song-Cover, die als explizit gekennzeichnet sind, werden unscharf dargestellt\",\n        \"enableGridMultiSelect\": \"Raster-Mehrfachauswahl aktivieren\",\n        \"enableGridMultiSelect_description\": \"Wenn aktiviert, können in Rasteransichten mehrere Elemente ausgewählt werden. Wenn deaktiviert, führt ein Klick auf Rasterelement-Bilder zur Artikelseite\",\n        \"playerbarOpenDrawer_description\": \"Ermöglicht das Anklicken der Playerleiste, um den Vollbild-Player zu öffnen\",\n        \"playerbarOpenDrawer\": \"Playerleiste Vollbild-Umschalter\",\n        \"playerbarSlider\": \"Playerleiste-Schieberegler\",\n        \"playerbarSlider_description\": \"Die Wellenform Darstellung wird nicht empfohlen, wenn eine langsame oder tarifierte Internetverbindung genutzt wird\",\n        \"playerbarSliderType_optionSlider\": \"Schieberegler\",\n        \"playerbarSliderType_optionWaveform\": \"Wellenform\",\n        \"playerbarWaveformAlign\": \"Wellenform ausrichten\",\n        \"playerbarWaveformBarWidth\": \"Breite der Wellenform Leiste\",\n        \"playerbarWaveformGap\": \"Wellenform Lücke\",\n        \"playerbarWaveformRadius\": \"Wellenform Radius\",\n        \"artistRadioCount\": \"Interpreten/Song Radio Länge\",\n        \"artistRadioCount_description\": \"legt die Anzahl der Songs fest, die beim Interpreten-Radio und Song-Radio abgerufen werden\",\n        \"sidebarPlaylistListFilterRegex_description\": \"versteckt Wiedergabelisten in der Seitenleiste, die diesem regulären Ausdruck entsprechen\",\n        \"sidebarPlaylistListFilterRegex_placeholder\": \"z.B. ^Täglicher Mix.*\",\n        \"sidebarPlaylistListFilterRegex\": \"Wiedergabelisten Regex-Filter\",\n        \"showVisualizerInSidebar_description\": \"Ein Panel wird zur Player-Seitenleiste hinzugefügt, das den Visualizer anzeigt\",\n        \"showVisualizerInSidebar\": \"Visualizer in Seitenleiste anzeigen\",\n        \"combinedLyricsAndVisualizer_description\": \"Songtexte und Visualizer im selben Panel anzeigen\",\n        \"combinedLyricsAndVisualizer\": \"Songtexte und Visualizer geimeinsam in der Seitenleiste anzeigen\",\n        \"mpvExtraParameters_description\": \"zusätzliche Argumente die an mpv übergeben werden sollen\",\n        \"mpvExtraParameters_help\": \"Eins pro Zeile\",\n        \"pathReplace\": \"Dateipfad-Ersetzung\",\n        \"pathReplace_description\": \"Ersetze den Standard Dateipfad des Servers\",\n        \"pathReplace_optionRemovePrefix\": \"Präfix entfernen\",\n        \"pathReplace_optionAddPrefix\": \"Präfix hinzufügen\",\n        \"imageResolution_description\": \"Die Auflösung für die in der App verwendeten Bilder. Bei einem Wert von 0 wird die originale Bildauflösung verwendet\",\n        \"preservePitch_description\": \"Behält beim Anpassen der Wiedergabegeschwindigkeit die Tonhöhe bei\",\n        \"preventSleepOnPlayback_description\": \"Verhindert das Abschalten des Displays während der Musikwiedergabe\",\n        \"preventSleepOnPlayback\": \"Verhindert den Energiesparmodus während der Musikwiedergabe\",\n        \"trayEnabled_description\": \"Tray-Symbol anzeigen/verbergen. Bei Deaktivierung werden auch Minimieren/Beenden zum Tray deaktiviert\",\n        \"queryBuilder\": \"Abfrage-Editor\",\n        \"queryBuilderCustomFields_inputLabel\": \"Label\",\n        \"queryBuilderCustomFields_description\": \"Füge benutzerdefinierte Felder für den Abfrage-Editor hinzu\",\n        \"autosave\": \"Automatisch aktuelle Wiedergabeliste speichern\",\n        \"autosave_description\": \"Aktiviere die automatische Speicherung der aktuellen Wiedergabe auf dem Server. Diese Funktion ist nur bei Navidrome/Subsonic Servern verfügbar und es darf sich nicht um eine gemischte Wiedergabeliste handeln.\",\n        \"autosaveCount\": \"Häufigkeit der automatischen Speicherung bei Wiedergabelisten\",\n        \"autosaveCount_description\": \"Wieviele Lieder gespielt werden, bevor die Wiedergabeliste gespeichert wird. 1 (Minimum) bedeutet die Speicherung nach jedem gespielten Lied\",\n        \"useThemeAccentColor_description\": \"Verwendet die Primärfarbe des gewählten Themas anstatt einer ausgewählten Akzentfarbe\",\n        \"useThemePrimaryShade\": \"Primärschatten des Themas nutzen\",\n        \"useThemePrimaryShade_description\": \"Verwendet den Primärschatten des ausgewählten Themas als primäre Farbvarianten\",\n        \"primaryShade\": \"Primärschatten\"\n    },\n    \"dragDropZone\": {\n        \"error_oneFileOnly\": \"Bitte wähle nur 1 Datei\",\n        \"error_readingFile\": \"Beim Lesen der Datei trat ein Fehler auf: {{errorMessage}}\",\n        \"mainText\": \"Datei hier ablegen\"\n    },\n    \"filterOperator\": {\n        \"contains\": \"enthält\",\n        \"endsWith\": \"endet mit\",\n        \"inPlaylist\": \"ist in\",\n        \"inTheLast\": \"ist in den letzten\",\n        \"is\": \"ist\",\n        \"isNot\": \"ist nicht\",\n        \"isGreaterThan\": \"ist größer als\",\n        \"isLessThan\": \"ist kleiner als\",\n        \"notContains\": \"enthält nicht\",\n        \"notInPlaylist\": \"ist nicht in\",\n        \"notInTheLast\": \"ist nicht in den letzten\",\n        \"startsWith\": \"beginnt mit\",\n        \"after\": \"ist nach\",\n        \"afterDate\": \"ist nach (Datum)\",\n        \"before\": \"ist vor\",\n        \"beforeDate\": \"ist vor (Datum)\",\n        \"inTheRange\": \"ist im Bereich\",\n        \"inTheRangeDate\": \"ist im Bereich (Datum)\",\n        \"matchesRegex\": \"entspricht Regex\"\n    },\n    \"queryBuilder\": {\n        \"standardTags\": \"Standardtags\",\n        \"customTags\": \"benutzerdefinierte Tags\"\n    },\n    \"releaseType\": {\n        \"primary\": {\n            \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n            \"broadcast\": \"Broadcast\",\n            \"ep\": \"EP\",\n            \"other\": \"andere\",\n            \"single\": \"Single\"\n        },\n        \"secondary\": {\n            \"audiobook\": \"Hörbuch\",\n            \"audioDrama\": \"Hörspiel\",\n            \"compilation\": \"Compilation\",\n            \"djMix\": \"DJ Mix\",\n            \"demo\": \"Demo\",\n            \"fieldRecording\": \"Außenaufnahme\",\n            \"interview\": \"Interview\",\n            \"live\": \"Live\",\n            \"mixtape\": \"Mixtape\",\n            \"remix\": \"Remix\",\n            \"soundtrack\": \"Soundtrack\",\n            \"spokenWord\": \"Gesprochenes Wort\"\n        }\n    },\n    \"datetime\": {\n        \"minuteShort\": \"Min\",\n        \"secondShort\": \"Sek\",\n        \"hourShort\": \"Std\",\n        \"dayShort\": \"Tag\"\n    },\n    \"visualizer\": {\n        \"options\": {\n            \"weightingFilter\": {\n                \"z\": \"Z\",\n                \"d\": \"D\",\n                \"c\": \"C\",\n                \"none\": \"Keine\",\n                \"a\": \"A\",\n                \"b\": \"B\"\n            },\n            \"frequencyScale\": {\n                \"linear\": \"Lineare Skala\",\n                \"log\": \"Log Skala\",\n                \"mel\": \"Mel Skala\",\n                \"bark\": \"Bark-Skala\",\n                \"none\": \"Keine\"\n            },\n            \"gradient\": {\n                \"classic\": \"Klassisch\",\n                \"prism\": \"Prisma\",\n                \"rainbow\": \"Regenbogen\",\n                \"steelblue\": \"Stahlblau\",\n                \"orangered\": \"Orange-Rot\"\n            },\n            \"channelLayout\": {\n                \"dualHorizontal\": \"Dual-Horizontal\",\n                \"dualVertical\": \"Dual-Vertikal\"\n            }\n        },\n        \"minimumFrequency\": \"Mindestfrequenz\",\n        \"minimumDecibels\": \"Minimale Dezibel\",\n        \"visualizerType\": \"Visualizer Art\",\n        \"cyclePresets\": \"Vorlagen durchrotieren\",\n        \"cycleTime\": \"Abspieldauer je Vorlage (Sekunden)\",\n        \"includeAllPresets\": \"Alle Vorlagen verwenden\",\n        \"ignoredPresets\": \"Ignorierte Vorlagen\",\n        \"selectedPresets\": \"Ausgewählte Vorlagen\",\n        \"randomizeNextPreset\": \"Nächste Vorlage zufällig wählen\",\n        \"blendTime\": \"Übergangsdauer\",\n        \"presets\": \"Vorlage\",\n        \"selectPreset\": \"Vorlage auswählen\",\n        \"applyPreset\": \"Vorlage anwenden\",\n        \"saveAsPreset\": \"Als Vorlage speichern\",\n        \"updatePreset\": \"Vorlage aktualisieren\",\n        \"copyConfiguration\": \"Konfiguration kopieren\",\n        \"pasteConfiguration\": \"Konfiguration einfügen\",\n        \"pasteConfigurationPlaceholder\": \"JSON Konfiguration hier einfügen...\",\n        \"pasteFromClipboard\": \"Aus Zwischenablage einfügen\",\n        \"applyConfiguration\": \"Konfiguration anwenden\",\n        \"configCopied\": \"Konfiguration in Zwischenablage kopiert\",\n        \"configCopyFailed\": \"Konfiguration konnte nicht kopiert werden\",\n        \"configPasted\": \"Konfiguration erfolgreich angewandt\",\n        \"configPasteFailed\": \"Konfiguration konnte nicht angewandt werden. Bitte Format überprüfen.\",\n        \"configPasteReadFailed\": \"Zwischenablage konnte nicht ausgelesen werden\",\n        \"presetName\": \"Vorlagen Name\",\n        \"presetNamePlaceholder\": \"Name der Vorlage eingeben\",\n        \"general\": \"Allgemein\",\n        \"mode\": \"Modus\",\n        \"mode1To8\": \"Modus 1 - 8\",\n        \"mode10\": \"Modus 10\",\n        \"lineWidth\": \"Linienbreite\",\n        \"channelLayout\": \"Kanallayout\",\n        \"maxFPS\": \"Max FPS\",\n        \"opacity\": \"Deckkraft\",\n        \"customGradients\": \"Benutzerdefinierte Gradienten\",\n        \"addCustomGradient\": \"Benutzerdefinierten Gradienten hinzufügen\",\n        \"gradientName\": \"Gradientenname\",\n        \"gradientNamePlaceholder\": \"Gradientenname\",\n        \"vertical\": \"Vertikal\",\n        \"horizontal\": \"Horizontal\",\n        \"addColor\": \"Farbe hinzufügen\",\n        \"position\": \"Position\",\n        \"level\": \"Ebene\",\n        \"remove\": \"Entfernen\",\n        \"pasteGradient\": \"Gradient einfügen\",\n        \"pasteGradientPlaceholder\": \"Gradient JSON hier einfügen...\",\n        \"custom\": \"Benutzerdefiniert\",\n        \"builtIn\": \"Eingebaut\",\n        \"colors\": \"Farben\",\n        \"colorMode\": \"Farbmodus\",\n        \"gradient\": \"Gradienten\",\n        \"gradientLeft\": \"Gradienten links\",\n        \"gradientRight\": \"Gradienten rechts\",\n        \"fft\": \"FFT\",\n        \"fftSize\": \"FFT Größe\",\n        \"smoothing\": \"Glätten\",\n        \"frequencyRangeAndScaling\": \"Frequenzbereich und Skalierung\",\n        \"maximumFrequency\": \"Maximale Frequenz\",\n        \"sensitivity\": \"Empfindlichkeit\",\n        \"weightingFilter\": \"Gewichtungsfilter\",\n        \"maximumDecibels\": \"Maximale Dezibel\",\n        \"linearAmplitude\": \"Lineare Amplitude\",\n        \"linearBoost\": \"Linearer Boost\",\n        \"radialSpectrum\": \"Radiales Spektrum\",\n        \"radial\": \"Radial\",\n        \"radialInvert\": \"Radial invertiert\",\n        \"radius\": \"Radius\",\n        \"miscellaneousSettings\": \"Verschiedenes Einstellungen\",\n        \"ansiBands\": \"ANSI Bänder\",\n        \"lowResolution\": \"Niedrige Auflösung\",\n        \"showFPS\": \"FPS anzeigen\"\n    }\n}\n"
  },
  {
    "path": "src/i18n/locales/en.json",
    "content": "{\n    \"action\": {\n        \"addToFavorites\": \"add to $t(entity.favorite, {\\\"count\\\": 2})\",\n        \"addToPlaylist\": \"add to $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"addOrRemoveFromSelection\": \"add or remove from selection\",\n        \"selectRangeOfItems\": \"select a range of items\",\n        \"clearQueue\": \"clear queue\",\n        \"goToCurrent\": \"go to current item\",\n        \"createPlaylist\": \"create $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"createRadioStation\": \"create $t(entity.radioStation, {\\\"count\\\": 1})\",\n        \"deletePlaylist\": \"delete $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"deleteRadioStation\": \"delete $t(entity.radioStation, {\\\"count\\\": 1})\",\n        \"selectAll\": \"select all\",\n        \"deselectAll\": \"deselect all\",\n        \"downloadStarted\": \"started download of {{count}} items\",\n        \"editPlaylist\": \"edit $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"goToPage\": \"go to page\",\n        \"moveToNext\": \"move to next\",\n        \"moveToBottom\": \"move to bottom\",\n        \"moveToTop\": \"move to top\",\n        \"moveUp\": \"move up\",\n        \"moveDown\": \"move down\",\n        \"holdToMoveToTop\": \"hold to move to top\",\n        \"holdToMoveToBottom\": \"hold to move to bottom\",\n        \"moveItems\": \"move items\",\n        \"shuffle\": \"shuffle\",\n        \"shuffleAll\": \"shuffle all\",\n        \"shuffleSelected\": \"shuffle selected\",\n        \"refresh\": \"$t(common.refresh)\",\n        \"removeFromFavorites\": \"remove from $t(entity.favorite, {\\\"count\\\": 2})\",\n        \"removeFromPlaylist\": \"remove from $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"removeFromQueue\": \"remove from queue\",\n        \"setRating\": \"set rating\",\n        \"toggleSmartPlaylistEditor\": \"toggle $t(entity.smartPlaylist) editor\",\n        \"viewPlaylists\": \"view $t(entity.playlist, {\\\"count\\\": 2})\",\n        \"viewMore\": \"view more\",\n        \"openApplicationDirectory\": \"open application directory\",\n        \"openIn\": {\n            \"lastfm\": \"Open in Last.fm\",\n            \"listenbrainz\": \"Open in ListenBrainz\",\n            \"musicbrainz\": \"Open in MusicBrainz\",\n            \"qobuz\": \"Open in Qobuz\",\n            \"spotify\": \"Open in Spotify\"\n        }\n    },\n    \"common\": {\n        \"countSelected\": \"{{count}} selected\",\n        \"explicitStatus\": \"explicit status\",\n        \"action_one\": \"action\",\n        \"action_other\": \"actions\",\n        \"add\": \"add\",\n        \"additionalParticipants\": \"additional participants\",\n        \"newVersion\": \"a new version has been installed ({{version}})\",\n        \"viewReleaseNotes\": \"view release notes\",\n        \"albumGain\": \"album gain\",\n        \"albumPeak\": \"album peak\",\n        \"areYouSure\": \"are you sure?\",\n        \"ascending\": \"ascending\",\n        \"backward\": \"backward\",\n        \"biography\": \"biography\",\n        \"bitDepth\": \"bit depth\",\n        \"bitrate\": \"bitrate\",\n        \"bpm\": \"bpm\",\n        \"cancel\": \"cancel\",\n        \"center\": \"center\",\n        \"channel_one\": \"channel\",\n        \"channel_other\": \"channels\",\n        \"clear\": \"clear\",\n        \"close\": \"close\",\n        \"codec\": \"codec\",\n        \"collapse\": \"collapse\",\n        \"comingSoon\": \"coming soon…\",\n        \"configure\": \"configure\",\n        \"confirm\": \"confirm\",\n        \"create\": \"create\",\n        \"currentSong\": \"current $t(entity.track, {\\\"count\\\": 1})\",\n        \"decrease\": \"decrease\",\n        \"delete\": \"delete\",\n        \"descending\": \"descending\",\n        \"description\": \"description\",\n        \"disable\": \"disable\",\n        \"disc\": \"disc\",\n        \"dismiss\": \"dismiss\",\n        \"doNotShowAgain\": \"do not show this again\",\n        \"duration\": \"duration\",\n        \"view\": \"view\",\n        \"edit\": \"edit\",\n        \"enable\": \"enable\",\n        \"expand\": \"expand\",\n        \"example\": \"example\",\n        \"externalLinks\": \"external links\",\n        \"faster\": \"faster\",\n        \"favorite\": \"favorite\",\n        \"filter_one\": \"filter\",\n        \"filter_other\": \"filters\",\n        \"filters\": \"filters\",\n        \"filter_single\": \"single\",\n        \"filter_multiple\": \"multi\",\n        \"forceRestartRequired\": \"restart to apply changes… close the notification to restart\",\n        \"forward\": \"forward\",\n        \"gap\": \"gap\",\n        \"home\": \"home\",\n        \"increase\": \"increase\",\n        \"left\": \"left\",\n        \"limit\": \"limit\",\n        \"manage\": \"manage\",\n        \"maximize\": \"maximize\",\n        \"menu\": \"menu\",\n        \"minimize\": \"minimize\",\n        \"modified\": \"modified\",\n        \"mbid\": \"MusicBrainz ID\",\n        \"mood\": \"mood\",\n        \"name\": \"name\",\n        \"no\": \"no\",\n        \"none\": \"none\",\n        \"noResultsFromQuery\": \"the query returned no results\",\n        \"numberOfResults\": \"{{numberOfResults}} results\",\n        \"noFilters\": \"no filters configured\",\n        \"note\": \"note\",\n        \"ok\": \"ok\",\n        \"owner\": \"owner\",\n        \"path\": \"path\",\n        \"playerMustBePaused\": \"player must be paused\",\n        \"preview\": \"preview\",\n        \"previousSong\": \"previous $t(entity.track, {\\\"count\\\": 1})\",\n        \"private\": \"private\",\n        \"public\": \"public\",\n        \"quit\": \"quit\",\n        \"random\": \"random\",\n        \"rating\": \"rating\",\n        \"retry\": \"retry\",\n        \"recordLabel\": \"record label\",\n        \"releaseType\": \"release type\",\n        \"refresh\": \"refresh\",\n        \"reload\": \"reload\",\n        \"rename\": \"rename\",\n        \"reset\": \"reset\",\n        \"resetToDefault\": \"reset to default\",\n        \"restartRequired\": \"restart required\",\n        \"right\": \"right\",\n        \"sampleRate\": \"sample rate\",\n        \"save\": \"save\",\n        \"saveAndReplace\": \"save and replace\",\n        \"saveAs\": \"save as\",\n        \"search\": \"search\",\n        \"setting_one\": \"setting\",\n        \"setting_other\": \"settings\",\n        \"slower\": \"slower\",\n        \"share\": \"share\",\n        \"size\": \"size\",\n        \"sort\": \"sort\",\n        \"sortOrder\": \"order\",\n        \"tags\": \"tags\",\n        \"title\": \"title\",\n        \"trackNumber\": \"track\",\n        \"trackGain\": \"track gain\",\n        \"trackPeak\": \"track peak\",\n        \"translation\": \"translation\",\n        \"unknown\": \"unknown\",\n        \"version\": \"version\",\n        \"year\": \"year\",\n        \"yes\": \"yes\",\n        \"explicit\": \"explicit\",\n        \"clean\": \"clean\",\n        \"gridRows\": \"grid rows\",\n        \"tableColumns\": \"table columns\",\n        \"itemsMore\": \"{{count}} more\",\n        \"newVersionAvailable\": \"a new version is available\"\n    },\n    \"entity\": {\n        \"album_one\": \"album\",\n        \"album_other\": \"albums\",\n        \"albumArtist_one\": \"album artist\",\n        \"albumArtist_other\": \"album artists\",\n        \"albumArtistCount_one\": \"{{count}} album artist\",\n        \"albumArtistCount_other\": \"{{count}} album artists\",\n        \"albumWithCount_one\": \"{{count}} album\",\n        \"albumWithCount_other\": \"{{count}} albums\",\n        \"radioStation_one\": \"radio station\",\n        \"radioStation_other\": \"radio stations\",\n        \"radioStationWithCount_one\": \"{{count}} radio station\",\n        \"radioStationWithCount_other\": \"{{count}} radio stations\",\n        \"artist_one\": \"artist\",\n        \"artist_other\": \"artists\",\n        \"artistWithCount_one\": \"{{count}} artist\",\n        \"artistWithCount_other\": \"{{count}} artists\",\n        \"favorite_one\": \"favorite\",\n        \"favorite_other\": \"favorites\",\n        \"folder_one\": \"folder\",\n        \"folder_other\": \"folders\",\n        \"folderWithCount_one\": \"{{count}} folder\",\n        \"folderWithCount_other\": \"{{count}} folders\",\n        \"genre_one\": \"genre\",\n        \"genre_other\": \"genres\",\n        \"genreWithCount_one\": \"{{count}} genre\",\n        \"genreWithCount_other\": \"{{count}} genres\",\n        \"playlist_one\": \"playlist\",\n        \"playlist_other\": \"playlists\",\n        \"play_one\": \"{{count}} play\",\n        \"play_other\": \"{{count}} plays\",\n        \"playlistWithCount_one\": \"{{count}} playlist\",\n        \"playlistWithCount_other\": \"{{count}} playlists\",\n        \"smartPlaylist\": \"smart $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"track_one\": \"track\",\n        \"track_other\": \"tracks\",\n        \"song_one\": \"song\",\n        \"song_other\": \"songs\",\n        \"trackWithCount_one\": \"{{count}} track\",\n        \"trackWithCount_other\": \"{{count}} tracks\"\n    },\n    \"error\": {\n        \"apiRouteError\": \"unable to route request\",\n        \"audioDeviceFetchError\": \"an error occurred when trying to get audio devices\",\n        \"authenticationFailed\": \"authentication failed\",\n        \"badAlbum\": \"you are seeing this page because this song is not part of an album. you are most likely seeing this issue if you have a song at the top level of your music folder. Jellyfin only groups tracks if they are in a folder\",\n        \"badValue\": \"invalid option \\\"{{value}}\\\". this value no longer exists\",\n        \"credentialsRequired\": \"credentials required\",\n        \"endpointNotImplementedError\": \"endpoint {{endpoint}} is not implemented for {{serverType}}\",\n        \"genericError\": \"an error occurred\",\n        \"invalidJson\": \"invalid JSON\",\n        \"invalidServer\": \"invalid server\",\n        \"localFontAccessDenied\": \"access denied to local fonts\",\n        \"loginRateError\": \"too many login attempts, please try again in a few seconds\",\n        \"mpvRequired\": \"MPV required\",\n        \"multipleServerSaveQueueError\": \"the play queue has one or more songs which are not from the current server. this is not supported\",\n        \"networkError\": \"a network error occurred\",\n        \"noNetwork\": \"server unavailable\",\n        \"noNetworkDescription\": \"couldn't connect to this server\",\n        \"notificationDenied\": \"permissions for notifications were denied. this setting has no effect\",\n        \"openError\": \"could not open file\",\n        \"playbackError\": \"an error occurred when trying to play the media\",\n        \"playbackPausedDueToError\": \"playback was paused due to an error\",\n        \"remoteDisableError\": \"an error occurred when trying to $t(common.disable) the remote server\",\n        \"remoteEnableError\": \"an error occurred when trying to $t(common.enable) the remote server\",\n        \"remotePortError\": \"an error occurred when trying to set the remote server port\",\n        \"remotePortWarning\": \"restart the server to apply the new port\",\n        \"saveQueueFailed\": \"failed to save queue\",\n        \"serverLockSingleServer\": \"only one server is allowed when server is locked\",\n        \"serverNotSelectedError\": \"no server selected\",\n        \"serverRequired\": \"server required\",\n        \"sessionExpiredError\": \"your session has expired\",\n        \"systemFontError\": \"an error occurred when trying to get system fonts\",\n        \"settingsSyncError\": \"discrepancies were found between the settings in the renderer and the main process. restart the application to apply the changes\"\n    },\n    \"filter\": {\n        \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n        \"albumArtist\": \"$t(entity.albumArtist, {\\\"count\\\": 1})\",\n        \"matchAnd\": \"and\",\n        \"matchOr\": \"or\",\n        \"albumCount\": \"$t(entity.album, {\\\"count\\\": 2}) count\",\n        \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n        \"biography\": \"biography\",\n        \"bitrate\": \"bitrate\",\n        \"bpm\": \"bpm\",\n        \"channels\": \"$t(common.channel, {\\\"count\\\": 2})\",\n        \"comment\": \"comment\",\n        \"communityRating\": \"community rating\",\n        \"criticRating\": \"critic rating\",\n        \"dateAdded\": \"date added\",\n        \"disc\": \"disc\",\n        \"duration\": \"duration\",\n        \"favorited\": \"favorited\",\n        \"fromYear\": \"from year\",\n        \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n        \"id\": \"id\",\n        \"isCompilation\": \"is compilation\",\n        \"isFavorited\": \"is favorited\",\n        \"isPublic\": \"is public\",\n        \"isRated\": \"is rated\",\n        \"isRecentlyPlayed\": \"is recently played\",\n        \"lastPlayed\": \"last played\",\n        \"mostPlayed\": \"most played\",\n        \"name\": \"name\",\n        \"note\": \"note\",\n        \"owner\": \"$t(common.owner)\",\n        \"path\": \"path\",\n        \"playCount\": \"play count\",\n        \"random\": \"random\",\n        \"rating\": \"rating\",\n        \"recentlyAdded\": \"recently added\",\n        \"recentlyPlayed\": \"recently played\",\n        \"recentlyUpdated\": \"recently updated\",\n        \"releaseDate\": \"release date\",\n        \"releaseYear\": \"release year\",\n        \"search\": \"search\",\n        \"songCount\": \"song count\",\n        \"sortName\": \"sort name\",\n        \"title\": \"title\",\n        \"toYear\": \"to year\",\n        \"trackNumber\": \"track\",\n        \"explicitStatus\": \"$t(common.explicitStatus)\"\n    },\n    \"datetime\": {\n        \"minuteShort\": \"m\",\n        \"secondShort\": \"s\",\n        \"hourShort\": \"h\",\n        \"dayShort\": \"d\"\n    },\n    \"filterOperator\": {\n        \"after\": \"is after\",\n        \"afterDate\": \"is after (date)\",\n        \"before\": \"is before\",\n        \"beforeDate\": \"is before (date)\",\n        \"contains\": \"contains\",\n        \"endsWith\": \"ends with\",\n        \"inPlaylist\": \"is in\",\n        \"inTheLast\": \"is in the last\",\n        \"inTheRange\": \"is in the range\",\n        \"inTheRangeDate\": \"is in the range (date)\",\n        \"is\": \"is\",\n        \"isNot\": \"is not\",\n        \"isGreaterThan\": \"is greater than\",\n        \"isLessThan\": \"is less than\",\n        \"matchesRegex\": \"matches regex\",\n        \"notContains\": \"does not contain\",\n        \"notInPlaylist\": \"is not in\",\n        \"notInTheLast\": \"is not in the last\",\n        \"startsWith\": \"starts with\"\n    },\n    \"form\": {\n        \"addServer\": {\n            \"error_savePassword\": \"an error occurred when trying to save the password\",\n            \"ignoreCors\": \"ignore cors ($t(common.restartRequired))\",\n            \"ignoreSsl\": \"ignore ssl ($t(common.restartRequired))\",\n            \"input_legacyAuthentication\": \"enable legacy authentication\",\n            \"input_name\": \"server name\",\n            \"input_password\": \"password\",\n            \"input_preferInstantMix\": \"prefer instant mix\",\n            \"input_preferInstantMixDescription\": \"only use instant mix to get similar songs. useful if you have plugins that modify this behavior\",\n            \"input_preferRemoteUrl\": \"prefer public url\",\n            \"input_remoteUrl\": \"public url\",\n            \"input_remoteUrlPlaceholder\": \"optional: public url for external features\",\n            \"input_savePassword\": \"save password\",\n            \"input_url\": \"url\",\n            \"input_username\": \"username\",\n            \"success\": \"server added successfully\",\n            \"title\": \"add server\"\n        },\n        \"largeFetchConfirmation\": {\n            \"title\": \"add items to the queue\",\n            \"description\": \"This action will add all items in the current filtered view\"\n        },\n        \"addToPlaylist\": {\n            \"create\": \"create $t(entity.playlist, {\\\"count\\\": 1}) {{playlist}}\",\n            \"input_playlists\": \"$t(entity.playlist, {\\\"count\\\": 2})\",\n            \"input_skipDuplicates\": \"skip duplicates\",\n            \"searchOrCreate\": \"search $t(entity.playlist, {\\\"count\\\": 2}) or type to create a new one\",\n            \"success\": \"added $t(entity.trackWithCount, {\\\"count\\\": {{message}} }) to $t(entity.playlistWithCount, {\\\"count\\\": {{numOfPlaylists}} })\",\n            \"title\": \"add to $t(entity.playlist, {\\\"count\\\": 1})\"\n        },\n        \"createPlaylist\": {\n            \"input_description\": \"$t(common.description)\",\n            \"input_name\": \"$t(common.name)\",\n            \"input_owner\": \"$t(common.owner)\",\n            \"input_public\": \"public\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) created successfully\",\n            \"title\": \"create $t(entity.playlist, {\\\"count\\\": 1})\"\n        },\n        \"createRadioStation\": {\n            \"success\": \"radio station created successfully\",\n            \"title\": \"create radio station\",\n            \"input_homepageUrl\": \"homepage url\",\n            \"input_name\": \"name\",\n            \"input_streamUrl\": \"stream url\"\n        },\n        \"deletePlaylist\": {\n            \"input_confirm\": \"type the name of the $t(entity.playlist, {\\\"count\\\": 1}) to confirm\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) deleted successfully\",\n            \"title\": \"delete $t(entity.playlist, {\\\"count\\\": 1})\"\n        },\n        \"editPlaylist\": {\n            \"publicJellyfinNote\": \"Jellyfin for some reason does not expose whether a playlist is public or not. If you wish for this to remain public, please have the following input selected\",\n            \"editNote\": \"manual edits are not recommended for large playlists. are you sure you accept the risk of data loss incurred by overwriting the existing playlist?\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) updated successfully\",\n            \"title\": \"edit $t(entity.playlist, {\\\"count\\\": 1})\"\n        },\n        \"lyricsExport\": {\n            \"export\": \"export lyrics\",\n            \"input_synced\": \"export synced lyrics\",\n            \"input_offset\": \"$t(setting.lyricOffset)\"\n        },\n        \"lyricSearch\": {\n            \"input_artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n            \"input_name\": \"$t(common.name)\",\n            \"title\": \"lyric search\"\n        },\n        \"queryEditor\": {\n            \"title\": \"query editor\",\n            \"input_optionMatchAll\": \"match all\",\n            \"input_optionMatchAny\": \"match any\",\n            \"addRuleGroup\": \"add rule group\",\n            \"removeRuleGroup\": \"remove rule group\",\n            \"resetToDefault\": \"reset to default\",\n            \"clearFilters\": \"clear filters\"\n        },\n        \"saveQueue\": {\n            \"success\": \"saved play queue to server\"\n        },\n        \"shareItem\": {\n            \"allowDownloading\": \"allow downloading\",\n            \"copyToClipboard\": \"Copy to clipboard: Ctrl+C, Enter\",\n            \"description\": \"description\",\n            \"setExpiration\": \"set expiration\",\n            \"success\": \"share link copied to clipboard (or click here to open)\",\n            \"successMustClick\": \"share created successfully. click here to open\",\n            \"expireInvalid\": \"expiration must be in the future\",\n            \"createFailed\": \"failed to create share (is sharing enabled?)\"\n        },\n        \"shuffleAll\": {\n            \"title\": \"play random\",\n            \"input_genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n            \"input_limit\": \"how many songs?\",\n            \"input_minYear\": \"from year\",\n            \"input_maxYear\": \"to year\",\n            \"input_played\": \"play filter\",\n            \"input_played_optionAll\": \"all tracks\",\n            \"input_played_optionUnplayed\": \"only unplayed tracks\",\n            \"input_played_optionPlayed\": \"only played tracks\"\n        },\n        \"updateServer\": {\n            \"success\": \"server updated successfully\",\n            \"title\": \"update server\"\n        },\n        \"privateMode\": {\n            \"enabled\": \"private mode enabled, playback status is now hidden from external integrations\",\n            \"disabled\": \"private mode disabled, playback status is now visible to enabled external integrations\",\n            \"title\": \"private mode\"\n        }\n    },\n    \"page\": {\n        \"albumArtistDetail\": {\n            \"about\": \"About {{artist}}\",\n            \"appearsOn\": \"appears on\",\n            \"favoriteSongs\": \"favorite songs\",\n            \"groupingTypeAll\": \"all release types\",\n            \"groupingTypePrimary\": \"primary release types\",\n            \"recentReleases\": \"recent releases\",\n            \"viewDiscography\": \"view discography\",\n            \"relatedArtists\": \"related $t(entity.artist, {\\\"count\\\": 2})\",\n            \"topSongs\": \"top songs\",\n            \"topSongsCommunity\": \"community\",\n            \"topSongsFrom\": \"top songs from {{title}}\",\n            \"topSongsPersonal\": \"personal\",\n            \"favoriteSongsFrom\": \"favorite songs from {{title}}\",\n            \"viewAll\": \"view all\",\n            \"viewAllTracks\": \"view all $t(entity.track, {\\\"count\\\": 2})\"\n        },\n        \"albumArtistList\": {\n            \"title\": \"$t(entity.albumArtist, {\\\"count\\\": 2})\"\n        },\n        \"albumDetail\": {\n            \"moreFromArtist\": \"more from this $t(entity.artist, {\\\"count\\\": 1})\",\n            \"moreFromGeneric\": \"more from {{item}}\",\n            \"released\": \"released\"\n        },\n        \"albumList\": {\n            \"artistAlbums\": \"albums by {{artist}}\",\n            \"genreAlbums\": \"\\\"{{genre}}\\\" $t(entity.album, {\\\"count\\\": 2})\",\n            \"title\": \"$t(entity.album, {\\\"count\\\": 2})\"\n        },\n        \"radioList\": {\n            \"title\": \"radio stations\"\n        },\n        \"releasenotes\": {\n            \"commitsSinceStable\": \"commits since {{stable}}\",\n            \"noNewCommits\": \"no new commits in this range\",\n            \"noStableReleaseToCompare\": \"no stable release available to compare with\"\n        },\n        \"favorites\": {\n            \"title\": \"$t(entity.favorite, {\\\"count\\\": 2})\"\n        },\n        \"windowBar\": {\n            \"paused\": \"(Paused) \",\n            \"privateMode\": \"(Private mode)\"\n        },\n        \"appMenu\": {\n            \"collapseSidebar\": \"collapse sidebar\",\n            \"commandPalette\": \"open command palette\",\n            \"expandSidebar\": \"expand sidebar\",\n            \"goBack\": \"go back\",\n            \"goForward\": \"go forward\",\n            \"manageServers\": \"manage servers\",\n            \"privateModeOff\": \"turn off private mode\",\n            \"privateModeOn\": \"turn on private mode\",\n            \"openBrowserDevtools\": \"open browser devtools\",\n            \"quit\": \"$t(common.quit)\",\n            \"selectServer\": \"select server\",\n            \"selectMusicFolder\": \"select music folder\",\n            \"noMusicFolder\": \"no music folder selected\",\n            \"multipleMusicFolders\": \"{{count}} music folders selected\",\n            \"settings\": \"$t(common.setting, {\\\"count\\\": 2})\",\n            \"version\": \"version {{version}}\"\n        },\n        \"manageServers\": {\n            \"title\": \"manage servers\",\n            \"serverDetails\": \"server details\",\n            \"url\": \"URL\",\n            \"username\": \"username\",\n            \"editServerDetailsTooltip\": \"edit server details\",\n            \"removeServer\": \"remove server\"\n        },\n        \"contextMenu\": {\n            \"addFavorite\": \"$t(action.addToFavorites)\",\n            \"addLast\": \"$t(player.addLast)\",\n            \"addNext\": \"$t(player.addNext)\",\n            \"addToFavorites\": \"$t(action.addToFavorites)\",\n            \"addToPlaylist\": \"$t(action.addToPlaylist)\",\n            \"createPlaylist\": \"$t(action.createPlaylist)\",\n            \"deletePlaylist\": \"$t(action.deletePlaylist)\",\n            \"deselectAll\": \"$t(action.deselectAll)\",\n            \"download\": \"download\",\n            \"moveItems\": \"$t(action.moveItems)\",\n            \"moveToNext\": \"$t(action.moveToNext)\",\n            \"moveToBottom\": \"$t(action.moveToBottom)\",\n            \"moveToTop\": \"$t(action.moveToTop)\",\n            \"numberSelected\": \"{{count}} selected\",\n            \"play\": \"$t(player.play)\",\n            \"playSimilarSongs\": \"$t(player.playSimilarSongs)\",\n            \"removeFromFavorites\": \"$t(action.removeFromFavorites)\",\n            \"removeFromPlaylist\": \"$t(action.removeFromPlaylist)\",\n            \"removeFromQueue\": \"$t(action.removeFromQueue)\",\n            \"setRating\": \"$t(action.setRating)\",\n            \"playShuffled\": \"$t(player.shuffle)\",\n            \"shareItem\": \"share item\",\n            \"goTo\": \"go to\",\n            \"goToAlbum\": \"go to $t(entity.album, {\\\"count\\\": 1})\",\n            \"goToAlbumArtist\": \"go to $t(entity.albumArtist, {\\\"count\\\": 1})\",\n            \"showDetails\": \"get info\"\n        },\n        \"fullscreenPlayer\": {\n            \"config\": {\n                \"dynamicBackground\": \"dynamic background\",\n                \"dynamicImageBlur\": \"image blur size\",\n                \"dynamicIsImage\": \"enable background image\",\n                \"followCurrentLyric\": \"follow current lyric\",\n                \"lyricAlignment\": \"lyric alignment\",\n                \"lyricOffset\": \"lyrics offset (ms)\",\n                \"lyricGap\": \"lyric gap\",\n                \"lyricSize\": \"lyric size\",\n                \"opacity\": \"opacity\",\n                \"showLyricMatch\": \"show lyric match\",\n                \"showLyricProvider\": \"show lyric provider\",\n                \"synchronized\": \"synchronized\",\n                \"unsynchronized\": \"unsynchronized\",\n                \"useImageAspectRatio\": \"use image aspect ratio\"\n            },\n            \"lyrics\": \"lyrics\",\n            \"related\": \"related\",\n            \"upNext\": \"up next\",\n            \"visualizer\": \"visualizer\",\n            \"noLyrics\": \"no lyrics found\"\n        },\n        \"genreList\": {\n            \"showAlbums\": \"show $t(entity.genre, {\\\"count\\\": 1}) $t(entity.album, {\\\"count\\\": 2})\",\n            \"showTracks\": \"show $t(entity.genre, {\\\"count\\\": 1}) $t(entity.track, {\\\"count\\\": 2})\",\n            \"title\": \"$t(entity.genre, {\\\"count\\\": 2})\"\n        },\n        \"folderList\": {\n            \"title\": \"$t(entity.folder, {\\\"count\\\": 2})\"\n        },\n        \"globalSearch\": {\n            \"commands\": {\n                \"goToPage\": \"go to page\",\n                \"searchFor\": \"search for {{query}}\",\n                \"serverCommands\": \"server commands\"\n            },\n            \"title\": \"commands\"\n        },\n        \"home\": {\n            \"explore\": \"explore from your library\",\n            \"genres\": \"$t(entity.genre, {\\\"count\\\": 2})\",\n            \"mostPlayed\": \"most played\",\n            \"newlyAdded\": \"newly added releases\",\n            \"recentlyPlayed\": \"recently played\",\n            \"recentlyReleased\": \"recently released\",\n            \"title\": \"$t(common.home)\"\n        },\n        \"itemDetail\": {\n            \"copyPath\": \"copy path to clipboard\",\n            \"copiedPath\": \"path copied successfully\",\n            \"openFile\": \"show track in file manager\"\n        },\n        \"playlist\": {\n            \"reorder\": \"reordering only enabled when sorting by id\"\n        },\n        \"playlistList\": {\n            \"title\": \"$t(entity.playlist, {\\\"count\\\": 2})\"\n        },\n        \"collections\": {\n            \"overrideExisting\": \"override existing\",\n            \"saveAsCollection\": \"save as collection\"\n        },\n        \"setting\": {\n            \"advanced\": \"advanced\",\n            \"analytics\": \"analytics\",\n            \"generalTab\": \"general\",\n            \"hotkeysTab\": \"hotkeys\",\n            \"playbackTab\": \"playback\",\n            \"windowTab\": \"window\",\n            \"updates\": \"update\",\n            \"cache\": \"cache\",\n            \"application\": \"application\",\n            \"queryBuilder\": \"query builder\",\n            \"theme\": \"theme\",\n            \"controls\": \"controls\",\n            \"sidebar\": \"sidebar\",\n            \"remote\": \"remote\",\n            \"exportImport\": \"import/export\",\n            \"scrobble\": \"scrobble\",\n            \"audio\": \"audio\",\n            \"lyrics\": \"lyrics\",\n            \"lyricsDisplay\": \"lyrics display\",\n            \"transcoding\": \"transcoding\",\n            \"discord\": \"discord\",\n            \"logger\": \"logger\",\n            \"playerFilters\": \"player filters\"\n        },\n        \"sidebar\": {\n            \"albumArtists\": \"$t(entity.albumArtist, {\\\"count\\\": 2})\",\n            \"albums\": \"$t(entity.album, {\\\"count\\\": 2})\",\n            \"collections\": \"collections\",\n            \"artists\": \"$t(entity.artist, {\\\"count\\\": 2})\",\n            \"favorites\": \"$t(entity.favorite, {\\\"count\\\": 2})\",\n            \"folders\": \"$t(entity.folder, {\\\"count\\\": 2})\",\n            \"genres\": \"$t(entity.genre, {\\\"count\\\": 2})\",\n            \"home\": \"$t(common.home)\",\n            \"radio\": \"$t(entity.radioStation, {\\\"count\\\": 2})\",\n            \"myLibrary\": \"my library\",\n            \"nowPlaying\": \"now playing\",\n            \"playlists\": \"$t(entity.playlist, {\\\"count\\\": 2})\",\n            \"search\": \"$t(common.search)\",\n            \"settings\": \"$t(common.setting, {\\\"count\\\": 2})\",\n            \"shared\": \"shared $t(entity.playlist, {\\\"count\\\": 2})\",\n            \"tracks\": \"$t(entity.track, {\\\"count\\\": 2})\"\n        },\n        \"trackList\": {\n            \"artistTracks\": \"tracks by {{artist}}\",\n            \"genreTracks\": \"\\\"{{genre}}\\\" $t(entity.track, {\\\"count\\\": 2})\",\n            \"title\": \"$t(entity.track, {\\\"count\\\": 2})\"\n        }\n    },\n    \"player\": {\n        \"addLast\": \"last\",\n        \"addNext\": \"next\",\n        \"addLastShuffled\": \"last (shuffled)\",\n        \"addNextShuffled\": \"next (shuffled)\",\n        \"albumRadio\": \"album radio\",\n        \"artistRadio\": \"artist radio\",\n        \"holdToShuffle\": \"hold to shuffle\",\n        \"favorite\": \"favorite\",\n        \"lyrics\": \"lyrics\",\n        \"mute\": \"mute\",\n        \"muted\": \"muted\",\n        \"next\": \"next\",\n        \"play\": \"play\",\n        \"playbackFetchCancel\": \"this is taking a while… close the notification to cancel\",\n        \"playbackFetchInProgress\": \"loading songs…\",\n        \"playbackFetchNoResults\": \"no songs found\",\n        \"playbackSpeed\": \"playback speed\",\n        \"playRandom\": \"play random\",\n        \"playSimilarSongs\": \"play similar songs\",\n        \"previous\": \"previous\",\n        \"queue_clear\": \"clear queue\",\n        \"queue_moveToBottom\": \"move selected to top\",\n        \"queue_moveToTop\": \"move selected to bottom\",\n        \"queue_remove\": \"remove selected\",\n        \"repeat\": \"repeat\",\n        \"repeat_all\": \"repeat all\",\n        \"repeat_off\": \"repeat disabled\",\n        \"repeat_one\": \"repeat one\",\n        \"repeat_other\": \"\",\n        \"restoreQueueFromServer\": \"restore queue from server\",\n        \"saveQueueToServer\": \"save queue to server\",\n        \"shuffle\": \"play (shuffled)\",\n        \"shuffle_off\": \"shuffle disabled\",\n        \"skip\": \"skip\",\n        \"skip_back\": \"skip backwards\",\n        \"skip_forward\": \"skip forwards\",\n        \"stop\": \"stop\",\n        \"toggleFullscreenPlayer\": \"toggle fullscreen player\",\n        \"trackRadio\": \"track radio\",\n        \"unfavorite\": \"unfavorite\",\n        \"pause\": \"pause\",\n        \"viewQueue\": \"view queue\",\n        \"sleepTimer\": \"sleep timer\",\n        \"sleepTimer_endOfSong\": \"end of current song\",\n        \"sleepTimer_minutes\": \"{{count}} min\",\n        \"sleepTimer_hours\": \"{{count}} hr\",\n        \"sleepTimer_custom\": \"custom\",\n        \"sleepTimer_off\": \"off\",\n        \"sleepTimer_timeRemaining\": \"{{time}} remaining\",\n        \"sleepTimer_setCustom\": \"set timer\",\n        \"sleepTimer_cancel\": \"cancel timer\"\n    },\n    \"queryBuilder\": {\n        \"standardTags\": \"standard tags\",\n        \"customTags\": \"custom tags\"\n    },\n    \"releaseType\": {\n        \"primary\": {\n            \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n            \"broadcast\": \"broadcast\",\n            \"ep\": \"ep\",\n            \"other\": \"other\",\n            \"single\": \"single\"\n        },\n        \"secondary\": {\n            \"audiobook\": \"audiobook\",\n            \"audioDrama\": \"audio drama\",\n            \"compilation\": \"compilation\",\n            \"djMix\": \"dj mix\",\n            \"demo\": \"demo\",\n            \"fieldRecording\": \"field recording\",\n            \"interview\": \"interview\",\n            \"live\": \"live\",\n            \"mixtape\": \"mixtape\",\n            \"remix\": \"remix\",\n            \"soundtrack\": \"soundtrack\",\n            \"spokenWord\": \"spoken word\"\n        }\n    },\n    \"setting\": {\n        \"autoDJ\": \"auto DJ\",\n        \"autoDJ_description\": \"automatically add similar songs to the queue\",\n        \"autoDJ_itemCount\": \"item count\",\n        \"autoDJ_itemCount_description\": \"the number of items attempted to be added to the queue when auto DJ is enabled\",\n        \"autoDJ_timing\": \"timing\",\n        \"autoDJ_timing_description\": \"the number of songs remaining in the queue before auto DJ is triggered\",\n        \"autosave\": \"automatically save play queue\",\n        \"autosave_description\": \"enable automatically saving the play queue to your server. this is only possible when using Navidrome/Subsonic, and you cannot have a mixed play queue.\",\n        \"autosaveCount\": \"automatic play queue save frequency\",\n        \"autosaveCount_description\": \"how many track changes before the queue is saved. 1 (minimum) means every song change\",\n        \"accentColor_description\": \"sets the accent color for the application\",\n        \"accentColor\": \"accent color\",\n        \"useThemeAccentColor\": \"use theme accent color\",\n        \"useThemeAccentColor_description\": \"use the primary color defined in the selected theme instead of the custom accent color\",\n        \"useThemePrimaryShade\": \"use theme primary shade\",\n        \"useThemePrimaryShade_description\": \"use the primary shade defined in the selected theme for primary color variants\",\n        \"primaryShade\": \"primary shade\",\n        \"primaryShade_description\": \"override the primary shade (0–9) used for buttons, links, and other primary-colored elements\",\n        \"albumBackground_description\": \"adds a background image for album pages containing the album art\",\n        \"albumBackground\": \"album background image\",\n        \"albumBackgroundBlur_description\": \"adjusts the amount of blur applied to the album background image\",\n        \"albumBackgroundBlur\": \"album background image blur size\",\n        \"analyticsDisable\": \"Opt-out of usage based analytics\",\n        \"analyticsDisable_description\": \"Anonymized usage data is sent to the developer to help improve the application\",\n        \"analyticsEnable\": \"Send usage-based analytics\",\n        \"analyticsEnable_description\": \"Anonymized usage data is sent to the developer to help improve the application\",\n        \"applicationHotkeys_description\": \"configure application hotkeys. toggle the checkbox to set as a global hotkey (desktop only)\",\n        \"applicationHotkeys\": \"application hotkeys\",\n        \"artistBackground\": \"artist background image\",\n        \"artistBackground_description\": \"adds a background image for artist pages containing the artist art\",\n        \"artistBackgroundBlur\": \"artist background image blur size\",\n        \"artistBackgroundBlur_description\": \"adjusts the amount of blur applied to the artist background image\",\n        \"artistConfiguration\": \"album artist page configuration\",\n        \"artistConfiguration_description\": \"configure what items are shown, and in what order, on the album artist page\",\n        \"artistReleaseTypeConfiguration\": \"artist release type configuration\",\n        \"artistReleaseTypeConfiguration_description\": \"configure what release types are shown, and in what order, on the album artist page\",\n        \"audioDevice_description\": \"select the audio device to use for playback\",\n        \"audioDevice\": \"audio device\",\n        \"audioExclusiveMode_description\": \"enable exclusive output mode. In this mode, the system is usually locked out, and only mpv will be able to output audio\",\n        \"audioExclusiveMode\": \"audio exclusive mode\",\n        \"audioPlayer_description\": \"select the audio player to use for playback\",\n        \"audioPlayer\": \"audio player\",\n        \"buttonSize_description\": \"the size of the player bar buttons\",\n        \"buttonSize\": \"player bar button size\",\n        \"clearCache_description\": \"a 'hard clear' of feishin. in addition to clearing feishin's cache, empty the browser cache (saved images and other assets). server credentials and settings are preserved\",\n        \"clearCache\": \"clear browser cache\",\n        \"clearCacheSuccess\": \"cache cleared successfully\",\n        \"clearQueryCache_description\": \"a 'soft clear' of feishin. this will refresh playlists, track metadata, and reset saved lyrics. settings, server credentials and cached images are preserved\",\n        \"clearQueryCache\": \"clear feishin cache\",\n        \"contextMenu_description\": \"allows you to hide items that are shown in the menu when you right click on an item. items that are unchecked will be hidden\",\n        \"contextMenu\": \"context menu (right click) configuration\",\n        \"crossfadeDuration_description\": \"sets the duration of the crossfade effect\",\n        \"crossfadeDuration\": \"crossfade duration\",\n        \"crossfadeStyle\": \"crossfade style\",\n        \"crossfadeStyle_description\": \"select the crossfade style to use for the audio player\",\n        \"customCss_description\": \"custom css content. Note: content and remote urls are disallowed properties. A preview of your content is shown below. Additional fields you didn't set are present due to sanitization\",\n        \"customCss\": \"custom css\",\n        \"customCssEnable_description\": \"allow for writing custom css\",\n        \"customCssEnable\": \"enable custom css\",\n        \"customCssNotice\": \"Warning: while there is some sanitization (disallowing url() and content:), using custom css can still pose risks by changing the interface\",\n        \"customFontPath_description\": \"sets the path to the custom font to use for the application\",\n        \"customFontPath\": \"custom font path\",\n        \"automaticUpdates\": \"Automatic updates\",\n        \"automaticUpdates_description\": \"Check for and install updates automatically\",\n        \"releaseChannel_optionAlpha\": \"alpha (nightly)\",\n        \"releaseChannel_optionBeta\": \"beta\",\n        \"releaseChannel_optionLatest\": \"latest\",\n        \"releaseChannel\": \"release channel\",\n        \"releaseChannel_description\": \"choose between stable, beta, or alpha (nightly) releases for automatic updates\",\n        \"disableLibraryUpdateOnStartup\": \"disable checking for new versions on startup\",\n        \"discordApplicationId_description\": \"the application id for {{discord}} rich presence (defaults to {{defaultId}})\",\n        \"discordApplicationId\": \"{{discord}} application id\",\n        \"discordDisplayType_artistname\": \"artist name(s)\",\n        \"discordDisplayType_description\": \"changes what you are listening to in your status\",\n        \"discordDisplayType_songname\": \"song name\",\n        \"discordDisplayType\": \"{{discord}} presence display type\",\n        \"discordIdleStatus_description\": \"when enabled, update status while player is idle\",\n        \"discordIdleStatus\": \"show rich presence idle status\",\n        \"discordLinkType_description\": \"adds external links to {{lastfm}} or {{musicbrainz}} to the song and artist fields in {{discord}} rich presence. {{musicbrainz}} is the most accurate but requires tags and doesn't provide artist links while {{lastfm}} should always provide a link. makes no extra network requests\",\n        \"discordLinkType_mbz_lastfm\": \"{{musicbrainz}} with {{lastfm}} fallback\",\n        \"discordLinkType_none\": \"$t(common.none)\",\n        \"discordLinkType\": \"{{discord}} presence links\",\n        \"discordListening_description\": \"show status as listening instead of playing\",\n        \"discordListening\": \"show status as listening\",\n        \"discordPausedStatus_description\": \"when enabled, status will show when player is paused\",\n        \"discordPausedStatus\": \"show rich presence when paused\",\n        \"discordRichPresence\": \"{{discord}} rich presence\",\n        \"discordRichPresence_description\": \"enable playback status in {{discord}} rich presence. Image keys are: {{icon}}, {{playing}}, and {{paused}}\",\n        \"discordServeImage\": \"serve {{discord}} images from server\",\n        \"discordServeImage_description\": \"share cover art for {{discord}} rich presence from server itself, only available for Jellyfin and Navidrome. {{discord}} uses a bot to fetch images, so your server must be reachable from the public internet\",\n        \"discordStateIcon\": \"show playing icon\",\n        \"discordStateIcon_description\": \"show a small playing icon in the rich presence status. the paused icon is always shown when \\\"Show rich presence when paused\\\" is enabled\",\n        \"discordUpdateInterval\": \"{{discord}} rich presence update interval\",\n        \"discordUpdateInterval_description\": \"the time in seconds between each update (minimum 15 seconds)\",\n        \"enableAutoTranslation_description\": \"enable translation automatically when lyrics are loaded\",\n        \"enableAutoTranslation\": \"enable auto translation\",\n        \"enableRemote_description\": \"enables the remote control server to allow other devices to control the application\",\n        \"enableRemote\": \"enable remote control server\",\n        \"exitToTray_description\": \"exit the application to the system tray\",\n        \"exitToTray\": \"exit to tray\",\n        \"exportImportSettings_control_description\": \"export and import settings via JSON\",\n        \"exportImportSettings_control_exportText\": \"export settings\",\n        \"exportImportSettings_control_importText\": \"import settings\",\n        \"exportImportSettings_control_title\": \"import / export settings\",\n        \"exportImportSettings_destructiveWarning\": \"importing settings is destructive, please review the above before clicking \\\"import\\\" below!\",\n        \"exportImportSettings_importBtn\": \"import settings\",\n        \"exportImportSettings_importModalTitle\": \"import feishin settings\",\n        \"exportImportSettings_importSuccess\": \"settings have been imported successfully!\",\n        \"exportImportSettings_notValidJSON\": \"the file passed is not valid JSON\",\n        \"exportImportSettings_offendingKeyError\": \"\\\"{{offendingKey}}\\\" is incorrect - {{reason}}\",\n        \"externalLinks_description\": \"enables showing external links (Last.fm, MusicBrainz) on artist/album pages\",\n        \"externalLinks\": \"show external links\",\n        \"followCurrentSong_description\": \"automatically scroll the play queue to the current playing song\",\n        \"followCurrentSong\": \"follow current song\",\n        \"followLyric_description\": \"scroll the lyric to the current playing position\",\n        \"followLyric\": \"follow current lyric\",\n        \"font_description\": \"sets the font to use for the application\",\n        \"font\": \"font\",\n        \"fontType_description\": \"built-in font selects one of the fonts provided by feishin. system font allows you to select any font provided by your operating system. custom allows you to provide your own font\",\n        \"fontType_optionBuiltIn\": \"built-in font\",\n        \"fontType_optionCustom\": \"custom font\",\n        \"fontType_optionSystem\": \"system font\",\n        \"fontType\": \"font type\",\n        \"gaplessAudio_description\": \"sets the gapless audio setting for mpv\",\n        \"gaplessAudio_optionWeak\": \"weak (recommended)\",\n        \"gaplessAudio\": \"gapless audio\",\n        \"globalMediaHotkeys_description\": \"enable or disable the usage of your system media hotkeys to control playback\",\n        \"globalMediaHotkeys\": \"global media hotkeys\",\n        \"homeConfiguration_description\": \"configure what items are shown, and in what order, on the home page\",\n        \"homeConfiguration\": \"home page configuration\",\n        \"homeFeature_description\": \"controls whether to show the large featured carousel on the home page\",\n        \"homeFeature\": \"home featured carousel\",\n        \"homeFeatureStyle_description\": \"controls the style of the home featured carousel\",\n        \"homeFeatureStyle\": \"home featured carousel style\",\n        \"homeFeatureStyle_optionMultiple\": \"multiple\",\n        \"homeFeatureStyle_optionSingle\": \"single\",\n        \"hotkey_browserBack\": \"browser back\",\n        \"hotkey_browserForward\": \"browser forward\",\n        \"hotkey_favoriteCurrentSong\": \"favorite $t(common.currentSong)\",\n        \"hotkey_favoritePreviousSong\": \"favorite $t(common.previousSong)\",\n        \"hotkey_globalSearch\": \"global search\",\n        \"hotkey_localSearch\": \"in-page search\",\n        \"hotkey_listNavigateToPage\": \"list navigate to item page\",\n        \"hotkey_listPlayDefault\": \"list play\",\n        \"hotkey_listPlayLast\": \"list play last\",\n        \"hotkey_listPlayNext\": \"list play next\",\n        \"hotkey_listPlayNow\": \"list play now\",\n        \"hotkey_navigateHome\": \"navigate to home\",\n        \"hotkey_playbackNext\": \"next track\",\n        \"hotkey_playbackPause\": \"pause\",\n        \"hotkey_playbackPlay\": \"play\",\n        \"hotkey_playbackPlayPause\": \"play / pause\",\n        \"hotkey_playbackPrevious\": \"previous track\",\n        \"hotkey_playbackStop\": \"stop\",\n        \"hotkey_rate0\": \"rating clear\",\n        \"hotkey_rate1\": \"rating 1 star\",\n        \"hotkey_rate2\": \"rating 2 stars\",\n        \"hotkey_rate3\": \"rating 3 stars\",\n        \"hotkey_rate4\": \"rating 4 stars\",\n        \"hotkey_rate5\": \"rating 5 stars\",\n        \"hotkey_skipBackward\": \"skip backward\",\n        \"hotkey_skipForward\": \"skip forward\",\n        \"hotkey_toggleCurrentSongFavorite\": \"toggle $t(common.currentSong) favorite\",\n        \"hotkey_toggleFullScreenPlayer\": \"toggle full screen player\",\n        \"hotkey_togglePreviousSongFavorite\": \"toggle $t(common.previousSong) favorite\",\n        \"hotkey_toggleQueue\": \"toggle queue\",\n        \"hotkey_toggleRepeat\": \"toggle repeat\",\n        \"hotkey_toggleShuffle\": \"toggle shuffle\",\n        \"hotkey_unfavoriteCurrentSong\": \"unfavorite $t(common.currentSong)\",\n        \"hotkey_unfavoritePreviousSong\": \"unfavorite $t(common.previousSong)\",\n        \"hotkey_volumeDown\": \"volume down\",\n        \"hotkey_volumeMute\": \"volume mute\",\n        \"hotkey_volumeUp\": \"volume up\",\n        \"hotkey_zoomIn\": \"zoom in\",\n        \"hotkey_zoomOut\": \"zoom out\",\n        \"imageAspectRatio_description\": \"if enabled, cover art will be shown using their native aspect ratio. for art that is not 1:1, the remaining space will be empty\",\n        \"imageAspectRatio\": \"use native cover art aspect ratio\",\n        \"language\": \"language\",\n        \"language_description\": \"sets the language for the application ($t(common.restartRequired))\",\n        \"lastfm_description\": \"show links to Last.fm on artist/album pages\",\n        \"lastfm\": \"show last.fm links\",\n        \"listenbrainz_description\": \"show links to ListenBrainz on artist/album pages\",\n        \"listenbrainz\": \"show ListenBrainz links\",\n        \"lastfmApiKey_description\": \"the API key for {{lastfm}}. required for cover art\",\n        \"lastfmApiKey\": \"{{lastfm}} API key\",\n        \"lyricFetch_description\": \"fetch lyrics from various internet sources\",\n        \"lyricFetch\": \"fetch lyrics from the internet\",\n        \"lyricFetchProvider_description\": \"select the providers to fetch lyrics from\",\n        \"lyricFetchProvider\": \"providers to fetch lyrics from\",\n        \"lyricOffset_description\": \"offset the lyric by the specified amount of milliseconds\",\n        \"lyricOffset\": \"lyric offset (ms)\",\n        \"logLevel\": \"log level\",\n        \"logLevel_description\": \"sets the minimum log level to display. debug shows all logs, error only shows errors\",\n        \"logLevel_optionDebug\": \"debug\",\n        \"logLevel_optionError\": \"error\",\n        \"logLevel_optionInfo\": \"info\",\n        \"logLevel_optionWarn\": \"warn\",\n        \"minimizeToTray_description\": \"minimize the application to the system tray\",\n        \"minimizeToTray\": \"minimize to tray\",\n        \"minimumScrobblePercentage_description\": \"the minimum percentage of the song that must be played before it is scrobbled\",\n        \"minimumScrobblePercentage\": \"minimum scrobble duration (percentage)\",\n        \"minimumScrobbleSeconds_description\": \"the minimum duration in seconds of the song that must be played before it is scrobbled\",\n        \"minimumScrobbleSeconds\": \"minimum scrobble (seconds)\",\n        \"mpvExecutablePath_description\": \"sets the path to the mpv executable. if left empty, the default path will be used\",\n        \"mpvExecutablePath\": \"mpv executable path\",\n        \"mpvExtraParameters\": \"mpv extra parameters\",\n        \"mpvExtraParameters_description\": \"extra arguments to pass to mpv\",\n        \"mpvExtraParameters_help\": \"one per line\",\n        \"musicbrainz_description\": \"show links to MusicBrainz on artist/album pages, where MusicBrainz ID exists\",\n        \"musicbrainz\": \"show MusicBrainz links\",\n        \"qobuz_description\": \"show links to Qobuz on artist/album pages\",\n        \"qobuz\": \"show Qobuz links\",\n        \"spotify_description\": \"show links to Spotify on artist/album pages\",\n        \"spotify\": \"show Spotify links\",\n        \"nativeSpotify_description\": \"open in the Spotify app instead of your browser\",\n        \"nativeSpotify\": \"use Spotify app\",\n        \"neteaseTranslation_description\": \"When enabled, fetches and displays translated lyrics from NetEase if available\",\n        \"neteaseTranslation\": \"Enable NetEase translations\",\n        \"notify\": \"enable song notifications\",\n        \"notify_description\": \"show notifications when changing the current song\",\n        \"pathReplace\": \"file path replacement\",\n        \"pathReplace_description\": \"replace your server's default filepath\",\n        \"pathReplace_optionRemovePrefix\": \"remove prefix\",\n        \"pathReplace_optionAddPrefix\": \"add prefix\",\n        \"passwordStore_description\": \"what password/secret store to use. change this if you are having issues storing passwords\",\n        \"passwordStore\": \"passwords/secret store\",\n        \"playerFilters\": \"Filter songs from the queue\",\n        \"playerFilters_description\": \"omit songs from being added to the queue based on the following criteria\",\n        \"playbackStyle_description\": \"select the playback style to use for the audio player\",\n        \"playbackStyle_optionCrossFade\": \"crossfade\",\n        \"playbackStyle_optionNormal\": \"normal\",\n        \"playbackStyle\": \"playback style\",\n        \"playButtonBehavior_description\": \"sets the default behavior of the play button when adding songs to the queue\",\n        \"playButtonBehavior_optionAddLast\": \"$t(player.addLast)\",\n        \"playButtonBehavior_optionAddNext\": \"$t(player.addNext)\",\n        \"playButtonBehavior_optionPlay\": \"$t(player.play)\",\n        \"playButtonBehavior_optionPlayShuffled\": \"$t(player.shuffle)\",\n        \"playButtonBehavior\": \"play button behavior\",\n        \"artistRadioCount_description\": \"sets the number of songs to fetch for artist radio and track radio\",\n        \"artistRadioCount\": \"artist/track radio count\",\n        \"imageResolution\": \"image resolution\",\n        \"imageResolution_description\": \"the resolution for the images used around the app. using a value of 0 will default to the native image resolution\",\n        \"imageResolution_optionTable\": \"table\",\n        \"imageResolution_optionItemCard\": \"item card\",\n        \"imageResolution_optionSidebar\": \"sidebar\",\n        \"imageResolution_optionHeader\": \"header\",\n        \"imageResolution_optionFullScreenPlayer\": \"fullscreen player\",\n        \"playerbarOpenDrawer_description\": \"allows clicking of the playerbar to open the full screen player\",\n        \"playerbarOpenDrawer\": \"playerbar fullscreen toggle\",\n        \"playerbarSlider\": \"playerbar slider\",\n        \"playerbarSlider_description\": \"the waveform is not recommended if on a slow or metered internet connection\",\n        \"playerbarSliderType_optionSlider\": \"slider\",\n        \"playerbarSliderType_optionWaveform\": \"waveform\",\n        \"playerbarWaveformAlign\": \"waveform align\",\n        \"playerbarWaveformAlign_optionTop\": \"top\",\n        \"playerbarWaveformAlign_optionCenter\": \"center\",\n        \"playerbarWaveformAlign_optionBottom\": \"bottom\",\n        \"playerbarWaveformBarWidth\": \"waveform bar width\",\n        \"playerbarWaveformGap\": \"waveform gap\",\n        \"playerbarWaveformRadius\": \"waveform radius\",\n        \"preferLocalLyrics_description\": \"prefer local lyrics over remote lyrics when available\",\n        \"preferLocalLyrics\": \"prefer local lyrics\",\n        \"showLyricsInSidebar_description\": \"a panel will be added to the attached play queue that displays the lyrics\",\n        \"showLyricsInSidebar\": \"show lyrics in player sidebar\",\n        \"showRatings_description\": \"controls if the star ratings feature shows up in the interface\",\n        \"showRatings\": \"show star ratings\",\n        \"blurExplicitImages\": \"blur explicit images\",\n        \"blurExplicitImages_description\": \"album and song artwork tagged as explicit will be blurred\",\n        \"enableGridMultiSelect\": \"enable grid multi-select\",\n        \"enableGridMultiSelect_description\": \"when enabled, allows selecting multiple items in grid views. when disabled, clicking grid item images will navigate to the item page\",\n        \"showVisualizerInSidebar_description\": \"a panel will be added to the player sidebar that displays the visualizer\",\n        \"showVisualizerInSidebar\": \"show visualizer in player sidebar\",\n        \"combinedLyricsAndVisualizer_description\": \"combine lyrics and visualizer into the same panel\",\n        \"combinedLyricsAndVisualizer\": \"combine lyrics and visualizer in player sidebar\",\n        \"preservePitch_description\": \"preserves pitch when modifying playback speed\",\n        \"preservePitch\": \"preserve pitch\",\n        \"audioFadeOnStatusChange\": \"audio fade on status change\",\n        \"audioFadeOnStatusChange_description\": \"enables fade out and fade in when play/pause status changes\",\n        \"preventSleepOnPlayback_description\": \"prevent the display from sleeping while music is playing\",\n        \"preventSleepOnPlayback\": \"prevent sleep on playback\",\n        \"remotePassword_description\": \"sets the password for the remote control server. These credentials are by default transferred insecurely, so you should use a unique password that you do not care about\",\n        \"remotePassword\": \"remote control server password\",\n        \"remotePort_description\": \"sets the port for the remote control server\",\n        \"remotePort\": \"remote control server port\",\n        \"remoteUsername_description\": \"sets the username for the remote control server. if both username and password are empty, authentication will be disabled\",\n        \"remoteUsername\": \"remote control server username\",\n        \"replayGainClipping_description\": \"Prevent clipping caused by {{ReplayGain}} by automatically lowering the gain\",\n        \"replayGainClipping\": \"{{ReplayGain}} clipping\",\n        \"replayGainFallback_description\": \"gain in db to apply if the file has no {{ReplayGain}} tags\",\n        \"replayGainFallback\": \"{{ReplayGain}} fallback\",\n        \"replayGainMode_description\": \"adjust volume gain according to {{ReplayGain}} values stored in the file metadata\",\n        \"replayGainMode_optionAlbum\": \"$t(entity.album, {\\\"count\\\": 1})\",\n        \"replayGainMode_optionNone\": \"$t(common.none)\",\n        \"replayGainMode_optionTrack\": \"$t(entity.track, {\\\"count\\\": 1})\",\n        \"replayGainMode\": \"{{ReplayGain}} mode\",\n        \"replayGainPreamp_description\": \"adjust the preamp gain applied to the {{ReplayGain}} values\",\n        \"replayGainPreamp\": \"{{ReplayGain}} preamp (dB)\",\n        \"sampleRate_description\": \"select the output sample rate to be used if the sample frequency selected is different from that of the current media. a value less than 8000 will use the default frequency\",\n        \"sampleRate\": \"sample rate\",\n        \"savePlayQueue_description\": \"save the play queue when the application is closed and restore it when the application is opened\",\n        \"savePlayQueue\": \"save play queue\",\n        \"scrobble_description\": \"scrobble plays to your media server\",\n        \"scrobble\": \"scrobble\",\n        \"showSkipButton_description\": \"show or hide the skip buttons on the player bar\",\n        \"showSkipButton\": \"show skip buttons\",\n        \"showSkipButtons_description\": \"show or hide the skip buttons on the player bar\",\n        \"showSkipButtons\": \"show skip buttons\",\n        \"sidebarCollapsedNavigation_description\": \"show or hide the navigation in the collapsed sidebar\",\n        \"sidebarCollapsedNavigation\": \"sidebar (collapsed) navigation\",\n        \"sidebarConfiguration_description\": \"select the items and order in which they appear in the sidebar\",\n        \"sidebarConfiguration\": \"sidebar configuration\",\n        \"playerItemConfiguration_description\": \"configure what items are shown, and in what order, on the fullscreen player\",\n        \"playerItemConfiguration\": \"player item configuration\",\n        \"sidebarPlaylistList_description\": \"show or hide the playlist list in the sidebar\",\n        \"sidebarPlaylistList\": \"sidebar playlist list\",\n        \"sidebarPlaylistSorting_description\": \"allows manual playlist sorting in the sidebar using drag and drop instead of the default server order\",\n        \"sidebarPlaylistSorting\": \"sidebar playlist sorting\",\n        \"sidebarPlaylistListFilterRegex_description\": \"hide playlists in the sidebar that match this regular expression\",\n        \"sidebarPlaylistListFilterRegex_placeholder\": \"e.g. ^Daily Mix.*\",\n        \"sidebarPlaylistListFilterRegex\": \"playlist filter regex\",\n        \"sidePlayQueueStyle_description\": \"sets the style of the side play queue\",\n        \"sidePlayQueueStyle_optionAttached\": \"attached\",\n        \"sidePlayQueueStyle_optionDetached\": \"detached\",\n        \"sidePlayQueueLayout\": \"side play queue layout\",\n        \"sidePlayQueueLayout_description\": \"sets the layout of the attached side play queue\",\n        \"sidePlayQueueLayout_optionHorizontal\": \"horizontal\",\n        \"sidePlayQueueLayout_optionVertical\": \"vertical\",\n        \"mediaSession_description\": \"enables Media Session integration, displaying media controls and metadata in the system volume overlay and lock screen\",\n        \"mediaSession\": \"enable media session\",\n        \"sidePlayQueueStyle\": \"side play queue style\",\n        \"skipDuration_description\": \"sets the duration to skip when using the skip buttons on the player bar\",\n        \"skipDuration\": \"skip duration\",\n        \"skipPlaylistPage_description\": \"when navigating to a playlist, go to the playlist song list page instead of the default page\",\n        \"skipPlaylistPage\": \"skip playlist page\",\n        \"startMinimized_description\": \"start the application in system tray\",\n        \"startMinimized\": \"start minimized\",\n        \"theme_description\": \"sets the theme to use for the application\",\n        \"theme\": \"theme\",\n        \"themeDark_description\": \"sets the dark theme to use for the application\",\n        \"themeDark\": \"theme (dark)\",\n        \"themeLight_description\": \"sets the light theme to use for the application\",\n        \"themeLight\": \"theme (light)\",\n        \"transcode\": \"enable transcoding\",\n        \"transcode_description\": \"enables transcoding to different formats\",\n        \"transcodeBitrate_description\": \"selects the bitrate to transcode. 0 means let the server pick\",\n        \"transcodeBitrate\": \"bitrate to transcode\",\n        \"transcodeFormat_description\": \"selects the format to transcode. leave empty to let the server decide\",\n        \"transcodeFormat\": \"format to transcode\",\n        \"translationApiKey_description\": \"api key for translation (global service endpoint only)\",\n        \"translationApiKey\": \"translation api key\",\n        \"translationApiProvider_description\": \"api provider for translation\",\n        \"translationApiProvider\": \"translation api provider\",\n        \"translationTargetLanguage_description\": \"target language for translation\",\n        \"translationTargetLanguage\": \"translation target language\",\n        \"trayEnabled_description\": \"show/hide tray icon/menu. if disabled, also disables minimize/exit to tray\",\n        \"trayEnabled\": \"show tray\",\n        \"useSystemTheme_description\": \"follow the system-defined light or dark preference\",\n        \"useSystemTheme\": \"use system theme\",\n        \"volumeWheelStep_description\": \"the amount of volume to change when scrolling the mouse wheel on the volume slider\",\n        \"volumeWheelStep\": \"volume wheel step\",\n        \"volumeWidth_description\": \"the width of the volume slider\",\n        \"volumeWidth\": \"volume slider width\",\n        \"webAudio_description\": \"use web audio. this enables advanced features like replaygain. disable if you experience otherwise\",\n        \"webAudio\": \"use web audio\",\n        \"windowBarStyle_description\": \"select the style of the window bar\",\n        \"windowBarStyle\": \"window bar style\",\n        \"zoom_description\": \"sets the zoom percentage for the application\",\n        \"zoom\": \"zoom percentage\",\n        \"queryBuilder\": \"query builder\",\n        \"queryBuilderCustomFields_inputLabel\": \"label\",\n        \"queryBuilderCustomFields_inputTag\": \"tag\",\n        \"queryBuilderCustomFields\": \"custom fields\",\n        \"queryBuilderCustomFields_description\": \"add custom fields to use in query builders\"\n    },\n    \"table\": {\n        \"column\": {\n            \"album\": \"album\",\n            \"albumArtist\": \"album artist\",\n            \"albumCount\": \"$t(entity.album, {\\\"count\\\": 2})\",\n            \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n            \"biography\": \"biography\",\n            \"bitDepth\": \"$t(common.bitDepth)\",\n            \"bitrate\": \"bitrate\",\n            \"bpm\": \"bpm\",\n            \"channels\": \"$t(common.channel, {\\\"count\\\": 2})\",\n            \"codec\": \"$t(common.codec)\",\n            \"comment\": \"comment\",\n            \"dateAdded\": \"date added\",\n            \"discNumber\": \"disc\",\n            \"favorite\": \"favorite\",\n            \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n            \"lastPlayed\": \"last played\",\n            \"path\": \"path\",\n            \"playCount\": \"plays\",\n            \"rating\": \"rating\",\n            \"releaseDate\": \"release date\",\n            \"releaseYear\": \"year\",\n            \"sampleRate\": \"$t(common.sampleRate)\",\n            \"size\": \"$t(common.size)\",\n            \"songCount\": \"$t(entity.track, {\\\"count\\\": 2})\",\n            \"title\": \"title\",\n            \"trackNumber\": \"track\",\n            \"owner\": \"owner\"\n        },\n        \"config\": {\n            \"general\": {\n                \"advancedSettings\": \"advanced settings\",\n                \"autoFitColumns\": \"auto fit columns\",\n                \"autosize\": \"autosize\",\n                \"moveUp\": \"move up\",\n                \"moveDown\": \"move down\",\n                \"pinToLeft\": \"pin to left\",\n                \"pinToRight\": \"pin to right\",\n                \"alignLeft\": \"align left\",\n                \"alignCenter\": \"align center\",\n                \"alignRight\": \"align right\",\n                \"followCurrentSong\": \"follow current song\",\n                \"displayType\": \"display type\",\n                \"gap\": \"$t(common.gap)\",\n                \"itemGap\": \"item gap (px)\",\n                \"itemSize\": \"item size (px)\",\n                \"itemsPerRow\": \"items per row\",\n                \"size\": \"$t(common.size)\",\n                \"size_default\": \"default\",\n                \"size_compact\": \"compact\",\n                \"size_large\": \"large\",\n                \"tableColumns\": \"table columns\",\n                \"pagination\": \"pagination\",\n                \"pagination_itemsPerPage\": \"items per page\",\n                \"pagination_infinite\": \"infinite\",\n                \"pagination_paginate\": \"paginated\",\n                \"alternateRowColors\": \"alternate row colors\",\n                \"horizontalBorders\": \"row borders\",\n                \"rowHoverHighlight\": \"row hover highlight\",\n                \"showHeader\": \"show header\",\n                \"verticalBorders\": \"column borders\"\n            },\n            \"label\": {\n                \"actions\": \"$t(common.action, {\\\"count\\\": 2})\",\n                \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n                \"albumGroup\": \"album group\",\n                \"albumCount\": \"$t(entity.album, {\\\"count\\\": 2})\",\n                \"albumArtist\": \"$t(entity.albumArtist, {\\\"count\\\": 1})\",\n                \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n                \"biography\": \"$t(common.biography)\",\n                \"bitDepth\": \"$t(common.bitDepth)\",\n                \"bitrate\": \"$t(common.bitrate)\",\n                \"bpm\": \"$t(common.bpm)\",\n                \"channels\": \"$t(common.channel, {\\\"count\\\": 2})\",\n                \"codec\": \"$t(common.codec)\",\n                \"composer\": \"composer\",\n                \"dateAdded\": \"date added\",\n                \"discNumber\": \"disc number\",\n                \"duration\": \"$t(common.duration)\",\n                \"favorite\": \"$t(common.favorite)\",\n                \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n                \"genreBadge\": \"$t(entity.genre, {\\\"count\\\": 1}) (badges)\",\n                \"image\": \"image\",\n                \"lastPlayed\": \"last played\",\n                \"note\": \"$t(common.note)\",\n                \"owner\": \"$t(common.owner)\",\n                \"path\": \"$t(common.path)\",\n                \"playCount\": \"play count\",\n                \"rating\": \"$t(common.rating)\",\n                \"releaseDate\": \"release date\",\n                \"rowIndex\": \"row index\",\n                \"sampleRate\": \"$t(common.sampleRate)\",\n                \"size\": \"$t(common.size)\",\n                \"songCount\": \"$t(entity.track, {\\\"count\\\": 2})\",\n                \"title\": \"$t(common.title)\",\n                \"titleArtist\": \"$t(common.title) (artist)\",\n                \"titleCombined\": \"$t(common.title) (combined)\",\n                \"trackNumber\": \"track number\",\n                \"year\": \"$t(common.year)\"\n            },\n            \"view\": {\n                \"detail\": \"detail\",\n                \"grid\": \"grid\",\n                \"list\": \"list\",\n                \"table\": \"table\"\n            }\n        }\n    },\n    \"dragDropZone\": {\n        \"error_oneFileOnly\": \"Please only select 1 file\",\n        \"error_readingFile\": \"there has been an issue reading the file: {{errorMessage}}\",\n        \"mainText\": \"drop a file here\"\n    },\n    \"visualizer\": {\n        \"visualizerType\": \"Visualizer Type\",\n        \"cyclePresets\": \"Cycle Presets\",\n        \"cycleTime\": \"Cycle Time (seconds)\",\n        \"includeAllPresets\": \"Include All Presets\",\n        \"ignoredPresets\": \"Ignored Presets\",\n        \"selectedPresets\": \"Selected Presets\",\n        \"randomizeNextPreset\": \"Randomize Next Preset\",\n        \"blendTime\": \"Blend Time\",\n        \"presets\": \"Presets\",\n        \"selectPreset\": \"Select Preset\",\n        \"applyPreset\": \"Apply Preset\",\n        \"saveAsPreset\": \"Save as Preset\",\n        \"updatePreset\": \"Update Preset\",\n        \"copyConfiguration\": \"Copy Configuration\",\n        \"pasteConfiguration\": \"Paste Configuration\",\n        \"pasteConfigurationPlaceholder\": \"Paste JSON configuration here...\",\n        \"pasteFromClipboard\": \"Paste from Clipboard\",\n        \"applyConfiguration\": \"Apply Configuration\",\n        \"configCopied\": \"Configuration copied to clipboard\",\n        \"configCopyFailed\": \"Failed to copy configuration\",\n        \"configPasted\": \"Configuration applied successfully\",\n        \"configPasteFailed\": \"Failed to apply configuration. Please check the format.\",\n        \"configPasteReadFailed\": \"Failed to read from clipboard\",\n        \"presetName\": \"Preset Name\",\n        \"presetNamePlaceholder\": \"Enter preset name\",\n        \"general\": \"General\",\n        \"mode\": \"Mode\",\n        \"mode1To8\": \"Mode 1 - 8\",\n        \"mode10\": \"Mode 10\",\n        \"barSpace\": \"Bar Space\",\n        \"lineWidth\": \"Line Width\",\n        \"fillAlpha\": \"Fill Alpha\",\n        \"channelLayout\": \"Channel Layout\",\n        \"maxFPS\": \"Max FPS\",\n        \"opacity\": \"Opacity\",\n        \"customGradients\": \"Custom Gradients\",\n        \"addCustomGradient\": \"Add Custom Gradient\",\n        \"gradientName\": \"Gradient Name\",\n        \"gradientNamePlaceholder\": \"Gradient Name\",\n        \"vertical\": \"Vertical\",\n        \"horizontal\": \"Horizontal\",\n        \"colorStops\": \"Color Stops\",\n        \"addColor\": \"Add Color\",\n        \"position\": \"Position\",\n        \"level\": \"Level\",\n        \"remove\": \"Remove\",\n        \"pasteGradient\": \"Paste Gradient\",\n        \"pasteGradientPlaceholder\": \"Paste gradient JSON here...\",\n        \"custom\": \"Custom\",\n        \"builtIn\": \"Built-in\",\n        \"colors\": \"Colors\",\n        \"colorMode\": \"Color Mode\",\n        \"gradient\": \"Gradient\",\n        \"gradientLeft\": \"Gradient Left\",\n        \"gradientRight\": \"Gradient Right\",\n        \"fft\": \"FFT\",\n        \"fftSize\": \"FFT Size\",\n        \"smoothing\": \"Smoothing\",\n        \"frequencyRangeAndScaling\": \"Frequency range and scaling\",\n        \"minimumFrequency\": \"Minimum Frequency\",\n        \"maximumFrequency\": \"Maximum Frequency\",\n        \"frequencyScale\": \"Frequency Scale\",\n        \"sensitivity\": \"Sensitivity\",\n        \"weightingFilter\": \"Weighting Filter\",\n        \"minimumDecibels\": \"Minimum Decibels\",\n        \"maximumDecibels\": \"Maximum Decibels\",\n        \"linearAmplitude\": \"Linear Amplitude\",\n        \"linearBoost\": \"Linear Boost\",\n        \"peakBehavior\": \"Peak Behavior\",\n        \"showPeaks\": \"Show Peaks\",\n        \"fadePeaks\": \"Fade Peaks\",\n        \"peakLine\": \"Peak Line\",\n        \"gravity\": \"Gravity\",\n        \"peakFadeTime\": \"Peak Fade Time (ms)\",\n        \"peakHoldTime\": \"Peak Hold Time (ms)\",\n        \"radialSpectrum\": \"Radial Spectrum\",\n        \"radial\": \"Radial\",\n        \"radialInvert\": \"Radial Invert\",\n        \"spinSpeed\": \"Spin Speed\",\n        \"radius\": \"Radius\",\n        \"reflexMirror\": \"Reflex Mirror\",\n        \"reflexFit\": \"Reflex Fit\",\n        \"reflexRatio\": \"Reflex Ratio\",\n        \"reflexAlpha\": \"Reflex Alpha\",\n        \"reflexBrightness\": \"Reflex Brightness\",\n        \"mirror\": \"Mirror\",\n        \"miscellaneousSettings\": \"Miscellaneous Settings\",\n        \"alphaBars\": \"Alpha Bars\",\n        \"ansiBands\": \"ANSI Bands\",\n        \"ledBars\": \"LED Bars\",\n        \"trueLeds\": \"True LEDs\",\n        \"lumiBars\": \"Lumi Bars\",\n        \"outlineBars\": \"Outline Bars\",\n        \"roundBars\": \"Round Bars\",\n        \"lowResolution\": \"Low Resolution\",\n        \"splitGradient\": \"Split Gradient\",\n        \"showFPS\": \"Show FPS\",\n        \"showScaleX\": \"Show Scale X\",\n        \"noteLabels\": \"Note Labels\",\n        \"showScaleY\": \"Show Scale Y\",\n        \"options\": {\n            \"mode\": {\n                \"0\": \"[0] Discrete Frequencies\",\n                \"1\": \"[1] 1/24th octave / 240 bands\",\n                \"2\": \"[2] 1/12th octave / 120 bands\",\n                \"3\": \"[3] 1/8th octave / 80 bands\",\n                \"4\": \"[4] 1/6th octave / 60 bands\",\n                \"5\": \"[5] 1/4th octave / 40 bands\",\n                \"6\": \"[6] 1/3rd octave / 30 bands\",\n                \"7\": \"[7] Half octave / 20 bands\",\n                \"8\": \"[8] Full octave / 10 bands\",\n                \"10\": \"[10] Line / Area graph\"\n            },\n            \"colorMode\": {\n                \"gradient\": \"Gradient\",\n                \"barIndex\": \"Bar-Index\",\n                \"barLevel\": \"Bar-Level\"\n            },\n            \"gradient\": {\n                \"classic\": \"Classic\",\n                \"prism\": \"Prism\",\n                \"rainbow\": \"Rainbow\",\n                \"steelblue\": \"Steelblue\",\n                \"orangered\": \"Orangered\"\n            },\n            \"channelLayout\": {\n                \"single\": \"Single\",\n                \"dualCombined\": \"Dual-Combined\",\n                \"dualHorizontal\": \"Dual-Horizontal\",\n                \"dualVertical\": \"Dual-Vertical\"\n            },\n            \"frequencyScale\": {\n                \"none\": \"None\",\n                \"bark\": \"Bark Scale\",\n                \"linear\": \"Linear Scale\",\n                \"log\": \"Log Scale\",\n                \"mel\": \"Mel Scale\"\n            },\n            \"weightingFilter\": {\n                \"none\": \"None\",\n                \"a\": \"A\",\n                \"b\": \"B\",\n                \"c\": \"C\",\n                \"d\": \"D\",\n                \"z\": \"Z\"\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "src/i18n/locales/es.json",
    "content": "{\n    \"player\": {\n        \"repeat_all\": \"repetir todo\",\n        \"stop\": \"detener\",\n        \"repeat\": \"repetir\",\n        \"queue_remove\": \"eliminar seleccionado\",\n        \"playRandom\": \"reproducción aleatoria\",\n        \"skip\": \"saltar\",\n        \"previous\": \"anterior\",\n        \"toggleFullscreenPlayer\": \"activar el reproductor a pantalla completa\",\n        \"skip_back\": \"retroceder\",\n        \"favorite\": \"favorito\",\n        \"next\": \"siguiente\",\n        \"shuffle\": \"Reproducir (mezclado)\",\n        \"playbackFetchNoResults\": \"ninguna canción encontrada\",\n        \"playbackFetchInProgress\": \"cargando canciones…\",\n        \"addNext\": \"Siguiente\",\n        \"playbackSpeed\": \"velocidad de reproducción\",\n        \"playbackFetchCancel\": \"esto está tomando un tiempo... cierra la notificación para cancelar\",\n        \"play\": \"reproducir\",\n        \"repeat_off\": \"repetir desactivado\",\n        \"queue_clear\": \"limpiar cola\",\n        \"muted\": \"silenciado\",\n        \"unfavorite\": \"no favorita\",\n        \"queue_moveToTop\": \"mover seleccionado al final\",\n        \"queue_moveToBottom\": \"mover seleccionado al principio\",\n        \"shuffle_off\": \"mezclar desactivado\",\n        \"addLast\": \"Al final\",\n        \"mute\": \"silencio\",\n        \"skip_forward\": \"saltar hacia delante\",\n        \"pause\": \"pausa\",\n        \"playSimilarSongs\": \"Reproducir canciones similares\",\n        \"viewQueue\": \"ver cola\",\n        \"addLastShuffled\": \"Al final (mezclado)\",\n        \"addNextShuffled\": \"Siguiente (mezclado)\",\n        \"holdToShuffle\": \"Mantener para mezclar\",\n        \"lyrics\": \"Letras\",\n        \"restoreQueueFromServer\": \"Restaurar cola del servidor\",\n        \"saveQueueToServer\": \"Guardar cola en el servidor\",\n        \"artistRadio\": \"Radio de artista\",\n        \"trackRadio\": \"Radio de pista\",\n        \"sleepTimer_minutes\": \"{{count}} min\",\n        \"sleepTimer_hours\": \"{{count}} h\",\n        \"sleepTimer_custom\": \"Personalizado\",\n        \"sleepTimer_setCustom\": \"Configurar temporizador\",\n        \"sleepTimer_cancel\": \"Cancelar temporizador\",\n        \"sleepTimer_timeRemaining\": \"{{time}} restante\",\n        \"sleepTimer_off\": \"Apagado\",\n        \"sleepTimer_endOfSong\": \"Fin de la canción actual\",\n        \"sleepTimer\": \"Temporizador de apagado\",\n        \"albumRadio\": \"Radio del álbum\"\n    },\n    \"setting\": {\n        \"crossfadeStyle_description\": \"selecciona el estilo de crossfade a usar por el reproductor de audio\",\n        \"remotePort_description\": \"establece el puerto para el control remoto del servidor\",\n        \"hotkey_skipBackward\": \"retroceder\",\n        \"replayGainMode_description\": \"ajusta el volumen de ganancia acorde a los valores de {{ReplayGain}} almacenados en los metadatos del archivo\",\n        \"audioDevice_description\": \"selecciona el dispositivo de audio a usar durante la reproducción\",\n        \"theme_description\": \"establece el tema a usar por la aplicación\",\n        \"hotkey_playbackPause\": \"pausa\",\n        \"replayGainFallback\": \"{{ReplayGain}} alternativa\",\n        \"sidebarCollapsedNavigation_description\": \"Muestra u oculta la navegación en la barra lateral contraída\",\n        \"hotkey_volumeUp\": \"subir volumen\",\n        \"skipDuration\": \"duración de salto\",\n        \"discordIdleStatus_description\": \"cuando se activa, actualiza el estado mientras el reproductor está inactivo\",\n        \"showSkipButtons\": \"mostrar botones de saltar\",\n        \"playButtonBehavior_optionPlay\": \"$t(player.play)\",\n        \"minimumScrobblePercentage\": \"mínima duración de scrobble (porcentaje)\",\n        \"lyricFetch\": \"busca letras en Internet\",\n        \"scrobble\": \"scrobble\",\n        \"skipDuration_description\": \"establece la duración a saltar cuando se usa los botones de saltar en la barra del reproductor\",\n        \"enableRemote_description\": \"activa el control remoto del servidor para permitir a otros dispositivos controlar la aplicación\",\n        \"fontType_optionSystem\": \"fuente del sistema\",\n        \"mpvExecutablePath_description\": \"establece la ruta del ejecutable mpv. si se deja vacío, se usará la ruta predeterminada\",\n        \"replayGainClipping_description\": \"previene el recorte causado por {{ReplayGain}} bajando automáticamente la ganancia\",\n        \"replayGainPreamp\": \"preamplificador (dB) de {{ReplayGain}}\",\n        \"hotkey_favoriteCurrentSong\": \"$t(common.currentSong) favorita\",\n        \"sampleRate\": \"ratio de muestreo\",\n        \"sidePlayQueueStyle_optionAttached\": \"acoplada\",\n        \"sidebarConfiguration\": \"configuración de la barra lateral\",\n        \"sampleRate_description\": \"selecciona el ratio de muestreo de salida a ser usado si la frecuencia de muestreo seleccionada es diferente de la del medio actual. un valor inferior a 8000 usará la frecuencia predeterminada\",\n        \"replayGainMode_optionNone\": \"$t(common.none)\",\n        \"replayGainClipping\": \"recortar {{ReplayGain}}\",\n        \"hotkey_zoomIn\": \"ampliar\",\n        \"scrobble_description\": \"hace scrobble de las reproducciones en tu servidor de medios\",\n        \"audioExclusiveMode_description\": \"activa el modo de audio exclusivo. En este modo, el sistema es normalmente bloqueado, y solo se permitirá mpv en la salida de audio\",\n        \"discordUpdateInterval\": \"intervalo de actualización del estado de actividad de {{discord}}\",\n        \"themeLight\": \"tema (claro)\",\n        \"fontType_optionBuiltIn\": \"fuente incorporada\",\n        \"hotkey_playbackPlayPause\": \"play / pausa\",\n        \"hotkey_rate1\": \"calificar con 1 estrella\",\n        \"hotkey_skipForward\": \"saltar hacia delante\",\n        \"disableLibraryUpdateOnStartup\": \"desactiva la comprobación de nuevas versiones al inicio\",\n        \"discordApplicationId_description\": \"el id de aplicación para el estado de actividad de {{discord}} (por defecto es {{defaultId}})\",\n        \"sidePlayQueueStyle\": \"estilo de la cola de reproducción lateral\",\n        \"gaplessAudio\": \"audio sin pausas\",\n        \"playButtonBehavior_optionAddLast\": \"$t(player.addLast)\",\n        \"minimizeToTray_description\": \"minimiza la aplicación a la bandeja del sistema\",\n        \"hotkey_playbackPlay\": \"reproducir\",\n        \"hotkey_togglePreviousSongFavorite\": \"cambia $t(common.previousSong) a favorito\",\n        \"hotkey_volumeDown\": \"bajar volumen\",\n        \"hotkey_unfavoritePreviousSong\": \"$t(common.previousSong) no favorita\",\n        \"audioPlayer_description\": \"selecciona el reproductor de audio a usar durante la reproducción\",\n        \"globalMediaHotkeys\": \"teclas de acceso rápido globales a medios\",\n        \"hotkey_globalSearch\": \"búsqueda global\",\n        \"gaplessAudio_description\": \"establece la configuración de audio sin pausas para mpv\",\n        \"remoteUsername_description\": \"establece el nombre de usuario para el control remoto del servidor. si el usuario y la contraseña están vacíos, la autenticación será deshabilitada\",\n        \"exitToTray_description\": \"sale de la aplicación a la bandeja del sistema\",\n        \"followLyric_description\": \"desplaza la letra a la posición de reproducción actual\",\n        \"hotkey_favoritePreviousSong\": \"$t(common.previousSong) favorita\",\n        \"replayGainMode_optionAlbum\": \"$t(entity.album, {\\\"count\\\": 1})\",\n        \"lyricOffset\": \"desfase de letra (ms)\",\n        \"discordUpdateInterval_description\": \"el tiempo en segundos entre cada actualización (mínimo 15 segundos)\",\n        \"fontType_optionCustom\": \"fuente personalizada\",\n        \"themeDark_description\": \"establece el tema oscuro a usar por la aplicación\",\n        \"audioExclusiveMode\": \"modo de audio exclusivo\",\n        \"remotePassword\": \"contraseña del control remoto del servidor\",\n        \"lyricFetchProvider\": \"proveedores para buscar letras\",\n        \"language_description\": \"establece el idioma de la aplicación ($t(common.restartRequired))\",\n        \"playbackStyle_optionCrossFade\": \"crossfade\",\n        \"hotkey_rate3\": \"calificar con 3 estrellas\",\n        \"font\": \"fuente\",\n        \"replayGainMode_optionTrack\": \"$t(entity.track, {\\\"count\\\": 1})\",\n        \"themeLight_description\": \"establece el tema claro a usar por la aplicación\",\n        \"hotkey_toggleFullScreenPlayer\": \"cambia el reproductor a pantalla completa\",\n        \"hotkey_localSearch\": \"búsqueda en la página\",\n        \"hotkey_toggleQueue\": \"cambia la cola\",\n        \"remotePassword_description\": \"establece la contraseña para el control remoto del servidor. Esas credenciales son transferidas de forma insegura por defecto, por lo que deberías usar una contraseña única para que no tengas nada de lo que preocuparte\",\n        \"hotkey_rate5\": \"calificar con 5 estrellas\",\n        \"hotkey_playbackPrevious\": \"pista anterior\",\n        \"showSkipButtons_description\": \"Muestra u oculta los botones de saltar en la barra del reproductor\",\n        \"crossfadeDuration_description\": \"establece la duración del efecto de crossfade\",\n        \"playbackStyle\": \"estilo de reproducción\",\n        \"hotkey_toggleShuffle\": \"alterna aleatorio\",\n        \"theme\": \"tema\",\n        \"playbackStyle_description\": \"selecciona el estilo de reproducción a usar por el reproductor de audio\",\n        \"discordRichPresence_description\": \"activa el estado de reproducción en el estado de actividad de {{discord}}. Las teclas de imagen son: {{icon}}, {{playing}}, y {{paused}}\",\n        \"mpvExecutablePath\": \"ruta del ejecutable mpv\",\n        \"audioDevice\": \"dispositivo de audio\",\n        \"hotkey_rate2\": \"calificar con 2 estrellas\",\n        \"playButtonBehavior_description\": \"establece el comportamiento por defecto del botón de reproducción cuando se añaden canciones a la cola\",\n        \"minimumScrobblePercentage_description\": \"el porcentaje mínimo de la canción que debe ser reproducido antes de hacer scrobble\",\n        \"exitToTray\": \"salir a la bandeja\",\n        \"hotkey_rate4\": \"calificar con 4 estrellas\",\n        \"enableRemote\": \"activar control remoto del servidor\",\n        \"showSkipButton_description\": \"Muestra u oculta los botones de saltar en la barra del reproductor\",\n        \"savePlayQueue\": \"guardar cola de reproducción\",\n        \"minimumScrobbleSeconds_description\": \"la duración mínima en segundos de la canción que debe ser reproducida antes de hacer scrobble\",\n        \"fontType_description\": \"Fuente incorporada selecciona una de las fuentes proporcionadas por feishin. Fuente del sistema te permite seleccionar cualquier fuente proporcionada por tu sistema operativo. Personalizada te permite proporcionar tu propia fuente\",\n        \"playButtonBehavior\": \"comportamiento del botón de reproducción\",\n        \"sidebarPlaylistList_description\": \"Muestra u oculta las listas de reproducción en la barra lateral\",\n        \"sidePlayQueueStyle_description\": \"establece el estilo de la cola de reproducción lateral\",\n        \"replayGainMode\": \"modo de {{ReplayGain}}\",\n        \"playbackStyle_optionNormal\": \"normal\",\n        \"replayGainFallback_description\": \"ganancia en db a aplicar si el archivo no tiene etiquetas de {{ReplayGain}}\",\n        \"replayGainPreamp_description\": \"ajusta la ganancia del preamplificador aplicada a los valores de {{ReplayGain}}\",\n        \"hotkey_toggleRepeat\": \"alterna repetir\",\n        \"lyricOffset_description\": \"desfasa la letra en la cantidad de milisegundos especificada\",\n        \"sidebarConfiguration_description\": \"selecciona los elementos y el orden en que aparecerán en la barra lateral\",\n        \"fontType\": \"tipo de fuente\",\n        \"remotePort\": \"puerto del control remoto del servidor\",\n        \"applicationHotkeys\": \"teclas de acceso rápido de la aplicación\",\n        \"hotkey_playbackNext\": \"pista siguiente\",\n        \"useSystemTheme_description\": \"sigue la preferencia clara u oscura definida por el sistema\",\n        \"playButtonBehavior_optionAddNext\": \"$t(player.addNext)\",\n        \"lyricFetch_description\": \"busca letras en varias fuentes de Internet\",\n        \"lyricFetchProvider_description\": \"Selecciona los proveedores para buscar letras\",\n        \"globalMediaHotkeys_description\": \"activa o desactiva el uso de las teclas de acceso rápidas del sistema a medios para controlar la reproducción\",\n        \"customFontPath\": \"ruta de fuente personalizada\",\n        \"followLyric\": \"seguir la letra actual\",\n        \"crossfadeDuration\": \"duración del crossfade\",\n        \"discordIdleStatus\": \"mostrar estado inactivo en el estado de actividad\",\n        \"sidePlayQueueStyle_optionDetached\": \"separada\",\n        \"audioPlayer\": \"reproductor de audio\",\n        \"hotkey_zoomOut\": \"reducir\",\n        \"hotkey_unfavoriteCurrentSong\": \"$t(common.currentSong) no favorita\",\n        \"hotkey_rate0\": \"Limpiar calificación\",\n        \"discordApplicationId\": \"id de aplicación {{discord}}\",\n        \"applicationHotkeys_description\": \"configura las teclas de acceso rápido de la aplicación. marca la casilla para establecerlas como teclas de acceso rápido globales (solo escritorio)\",\n        \"hotkey_volumeMute\": \"silenciar volumen\",\n        \"hotkey_toggleCurrentSongFavorite\": \"$t(common.currentSong) cambia a favorita\",\n        \"remoteUsername\": \"nombre de usuario del control remoto del servidor\",\n        \"showSkipButton\": \"mostrar botones de saltar\",\n        \"sidebarPlaylistList\": \"listas de reproducción de la barra lateral\",\n        \"minimizeToTray\": \"minimizar a la bandeja\",\n        \"themeDark\": \"tema (oscuro)\",\n        \"sidebarCollapsedNavigation\": \"navegación de barra lateral (contraída)\",\n        \"customFontPath_description\": \"establece la ruta de la fuente personalizada a usar por la aplicación\",\n        \"gaplessAudio_optionWeak\": \"débil (recomendado)\",\n        \"minimumScrobbleSeconds\": \"mínimo scrobble (segundos)\",\n        \"hotkey_playbackStop\": \"parar\",\n        \"font_description\": \"establece la fuente a usar por la aplicación\",\n        \"savePlayQueue_description\": \"guarda la cola de reproducción cuando se cierra la aplicación y la restaura cuando se abre\",\n        \"useSystemTheme\": \"usar tema del sistema\",\n        \"volumeWheelStep_description\": \"la cantidad de volumen a cambiar cuando se desplaza la rueda del ratón en el control deslizante del volumen\",\n        \"zoom\": \"porcentaje de zoom\",\n        \"zoom_description\": \"establece el porcentaje de zoom de la aplicación\",\n        \"volumeWheelStep\": \"paso de rueda del volumen\",\n        \"windowBarStyle\": \"estilo de la barra de ventana\",\n        \"windowBarStyle_description\": \"selecciona el estilo de la barra de ventana\",\n        \"skipPlaylistPage_description\": \"cuando se navega a una lista de reproducción, se va a la página de lista de canciones de la lista de reproducción en lugar de a la página por defecto\",\n        \"accentColor\": \"color de realce\",\n        \"accentColor_description\": \"establece el color de realce de la aplicación\",\n        \"skipPlaylistPage\": \"saltar página de lista de reproducción\",\n        \"hotkey_browserForward\": \"avance\",\n        \"hotkey_browserBack\": \"retroceso\",\n        \"clearCache\": \"Limpiar la caché del navegador\",\n        \"clearQueryCache\": \"Limpiar la caché de Feishin\",\n        \"clearQueryCache_description\": \"Una 'limpieza suave' de Feishin. Esto refrescará las listas de reproducción, los metadatos de las pistas y restablecerá las letras guardadas. Se mantienen los ajustes, credenciales del servidor y las imágenes en caché\",\n        \"buttonSize\": \"tamaño del botón de la barra de reproducción\",\n        \"clearCache_description\": \"Una 'limpieza fuerte' de Feishin. Para limpiar la caché de Feishin, vacía la caché del navegador (imágenes guardadas y otros elementos). Se mantienen las credenciales y ajustes del servidor\",\n        \"buttonSize_description\": \"el tamaño de los botones de la barra de reproducción\",\n        \"passwordStore_description\": \"qué método de almacenamiento de contraseñas/claves secretas utilizar. cambia esta opción si tienes problemas para guardar contraseñas\",\n        \"startMinimized_description\": \"inicia la aplicación en la bandeja del sistema\",\n        \"startMinimized\": \"iniciar minimizado\",\n        \"passwordStore\": \"contraseñas/almacenamiento secreto\",\n        \"homeConfiguration\": \"Configuración de la página de inicio\",\n        \"mpvExtraParameters_help\": \"Uno por línea\",\n        \"externalLinks_description\": \"Permite mostrar enlaces externos (Last.fm, MusicBrainz) en las páginas de artistas/álbumes\",\n        \"homeConfiguration_description\": \"Configura qué elementos son mostrados y en qué orden en la página de inicio\",\n        \"clearCacheSuccess\": \"Caché limpiada correctamente\",\n        \"externalLinks\": \"Mostrar enlaces externos\",\n        \"homeFeature\": \"Carrusel destacado de inicio\",\n        \"homeFeature_description\": \"Controla si se muestra el gran carrusel destacado en la página de inicio\",\n        \"imageAspectRatio_description\": \"Si está habilitado, la portada será mostrada usando su relación de aspecto nativa. Para arte que no es 1:1, el espacio restante estará vacío\",\n        \"imageAspectRatio\": \"Usar relación de aspecto nativa de portada\",\n        \"volumeWidth\": \"Ancho del deslizador de volumen\",\n        \"volumeWidth_description\": \"La anchura del deslizador de volumen\",\n        \"discordListening_description\": \"muestra el estado como Escuchando en lugar de Jugando a\",\n        \"discordListening\": \"Mostrar estado como escuchando\",\n        \"contextMenu\": \"Configuración del menú de contexto (clic derecho)\",\n        \"contextMenu_description\": \"Te permite ocultar elementos que son mostrados en el menú cuando haces clic derecho en un elemento. Los elementos que no estén seleccionados se ocultarán\",\n        \"customCssEnable\": \"Habilitar CSS personalizado\",\n        \"customCssEnable_description\": \"Permite escribir CSS personalizado\",\n        \"customCss\": \"CSS personalizado\",\n        \"customCssNotice\": \"Aviso: mientras hay alguna sanitización (rechazar url() y content:), usar CSS personalizado puede aún entrañar riesgos cambiando la interfaz\",\n        \"customCss_description\": \"Content CSS personalizado. Nota: content y urls remotas son propiedades rechazadas. Una vista previa de tu content se muestra debajo. Las entradas adicionales que no estableciste están presentes debido a la sanitización\",\n        \"webAudio\": \"usar audio web\",\n        \"webAudio_description\": \"Utilizar audio web. Esto habilita funciones avanzadas como Replaygain. Desactiva esta opción si tienes problemas\",\n        \"transcode_description\": \"permite la transcodificación a distintos formatos\",\n        \"transcodeBitrate\": \"tasa de bits a transcodificar\",\n        \"transcodeBitrate_description\": \"selecciona el bitrate a transcodificar. 0 significa dejar que el servidor elija\",\n        \"transcodeFormat\": \"formato a transcodificar\",\n        \"transcodeFormat_description\": \"selecciona el formato a transcodificar. dejar vacío para que el servidor decida\",\n        \"albumBackground\": \"imagen de fondo del álbum\",\n        \"albumBackground_description\": \"Añade una imagen de fondo a las páginas de álbumes que contienen la carátula del álbum\",\n        \"albumBackgroundBlur\": \"Tamaño de desenfoque de la imagen de fondo del álbum\",\n        \"albumBackgroundBlur_description\": \"Ajusta la cantidad de desenfoque aplicado a la imagen de fondo del álbum\",\n        \"playerbarOpenDrawer\": \"Cambiar la barra del reproductor a pantalla completa\",\n        \"playerbarOpenDrawer_description\": \"Permite hacer clic en la barra del reproductor para abrir el reproductor a pantalla completa\",\n        \"artistConfiguration\": \"Configuración de la página de artistas del álbum\",\n        \"artistConfiguration_description\": \"Configura qué elementos se muestran y en qué orden en la página de artistas del álbum\",\n        \"playButtonBehavior_optionPlayShuffled\": \"$t(player.shuffle)\",\n        \"trayEnabled\": \"Mostrar en el área de notificación\",\n        \"trayEnabled_description\": \"muestra/oculta el icono/menú del área de notificación. si está deshabilitado, también deshabilita minimizar/salir a la bandeja\",\n        \"translationApiProvider\": \"Proveedor de API de traducción\",\n        \"translationApiProvider_description\": \"Proveedor de API para traducción\",\n        \"translationApiKey\": \"clave api de traducción\",\n        \"translationApiKey_description\": \"Clave API para la traducción (solo para el punto final del servicio global)\",\n        \"translationTargetLanguage\": \"idioma final de la traducción\",\n        \"translationTargetLanguage_description\": \"lengua de destino de la traducción\",\n        \"lastfmApiKey_description\": \"la clave API para {{lastfm}}. Requerida para la portada\",\n        \"lastfmApiKey\": \"Clave API para {{lastfm}}\",\n        \"discordServeImage\": \"Servir imágenes de {{discord}} desde el servidor\",\n        \"discordServeImage_description\": \"Comparte el arte de la portada para el estado de actividad de {{discord}} desde el propio servidor, solo disponible para Jellyfin y Navidrome. {{discord}} usa un bot para obtener las imágenes, por lo que tu servidor debe ser alcanzable desde el Internet público\",\n        \"lastfm\": \"Mostrar enlaces de last.fm\",\n        \"lastfm_description\": \"Muestra enlaces a Last.fm en las páginas de artistas/álbumes\",\n        \"musicbrainz\": \"Mostrar enlaces de MusicBrainz\",\n        \"musicbrainz_description\": \"Muestra enlaces a MusicBrainz en las páginas de artistas/álbumes, donde exista MusicBrainz ID\",\n        \"neteaseTranslation\": \"Activar traducciones de NetEase\",\n        \"neteaseTranslation_description\": \"Cuando se habilita, busca y muestra letras traducidas desde NetEase si está disponible\",\n        \"preferLocalLyrics_description\": \"Prefiere letras locales sobre letras remotas cuando esté disponible\",\n        \"preferLocalLyrics\": \"Preferir letras locales\",\n        \"discordPausedStatus\": \"Mostrar estado de actividad cuando esté en pausa\",\n        \"discordPausedStatus_description\": \"Cuando está activado, el estado mostrará cuando el reproductor esté en pausa\",\n        \"preservePitch\": \"Mantener el tono\",\n        \"preservePitch_description\": \"Mantiene el tono cuando se modifica la velocidad de reproducción\",\n        \"discordDisplayType_songname\": \"Nombre de la canción\",\n        \"discordDisplayType_artistname\": \"Nombre(s) del artista(s)\",\n        \"discordDisplayType_description\": \"Cambia qué estás escuchando en tu estado\",\n        \"discordDisplayType\": \"Tipo de pantalla de actividad de {{discord}}\",\n        \"hotkey_navigateHome\": \"Navegar a inicio\",\n        \"preventSleepOnPlayback\": \"Evitar entrar en reposo durante la reproducción\",\n        \"preventSleepOnPlayback_description\": \"Evita que la pantalla entre en reposo mientras se está reproduciendo música\",\n        \"discordLinkType\": \"Enlaces de estado de {{discord}}\",\n        \"discordLinkType_none\": \"$t(common.none)\",\n        \"discordLinkType_mbz_lastfm\": \"{{musicbrainz}} con {{lastfm}} como alternativa\",\n        \"discordLinkType_description\": \"Añade enlaces externos a {{lastfm}} o {{musicbrainz}} a la canción y campos del artista en el estado de actividad de {{discord}} . {{musicbrainz}} es el más preciso pero requiere etiquetas y no proporciona enlaces del artista mientras que {{lastfm}} debería siempre proporcionar un enlace. No realiza peticiones de red adicionales\",\n        \"artistBackground\": \"imagen de fondo del artista\",\n        \"artistBackgroundBlur\": \"tamaño de desenfoque de imagen de fondo del artista\",\n        \"artistBackgroundBlur_description\": \"ajusta la cantidad de desenfoque aplicado a la imagen de fondo del artista\",\n        \"releaseChannel_optionLatest\": \"Última versión\",\n        \"releaseChannel_optionBeta\": \"Beta\",\n        \"releaseChannel\": \"Canal de lanzamiento\",\n        \"releaseChannel_description\": \"Elige entre lanzamientos estables, beta, o alpha (nightly) para las actualizaciones automáticas\",\n        \"artistBackground_description\": \"Añade una imagen de fondo para las páginas de artistas que contienen el arte de los artistas\",\n        \"mediaSession\": \"Activar sesión de medios\",\n        \"mediaSession_description\": \"Activa la integración de la sesión de medios, mostrando los controles de medios y los metadatos en la superposición del volumen del sistema y en la pantalla de bloqueo\",\n        \"exportImportSettings_control_description\": \"Exporta e importa la configuración a través de JSON\",\n        \"exportImportSettings_control_exportText\": \"exportar configuración\",\n        \"exportImportSettings_control_importText\": \"importar configuración\",\n        \"exportImportSettings_control_title\": \"importar / exportar configuración\",\n        \"exportImportSettings_destructiveWarning\": \"importar la configuración es perjudicial, ¡por favor revisa lo de arriba antes de hacer clic en \\\"importar\\\" abajo!\",\n        \"exportImportSettings_importBtn\": \"importar configuración\",\n        \"exportImportSettings_importModalTitle\": \"importar configuración de feishin\",\n        \"exportImportSettings_importSuccess\": \"¡la configuración ha sido importada con éxito!\",\n        \"exportImportSettings_notValidJSON\": \"el archivo suministrado no es un JSON válido\",\n        \"exportImportSettings_offendingKeyError\": \"\\\"{{offendingKey}}\\\" es incorrecto - {{reason}}\",\n        \"enableAutoTranslation_description\": \"Activa la traducción automáticamente cuando se cargan las letras\",\n        \"enableAutoTranslation\": \"Activar traducción automática\",\n        \"discordRichPresence\": \"Estado de actividad de {{discord}}\",\n        \"crossfadeStyle\": \"Estilo del crossfade\",\n        \"language\": \"Idioma\",\n        \"notify\": \"Activar notificaciones de canciones\",\n        \"notify_description\": \"Muestra notificaciones cuando se cambia la canción actual\",\n        \"transcode\": \"Activar transcodificación\",\n        \"analyticsDisable\": \"Exclusión voluntaria de analíticas basadas en el uso\",\n        \"analyticsDisable_description\": \"Se envía el uso de datos anónimos al desarrollador para ayudar a mejorar la aplicación\",\n        \"playerbarSlider\": \"Barra de reproducción deslizante\",\n        \"playerbarSliderType_optionWaveform\": \"Forma de onda\",\n        \"playerbarWaveformAlign\": \"Alineación de la forma de onda\",\n        \"playerbarSliderType_optionSlider\": \"Deslizador\",\n        \"playerbarWaveformAlign_optionTop\": \"Superior\",\n        \"playerbarWaveformAlign_optionCenter\": \"Centrado\",\n        \"playerbarWaveformAlign_optionBottom\": \"Inferior\",\n        \"playerbarWaveformBarWidth\": \"Ancho de barra de la forma de onda\",\n        \"playerbarWaveformGap\": \"Brecha de la forma de onda\",\n        \"playerbarWaveformRadius\": \"Radio de la forma de onda\",\n        \"showLyricsInSidebar_description\": \"Se añadirá un panel a la cola de reproducción acoplada que muestra las letras\",\n        \"showLyricsInSidebar\": \"Mostrar letras en la barra lateral del reproductor\",\n        \"showVisualizerInSidebar_description\": \"Se añadirá un panel a la barra lateral del reproductor que muestra el visualizador\",\n        \"showVisualizerInSidebar\": \"Mostrar visualizador en la barra lateral del reproductor\",\n        \"queryBuilder\": \"Generador de consultas\",\n        \"queryBuilderCustomFields_inputTag\": \"Etiqueta\",\n        \"queryBuilderCustomFields\": \"Campos personalizados\",\n        \"queryBuilderCustomFields_description\": \"Añade campos personalizados a usar en los generadores de consultas\",\n        \"queryBuilderCustomFields_inputLabel\": \"Rótulo\",\n        \"audioFadeOnStatusChange\": \"Fundido del audio al cambiar de estado\",\n        \"audioFadeOnStatusChange_description\": \"Activa el fundido de salida y el de entrada cuando cambia el estado al reproducir/pausar\",\n        \"followCurrentSong_description\": \"Desplaza automáticamente la cola de reproducción a la canción en reproducción actual\",\n        \"followCurrentSong\": \"Seguir la canción actual\",\n        \"playerFilters\": \"Filtrar las canciones de la cola\",\n        \"playerFilters_description\": \"Omite la adición de canciones a la cola basado en los siguientes criterios\",\n        \"playerbarSlider_description\": \"La forma de onda no es recomendable en una conexión a Internet lenta o medida\",\n        \"autoDJ\": \"DJ automático\",\n        \"autoDJ_description\": \"Añade canciones similares a las de la cola automáticamente\",\n        \"autoDJ_itemCount\": \"Recuento de elementos\",\n        \"autoDJ_itemCount_description\": \"El número de elementos que se ha intentado añadir a la cola cuando DJ automático está activado\",\n        \"autoDJ_timing_description\": \"El número de canciones restantes en la cola antes de que DJ automático se dispare\",\n        \"autoDJ_timing\": \"Tiempo\",\n        \"logLevel\": \"Nivel de registro\",\n        \"logLevel_description\": \"Establece el mínimo nivel de registro a mostrar. Depuración muestra todos los registros, error solo muestra errores\",\n        \"logLevel_optionDebug\": \"Depuración\",\n        \"logLevel_optionError\": \"Error\",\n        \"logLevel_optionInfo\": \"Información\",\n        \"logLevel_optionWarn\": \"Advertencia\",\n        \"useThemeAccentColor\": \"Usar color de acentuación de tema\",\n        \"useThemeAccentColor_description\": \"Usa el color principal definido en el tema seleccionado en lugar del color de acentuación personalizado\",\n        \"artistRadioCount_description\": \"Establece el número de canciones a buscar para la radio de artista y de pista\",\n        \"artistRadioCount\": \"Recuento de radio de artista/pista\",\n        \"imageResolution\": \"Resolución de imagen\",\n        \"imageResolution_description\": \"La resolución de las imágenes usadas en la aplicación. Usar un valor de 0 lo dejará de forma predeterminada a la resolución nativa de la imagen\",\n        \"imageResolution_optionTable\": \"Tabla\",\n        \"imageResolution_optionItemCard\": \"Tarjeta de elemento\",\n        \"imageResolution_optionSidebar\": \"Barra lateral\",\n        \"imageResolution_optionHeader\": \"Cabecera\",\n        \"imageResolution_optionFullScreenPlayer\": \"Reproductor a pantalla completa\",\n        \"showRatings_description\": \"Controla si la característica de calificación de estrellas aparece en la interfaz\",\n        \"showRatings\": \"Mostrar calificación de estrellas\",\n        \"combinedLyricsAndVisualizer_description\": \"Combina letras y visualizador en el mismo panel\",\n        \"combinedLyricsAndVisualizer\": \"Combinar letras y visualizador en la barra lateral del reproductor\",\n        \"artistReleaseTypeConfiguration\": \"Configuración de tipo de lanzamiento de artista\",\n        \"artistReleaseTypeConfiguration_description\": \"Configura qué tipos de lanzamiento son mostrados, y en qué orden, en la página de artistas del álbum\",\n        \"mpvExtraParameters\": \"Parámetros adicionales de MPV\",\n        \"mpvExtraParameters_description\": \"Argumentos adicionales a pasar a MPV\",\n        \"hotkey_listPlayDefault\": \"Reproducir lista\",\n        \"hotkey_listPlayLast\": \"Reproducir lista al final\",\n        \"hotkey_listPlayNext\": \"Reproducir lista a continuación\",\n        \"hotkey_listPlayNow\": \"Reproducir lista ahora\",\n        \"hotkey_listNavigateToPage\": \"Navegar por la lista hasta la página del elemento\",\n        \"pathReplace_description\": \"Reemplaza la ruta de archivo predeterminada de tu servidor\",\n        \"pathReplace\": \"Reemplazo de la ruta de archivo\",\n        \"pathReplace_optionRemovePrefix\": \"Eliminar prefijo\",\n        \"pathReplace_optionAddPrefix\": \"Añadir prefijo\",\n        \"homeFeatureStyle\": \"Estilo del carrusel de destacados del inicio\",\n        \"homeFeatureStyle_description\": \"Controla el estilo del carrusel de destacados del inicio\",\n        \"homeFeatureStyle_optionMultiple\": \"Múltiple\",\n        \"homeFeatureStyle_optionSingle\": \"Simple\",\n        \"enableGridMultiSelect\": \"Activar selección múltiple de rejilla\",\n        \"enableGridMultiSelect_description\": \"Cuando está activo, permite seleccionar múltiples elementos en las vistas de rejilla. Cuando está desactivado, hacer clic en las imágenes de los elementos de la rejilla navegará a la página del elemento\",\n        \"sidebarPlaylistSorting\": \"Ordenación de la lista de reproducción de la barra lateral\",\n        \"sidebarPlaylistSorting_description\": \"Permite la ordenación manual de la lista de reproducción en la barra lateral usando arrastrar y soltar en lugar del orden predeterminado del servidor\",\n        \"sidebarPlaylistListFilterRegex\": \"Expresión regular de filtrado de listas de reproducción\",\n        \"sidebarPlaylistListFilterRegex_description\": \"Esconde las listas de reproducción en la barra lateral que coincidan con esta expresión regular\",\n        \"sidebarPlaylistListFilterRegex_placeholder\": \"p. ej. ^Mezcla diaria.*\",\n        \"blurExplicitImages\": \"Desenfocar imágenes explícitas\",\n        \"blurExplicitImages_description\": \"El álbum y la carátula de la canción etiquetados como explícitos serán desenfocados\",\n        \"releaseChannel_optionAlpha\": \"Alpha (nightly)\",\n        \"analyticsEnable\": \"Enviar analíticas basadas en el uso\",\n        \"analyticsEnable_description\": \"Se envían datos de uso anonimizados al desarrollador para ayudar a mejorar la aplicación\",\n        \"automaticUpdates\": \"Actualizaciones automáticas\",\n        \"automaticUpdates_description\": \"Busca e instala actualizaciones automáticamente\",\n        \"discordStateIcon\": \"Mostrar icono de reproducción\",\n        \"discordStateIcon_description\": \"Muestra un icono pequeño de reproducción en el estado de actividad. El icono de pausa se muestra siempre cuando \\\"Mostrar estado de actividad cuando esté en pausa\\\" esté activado\",\n        \"playerItemConfiguration\": \"Configuración de elementos del reproductor\",\n        \"playerItemConfiguration_description\": \"Configura qué elementos se muestran, y en qué orden, en el reproductor a pantalla completa\",\n        \"primaryShade\": \"Tono principal\",\n        \"useThemePrimaryShade\": \"Usar tono principal del tema\",\n        \"useThemePrimaryShade_description\": \"Usa el tono principal definido en el tema seleccionado para las variantes de color primario\",\n        \"primaryShade_description\": \"Sobreescribe el tono principal (0-9) usado para los botones, enlaces, y otros elementos de colores primarios\",\n        \"autosave\": \"Guardar automáticamente la cola de reproducción\",\n        \"autosaveCount\": \"Frecuencia de guardado automática de la cola de reproducción\",\n        \"autosave_description\": \"Permite guardar automáticamente la cola de reproducción en tu servidor. Esto solo es posible cuando se usa Navidrome/Subsonic, y no puedes tener una cola de reproducción mezclada.\",\n        \"autosaveCount_description\": \"Cuántas pistas cambian antes de que la cola sea guardada. 1 (mínimo) quiere decir que todas las canciones cambian\",\n        \"spotify_description\": \"Muestra enlaces a Spotify en las páginas de artistas/álbumes\",\n        \"spotify\": \"Mostrar enlaces de Spotify\",\n        \"nativeSpotify_description\": \"Abre en la aplicación de Spotify en lugar de tu navegador\",\n        \"nativeSpotify\": \"Usar la aplicación de Spotify\",\n        \"listenbrainz\": \"Mostrar enlaces a ListenBrainz\",\n        \"listenbrainz_description\": \"Muestra enlaces a ListenBrainz en las páginas de artistas/álbumes\",\n        \"qobuz_description\": \"Muestra enlaces a Qobuz en las páginas de artistas/álbumes\",\n        \"qobuz\": \"Mostrar enlaces a Qobuz\",\n        \"sidePlayQueueLayout_optionHorizontal\": \"Horizontal\",\n        \"sidePlayQueueLayout_optionVertical\": \"Vertical\",\n        \"sidePlayQueueLayout\": \"Diseño de la cola de reproducción lateral\",\n        \"sidePlayQueueLayout_description\": \"Establece el diseño de la cola de reproducción lateral adjunta\"\n    },\n    \"action\": {\n        \"editPlaylist\": \"editar $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"goToPage\": \"ir a la página\",\n        \"moveToTop\": \"mover al principio\",\n        \"clearQueue\": \"limpiar cola\",\n        \"addToFavorites\": \"añadir a $t(entity.favorite, {\\\"count\\\": 2})\",\n        \"addToPlaylist\": \"añadir a $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"createPlaylist\": \"crear $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"removeFromPlaylist\": \"eliminar de $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"viewPlaylists\": \"ver $t(entity.playlist, {\\\"count\\\": 2})\",\n        \"refresh\": \"$t(common.refresh)\",\n        \"deletePlaylist\": \"eliminar $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"removeFromQueue\": \"eliminar de la cola\",\n        \"deselectAll\": \"desmarcar todo\",\n        \"moveToBottom\": \"mover al final\",\n        \"setRating\": \"establecer calificación\",\n        \"toggleSmartPlaylistEditor\": \"cambiar editor $t(entity.smartPlaylist)\",\n        \"removeFromFavorites\": \"eliminar de $t(entity.favorite, {\\\"count\\\": 2})\",\n        \"openIn\": {\n            \"lastfm\": \"Abrir en Last.fm\",\n            \"musicbrainz\": \"Abrir en MusicBrainz\",\n            \"spotify\": \"Abrir en Spotify\",\n            \"listenbrainz\": \"Abrir en ListenBrainz\",\n            \"qobuz\": \"Abrir en Qobuz\"\n        },\n        \"moveToNext\": \"pasar al siguiente\",\n        \"downloadStarted\": \"Iniciada descarga de {{count}} elementos\",\n        \"moveItems\": \"Mover elementos\",\n        \"shuffle\": \"Mezclar\",\n        \"shuffleAll\": \"Mezclar todo\",\n        \"shuffleSelected\": \"Mezclar seleccionados\",\n        \"viewMore\": \"Ver más\",\n        \"holdToMoveToBottom\": \"Mantener pulsado para desplazar hacia abajo\",\n        \"holdToMoveToTop\": \"Mantener pulsado para desplazar hacia arriba\",\n        \"moveUp\": \"Desplazar hacia arriba\",\n        \"moveDown\": \"Desplazar hacia abajo\",\n        \"createRadioStation\": \"Crear $t(entity.radioStation, {\\\"count\\\": 1})\",\n        \"deleteRadioStation\": \"Borrar $t(entity.radioStation, {\\\"count\\\": 1})\",\n        \"openApplicationDirectory\": \"Abrir directorio de la aplicación\",\n        \"addOrRemoveFromSelection\": \"Añadir o quitar de la selección\",\n        \"selectRangeOfItems\": \"Seleccionar un intervalo de elementos\",\n        \"selectAll\": \"Seleccionar todo\",\n        \"goToCurrent\": \"Ir al elemento actual\"\n    },\n    \"common\": {\n        \"backward\": \"hacia atrás\",\n        \"increase\": \"aumentar\",\n        \"rating\": \"calificación\",\n        \"bpm\": \"bpm\",\n        \"refresh\": \"actualizar\",\n        \"unknown\": \"desconocido\",\n        \"areYouSure\": \"seguro?\",\n        \"edit\": \"editar\",\n        \"favorite\": \"favorito\",\n        \"left\": \"izquierda\",\n        \"save\": \"guardar\",\n        \"right\": \"derecha\",\n        \"currentSong\": \"$t(entity.track, {\\\"count\\\": 1}) actual\",\n        \"collapse\": \"contraer\",\n        \"trackNumber\": \"pista\",\n        \"descending\": \"descendente\",\n        \"add\": \"añadir\",\n        \"ascending\": \"ascendente\",\n        \"dismiss\": \"descartar\",\n        \"year\": \"año\",\n        \"manage\": \"gestionar\",\n        \"limit\": \"limitar\",\n        \"minimize\": \"minimizar\",\n        \"modified\": \"modificado\",\n        \"duration\": \"duración\",\n        \"name\": \"nombre\",\n        \"maximize\": \"maximizar\",\n        \"decrease\": \"reducir\",\n        \"ok\": \"vale\",\n        \"description\": \"descripción\",\n        \"configure\": \"configurar\",\n        \"path\": \"ruta\",\n        \"center\": \"centrar\",\n        \"no\": \"no\",\n        \"owner\": \"propietario\",\n        \"enable\": \"activar\",\n        \"clear\": \"limpiar\",\n        \"forward\": \"hacia delante\",\n        \"delete\": \"eliminar\",\n        \"cancel\": \"cancelar\",\n        \"forceRestartRequired\": \"reiniciar para aplicar cambios... cerrar la notificación para reiniciar\",\n        \"setting_one\": \"configuración\",\n        \"setting_many\": \"configuración\",\n        \"setting_other\": \"configuración\",\n        \"version\": \"versión\",\n        \"title\": \"título\",\n        \"filters\": \"filtros\",\n        \"create\": \"crear\",\n        \"bitrate\": \"tasa de bits\",\n        \"saveAndReplace\": \"guardar y reemplazar\",\n        \"playerMustBePaused\": \"el reproductor debe pausarse\",\n        \"confirm\": \"confirmar\",\n        \"resetToDefault\": \"restablecer al valor predeterminado\",\n        \"home\": \"inicio\",\n        \"comingSoon\": \"próximamente…\",\n        \"reset\": \"restablecer\",\n        \"disable\": \"desactivar\",\n        \"sortOrder\": \"ordenar\",\n        \"none\": \"ninguno\",\n        \"menu\": \"menú\",\n        \"restartRequired\": \"reinicio requerido\",\n        \"previousSong\": \"$t(entity.track, {\\\"count\\\": 1}) anterior\",\n        \"noResultsFromQuery\": \"la petición no devolvió resultados\",\n        \"quit\": \"salir\",\n        \"expand\": \"expandir\",\n        \"search\": \"buscar\",\n        \"saveAs\": \"guardar como\",\n        \"disc\": \"disco\",\n        \"yes\": \"sí\",\n        \"random\": \"aleatorio\",\n        \"size\": \"tamaño\",\n        \"biography\": \"biografía\",\n        \"note\": \"nota\",\n        \"gap\": \"desfase\",\n        \"filter_one\": \"filtro\",\n        \"filter_many\": \"filtros\",\n        \"filter_other\": \"filtros\",\n        \"action_one\": \"acción\",\n        \"action_many\": \"acciones\",\n        \"action_other\": \"acciones\",\n        \"channel_one\": \"Canal\",\n        \"channel_many\": \"Canales\",\n        \"channel_other\": \"Canales\",\n        \"trackPeak\": \"pico de pista\",\n        \"albumPeak\": \"pico del álbum\",\n        \"albumGain\": \"Ganancia de álbum\",\n        \"mbid\": \"ID de MusicBrainz\",\n        \"codec\": \"Códec\",\n        \"close\": \"Cerrar\",\n        \"reload\": \"Recargar\",\n        \"share\": \"Compartir\",\n        \"trackGain\": \"Ganancia de pista\",\n        \"preview\": \"Vista previa\",\n        \"translation\": \"traducción\",\n        \"additionalParticipants\": \"Participantes adicionales\",\n        \"tags\": \"Etiquetas\",\n        \"newVersion\": \"Una nueva versión ha sido instalada ({{version}})\",\n        \"viewReleaseNotes\": \"Ver notas de lanzamiento\",\n        \"bitDepth\": \"Profundidad de bit\",\n        \"sampleRate\": \"Frecuencia de muestreo\",\n        \"explicitStatus\": \"Estado explícito\",\n        \"explicit\": \"Explícito\",\n        \"clean\": \"Limpio\",\n        \"private\": \"Privado\",\n        \"public\": \"Público\",\n        \"recordLabel\": \"Sello discográfico\",\n        \"releaseType\": \"Tipo de lanzamiento\",\n        \"doNotShowAgain\": \"No mostrar esto de nuevo\",\n        \"externalLinks\": \"Enlaces externos\",\n        \"faster\": \"Más rápido\",\n        \"slower\": \"Más lento\",\n        \"sort\": \"Ordenar\",\n        \"gridRows\": \"Filas de la cuadrícula\",\n        \"tableColumns\": \"Columnas de la tabla\",\n        \"itemsMore\": \"{{count}} más\",\n        \"noFilters\": \"Ningún filtro configurado\",\n        \"view\": \"Vista\",\n        \"countSelected\": \"{{count}} seleccionados\",\n        \"retry\": \"Reintentar\",\n        \"mood\": \"Estado de ánimo\",\n        \"example\": \"Ejemplo\",\n        \"filter_single\": \"simple\",\n        \"filter_multiple\": \"multi\",\n        \"rename\": \"Renombrar\",\n        \"newVersionAvailable\": \"Una nueva versión está disponible\",\n        \"numberOfResults\": \"{{numberOfResults}} resultados\"\n    },\n    \"error\": {\n        \"remotePortWarning\": \"reiniciar el servidor para aplicar el nuevo puerto\",\n        \"systemFontError\": \"un error ocurrió cuando se intentó obtener las fuentes del sistema\",\n        \"playbackError\": \"un error ocurrió cuando se intentó reproducir el medio\",\n        \"endpointNotImplementedError\": \"el punto final {{endpoint}} no está implementado para {{serverType}}\",\n        \"remotePortError\": \"un error ocurrió cuando se intentó establecer el puerto del servidor remoto\",\n        \"serverRequired\": \"servidor requerido\",\n        \"authenticationFailed\": \"autenticación fallida\",\n        \"apiRouteError\": \"no se puede encaminar la solicitud\",\n        \"genericError\": \"sucedió un error\",\n        \"credentialsRequired\": \"credenciales requeridas\",\n        \"sessionExpiredError\": \"tu sesión ha expirado\",\n        \"remoteEnableError\": \"un error ocurrió cuando se intentó $t(common.enable) el servidor remoto\",\n        \"localFontAccessDenied\": \"acceso denegado a las fuentes locales\",\n        \"serverNotSelectedError\": \"ningún servidor seleccionado\",\n        \"remoteDisableError\": \"un error ocurrió cuando se intentó $t(common.disable) el servidor remoto\",\n        \"mpvRequired\": \"MPV requerido\",\n        \"audioDeviceFetchError\": \"un error ocurrió cuando se intentó obtener los dispositivos de audio\",\n        \"invalidServer\": \"servidor inválido\",\n        \"loginRateError\": \"demasiados intentos de inicio de sesión, por favor inténtalo en unos segundos\",\n        \"badAlbum\": \"Estás viendo esta página porque esta canción no forma parte de un álbum. Este problema puede ocurrir si tienes una canción en el nivel superior de tu carpeta de música. Jellyfin solo agrupa pistas si están en una carpeta\",\n        \"networkError\": \"Ocurrió un error de red\",\n        \"openError\": \"No se pudo abrir el archivo\",\n        \"badValue\": \"Opción inválida \\\"{{value}}\\\". Este valor ya no existe\",\n        \"notificationDenied\": \"Se denegaron los permisos para notificaciones. Esta configuración no tiene efecto\",\n        \"saveQueueFailed\": \"Error al guardar la cola\",\n        \"multipleServerSaveQueueError\": \"La cola de reproducción tiene una o más canciones que no son del servidor actual. Esto no está soportado\",\n        \"settingsSyncError\": \"Se encontraron discrepancias entre las opciones del renderizador y el proceso principal. Reinicia la aplicación para aplicar los cambios\",\n        \"noNetwork\": \"Servidor no disponible\",\n        \"noNetworkDescription\": \"No se pudo conectar a este servidor\",\n        \"invalidJson\": \"JSON inválido\",\n        \"serverLockSingleServer\": \"Solo se permite un servidor cuando el servidor está bloqueado\",\n        \"playbackPausedDueToError\": \"La reproducción fue pausada debido a un error\"\n    },\n    \"filter\": {\n        \"mostPlayed\": \"más reproducidos\",\n        \"isCompilation\": \"es una compilación\",\n        \"recentlyPlayed\": \"recientemente reproducido\",\n        \"isRated\": \"Está calificado\",\n        \"title\": \"título\",\n        \"rating\": \"calificación\",\n        \"search\": \"buscar\",\n        \"bitrate\": \"tasa de bits\",\n        \"recentlyAdded\": \"recientemente añadido\",\n        \"note\": \"nota\",\n        \"name\": \"nombre\",\n        \"dateAdded\": \"fecha añadida\",\n        \"releaseDate\": \"fecha de lanzamiento\",\n        \"communityRating\": \"calificación de la comunidad\",\n        \"path\": \"ruta\",\n        \"favorited\": \"favoritos\",\n        \"albumArtist\": \"$t(entity.albumArtist, {\\\"count\\\": 1})\",\n        \"isRecentlyPlayed\": \"reproducido recientemente\",\n        \"isFavorited\": \"es favorito\",\n        \"bpm\": \"bpm\",\n        \"releaseYear\": \"año de lanzamiento\",\n        \"disc\": \"disco\",\n        \"biography\": \"biografía\",\n        \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n        \"duration\": \"duración\",\n        \"random\": \"aleatorio\",\n        \"lastPlayed\": \"última reproducción\",\n        \"toYear\": \"hasta el año\",\n        \"fromYear\": \"desde el año\",\n        \"criticRating\": \"calificación de la crítica\",\n        \"trackNumber\": \"pista\",\n        \"comment\": \"comentarios\",\n        \"playCount\": \"número de reproducciones\",\n        \"recentlyUpdated\": \"actualizado recientemente\",\n        \"channels\": \"$t(common.channel, {\\\"count\\\": 2})\",\n        \"owner\": \"$t(common.owner)\",\n        \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n        \"id\": \"id\",\n        \"songCount\": \"número de canciones\",\n        \"isPublic\": \"es público\",\n        \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n        \"albumCount\": \"Número de $t(entity.album, {\\\"count\\\": 2})\",\n        \"explicitStatus\": \"$t(common.explicitStatus)\",\n        \"sortName\": \"Ordenar por nombre\",\n        \"matchAnd\": \"y\",\n        \"matchOr\": \"o\"\n    },\n    \"page\": {\n        \"sidebar\": {\n            \"nowPlaying\": \"reproduciendo\",\n            \"playlists\": \"$t(entity.playlist, {\\\"count\\\": 2})\",\n            \"search\": \"$t(common.search)\",\n            \"tracks\": \"$t(entity.track, {\\\"count\\\": 2})\",\n            \"albums\": \"$t(entity.album, {\\\"count\\\": 2})\",\n            \"genres\": \"$t(entity.genre, {\\\"count\\\": 2})\",\n            \"folders\": \"$t(entity.folder, {\\\"count\\\": 2})\",\n            \"settings\": \"$t(common.setting, {\\\"count\\\": 2})\",\n            \"home\": \"$t(common.home)\",\n            \"artists\": \"$t(entity.artist, {\\\"count\\\": 2})\",\n            \"albumArtists\": \"$t(entity.albumArtist, {\\\"count\\\": 2})\",\n            \"shared\": \"compartido $t(entity.playlist, {\\\"count\\\": 2})\",\n            \"myLibrary\": \"Mi biblioteca\",\n            \"favorites\": \"$t(entity.favorite, {\\\"count\\\": 2})\",\n            \"radio\": \"$t(entity.radioStation, {\\\"count\\\": 2})\",\n            \"collections\": \"Colecciones\"\n        },\n        \"appMenu\": {\n            \"selectServer\": \"seleccionar servidor\",\n            \"version\": \"versión {{version}}\",\n            \"settings\": \"$t(common.setting, {\\\"count\\\": 2})\",\n            \"manageServers\": \"gestionar servidores\",\n            \"expandSidebar\": \"ampliar barra lateral\",\n            \"collapseSidebar\": \"contraer barra lateral\",\n            \"openBrowserDevtools\": \"abrir herramientas de desarrollador del navegador\",\n            \"quit\": \"$t(common.quit)\",\n            \"goBack\": \"retroceder\",\n            \"goForward\": \"avanzar\",\n            \"privateModeOff\": \"Desactivar modo privado\",\n            \"privateModeOn\": \"Activar modo privado\",\n            \"selectMusicFolder\": \"Seleccionar carpeta de música\",\n            \"noMusicFolder\": \"Ninguna carpeta de música seleccionada\",\n            \"multipleMusicFolders\": \"{{count}} carpetas de música seleccionadas\",\n            \"commandPalette\": \"Abrir paleta de comandos\"\n        },\n        \"contextMenu\": {\n            \"addToPlaylist\": \"$t(action.addToPlaylist)\",\n            \"addToFavorites\": \"$t(action.addToFavorites)\",\n            \"setRating\": \"$t(action.setRating)\",\n            \"moveToTop\": \"$t(action.moveToTop)\",\n            \"deletePlaylist\": \"$t(action.deletePlaylist)\",\n            \"moveToBottom\": \"$t(action.moveToBottom)\",\n            \"createPlaylist\": \"$t(action.createPlaylist)\",\n            \"removeFromPlaylist\": \"$t(action.removeFromPlaylist)\",\n            \"removeFromFavorites\": \"$t(action.removeFromFavorites)\",\n            \"addNext\": \"$t(player.addNext)\",\n            \"deselectAll\": \"$t(action.deselectAll)\",\n            \"addLast\": \"$t(player.addLast)\",\n            \"addFavorite\": \"$t(action.addToFavorites)\",\n            \"play\": \"$t(player.play)\",\n            \"numberSelected\": \"{{count}} seleccionado\",\n            \"removeFromQueue\": \"$t(action.removeFromQueue)\",\n            \"shareItem\": \"Compartir elemento\",\n            \"showDetails\": \"Obtener información\",\n            \"playSimilarSongs\": \"$t(player.playSimilarSongs)\",\n            \"download\": \"descargar\",\n            \"playShuffled\": \"$t(player.shuffle)\",\n            \"moveToNext\": \"$t(action.moveToNext)\",\n            \"goToAlbum\": \"Ir a $t(entity.album, {\\\"count\\\": 1})\",\n            \"goToAlbumArtist\": \"Ir a $t(entity.albumArtist, {\\\"count\\\": 1})\",\n            \"moveItems\": \"$t(action.moveItems)\",\n            \"goTo\": \"Ir a\"\n        },\n        \"home\": {\n            \"mostPlayed\": \"más reproducidos\",\n            \"newlyAdded\": \"nuevos lanzamientos añadidos\",\n            \"title\": \"$t(common.home)\",\n            \"explore\": \"explora desde tu biblioteca\",\n            \"recentlyPlayed\": \"reproducidos recientemente\",\n            \"recentlyReleased\": \"Lanzado recientemente\",\n            \"genres\": \"$t(entity.genre, {\\\"count\\\": 2})\"\n        },\n        \"fullscreenPlayer\": {\n            \"upNext\": \"siguiente\",\n            \"config\": {\n                \"dynamicBackground\": \"fondo dinámico\",\n                \"synchronized\": \"sincronizado\",\n                \"followCurrentLyric\": \"seguir la letra actual\",\n                \"opacity\": \"opacidad\",\n                \"lyricSize\": \"tamaño de letra\",\n                \"showLyricProvider\": \"mostrar proveedor de letra\",\n                \"unsynchronized\": \"no sincronizado\",\n                \"lyricAlignment\": \"alineación de letra\",\n                \"useImageAspectRatio\": \"usar ratio de aspecto de imagen\",\n                \"showLyricMatch\": \"mostrar coincidencia de letras\",\n                \"lyricGap\": \"desfase de letra\",\n                \"dynamicImageBlur\": \"tamaño de desenfoque de imagen\",\n                \"dynamicIsImage\": \"habilitar imagen de fondo\",\n                \"lyricOffset\": \"desplazamiento de letras (ms)\"\n            },\n            \"lyrics\": \"letras\",\n            \"related\": \"relacionado\",\n            \"visualizer\": \"visualizador\",\n            \"noLyrics\": \"sin letras\"\n        },\n        \"albumDetail\": {\n            \"moreFromArtist\": \"más de este $t(entity.artist, {\\\"count\\\": 1})\",\n            \"moreFromGeneric\": \"más de {{item}}\",\n            \"released\": \"publicado el\"\n        },\n        \"setting\": {\n            \"playbackTab\": \"reproducción\",\n            \"generalTab\": \"general\",\n            \"hotkeysTab\": \"teclas de acceso rápido\",\n            \"windowTab\": \"ventana\",\n            \"advanced\": \"Avanzado\",\n            \"analytics\": \"Analíticas\",\n            \"updates\": \"Actualización\",\n            \"cache\": \"Caché\",\n            \"application\": \"Aplicación\",\n            \"queryBuilder\": \"Generador de consultas\",\n            \"theme\": \"Tema\",\n            \"controls\": \"Controles\",\n            \"remote\": \"Remoto\",\n            \"exportImport\": \"Importar/Exportar\",\n            \"scrobble\": \"Scrobble\",\n            \"audio\": \"Audio\",\n            \"lyrics\": \"Letras\",\n            \"transcoding\": \"Transcodificación\",\n            \"discord\": \"Discord\",\n            \"sidebar\": \"Barra lateral\",\n            \"playerFilters\": \"Filtros del reproductor\",\n            \"logger\": \"Registrador\",\n            \"lyricsDisplay\": \"Mostrar letras\"\n        },\n        \"albumArtistList\": {\n            \"title\": \"$t(entity.albumArtist, {\\\"count\\\": 2})\"\n        },\n        \"genreList\": {\n            \"title\": \"$t(entity.genre, {\\\"count\\\": 2})\",\n            \"showAlbums\": \"Mostrar $t(entity.genre, {\\\"count\\\": 1}) $t(entity.album, {\\\"count\\\": 2})\",\n            \"showTracks\": \"Mostrar $t(entity.genre, {\\\"count\\\": 1}) $t(entity.track, {\\\"count\\\": 2})\"\n        },\n        \"trackList\": {\n            \"title\": \"$t(entity.track, {\\\"count\\\": 2})\",\n            \"genreTracks\": \"\\\"{{genre}}\\\" $t(entity.track, {\\\"count\\\": 2})\",\n            \"artistTracks\": \"Pistas de {{artist}}\"\n        },\n        \"globalSearch\": {\n            \"commands\": {\n                \"serverCommands\": \"comandos del servidor\",\n                \"goToPage\": \"ir a la página\",\n                \"searchFor\": \"buscar por {{query}}\"\n            },\n            \"title\": \"comandos\"\n        },\n        \"playlistList\": {\n            \"title\": \"$t(entity.playlist, {\\\"count\\\": 2})\"\n        },\n        \"albumList\": {\n            \"title\": \"$t(entity.album, {\\\"count\\\": 2})\",\n            \"genreAlbums\": \"\\\"{{genre}}\\\" $t(entity.album, {\\\"count\\\": 2})\",\n            \"artistAlbums\": \"Álbumes de {{artist}}\"\n        },\n        \"albumArtistDetail\": {\n            \"viewAllTracks\": \"ver todas las $t(entity.track, {\\\"count\\\": 2})\",\n            \"relatedArtists\": \"$t(entity.artist, {\\\"count\\\": 2}) similares\",\n            \"topSongs\": \"mejores canciones\",\n            \"topSongsFrom\": \"las mejores canciones de {{title}}\",\n            \"viewAll\": \"Ver todo\",\n            \"recentReleases\": \"Lanzamientos recientes\",\n            \"viewDiscography\": \"Ver discografía\",\n            \"about\": \"Sobre {{artist}}\",\n            \"appearsOn\": \"Aparece en\",\n            \"groupingTypeAll\": \"Todos los tipos de lanzamiento\",\n            \"groupingTypePrimary\": \"Tipos de lanzamiento principales\",\n            \"favoriteSongs\": \"Canciones favoritas\",\n            \"favoriteSongsFrom\": \"Canciones favoritas de {{title}}\",\n            \"topSongsPersonal\": \"Personal\",\n            \"topSongsCommunity\": \"Comunidad\"\n        },\n        \"itemDetail\": {\n            \"copiedPath\": \"Ruta copiada correctamente\",\n            \"openFile\": \"Mostrar pista en el gestor de archivos\",\n            \"copyPath\": \"Copiar ruta al portapapeles\"\n        },\n        \"playlist\": {\n            \"reorder\": \"la reordenación solo se activa al ordenar por id\"\n        },\n        \"manageServers\": {\n            \"removeServer\": \"eliminar servidor\",\n            \"title\": \"administrar servidores\",\n            \"serverDetails\": \"detalles del servidor\",\n            \"username\": \"nombre de usuario\",\n            \"editServerDetailsTooltip\": \"editar detalles del servidor\",\n            \"url\": \"URL\"\n        },\n        \"favorites\": {\n            \"title\": \"$t(entity.favorite, {\\\"count\\\": 2})\"\n        },\n        \"folderList\": {\n            \"title\": \"$t(entity.folder, {\\\"count\\\": 2})\"\n        },\n        \"radioList\": {\n            \"title\": \"Estaciones de radio\"\n        },\n        \"windowBar\": {\n            \"privateMode\": \"(Modo privado)\",\n            \"paused\": \"(Pausado) \"\n        },\n        \"collections\": {\n            \"overrideExisting\": \"Sobreescribir existente\",\n            \"saveAsCollection\": \"Guardar como colección\"\n        },\n        \"releasenotes\": {\n            \"commitsSinceStable\": \"Actualizaciones desde {{stable}}\",\n            \"noNewCommits\": \"Ninguna nueva actualización en este rango\",\n            \"noStableReleaseToCompare\": \"Ningún lanzamiento estable disponible con el que comparar\"\n        }\n    },\n    \"form\": {\n        \"deletePlaylist\": {\n            \"title\": \"eliminar $t(entity.playlist, {\\\"count\\\": 1})\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) eliminado correctamente\",\n            \"input_confirm\": \"escribe el nombre de $t(entity.playlist, {\\\"count\\\": 1}) para confirmar\"\n        },\n        \"createPlaylist\": {\n            \"input_description\": \"$t(common.description)\",\n            \"title\": \"crear $t(entity.playlist, {\\\"count\\\": 1})\",\n            \"input_public\": \"público\",\n            \"input_name\": \"$t(common.name)\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) creado correctamente\",\n            \"input_owner\": \"$t(common.owner)\"\n        },\n        \"addServer\": {\n            \"title\": \"añadir servidor\",\n            \"input_username\": \"nombre de usuario\",\n            \"input_url\": \"url\",\n            \"input_password\": \"contraseña\",\n            \"input_legacyAuthentication\": \"permitir autenticación heredada\",\n            \"input_name\": \"nombre del servidor\",\n            \"success\": \"servidor añadido correctamente\",\n            \"input_savePassword\": \"guardar contraseña\",\n            \"ignoreSsl\": \"Ignorar SSL ($t(common.restartRequired))\",\n            \"ignoreCors\": \"Ignorar CORS ($t(common.restartRequired))\",\n            \"error_savePassword\": \"un error ocurrió cuando se intentó guardar la contraseña\",\n            \"input_preferInstantMix\": \"Preferir mix instantáneo\",\n            \"input_preferInstantMixDescription\": \"Usa solo el mix instantáneo para obtener canciones similares. Útil si tienes complementos que modifican este comportamiento\",\n            \"input_remoteUrl\": \"URL pública\",\n            \"input_preferRemoteUrl\": \"Preferir URL pública\",\n            \"input_remoteUrlPlaceholder\": \"Opcional: URL pública para características externas\"\n        },\n        \"addToPlaylist\": {\n            \"success\": \"añadido $t(entity.trackWithCount, {\\\"count\\\": {{message}} }) a $t(entity.playlistWithCount, {\\\"count\\\": {{numOfPlaylists}} })\",\n            \"title\": \"añadir a $t(entity.playlist, {\\\"count\\\": 1})\",\n            \"input_skipDuplicates\": \"saltar duplicados\",\n            \"input_playlists\": \"$t(entity.playlist, {\\\"count\\\": 2})\",\n            \"create\": \"Crear $t(entity.playlist, {\\\"count\\\": 1}) {{playlist}}\",\n            \"searchOrCreate\": \"Buscar $t(entity.playlist, {\\\"count\\\": 2}) o escribir para crear uno nuevo\"\n        },\n        \"updateServer\": {\n            \"title\": \"actualizar servidor\",\n            \"success\": \"servidor actualizado correctamente\"\n        },\n        \"lyricSearch\": {\n            \"input_name\": \"$t(common.name)\",\n            \"input_artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n            \"title\": \"buscar letras\"\n        },\n        \"editPlaylist\": {\n            \"title\": \"editar $t(entity.playlist, {\\\"count\\\": 1})\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) actualizada correctamente\",\n            \"publicJellyfinNote\": \"Jellyfin por alguna razón no expone si una lista de reproducción es pública o no. Si deseas que ésta siga siendo pública, por favor ten seleccionada la siguiente entrada\",\n            \"editNote\": \"No se recomiendan las ediciones manuales para grandes listas de reproducción. ¿Seguro que aceptas el riesgo de pérdida de información incurrido por sobrescribir la lista de reproducción existente?\"\n        },\n        \"queryEditor\": {\n            \"input_optionMatchAll\": \"coincidir todos\",\n            \"input_optionMatchAny\": \"coincidir cualquiera\",\n            \"title\": \"Editor de consultas\",\n            \"addRuleGroup\": \"Añadir regla de grupo\",\n            \"removeRuleGroup\": \"Eliminar regla de grupo\",\n            \"resetToDefault\": \"Restablecer al valor predeterminado\",\n            \"clearFilters\": \"Limpiar filtros\"\n        },\n        \"shareItem\": {\n            \"createFailed\": \"No se pudo crear el recurso compartido (¿está habilitado el uso compartido?)\",\n            \"allowDownloading\": \"Permitir la descarga\",\n            \"description\": \"Descripción\",\n            \"setExpiration\": \"Establecer expiración\",\n            \"success\": \"Enlace de compartición copiado al portapapeles (o pulsa aquí para abrir)\",\n            \"expireInvalid\": \"La expiración debe ser en el futuro\",\n            \"copyToClipboard\": \"Copiar al portapapeles: Ctrl+C, Enter\",\n            \"successMustClick\": \"Compartir creado correctamente. Haz clic aquí para abrir\"\n        },\n        \"privateMode\": {\n            \"enabled\": \"Modo privado activado, el estado de reproducción ahora está oculto de integraciones externas\",\n            \"disabled\": \"Modo privado desactivado, el estado de reproducción ahora es visible a las integraciones externas habilitadas\",\n            \"title\": \"Modo privado\"\n        },\n        \"largeFetchConfirmation\": {\n            \"title\": \"Añadir elementos a la cola\",\n            \"description\": \"Esta acción agregará todos los elementos en la vista filtrada actual\"\n        },\n        \"shuffleAll\": {\n            \"title\": \"Reproducir aleatorio\",\n            \"input_genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n            \"input_limit\": \"¿Cuántas canciones?\",\n            \"input_minYear\": \"Del año\",\n            \"input_maxYear\": \"Hasta el año\",\n            \"input_played\": \"Reproducir filtro\",\n            \"input_played_optionAll\": \"Todas las pistas\",\n            \"input_played_optionUnplayed\": \"Solo las pistas sin reproducir\",\n            \"input_played_optionPlayed\": \"Solo las pistas reproducidas\"\n        },\n        \"saveQueue\": {\n            \"success\": \"Cola de reproducción guardada en el servidor\"\n        },\n        \"createRadioStation\": {\n            \"success\": \"Estación de radio creada con éxito\",\n            \"title\": \"Crear estación de radio\",\n            \"input_homepageUrl\": \"URL de la página de inicio\",\n            \"input_name\": \"Nombre\",\n            \"input_streamUrl\": \"URL de la transmisión\"\n        },\n        \"lyricsExport\": {\n            \"export\": \"Exportar letras\",\n            \"input_synced\": \"Exportar letras sincronizadas\",\n            \"input_offset\": \"$t(setting.lyricOffset)\"\n        }\n    },\n    \"table\": {\n        \"column\": {\n            \"rating\": \"calificación\",\n            \"comment\": \"comentarios\",\n            \"album\": \"álbum\",\n            \"favorite\": \"favorito\",\n            \"playCount\": \"reproducciones\",\n            \"albumCount\": \"$t(entity.album, {\\\"count\\\": 2})\",\n            \"releaseYear\": \"año\",\n            \"lastPlayed\": \"última reproducción\",\n            \"biography\": \"biografía\",\n            \"releaseDate\": \"fecha de lanzamiento\",\n            \"bitrate\": \"tasa de bits\",\n            \"title\": \"título\",\n            \"bpm\": \"bpm\",\n            \"dateAdded\": \"fecha de adición\",\n            \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n            \"songCount\": \"$t(entity.track, {\\\"count\\\": 2})\",\n            \"trackNumber\": \"pista\",\n            \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n            \"albumArtist\": \"artista del álbum\",\n            \"path\": \"ruta\",\n            \"discNumber\": \"disco\",\n            \"channels\": \"$t(common.channel, {\\\"count\\\": 2})\",\n            \"size\": \"$t(common.size)\",\n            \"codec\": \"$t(common.codec)\",\n            \"owner\": \"Propietario\",\n            \"bitDepth\": \"$t(common.bitDepth)\",\n            \"sampleRate\": \"$t(common.sampleRate)\"\n        },\n        \"config\": {\n            \"label\": {\n                \"rating\": \"$t(common.rating)\",\n                \"dateAdded\": \"fecha de adición\",\n                \"bpm\": \"$t(common.bpm)\",\n                \"lastPlayed\": \"última reproducción\",\n                \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n                \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n                \"biography\": \"$t(common.biography)\",\n                \"channels\": \"$t(common.channel, {\\\"count\\\": 2})\",\n                \"bitrate\": \"$t(common.bitrate)\",\n                \"actions\": \"$t(common.action, {\\\"count\\\": 2})\",\n                \"albumArtist\": \"$t(entity.albumArtist, {\\\"count\\\": 1})\",\n                \"discNumber\": \"número de disco\",\n                \"releaseDate\": \"fecha de lanzamiento\",\n                \"title\": \"$t(common.title)\",\n                \"duration\": \"$t(common.duration)\",\n                \"titleCombined\": \"$t(common.title) (combinado)\",\n                \"size\": \"$t(common.size)\",\n                \"trackNumber\": \"número de pista\",\n                \"rowIndex\": \"índice de filas\",\n                \"note\": \"$t(common.note)\",\n                \"owner\": \"$t(common.owner)\",\n                \"path\": \"$t(common.path)\",\n                \"playCount\": \"número de reproducciones\",\n                \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n                \"favorite\": \"$t(common.favorite)\",\n                \"year\": \"$t(common.year)\",\n                \"codec\": \"$t(common.codec)\",\n                \"songCount\": \"$t(entity.track, {\\\"count\\\": 2})\",\n                \"albumCount\": \"$t(entity.album, {\\\"count\\\": 2})\",\n                \"genreBadge\": \"$t(entity.genre, {\\\"count\\\": 1}) (insignias)\",\n                \"image\": \"Imagen\",\n                \"bitDepth\": \"$t(common.bitDepth)\",\n                \"sampleRate\": \"$t(common.sampleRate)\",\n                \"titleArtist\": \"$t(common.title) (artista)\",\n                \"composer\": \"Compositor\",\n                \"albumGroup\": \"Grupo del álbum\"\n            },\n            \"general\": {\n                \"gap\": \"$t(common.gap)\",\n                \"tableColumns\": \"columnas de la tabla\",\n                \"autoFitColumns\": \"ajuste automático de columnas\",\n                \"size\": \"$t(common.size)\",\n                \"displayType\": \"tipo de visualización\",\n                \"itemGap\": \"espacio entre elementos (px)\",\n                \"itemSize\": \"tamaño del elemento (px)\",\n                \"followCurrentSong\": \"seguir la canción actual\",\n                \"advancedSettings\": \"Opciones avanzadas\",\n                \"autosize\": \"Autodimensionar\",\n                \"moveUp\": \"Subir\",\n                \"moveDown\": \"Bajar\",\n                \"pinToLeft\": \"Anclar a la izquierda\",\n                \"pinToRight\": \"Anclar a la derecha\",\n                \"alignLeft\": \"Alinear a la izquierda\",\n                \"alignCenter\": \"Alinear al centro\",\n                \"alignRight\": \"Alinear a la derecha\",\n                \"itemsPerRow\": \"Elementos por fila\",\n                \"size_default\": \"Predeterminado\",\n                \"size_compact\": \"Compacto\",\n                \"size_large\": \"Grande\",\n                \"pagination\": \"Paginación\",\n                \"pagination_itemsPerPage\": \"Elementos por página\",\n                \"pagination_infinite\": \"Infinita\",\n                \"pagination_paginate\": \"Paginada\",\n                \"alternateRowColors\": \"Colores de fila alternativos\",\n                \"horizontalBorders\": \"Bordes de fila\",\n                \"verticalBorders\": \"Bordes de columna\",\n                \"rowHoverHighlight\": \"Resaltar al pasar el cursor por la fila\",\n                \"showHeader\": \"Mostrar cabecera\"\n            },\n            \"view\": {\n                \"table\": \"tabla\",\n                \"list\": \"Lista\",\n                \"grid\": \"Cuadrícula\",\n                \"detail\": \"Detalle\"\n            }\n        }\n    },\n    \"entity\": {\n        \"smartPlaylist\": \"$t(entity.playlist, {\\\"count\\\": 1}) inteligente\",\n        \"genre_one\": \"género\",\n        \"genre_many\": \"géneros\",\n        \"genre_other\": \"géneros\",\n        \"playlistWithCount_one\": \"{{count}} lista de reproducción\",\n        \"playlistWithCount_many\": \"{{count}} listas de reproducción\",\n        \"playlistWithCount_other\": \"{{count}} listas de reproducción\",\n        \"playlist_one\": \"lista de reproducción\",\n        \"playlist_many\": \"listas de reproducción\",\n        \"playlist_other\": \"listas de reproducción\",\n        \"artist_one\": \"artista\",\n        \"artist_many\": \"artistas\",\n        \"artist_other\": \"artistas\",\n        \"folderWithCount_one\": \"{{count}} carpeta\",\n        \"folderWithCount_many\": \"{{count}} carpetas\",\n        \"folderWithCount_other\": \"{{count}} carpetas\",\n        \"albumArtist_one\": \"artista del álbum\",\n        \"albumArtist_many\": \"artistas del álbum\",\n        \"albumArtist_other\": \"artistas del álbum\",\n        \"track_one\": \"pista\",\n        \"track_many\": \"pistas\",\n        \"track_other\": \"pistas\",\n        \"albumArtistCount_one\": \"{{count}} artista del álbum\",\n        \"albumArtistCount_many\": \"{{count}} artistas del álbum\",\n        \"albumArtistCount_other\": \"{{count}} artistas del álbum\",\n        \"albumWithCount_one\": \"{{count}} álbum\",\n        \"albumWithCount_many\": \"{{count}} álbumes\",\n        \"albumWithCount_other\": \"{{count}} álbumes\",\n        \"favorite_one\": \"favorito\",\n        \"favorite_many\": \"favoritos\",\n        \"favorite_other\": \"favoritos\",\n        \"artistWithCount_one\": \"{{count}} artista\",\n        \"artistWithCount_many\": \"{{count}} artistas\",\n        \"artistWithCount_other\": \"{{count}} artistas\",\n        \"folder_one\": \"carpeta\",\n        \"folder_many\": \"carpetas\",\n        \"folder_other\": \"carpetas\",\n        \"album_one\": \"álbum\",\n        \"album_many\": \"álbumes\",\n        \"album_other\": \"álbumes\",\n        \"genreWithCount_one\": \"{{count}} género\",\n        \"genreWithCount_many\": \"{{count}} géneros\",\n        \"genreWithCount_other\": \"{{count}} géneros\",\n        \"trackWithCount_one\": \"{{count}} pista\",\n        \"trackWithCount_many\": \"{{count}} pistas\",\n        \"trackWithCount_other\": \"{{count}} pistas\",\n        \"play_one\": \"{{count}} reproducción\",\n        \"play_many\": \"{{count}} reproducciones\",\n        \"play_other\": \"{{count}} reproducciones\",\n        \"song_one\": \"canción\",\n        \"song_many\": \"canciones\",\n        \"song_other\": \"canciones\",\n        \"radioStation_one\": \"Estación de radio\",\n        \"radioStation_many\": \"Estaciones de radio\",\n        \"radioStation_other\": \"Estaciones de radio\",\n        \"radioStationWithCount_one\": \"{{count}} estación de radio\",\n        \"radioStationWithCount_many\": \"{{count}} estaciones de radio\",\n        \"radioStationWithCount_other\": \"{{count}} estaciones de radio\"\n    },\n    \"dragDropZone\": {\n        \"error_oneFileOnly\": \"Por favor selecciona un solo archivo\",\n        \"error_readingFile\": \"Ha habido un problema leyendo el archivo: {{errorMessage}}\",\n        \"mainText\": \"Arrastra un archivo aquí\"\n    },\n    \"releaseType\": {\n        \"primary\": {\n            \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n            \"broadcast\": \"Emisión\",\n            \"ep\": \"EP\",\n            \"other\": \"Otro\",\n            \"single\": \"Sencillo\"\n        },\n        \"secondary\": {\n            \"audiobook\": \"Audiolibro\",\n            \"audioDrama\": \"Audio drama\",\n            \"compilation\": \"Compilación\",\n            \"djMix\": \"Mezcla del DJ\",\n            \"fieldRecording\": \"Grabación de campo\",\n            \"interview\": \"Entrevista\",\n            \"live\": \"En vivo\",\n            \"mixtape\": \"Recopilatorio\",\n            \"remix\": \"Remix\",\n            \"soundtrack\": \"Banda sonora\",\n            \"spokenWord\": \"Palabra hablada\",\n            \"demo\": \"Maqueta\"\n        }\n    },\n    \"queryBuilder\": {\n        \"standardTags\": \"Etiquetas estándar\",\n        \"customTags\": \"Etiquetas personalizadas\"\n    },\n    \"filterOperator\": {\n        \"after\": \"es después\",\n        \"afterDate\": \"es después (fecha)\",\n        \"before\": \"es antes\",\n        \"beforeDate\": \"es antes (fecha)\",\n        \"contains\": \"contiene\",\n        \"endsWith\": \"termina con\",\n        \"inPlaylist\": \"está en\",\n        \"inTheLast\": \"está en el último\",\n        \"inTheRange\": \"está en el rango\",\n        \"inTheRangeDate\": \"está en el rango (fecha)\",\n        \"is\": \"es\",\n        \"isNot\": \"no es\",\n        \"isGreaterThan\": \"es mayor que\",\n        \"isLessThan\": \"es menor que\",\n        \"notContains\": \"no contiene\",\n        \"notInPlaylist\": \"no está en\",\n        \"notInTheLast\": \"no está en el último\",\n        \"startsWith\": \"empieza con\",\n        \"matchesRegex\": \"coincide con expresión regular\"\n    },\n    \"datetime\": {\n        \"minuteShort\": \"m\",\n        \"secondShort\": \"s\",\n        \"hourShort\": \"h\",\n        \"dayShort\": \"d\"\n    },\n    \"visualizer\": {\n        \"visualizerType\": \"Tipo de visualizador\",\n        \"copyConfiguration\": \"Copiar configuración\",\n        \"pasteConfiguration\": \"Pegar configuración\",\n        \"pasteConfigurationPlaceholder\": \"Pegar configuración de JSON aquí...\",\n        \"pasteFromClipboard\": \"Pegar desde el portapapeles\",\n        \"applyConfiguration\": \"Aplicar configuración\",\n        \"configCopied\": \"Configuración copiada al portapapeles\",\n        \"configCopyFailed\": \"Error al copiar la configuración\",\n        \"configPasted\": \"Configuración aplicada con éxito\",\n        \"configPasteFailed\": \"Error al aplicar la configuración. Por favor revisa el formato.\",\n        \"configPasteReadFailed\": \"Error al leer del portapapeles\",\n        \"general\": \"General\",\n        \"mode\": \"Modo\",\n        \"mode1To8\": \"Modo 1 - 8\",\n        \"mode10\": \"Modo 10\",\n        \"barSpace\": \"Espacio de barra\",\n        \"lineWidth\": \"Ancho de línea\",\n        \"maxFPS\": \"FPS máximos\",\n        \"opacity\": \"Opacidad\",\n        \"channelLayout\": \"Diseño del canal\",\n        \"fillAlpha\": \"Rellenar alfa\",\n        \"customGradients\": \"Degradados personalizados\",\n        \"addCustomGradient\": \"Añadir degradado personalizado\",\n        \"gradientName\": \"Nombre del degradado\",\n        \"gradientNamePlaceholder\": \"Nombre del degradado\",\n        \"vertical\": \"Vertical\",\n        \"horizontal\": \"Horizontal\",\n        \"addColor\": \"Añadir color\",\n        \"colorStops\": \"Paradas de color\",\n        \"position\": \"Posición\",\n        \"level\": \"Nivel\",\n        \"remove\": \"Eliminar\",\n        \"custom\": \"Personalizado\",\n        \"builtIn\": \"Integrado\",\n        \"colors\": \"Colores\",\n        \"colorMode\": \"Modo de color\",\n        \"gradient\": \"Degradado\",\n        \"gradientLeft\": \"Izquierda del degradado\",\n        \"gradientRight\": \"Derecha del degradado\",\n        \"fft\": \"FFT\",\n        \"fftSize\": \"Tamaño del FFT\",\n        \"smoothing\": \"Suavizado\",\n        \"frequencyRangeAndScaling\": \"Rango de frecuencia y escala\",\n        \"minimumFrequency\": \"Frecuencia mínima\",\n        \"maximumFrequency\": \"Frecuencia máxima\",\n        \"frequencyScale\": \"Escala de frecuencia\",\n        \"sensitivity\": \"Sensibilidad\",\n        \"weightingFilter\": \"Filtro de ponderación\",\n        \"minimumDecibels\": \"Decibelios mínimos\",\n        \"maximumDecibels\": \"Decibelios máximos\",\n        \"linearAmplitude\": \"Amplitud lineal\",\n        \"linearBoost\": \"Aumento lineal\",\n        \"peakBehavior\": \"Comportamiento del pico\",\n        \"showPeaks\": \"Mostrar picos\",\n        \"fadePeaks\": \"Picos desvanecidos\",\n        \"peakLine\": \"Línea del pico\",\n        \"gravity\": \"Gravedad\",\n        \"peakFadeTime\": \"Tiempo de desvanecimiento del pico (ms)\",\n        \"peakHoldTime\": \"Tiempo de espera del pico (ms)\",\n        \"radialSpectrum\": \"Espectro radial\",\n        \"radial\": \"Radial\",\n        \"radialInvert\": \"Invertir radial\",\n        \"spinSpeed\": \"Velocidad de giro\",\n        \"radius\": \"Radio\",\n        \"reflexMirror\": \"Espejo del reflejo\",\n        \"reflexFit\": \"Ajuste del reflejo\",\n        \"reflexRatio\": \"Proporción del reflejo\",\n        \"reflexAlpha\": \"Alfa del reflejo\",\n        \"reflexBrightness\": \"Brillo del reflejo\",\n        \"mirror\": \"Espejo\",\n        \"miscellaneousSettings\": \"Miscelánea\",\n        \"alphaBars\": \"Barras alfa\",\n        \"ansiBands\": \"Bandas ANSI\",\n        \"ledBars\": \"Barras LED\",\n        \"trueLeds\": \"True LED\",\n        \"options\": {\n            \"colorMode\": {\n                \"gradient\": \"Degradado\",\n                \"barLevel\": \"Nivel de barra\",\n                \"barIndex\": \"Índice de barra\"\n            },\n            \"gradient\": {\n                \"classic\": \"Clásico\",\n                \"prism\": \"Prisma\",\n                \"rainbow\": \"Arcoíris\",\n                \"steelblue\": \"Azul acero\",\n                \"orangered\": \"Naranja rojizo\"\n            },\n            \"channelLayout\": {\n                \"single\": \"Sencillo\",\n                \"dualCombined\": \"Doble combinado\",\n                \"dualHorizontal\": \"Doble horizontal\",\n                \"dualVertical\": \"Doble vertical\"\n            },\n            \"frequencyScale\": {\n                \"linear\": \"Escala lineal\",\n                \"none\": \"Ninguna\",\n                \"log\": \"Escala de registro\",\n                \"bark\": \"Escala Bark\",\n                \"mel\": \"Escala Mel\"\n            },\n            \"weightingFilter\": {\n                \"none\": \"Ninguno\",\n                \"a\": \"A\",\n                \"b\": \"B\",\n                \"c\": \"C\",\n                \"d\": \"D\",\n                \"z\": \"Z\"\n            },\n            \"mode\": {\n                \"0\": \"[0] Frecuencias discretas\",\n                \"1\": \"[1] 1/24ª octava / 240 bandas\",\n                \"2\": \"[1] 1/12ª octava / 120 bandas\",\n                \"3\": \"[3] 1/8ª octava / 80 bandas\",\n                \"4\": \"[4] 1/6ª octava / 60 bandas\",\n                \"5\": \"[5] 1/4ª octava / 40 bandas\",\n                \"6\": \"[6] 1/3ª octava / 30 bandas\",\n                \"7\": \"[7] Media octava / 20 bandas\",\n                \"8\": \"[8] Octava completa / 10 bandas\",\n                \"10\": \"[10] Línea / Gráfico de área\"\n            }\n        },\n        \"showFPS\": \"Mostrar FPS\",\n        \"showScaleX\": \"Mostrar escala X\",\n        \"showScaleY\": \"Mostrar escala Y\",\n        \"cyclePresets\": \"Ajustes preestablecidos del ciclo\",\n        \"cycleTime\": \"Tiempo del ciclo (segundos)\",\n        \"includeAllPresets\": \"Incluir todos los ajustes preestablecidos\",\n        \"ignoredPresets\": \"Ajustes preestablecidos ignorados\",\n        \"selectedPresets\": \"Ajustes preestablecidos seleccionados\",\n        \"randomizeNextPreset\": \"Aleatorizar el siguiente ajuste preestablecido\",\n        \"blendTime\": \"Tiempo de mezcla\",\n        \"presets\": \"Ajustes preestablecidos\",\n        \"selectPreset\": \"Seleccionar ajuste preestablecido\",\n        \"applyPreset\": \"Aplicar ajuste preestablecido\",\n        \"saveAsPreset\": \"Guardar como ajuste preestablecido\",\n        \"updatePreset\": \"Actualizar ajuste preestablecido\",\n        \"presetName\": \"Nombre del ajuste preestablecido\",\n        \"presetNamePlaceholder\": \"Introduce el nombre del ajuste preestablecido\",\n        \"pasteGradient\": \"Pegar degradado\",\n        \"pasteGradientPlaceholder\": \"Pegar JSON del degradado aquí...\",\n        \"outlineBars\": \"Barras de contorno\",\n        \"roundBars\": \"Barras redondeadas\",\n        \"lowResolution\": \"Baja resolución\",\n        \"splitGradient\": \"Dividir degradado\",\n        \"noteLabels\": \"Etiquetas de notas\",\n        \"lumiBars\": \"Barras luminiscentes\"\n    }\n}\n"
  },
  {
    "path": "src/i18n/locales/eu.json",
    "content": "{\n    \"action\": {\n        \"deselectAll\": \"deshautatu dena\",\n        \"editPlaylist\": \"editatu $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"goToPage\": \"joan orrira\",\n        \"moveToNext\": \"mugitu hurrengora\",\n        \"moveToBottom\": \"mugitu behera\",\n        \"moveToTop\": \"mugitu gora\",\n        \"refresh\": \"$t(common.refresh)\",\n        \"removeFromFavorites\": \"kendu gogokoetatik\",\n        \"removeFromPlaylist\": \"kendu $t(entity.playlist, {\\\"count\\\": 1})-(e)tik\",\n        \"removeFromQueue\": \"kendu ilaratik\",\n        \"setRating\": \"ezarri balorazioa\",\n        \"toggleSmartPlaylistEditor\": \"txandakatu $t(entity.smartPlaylist) editorea\",\n        \"viewPlaylists\": \"ikusi $t(entity.playlist, {\\\"count\\\": 2})\",\n        \"openIn\": {\n            \"lastfm\": \"Ireki Last.fm-n\",\n            \"musicbrainz\": \"Ireki MusicBrainz-en\"\n        },\n        \"clearQueue\": \"garbitu ilara\",\n        \"createPlaylist\": \"sortu $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"deletePlaylist\": \"ezabatu $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"addToFavorites\": \"gehitu gogokoetara\",\n        \"addToPlaylist\": \"gehitu $t(entity.playlist, {\\\"count\\\": 1})ra\",\n        \"createRadioStation\": \"sortu $t(entity.radioStation, {\\\"count\\\": 1})\",\n        \"deleteRadioStation\": \"ezabatu $t(entity.radioStation, {\\\"count\\\": 1})\",\n        \"viewMore\": \"ikusi gehiago\",\n        \"shuffle\": \"nahastu\",\n        \"selectAll\": \"aukeratu guztiak\",\n        \"downloadStarted\": \"{{count}} elementuren deskarga hasi da\",\n        \"addOrRemoveFromSelection\": \"gehitu edo kendu hautapenetik\",\n        \"selectRangeOfItems\": \"aukeratu elementu sorta bat\",\n        \"shuffleAll\": \"nahastu dena\",\n        \"shuffleSelected\": \"nahastu aukeratutak\",\n        \"moveItems\": \"elementuak mugitu\",\n        \"openApplicationDirectory\": \"ireki aplikazioaren direktorioa\"\n    },\n    \"common\": {\n        \"add\": \"gehitu\",\n        \"additionalParticipants\": \"partaide gehigarriak\",\n        \"newVersion\": \"bertsio berri bat instalatu da ({{version}})\",\n        \"viewReleaseNotes\": \"ikusi argitalpen oharrak\",\n        \"areYouSure\": \"ziur zaude?\",\n        \"ascending\": \"goranzkoa\",\n        \"backward\": \"atzeraka\",\n        \"biography\": \"biografia\",\n        \"close\": \"itxi\",\n        \"codec\": \"kodeka\",\n        \"collapse\": \"tolestu\",\n        \"configure\": \"konfiguratu\",\n        \"confirm\": \"berretsi\",\n        \"create\": \"sortu\",\n        \"currentSong\": \"uneko $t(entity.track, {\\\"count\\\": 1})\",\n        \"decrease\": \"gutxitu\",\n        \"delete\": \"ezabatu\",\n        \"descending\": \"beheranzkoa\",\n        \"description\": \"deskripzioa\",\n        \"disable\": \"desgaitu\",\n        \"disc\": \"diskoa\",\n        \"dismiss\": \"baztertu\",\n        \"duration\": \"iraupena\",\n        \"edit\": \"editatu\",\n        \"enable\": \"gaitu\",\n        \"expand\": \"zabaldu\",\n        \"favorite\": \"gogokoa\",\n        \"filter_one\": \"iragazkia\",\n        \"filter_other\": \"iragazkiak\",\n        \"filters\": \"iragazkiak\",\n        \"forceRestartRequired\": \"berreabiarazi aldaketak aplikatzeko... itxi notifikazioa berreabiarazteko\",\n        \"setting_one\": \"ezarpenak\",\n        \"setting_other\": \"\",\n        \"share\": \"partekatu\",\n        \"action_one\": \"ekintza\",\n        \"action_other\": \"ekintzak\",\n        \"unknown\": \"ezezaguna\",\n        \"version\": \"bertsioa\",\n        \"year\": \"urtea\",\n        \"yes\": \"bai\",\n        \"bitrate\": \"bit-emaria\",\n        \"bpm\": \"bpm\",\n        \"cancel\": \"utzi\",\n        \"center\": \"lerrokatu\",\n        \"channel_one\": \"kanala\",\n        \"channel_other\": \"kanalak\",\n        \"clear\": \"garbitu\",\n        \"forward\": \"aurrerantz\",\n        \"home\": \"etxea\",\n        \"increase\": \"handitu\",\n        \"left\": \"ezkerra\",\n        \"limit\": \"mugatu\",\n        \"manage\": \"kudeatu\",\n        \"maximize\": \"maximizatu\",\n        \"menu\": \"menua\",\n        \"minimize\": \"minimizatu\",\n        \"modified\": \"aldatuta\",\n        \"mbid\": \"MusicBrainz IDa\",\n        \"name\": \"izena\",\n        \"no\": \"ez\",\n        \"none\": \"bat ere ez\",\n        \"noResultsFromQuery\": \"kontsultak ez du emaitzik itzuli\",\n        \"note\": \"oharra\",\n        \"ok\": \"ados\",\n        \"owner\": \"jabea\",\n        \"path\": \"bidea\",\n        \"playerMustBePaused\": \"erreproduzitzailea pausatuta egon behar da\",\n        \"preview\": \"aurrebista\",\n        \"previousSong\": \"aurreko $t(entity.track, {\\\"count\\\": 1})\",\n        \"quit\": \"irten\",\n        \"random\": \"ausazkoa\",\n        \"rating\": \"balorazioa\",\n        \"refresh\": \"freskatu\",\n        \"reload\": \"birkargatu\",\n        \"reset\": \"berrerazi\",\n        \"right\": \"eskuina\",\n        \"save\": \"gorde\",\n        \"search\": \"bilatu\",\n        \"size\": \"tamaina\",\n        \"sortOrder\": \"ordena\",\n        \"tags\": \"etiketak\",\n        \"title\": \"tituloa\",\n        \"trackNumber\": \"pista\",\n        \"translation\": \"itzulpena\",\n        \"albumGain\": \"album irabazpena\",\n        \"bitDepth\": \"bit-sakonera\",\n        \"resetToDefault\": \"lehenetsitako egoerara berrezarri\",\n        \"restartRequired\": \"berrabiarazi behar da\",\n        \"sampleRate\": \"laginketa-tasa\",\n        \"saveAndReplace\": \"gorde eta ordezkatu\",\n        \"saveAs\": \"gorde honela\",\n        \"trackGain\": \"pista irabazpena\",\n        \"comingSoon\": \"laster…\",\n        \"trackPeak\": \"pistaren gailurra\",\n        \"albumPeak\": \"albumaren gailurra\",\n        \"gap\": \"hutsunea\",\n        \"explicitStatus\": \"egoera esplizitua\",\n        \"explicit\": \"esplizitua\",\n        \"clean\": \"garbia\",\n        \"private\": \"pribatua\",\n        \"public\": \"publikoa\",\n        \"releaseType\": \"argitalpen mota\",\n        \"countSelected\": \"{{count}} hautatuta\",\n        \"view\": \"ikuspegia\",\n        \"externalLinks\": \"kanpoko estekak\",\n        \"faster\": \"azkarrago\",\n        \"noFilters\": \"ez dago iragazkirik konfiguratuta\",\n        \"retry\": \"saiatu berriro\",\n        \"slower\": \"motelago\",\n        \"itemsMore\": \"{{count}} gehiago\",\n        \"sort\": \"ordenatu\",\n        \"recordLabel\": \"diskoetxea\",\n        \"example\": \"adibidea\",\n        \"tableColumns\": \"taulako zutabeak\",\n        \"doNotShowAgain\": \"ez erakutsi hau berriro\"\n    },\n    \"player\": {\n        \"repeat\": \"errepikatu\",\n        \"play\": \"erreproduzitu\",\n        \"previous\": \"aurrekoa\",\n        \"pause\": \"pausatu\",\n        \"favorite\": \"gogokoa\",\n        \"mute\": \"isilarazi\",\n        \"muted\": \"isilduta\",\n        \"next\": \"hurrengoa\",\n        \"skip\": \"saltatu\",\n        \"stop\": \"gelditu\",\n        \"unfavorite\": \"kendu gogokoetatik\",\n        \"addLast\": \"gehitu azkena\",\n        \"addNext\": \"gehitu hurrengoa\",\n        \"playbackFetchInProgress\": \"abestiak kargatzen…\",\n        \"playbackSpeed\": \"erreprodukzio-abiadura\",\n        \"playRandom\": \"erreproduzitu auzaz\",\n        \"playbackFetchNoResults\": \"ez da abestirik aurkitu\",\n        \"playSimilarSongs\": \"erreproduzitu antzeko abestiak\",\n        \"queue_clear\": \"garbitu ilara\",\n        \"queue_moveToBottom\": \"gora eraman hautatutakoak\",\n        \"queue_moveToTop\": \"behera eraman hautatutakoak\",\n        \"queue_remove\": \"kendu hautatutakoak\",\n        \"repeat_all\": \"errepikatu dena\",\n        \"repeat_off\": \"errepikapena desgaituta\",\n        \"shuffle\": \"erreproduzitu (ausaz)\",\n        \"shuffle_off\": \"auza desgaituta\",\n        \"skip_back\": \"saltatu atzeraka\",\n        \"skip_forward\": \"saltatu aurreraka\",\n        \"toggleFullscreenPlayer\": \"txandakatu pantaila osoko erreproduzitzailea\",\n        \"viewQueue\": \"ikusi ilara\",\n        \"playbackFetchCancel\": \"honek denbora pixka bat behar du... itxi jakinarazpena bertan behera uzteko\",\n        \"lyrics\": \"letrak\",\n        \"restoreQueueFromServer\": \"berrezarri ilara zerbitzaritik\",\n        \"saveQueueToServer\": \"gorde ilara zerbitzarira\",\n        \"addLastShuffled\": \"azkena (ausaz)\",\n        \"addNextShuffled\": \"hurrengoa (ausaz)\",\n        \"artistRadio\": \"artista irratia\",\n        \"trackRadio\": \"pista irratia\"\n    },\n    \"table\": {\n        \"config\": {\n            \"view\": {\n                \"table\": \"taula\",\n                \"list\": \"zerrenda\",\n                \"grid\": \"sareta\"\n            },\n            \"general\": {\n                \"gap\": \"$t(common.gap)\",\n                \"size\": \"$t(common.size)\",\n                \"tableColumns\": \"taula zutabeak\",\n                \"itemSize\": \"elementuaren tamaina (px)\",\n                \"followCurrentSong\": \"jarraitu uneko abestia\",\n                \"size_default\": \"lehenetsia\",\n                \"advancedSettings\": \"ezarpen aurreratuak\",\n                \"autoFitColumns\": \"zutabeak automatikoki doitu\",\n                \"pinToLeft\": \"ezkerrera finkatu\",\n                \"pinToRight\": \"eskuinera finkatu\",\n                \"alignLeft\": \"ezkerrean lerrokatu\",\n                \"alignCenter\": \"lerrokatu erdian\",\n                \"alignRight\": \"eskuinean lerrokatu\",\n                \"itemGap\": \"elementuen arteko tartea (px)\",\n                \"itemsPerRow\": \"elementuak errenkada bakoitzeko\",\n                \"size_large\": \"handia\",\n                \"pagination_itemsPerPage\": \"elementuak orrialde bakoitzeko\",\n                \"pagination_infinite\": \"infinitua\"\n            },\n            \"label\": {\n                \"actions\": \"$t(common.action_other)\",\n                \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n                \"albumArtist\": \"$t(entity.albumArtist, {\\\"count\\\": 1})\",\n                \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n                \"biography\": \"$t(common.biography)\",\n                \"bitrate\": \"$t(common.bitrate)\",\n                \"bpm\": \"$t(common.bpm)\",\n                \"channels\": \"$t(common.channel_other)\",\n                \"codec\": \"$t(common.codec)\",\n                \"duration\": \"$t(common.duration)\",\n                \"favorite\": \"$t(common.favorite)\",\n                \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n                \"note\": \"$t(common.note)\",\n                \"owner\": \"$t(common.owner)\",\n                \"path\": \"$t(common.path)\",\n                \"rating\": \"$t(common.rating)\",\n                \"size\": \"$t(common.size)\",\n                \"songCount\": \"$t(entity.track, {\\\"count\\\": 2})\",\n                \"title\": \"$t(common.title)\",\n                \"year\": \"$t(common.year)\",\n                \"titleCombined\": \"$t(common.title) (batuta)\",\n                \"releaseDate\": \"argitalpen data\",\n                \"playCount\": \"erreprodukzio kopurua\",\n                \"lastPlayed\": \"azken aldiz entzunda\",\n                \"discNumber\": \"disko zenbakia\",\n                \"dateAdded\": \"gehitze data\",\n                \"albumCount\": \"$t(entity.album, {\\\"count\\\": 2})\",\n                \"image\": \"irudia\",\n                \"bitDepth\": \"$t(common.bitDepth)\",\n                \"sampleRate\": \"$t(common.sampleRate)\"\n            }\n        },\n        \"column\": {\n            \"album\": \"albuma\",\n            \"albumCount\": \"$t(entity.album, {\\\"count\\\": 2})\",\n            \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n            \"biography\": \"biografia\",\n            \"bitrate\": \"bit-emaria\",\n            \"channels\": \"$t(common.channel_other)\",\n            \"codec\": \"$t(common.codec)\",\n            \"discNumber\": \"diskoa\",\n            \"favorite\": \"gogokoa\",\n            \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n            \"path\": \"bidea\",\n            \"rating\": \"balorazioa\",\n            \"releaseYear\": \"urtea\",\n            \"size\": \"$t(common.size)\",\n            \"songCount\": \"$t(entity.track, {\\\"count\\\": 2})\",\n            \"title\": \"tituloa\",\n            \"trackNumber\": \"pista\",\n            \"bpm\": \"bpm\",\n            \"comment\": \"iruzkina\",\n            \"playCount\": \"erreprodukzioak\",\n            \"releaseDate\": \"argitalpen data\",\n            \"lastPlayed\": \"azken aldiz entzundakoa\",\n            \"dateAdded\": \"gehitutako data\",\n            \"albumArtist\": \"albumeko artista\",\n            \"owner\": \"jabea\",\n            \"bitDepth\": \"$t(common.bitDepth)\",\n            \"sampleRate\": \"$t(common.sampleRate)\"\n        }\n    },\n    \"entity\": {\n        \"album_one\": \"albuma\",\n        \"album_other\": \"albumak\",\n        \"albumArtist_one\": \"albumaren artista\",\n        \"albumArtist_other\": \"albumaren artistak\",\n        \"albumArtistCount_one\": \"album artista {{count}}\",\n        \"albumArtistCount_other\": \"{{count}} album artista\",\n        \"albumWithCount_one\": \"album {{count}}\",\n        \"albumWithCount_other\": \"{{count}} album\",\n        \"artist_one\": \"artista\",\n        \"artist_other\": \"artistak\",\n        \"artistWithCount_one\": \"artista {{count}}\",\n        \"artistWithCount_other\": \"{{count}} artista\",\n        \"favorite_one\": \"gogokoa\",\n        \"favorite_other\": \"gogokoak\",\n        \"folder_one\": \"karpeta\",\n        \"folder_other\": \"karpetak\",\n        \"folderWithCount_one\": \"karpeta {{count}}\",\n        \"folderWithCount_other\": \"{{count}} karpeta\",\n        \"genre_one\": \"generoa\",\n        \"genre_other\": \"generoak\",\n        \"genreWithCount_one\": \"genero {{count}}generoa\",\n        \"genreWithCount_other\": \"{{count}} genero\",\n        \"playlist_one\": \"erreprodukzio-zerrenda\",\n        \"playlist_other\": \"erreprodukzio-zerrendak\",\n        \"play_one\": \"erreprodukzio {{count}}\",\n        \"play_other\": \"{{count}} erreprodukzio\",\n        \"playlistWithCount_one\": \"erreprodukzio-zerrenda {{count}}\",\n        \"playlistWithCount_other\": \"{{count}} erreprodukzio-zerrenda\",\n        \"smartPlaylist\": \"$t(entity.playlist, {\\\"count\\\": 1}) adimentsua\",\n        \"track_one\": \"pista\",\n        \"track_other\": \"pistak\",\n        \"song_one\": \"abestia\",\n        \"song_other\": \"abestiak\",\n        \"trackWithCount_one\": \"pista {{count}}\",\n        \"trackWithCount_other\": \"{{count}} pista\",\n        \"radioStation_one\": \"irrati-katea\",\n        \"radioStation_other\": \"irrati-kateak\",\n        \"radioStationWithCount_one\": \"irrati-kate {{count}}\",\n        \"radioStationWithCount_other\": \"{{count}} irrati-kate\"\n    },\n    \"error\": {\n        \"apiRouteError\": \"ezin izan da eskaera bideratu\",\n        \"audioDeviceFetchError\": \"errore bat gertatu da audio gailuak lortzen saiatzean\",\n        \"authenticationFailed\": \"autentifikazioa huts egin du\",\n        \"badValue\": \"\\\"{{value}}\\\" aukera baliogabea. Balio hau ez da gehiago existitzen.\",\n        \"credentialsRequired\": \"kredentzialak beharrezkoak dira\",\n        \"endpointNotImplementedError\": \"{{endpoint}} amaiera-puntua ez dago {{serverType}}-(e)rako inplementatuta\",\n        \"genericError\": \"errore bat gertatu da\",\n        \"invalidServer\": \"zerbitzari baliogabea\",\n        \"localFontAccessDenied\": \"tokiko letra-tipoetarako sarbidea ukatuta\",\n        \"mpvRequired\": \"MPV beharrezkoa da\",\n        \"networkError\": \"sareko errore bat gertatu da\",\n        \"openError\": \"ezin izan da fitxategia ireki\",\n        \"playbackError\": \"errore bat gertatu da multimedia erreproduzitzen saiatzean\",\n        \"remoteDisableError\": \"errore bat gertatu da urruneko zerbitzaria $t(common.disable) desgaitzen saiatzean\",\n        \"remoteEnableError\": \"errore bat gertatu da urruneko zerbitzaria $t(common.enable) gaitzen saiatzean\",\n        \"remotePortError\": \"errore bat gertatu da urruneko zerbitzariaren ataka ezartzen saiatzean\",\n        \"remotePortWarning\": \"Berrabiarazi zerbitzaria portu berria aplikatzeko\",\n        \"serverNotSelectedError\": \"ez da zerbitzaririk hautatu\",\n        \"serverRequired\": \"zerbitzaria beharrezkoa da\",\n        \"sessionExpiredError\": \"zure saioa iraungi da\",\n        \"badAlbum\": \"Orrialde hau ikusten ari zara abesti hau album batekoa ez delako. Ziurrenik arazo hau ikusten ari zara zure musika karpetaren goiko mailan abesti bat baduzu. Jellyfinek abestiak karpeta batean badaude taldekatzen ditu bakarrik\",\n        \"loginRateError\": \"Saioa hasteko saiakera gehiegi egin dira, saiatu berriro segundo batzuk barru\",\n        \"notificationDenied\": \"jakinarazpenetarako baimenak ukatu dira. Ezarpen honek ez du eraginik\",\n        \"systemFontError\": \"errore bat gertatu da sistemaren letra-tipoak lortzen saiatzean\",\n        \"noNetwork\": \"zerbitzaria ez dago erabilgarri\",\n        \"noNetworkDescription\": \"ezin izan da zerbitzari honetara konektatu\",\n        \"saveQueueFailed\": \"huts egin du ilara gordetzean\",\n        \"multipleServerSaveQueueError\": \"erreprodukzio-ilarak zerbitzarikoak ez diren abesti bat edo gehiago ditu. hau ez da onartzen\"\n    },\n    \"filter\": {\n        \"disc\": \"diskoa\",\n        \"duration\": \"iraupena\",\n        \"id\": \"id-a\",\n        \"isPublic\": \"publikoa da\",\n        \"name\": \"izena\",\n        \"note\": \"oharra\",\n        \"owner\": \"$t(common.owner)\",\n        \"path\": \"bidea\",\n        \"random\": \"ausazkoa\",\n        \"rating\": \"balorazioa\",\n        \"trackNumber\": \"pista\",\n        \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n        \"albumArtist\": \"$t(entity.albumArtist, {\\\"count\\\": 1})\",\n        \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n        \"biography\": \"biografia\",\n        \"bitrate\": \"bit-emaria\",\n        \"bpm\": \"bpm-ak\",\n        \"channels\": \"$t(common.channel_other)\",\n        \"comment\": \"iruzkina\",\n        \"favorited\": \"gogoko gisa markatua\",\n        \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n        \"search\": \"bilatu\",\n        \"title\": \"tituloa\",\n        \"albumCount\": \"$t(entity.album, {\\\"count\\\": 2}) kopurua\",\n        \"communityRating\": \"komunitatearen balorazioa\",\n        \"criticRating\": \"kritikarien balorazioa\",\n        \"dateAdded\": \"gehitutako data\",\n        \"isCompilation\": \"konpilazioa da\",\n        \"isFavorited\": \"gogokoetan dago\",\n        \"isRated\": \"baloratua dago\",\n        \"isRecentlyPlayed\": \"duela gutxi entzundakoa\",\n        \"lastPlayed\": \"azken aldiz entzundakoa\",\n        \"mostPlayed\": \"gehien entzundakoa\",\n        \"playCount\": \"erreprodukzio kopurua\",\n        \"recentlyAdded\": \"duela gutxi gehitutakoa\",\n        \"recentlyPlayed\": \"duela gutxi entzundakoa\",\n        \"recentlyUpdated\": \"duela gutxi eguneratua\",\n        \"songCount\": \"abesti kopurua\",\n        \"releaseDate\": \"argitalpen data\",\n        \"releaseYear\": \"argitalpen urtea\",\n        \"toYear\": \"urtera arte\",\n        \"fromYear\": \"urtetik aurrera\",\n        \"explicitStatus\": \"$t(common.explicitStatus)\"\n    },\n    \"setting\": {\n        \"hotkey_playbackPause\": \"pausatu\",\n        \"hotkey_playbackPlay\": \"erreproduzitu\",\n        \"playbackStyle_optionNormal\": \"normala\",\n        \"playButtonBehavior_optionPlay\": \"$t(player.play)\",\n        \"playButtonBehavior_optionPlayShuffled\": \"$t(player.shuffle)\",\n        \"replayGainMode_optionAlbum\": \"$t(entity.album, {\\\"count\\\": 1})\",\n        \"replayGainMode_optionNone\": \"$t(common.none)\",\n        \"replayGainMode_optionTrack\": \"$t(entity.track, {\\\"count\\\": 1})\",\n        \"font\": \"letra-tipoa\",\n        \"hotkey_playbackStop\": \"gelditu\",\n        \"buttonSize_description\": \"erreproduzitzailearen barrako botoien tamaina\",\n        \"clearCache\": \"garbitu nabigatzailearen katxea\",\n        \"clearQueryCache\": \"garbitu feishinen katxea\",\n        \"clearCacheSuccess\": \"katxea behar bezala garbitu da\",\n        \"contextMenu\": \"testuinguru-menuaren konfigurazioa (klik eskuineko botoiarekin)\",\n        \"customCssEnable\": \"gaitu css pertsonalizatua\",\n        \"customCssEnable_description\": \"css pertsonalizatua idazteko aukera eman\",\n        \"customCss\": \"css pertsonalizatua\",\n        \"customFontPath\": \"letra-tipo pertsonalizatuaren bidea\",\n        \"customFontPath_description\": \"aplikazioan erabiliko den letra-tipo pertsonalizatuaren bidea ezartzen du\",\n        \"discordApplicationId\": \"{{discord}} aplikazioaren IDa\",\n        \"followLyric\": \"jarraitu uneko letra\",\n        \"font_description\": \"aplikazioan erabiliko den letra-tipoa ezartzen du\",\n        \"fontType\": \"letra-tipo mota\",\n        \"fontType_optionCustom\": \"letra-tipo pertsonalizatua\",\n        \"fontType_optionSystem\": \"sistemaren letra-tipoa\",\n        \"gaplessAudio_optionWeak\": \"ahula (gomendatua)\",\n        \"homeConfiguration\": \"hasierako orriaren konfigurazioa\",\n        \"hotkey_favoriteCurrentSong\": \"$t(common.currentSong) gogokoa\",\n        \"hotkey_favoritePreviousSong\": \"$t(common.previousSong) gogokoa\",\n        \"hotkey_navigateHome\": \"nabigatu etxera\",\n        \"hotkey_playbackNext\": \"hurrengo pista\",\n        \"hotkey_playbackPlayPause\": \"erreproduzitu / pausatu\",\n        \"hotkey_playbackPrevious\": \"aurreko pista\",\n        \"hotkey_skipBackward\": \"saltatu atzeraka\",\n        \"hotkey_skipForward\": \"saltatu aurrerantz\",\n        \"hotkey_toggleCurrentSongFavorite\": \"txandakatu $t(common.currentSong) gogokoa\",\n        \"hotkey_toggleFullScreenPlayer\": \"txandakatu pantaila osoko erreproduzitzailea\",\n        \"hotkey_togglePreviousSongFavorite\": \"txandakatu $t(common.previousSong) gogokoa\",\n        \"hotkey_toggleQueue\": \"txandakatu ilara\",\n        \"hotkey_toggleRepeat\": \"txandakatu errepikapena\",\n        \"hotkey_toggleShuffle\": \"txandakatu auzazkoa\",\n        \"hotkey_unfavoriteCurrentSong\": \"kendu $t(common.currentSong) gogokoetatik\",\n        \"hotkey_unfavoritePreviousSong\": \"kendu $t(common.previousSong) gogokoetatik\",\n        \"hotkey_volumeDown\": \"bolumena jaitsi\",\n        \"hotkey_volumeMute\": \"isilarazi bolumena\",\n        \"hotkey_volumeUp\": \"bolumena igo\",\n        \"hotkey_zoomIn\": \"hurbildu\",\n        \"hotkey_zoomOut\": \"txikiagotu\",\n        \"language_description\": \"aplikazioaren hizkuntza ezartzen du ($t(common.restartRequired))\",\n        \"lastfm\": \"erakutsi last.fm estekak\",\n        \"lastfm_description\": \"erakutsi Last.fm-rako estekak artista/album orrialdeetan\",\n        \"lastfmApiKey\": \"{{lastfm}} API gakoa\",\n        \"lastfmApiKey_description\": \"{{lastfm}}-ren API gakoa. Azaleko arterako beharrezkoa.\",\n        \"lyricFetch\": \"eskuratu letrak internetetik\",\n        \"lyricFetch_description\": \"Eskuratu letrak hainbat internet iturrietatik\",\n        \"audioExclusiveMode_description\": \"gaitu irteera esklusiboko modua. Modu honetan, sistema normalean blokeatuta egoten da, eta mpv-k bakarrik atera ahal izango du audioa\",\n        \"audioDevice_description\": \"aukeratu erreproduzitzeko erabiliko den audio gailua (web erreproduzitzailea bakarrik)\",\n        \"audioPlayer\": \"audio erreproduzitzailea\",\n        \"audioPlayer_description\": \"aukeratu erabiliko den audio erreproduzitzailea\",\n        \"buttonSize\": \"erreproduzitzaile barrako botoien tamaina\",\n        \"crossfadeDuration\": \"crossfade iraupena\",\n        \"crossfadeDuration_description\": \"crossfade efektuaren iraupena ezartzen du\",\n        \"crossfadeStyle_description\": \"aukeratu audio erreproduzitzailearentzat erabiliko den crossfade estiloa\",\n        \"disableLibraryUpdateOnStartup\": \"desgaitu bertsio berrien egiaztapena abiaraztean\",\n        \"discordApplicationId_description\": \"{{discord}} jarduera-egoeraren aplikazioaren IDa (lehenetsia {{defaultId}} da)\",\n        \"discordPausedStatus\": \"erakutsi jarduera-egoera pausatuta dagoenean\",\n        \"discordPausedStatus_description\": \"gaituta dagoenean, egoera agertuko da erreproduzitzailea pausatuta dagoenean\",\n        \"discordIdleStatus\": \"erakutsi inaktibo jarduera-egoeran\",\n        \"discordIdleStatus_description\": \"gaituta dagoenean, eguneratu egoera erreproduzitzailea inaktibo dagoen bitartean\",\n        \"discordListening_description\": \"erakutsi egoera entzuten bezala erreproduzitzen ordez\",\n        \"discordListening\": \"erakutsi egoera entzuten bezala\",\n        \"discordRichPresence_description\": \"gaitu erreprodukzioa egoera {{discord}}-en jarduera-egoeran. Irudi gakoak hauek dira: {{icon}}, {{playing}}, eta {{paused}}\",\n        \"discordServeImage\": \"zerbitzatu {{discord}} irudiak zerbitzaritik\",\n        \"discordServeImage_description\": \"partekatu {{discord}} jarduera-egoerarentzako azala artea zerbitzaritik bertatik, Jellyfin eta Navidrome-rentzat bakarrik eskuragarri. {{discord}}-(e)k bot bat erabiltzen du irudiak eskuratzeko, beraz, zure zerbitzaria internet publikotik eskuragarri egon behar da\",\n        \"discordUpdateInterval\": \"{{discord}} jarduera-egoera eguneraketa tartea\",\n        \"discordLinkType_none\": \"$t(common.none)\",\n        \"albumBackground\": \"albumaren atzeko planoaren irudia\",\n        \"albumBackground_description\": \"albumaren azala artea duten album orrietarako atzeko plano irudi bat gehitzen du\",\n        \"albumBackgroundBlur\": \"albumaren atzeko planoaren irudiaren lausotze tamaina\",\n        \"discordLinkType_description\": \"{{lastfm}} edo {{musicbrainz}}-(e)rako kanpoko estekak gehitzen ditu abesti eta artista eremuetan {{discord}} jarduera-egoeran. {{musicbrainz}} da zehatzena, baina etiketak behar ditu eta ez ditu artistaren estekak ematen, {{lastfm}}-k beti esteka bat eman beharko lukeen bitartean. ez du sareko eskaera gehigarririk egiten\",\n        \"playButtonBehavior_optionAddNext\": \"$t(player.addNext)\",\n        \"scrobble\": \"scrobble\",\n        \"sidePlayQueueStyle_optionAttached\": \"erantsita\",\n        \"sidePlayQueueStyle_optionDetached\": \"bereizita\",\n        \"theme\": \"gaia\",\n        \"audioDevice\": \"audio gailua\",\n        \"discordDisplayType_songname\": \"abesti izena\",\n        \"discordDisplayType_artistname\": \"artista izena(k)\",\n        \"fontType_optionBuiltIn\": \"barneko letra-tipoa\",\n        \"hotkey_globalSearch\": \"bilaketa globala\",\n        \"albumBackgroundBlur_description\": \"albumaren atzeko planoaren irudiari aplikatzen zaion lausotze-kopurua doitzen du\",\n        \"artistBackground\": \"artistaren atzeko planoaren irudia\",\n        \"artistBackgroundBlur\": \"artistaren atzeko planoko irudiaren lausotze-tamaina\",\n        \"artistBackgroundBlur_description\": \"artistaren atzeko planoaren irudiari aplikatzen zaion lausotze-kopurua doitzen du\",\n        \"artistConfiguration\": \"albumaren artistaren konfigurazio orria\",\n        \"artistConfiguration_description\": \"konfiguratu zein elementu erakusten diren eta zein ordenatan albumaren artistaren orrian\",\n        \"audioExclusiveMode\": \"audio esklusiboko modua\",\n        \"releaseChannel_optionLatest\": \"azken bertsioa\",\n        \"releaseChannel_optionBeta\": \"beta\",\n        \"releaseChannel\": \"argitalpen kanala\",\n        \"releaseChannel_description\": \"aukeratu argitalpen egonkorren edo beta artean eguneratze automatikoak lortzeko\",\n        \"discordUpdateInterval_description\": \"eguneratze bakoitzaren arteko denbora segundotan (gutxienez 15 segundo)\",\n        \"discordDisplayType\": \"{{discord}} jarduera-pantailaren mota\",\n        \"discordDisplayType_description\": \"zure egoeran entzuten ari zarena aldatzen du\",\n        \"discordLinkType\": \"{{discord}} egoera estekak\",\n        \"fontType_description\": \"barneko letra-tipoa feishinek eskaintzen dituen letra-tipoetako bat aukeratzen du. sistemaren letra-tipoa zure sistema eragileak eskaintzen duen edozein letra-tipo hautatzeko aukera ematen dizu. pertsonalizatua zure letra-tipoa eskaintzeko aukera ematen dizu\",\n        \"homeConfiguration_description\": \"konfiguratu zein elementu erakusten diren hasierako orrian eta zein ordenatan\",\n        \"homeFeature\": \"etxeko karrusela nabarmendua\",\n        \"homeFeature_description\": \"hasierako orrian karrusel nabarmen handia erakutsi behar den ala ez kontrolatzen du\",\n        \"hotkey_localSearch\": \"orrian bilatu\",\n        \"hotkey_rate0\": \"garbitu balorazioa\",\n        \"hotkey_rate1\": \"1 izarretako balorazioa\",\n        \"hotkey_rate2\": \"2 izarretako balorazioa\",\n        \"hotkey_rate3\": \"3 izarretako balorazioa\",\n        \"hotkey_rate4\": \"4 izarretako balorazioa\",\n        \"hotkey_rate5\": \"5 izarretako balorazioa\",\n        \"zoom_description\": \"aplikazioaren zoom ehunekoa ezartzen du\",\n        \"zoom\": \"zoom ehunekoa\",\n        \"windowBarStyle_description\": \"aukeratu leiho-barraren estiloa\",\n        \"windowBarStyle\": \"leiho-barra estiloa\",\n        \"webAudio\": \"erabili web audioa\",\n        \"useSystemTheme_description\": \"jarraitu sistemak definitutako argi edo iluntasun lehentasuna\",\n        \"useSystemTheme\": \"erabili sistemaren gaia\",\n        \"translationTargetLanguage_description\": \"itzulpenerako helburu-hizkuntza\",\n        \"translationTargetLanguage\": \"itzulpenerako helburu-hizkuntza\",\n        \"translationApiKey\": \"itzulpen api gakoa\",\n        \"translationApiProvider_description\": \"itzulpenerako api hornitzailea\",\n        \"translationApiProvider\": \"itzulpen api hornitzailea\",\n        \"mediaSession\": \"gaitu multimedia saioa\",\n        \"themeLight_description\": \"aplikaziorako erabiliko den gaia argia ezartzen du\",\n        \"themeLight\": \"gaia (argia)\",\n        \"themeDark_description\": \"aplikaziorako erabiliko den gai iluna ezartzen du\",\n        \"themeDark\": \"gaia (iluna)\",\n        \"theme_description\": \"aplikaziorako erabiliko den gaia ezartzen du\",\n        \"externalLinks\": \"kanpoko estekak erakutsi\",\n        \"externalLinks_description\": \"kanpoko estekak (Last.fm, MusicBrainz) artista/album orrietan erakustea gaitzen du\",\n        \"exitToTray\": \"irten erretilura\",\n        \"playButtonBehavior_optionAddLast\": \"$t(player.addLast)\",\n        \"replayGainMode\": \"{{ReplayGain}} modua\",\n        \"sidebarConfiguration\": \"alboko barraren konfigurazioa\",\n        \"skipDuration\": \"saltoaren iraupena\",\n        \"savePlayQueue\": \"gorde erreprodukzio ilara\",\n        \"playbackStyle_optionCrossFade\": \"crossfade-a\",\n        \"applicationHotkeys\": \"aplikazioaren laster-teklak\",\n        \"applicationHotkeys_description\": \"konfiguratu aplikazioaren laster-teklak. txandakatu kontrol-laukia laster-tekla orokor bezala ezartzeko (mahaigainerako soilik)\",\n        \"artistBackground_description\": \"artistaren artelanak dituzten artista-orrietarako atzeko planoko irudi bat gehitzen du\",\n        \"discordLinkType_mbz_lastfm\": \"{{musicbrainz}} {{lastfm}} alternatiba gisa erabiliz\",\n        \"globalMediaHotkeys\": \"laster-tekla multimedia globalak\",\n        \"globalMediaHotkeys_description\": \"gaitu edo desgaitu zure sistemaren laster-tekla multimedien erabilera erreprodukzioa kontrolatzeko\",\n        \"accentColor_description\": \"aplikazioaren azentu-kolorea ezartzen du\",\n        \"accentColor\": \"azentu-kolorea\",\n        \"clearCache_description\": \"feishinen 'garbiketa gogorra'. feishinen katxea garbitzeaz gain, hustu nabigatzailearen katxea (gordetako irudiak eta bestelako aktiboak). zerbitzari-kredentzialak eta ezarpenak gorde egiten dira\",\n        \"clearQueryCache_description\": \"feishinen 'garbiketa ahula'. honek erreprodukzio-zerrendak eta pisten metadatuak freskatuko ditu eta gordetako letrak berrezarriko ditu. ezarpenak, zerbitzari-kredentzialak eta katxetutako irudiak gorde egiten dira\",\n        \"exitToTray_description\": \"irten aplikaziotik sistemaren erretilura\",\n        \"followLyric_description\": \"mugitu letra uneko erreprodukzio-posiziora\",\n        \"preferLocalLyrics\": \"nahiago izan letra lokalak\",\n        \"preferLocalLyrics_description\": \"nahiago izan letra lokalak urrunekoak baino eskuragarri daudenean\",\n        \"hotkey_browserBack\": \"nabigatzailean atzeraka\",\n        \"hotkey_browserForward\": \"nabigatzailean aurreraka\",\n        \"imageAspectRatio\": \"erabili jatorrizko azaleko artearen aspektu-erlazioa\",\n        \"lyricFetchProvider\": \"letrak eskuratzeko hornitzaileak\",\n        \"lyricFetchProvider_description\": \"aukeratu letrak eskuratzeko hornitzaileak. hornitzaileen ordena kontsultatuko diren ordena da\",\n        \"minimizeToTray\": \"minimizatu erretilura\",\n        \"minimizeToTray_description\": \"minimizatu aplikazioa sistemaren erretilura\",\n        \"minimumScrobblePercentage\": \"scrobble iraupen minimoa (ehunekoa)\",\n        \"minimumScrobblePercentage_description\": \"erreproduzitu behar den abestiaren gutxieneko ehunekoa scrobble egin aurretik\",\n        \"minimumScrobbleSeconds\": \"gutxieneko scrobble-a (segundoak)\",\n        \"minimumScrobbleSeconds_description\": \"erreproduzitu behar den abestiaren gutxieneko iraupena segundotan scrobble egin aurretik\",\n        \"mpvExecutablePath\": \"mpv exekutagarriaren bidea\",\n        \"mpvExecutablePath_description\": \"ezartzen du mpv exekutagarriaren bidea. hutsik uzten bada, bide lehenetsia erabiliko da\",\n        \"mpvExtraParameters_help\": \"lerro bakoitzeko bat\",\n        \"musicbrainz\": \"erakutsi MusicBrainz estekak\",\n        \"musicbrainz_description\": \"erakutsi MusicbBrainz-erako estekak artista/album orrietan, MusicBrainz ID existitzen den lekuetan\",\n        \"neteaseTranslation\": \"Gaitu NetEase itzulpenak\",\n        \"neteaseTranslation_description\": \"Gaituta dagoenean, NetEase-tik itzulitako letrak eskuratu eta bistaratzen ditu, eskuragarri badaude\",\n        \"playbackStyle\": \"erreprodukzio estiloa\",\n        \"playbackStyle_description\": \"aukeratu audio erreproduzitzailearentzat erabiliko den erreprodukzio estiloa\",\n        \"playButtonBehavior\": \"erreprodukzio botoiaren portaera\",\n        \"playButtonBehavior_description\": \"ezartzen du erreprodukzio botoiaren portaera lehenetsia abestiak ilaran gehitzean\",\n        \"gaplessAudio\": \"hutsune gabeko audioa\",\n        \"gaplessAudio_description\": \"ezartzen du hutsunik gabeko audio ezarpena mpv-rako\",\n        \"passwordStore\": \"pasahitzak/biltegi sekretua\",\n        \"playerbarOpenDrawer\": \"txandakatu erreproduzitzailearen barra pantaila osora\",\n        \"playerbarOpenDrawer_description\": \"aukera ematen du erreproduzitzailearen barran klik egiteak pantaila osoko erreproduzitzailea irekitzeko\",\n        \"customCssNotice\": \"Abisua: garbiketa batzuk dauden arren (url() eta content: debekatuz), css pertsonalizatua erabiltzeak arriskuak sor ditzake interfazea aldatuz gero\",\n        \"customCss_description\": \"css eduki pertsonalizatua. Oharra: edukia eta urruneko URLak debekatutako propietateak dira. Zure edukiaren aurrebista erakusten da behean. Ezarri ez dituzun eremu gehigarriak daude garbiketa dela eta\",\n        \"enableRemote\": \"gaitu urruneko kontrol zerbitzaria\",\n        \"enableRemote_description\": \"urruneko kontrol zerbitzariari beste gailu batzuei aplikazioa kontrolatzeko aukera ematen dio\",\n        \"imageAspectRatio_description\": \"gaituta badago, azaleko artea jatorrizko aspektu-erlazioa erabiliz erakutsiko da. 1:1 ez den arterako, gainerako espazioa hutsik egongo da\",\n        \"crossfadeStyle\": \"crossfade estiloa\",\n        \"discordRichPresence\": \"{{discord}}-en jarduera-egoera\",\n        \"enableAutoTranslation\": \"gaitu itzulpen automatikoa\",\n        \"exportImportSettings_control_exportText\": \"esportatu ezarpenak\",\n        \"exportImportSettings_control_importText\": \"inportatu ezarpenak\",\n        \"exportImportSettings_control_title\": \"inportatu / esportatu ezarpenak\",\n        \"exportImportSettings_importBtn\": \"inportatu ezarpenak\",\n        \"exportImportSettings_importModalTitle\": \"inportatu feishin ezarpenak\",\n        \"autoDJ_itemCount\": \"elementu kopurua\",\n        \"language\": \"hizkuntza\",\n        \"queryBuilderCustomFields_inputTag\": \"etiketa\",\n        \"logLevel_optionError\": \"errore bat\",\n        \"logLevel_optionInfo\": \"informazioa\",\n        \"imageResolution_optionTable\": \"taula\",\n        \"imageResolution_optionSidebar\": \"alboko barra\",\n        \"replayGainClipping\": \"{{ReplayGain}} mozketa\",\n        \"replayGainFallback\": \"{{ReplayGain}} ordezko aukera\",\n        \"trayEnabled\": \"erakutsi erretilua\",\n        \"artistReleaseTypeConfiguration\": \"artistaren argitalpen motaren konfigurazioa\",\n        \"artistReleaseTypeConfiguration_description\": \"konfiguratu zein argitalpen mota erakusten diren, eta zein ordenatan, albumaren artistaren orrian\",\n        \"useThemeAccentColor\": \"erabili gaiaren azentu-kolorea\",\n        \"useThemeAccentColor_description\": \"erabili hautatutako gaian definitutako kolore nagusia azentu-kolore pertsonalizatuaren ordez\",\n        \"showRatings\": \"erakutsi izarren balorazioak\",\n        \"showRatings_description\": \"izarren balorazioen funtzioa interfazean agertzen den ala ez kontrolatzen du\",\n        \"imageResolution\": \"irudiaren erresoluzioa\",\n        \"imageResolution_description\": \"aplikazioan erabilitako irudien erresoluzioa. 0 balioa erabiliz gero, jatorrizko irudiaren erresoluzioa erabiliko da lehenespenez\",\n        \"followCurrentSong_description\": \"automatikoki korritu erreprodukzio-ilara uneko abestira\",\n        \"followCurrentSong\": \"jarraitu uneko abestia\",\n        \"lyricOffset_description\": \"letra zehaztutako milisegundo kopuruarekin desplazatu\",\n        \"lyricOffset\": \"letraren desplazamendua (ms)\",\n        \"mpvExtraParameters\": \"mpv parametro gehigarriak\",\n        \"mpvExtraParameters_description\": \"mpv-ri pasatzeko argumentu gehigarriak\",\n        \"notify\": \"abestien jakinarazpenak gaitu\",\n        \"notify_description\": \"erakutsi jakinarazpenak uneko abestia aldatzean\",\n        \"pathReplace\": \"fitxategiaren bidearen ordezkapena\",\n        \"pathReplace_description\": \"ordezkatu zure zerbitzariaren fitxategi-bide lehenetsia\",\n        \"pathReplace_optionRemovePrefix\": \"kendu aurrizkia\",\n        \"pathReplace_optionAddPrefix\": \"gehitu aurrizkia\",\n        \"passwordStore_description\": \"zein pasahitz/sekretu biltegi erabili. aldatu hau pasahitzak gordetzeko arazoak badituzu\",\n        \"playerFilters\": \"Iragazi ilarako abestiak\",\n        \"sidePlayQueueStyle_description\": \"alboko erreprodukzio-ilararen estiloa ezartzen du\",\n        \"mediaSession_description\": \"Windows Media Session integrazioa gaitzen du, multimedia kontrolak eta metadatuak sistemaren bolumenaren gainjartzean eta blokeo pantailan bistaratuz (Windows bakarrik)\",\n        \"sidePlayQueueStyle\": \"alboko erreprodukzio-ilarako estiloa\",\n        \"skipPlaylistPage\": \"saltatu erreprodukzio-zerrenda orria\",\n        \"startMinimized_description\": \"abiarazi aplikazioa sistemaren erretiluan\",\n        \"startMinimized\": \"hasi minimizatuta\",\n        \"transcode\": \"gaitu transkodetzea\",\n        \"transcode_description\": \"formatu ezberdinetara transkodetzea ahalbidetzen du\",\n        \"transcodeBitrate_description\": \"transkodetzeko bit-emaria hautatzen du. 0k zerbitzariari aukeratzen uzten diola esan nahi du\",\n        \"transcodeBitrate\": \"transkodetzeko bit-emaria\",\n        \"transcodeFormat_description\": \"transkodetzeko formatua hautatzen du. utzi hutsik zerbitzariak erabaki dezan\",\n        \"transcodeFormat\": \"transkodetzeko formatua\",\n        \"queryBuilderCustomFields_inputLabel\": \"etiketa\",\n        \"autoDJ\": \"DJ automatikoa\",\n        \"autoDJ_description\": \"automatikoki gehitu antzeko abestiak ilaran\",\n        \"autoDJ_itemCount_description\": \"DJ automatikoa gaituta dagoenean ilaran gehitzen saiatu diren elementuen kopurua\",\n        \"autoDJ_timing_description\": \"DJ automatikoa aktibatu aurretik ilaran geratzen diren abestien kopurua\",\n        \"analyticsDisable\": \"Erabileran oinarritutako analisiei uko egin\",\n        \"analyticsDisable_description\": \"Erabilera-datu anonimoak garatzaileari bidaltzen zaizkio aplikazioa hobetzen laguntzeko\",\n        \"contextMenu_description\": \"elementu batean eskuineko botoiarekin klik egitean menuan agertzen diren elementuak ezkutatzeko aukera ematen dizu. hautatuta ez dauden elementuak ezkutatuta egongo dira\",\n        \"enableAutoTranslation_description\": \"Gaitu itzulpena automatikoki letra kargatzen denean\",\n        \"exportImportSettings_control_description\": \"JSON bidez ezarpenak esportatu eta inportatu\",\n        \"exportImportSettings_destructiveWarning\": \"Ezarpenak inportatzea arriskutsua da, mesedez, berrikusi goikoa beheko \\\"inportatu\\\" klikatu aurretik!\",\n        \"exportImportSettings_importSuccess\": \"ezarpenak behar bezala inportatu dira!\",\n        \"exportImportSettings_offendingKeyError\": \"\\\"{{offendingKey}}\\\" okerra da - {{reason}}\",\n        \"hotkey_listPlayDefault\": \"zerrenda erreproduzitu\",\n        \"hotkey_listPlayLast\": \"zerrenda erreproduzitu amaieran\",\n        \"hotkey_listPlayNow\": \"zerrenda erreproduzitu orain\",\n        \"logLevel\": \"erregistro maila\",\n        \"logLevel_description\": \"Bistaratzeko erregistroen gutxieneko maila ezartzen du. Debug-ek erregistro guztiak erakusten ditu, «erroreak» erroreak bakarrik erakusten ditu\",\n        \"logLevel_optionDebug\": \"arazketa\",\n        \"playerFilters_description\": \"saltatu abestiak ilaran gehitzea irizpide hauen arabera\",\n        \"artistRadioCount_description\": \"artista eta abestien irratian bilatu beharreko abesti kopurua ezartzen du\",\n        \"artistRadioCount\": \"artista/abesti irratiko kopurua\",\n        \"imageResolution_optionItemCard\": \"elementu txartela\",\n        \"imageResolution_optionHeader\": \"goiburua\",\n        \"imageResolution_optionFullScreenPlayer\": \"pantaila osoko erreproduzitzailea\",\n        \"showVisualizerInSidebar\": \"erakutsi bistaratzailea erreproduzitzailearen alboko barran\",\n        \"combinedLyricsAndVisualizer_description\": \"konbinatu letrak eta bistaratzailea panel berean\",\n        \"combinedLyricsAndVisualizer\": \"konbinatu letrak eta bistaratzailea erreproduzitzailearen alboko barran\",\n        \"preventSleepOnPlayback_description\": \"saihestu pantaila lotan jartzea musika erreproduzitzen ari den bitartean\",\n        \"remotePassword_description\": \"urruneko kontrol zerbitzariaren pasahitza ezartzen du. Kredentzial hauek modu ez-seguruan transferitzen dira lehenespenez, beraz, axola ez zaizun pasahitz bakarra erabili beharko zenuke\",\n        \"remotePassword\": \"urruneko kontrol zerbitzariaren pasahitza\",\n        \"remotePort_description\": \"urruneko kontrol zerbitzariaren portua ezartzen du\",\n        \"remotePort\": \"urruneko kontrol zerbitzariaren ataka\",\n        \"remoteUsername_description\": \"urruneko kontrol zerbitzariaren erabiltzaile-izena ezartzen du. Erabiltzaile-izena eta pasahitza hutsik badaude, autentifikazioa desgaituta egongo da\",\n        \"remoteUsername\": \"urruneko kontrol zerbitzariaren erabiltzaile-izena\"\n    },\n    \"form\": {\n        \"addServer\": {\n            \"input_password\": \"pasahitza\",\n            \"input_url\": \"url-a\",\n            \"input_username\": \"erabiltzaile-izena\",\n            \"error_savePassword\": \"errore bat gertatu da pasahitza gordetzen saiatzean\",\n            \"input_name\": \"zerbitzari izena\",\n            \"input_savePassword\": \"pasahitza gorde\",\n            \"title\": \"zerbitzaria gehitu\",\n            \"ignoreCors\": \"alde batera utzi cors $t(common.restartRequired)\",\n            \"ignoreSsl\": \"alde batera utzi ssl $t(common.restartRequired)\",\n            \"input_legacyAuthentication\": \"gaitu zaharkitutako autentifikazioa\",\n            \"success\": \"zerbitzaria behar bezala gehitu da\",\n            \"input_preferInstantMix\": \"nahiago izan berehalako nahasketa\",\n            \"input_preferInstantMixDescription\": \"erabili berehalako nahasketa soilik antzeko abestiak lortzeko. erabilgarria portaera hau aldatzen duten pluginak badituzu\",\n            \"input_remoteUrl\": \"URL publikoa\"\n        },\n        \"addToPlaylist\": {\n            \"input_playlists\": \"$t(entity.playlist, {\\\"count\\\": 2})\",\n            \"success\": \"$t(entity.trackWithCount, {\\\"count\\\": {{message}} }) gehitu da $t(entity.playlistWithCount, {\\\"count\\\": {{numOfPlaylists}} })-ra\",\n            \"input_skipDuplicates\": \"saltatu bikoiztuak\",\n            \"title\": \"gehitu $t(entity.playlist, {\\\"count\\\": 1})-(a)ri\",\n            \"create\": \"sortu $t(entity.playlist, {\\\"count\\\": 1}) {{playlist}}\",\n            \"searchOrCreate\": \"bilatu $t(entity.playlist, {\\\"count\\\": 2}) edo idatzi berri bat sortzeko\"\n        },\n        \"createPlaylist\": {\n            \"input_description\": \"$t(common.description)\",\n            \"input_name\": \"$t(common.name)\",\n            \"input_owner\": \"$t(common.owner)\",\n            \"input_public\": \"publikoa\",\n            \"title\": \"$t(entity.playlist, {\\\"count\\\": 1}) sortu\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) behar bezala sortu da\"\n        },\n        \"lyricSearch\": {\n            \"input_artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n            \"input_name\": \"$t(common.name)\",\n            \"title\": \"letra bilatu\"\n        },\n        \"shareItem\": {\n            \"description\": \"deskripzioa\",\n            \"setExpiration\": \"iraungitze-data ezarri\",\n            \"success\": \"partekatzeko esteka arbelera kopiatu da (edo egin klik hemen irekitzeko)\",\n            \"expireInvalid\": \"iraungitze-data etorkizunean izan behar da\",\n            \"allowDownloading\": \"baimendu deskargatzea\",\n            \"createFailed\": \"partekatzea sortzeak huts egin du (partekatzea gaituta al dago?)\"\n        },\n        \"deletePlaylist\": {\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) behar bezala ezabatu da\",\n            \"title\": \"$t(entity.playlist, {\\\"count\\\": 1}) ezabatu\",\n            \"input_confirm\": \"idatzi $t(entity.playlist, {\\\"count\\\": 1})-(a)ren izena berresteko\"\n        },\n        \"editPlaylist\": {\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) behar bezala eguneratu da\",\n            \"title\": \"$t(entity.playlist, {\\\"count\\\": 1}) editatu\",\n            \"publicJellyfinNote\": \"Arrazoiren batengatik, Jellyfin ez du erakusten erreprodukzio-zerrendak publikoak diren edo ez. Hau publiko izaten jarraitzea nahi baduzu, hautatu sarrera hau\",\n            \"editNote\": \"ez da gomendatzen eskuzko edizioak egitea erreprodukzio-zerrenda handietarako. ziur zaude onartzen duzula lehendik dagoen erreprodukzio-zerrenda gainidazteagatik datuak galtzeko arriskua?\"\n        },\n        \"queryEditor\": {\n            \"title\": \"kontsulta editorea\",\n            \"input_optionMatchAll\": \"guztiak bat etorri\",\n            \"input_optionMatchAny\": \"edozeinekin bat etorri\",\n            \"resetToDefault\": \"lehenetsitako egoerara berrezarri\",\n            \"clearFilters\": \"garbitu iragazkiak\"\n        },\n        \"updateServer\": {\n            \"success\": \"zerbitzaria behar bezala eguneratu da\",\n            \"title\": \"zerbitzaria eguneratu\"\n        },\n        \"privateMode\": {\n            \"title\": \"modu pribatua\",\n            \"enabled\": \"modu pribatua gaituta, erreprodukzio egoera kanpoko integrazioetatik ezkutatuta dago orain\",\n            \"disabled\": \"modu pribatua desgaituta, erreprodukzio egoera ikusgai dago orain gaitutako kanpoko integrazioentzat\"\n        },\n        \"largeFetchConfirmation\": {\n            \"title\": \"gehitu elementuak ilaran\"\n        },\n        \"createRadioStation\": {\n            \"input_homepageUrl\": \"hasierako orriaren URLa\",\n            \"input_name\": \"izena\",\n            \"title\": \"irrati-katea sortu\",\n            \"success\": \"irrati-katea behar bezala sortu da\"\n        },\n        \"lyricsExport\": {\n            \"export\": \"esportatu letrak\",\n            \"input_synced\": \"esportatu sinkronizatutako letrak\",\n            \"input_offset\": \"$t(setting.lyricOffset)\"\n        },\n        \"shuffleAll\": {\n            \"input_genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n            \"title\": \"ausaz erreproduzitu\",\n            \"input_limit\": \"zenbat abesti?\",\n            \"input_played_optionAll\": \"pista guztiak\",\n            \"input_played_optionUnplayed\": \"erreproduzitu gabeko pistak bakarrik\",\n            \"input_played_optionPlayed\": \"erreproduzitutako pistak bakarrik\"\n        },\n        \"saveQueue\": {\n            \"success\": \"erreprodukzio-ilara zerbitzarian gordeta\"\n        }\n    },\n    \"page\": {\n        \"albumArtistList\": {\n            \"title\": \"$t(entity.albumArtist, {\\\"count\\\": 2})\"\n        },\n        \"albumDetail\": {\n            \"released\": \"argitaratuta\",\n            \"moreFromArtist\": \"$t(entity.artist, {\\\"count\\\": 1}) honetatik gehiago\",\n            \"moreFromGeneric\": \"{{item}}-(e)tik gehiago\"\n        },\n        \"albumList\": {\n            \"title\": \"$t(entity.album, {\\\"count\\\": 2})\",\n            \"genreAlbums\": \"\\\"{{genre}}\\\" $t(entity.album, {\\\"count\\\": 2})\",\n            \"artistAlbums\": \"{{artist}}-(a)ren albumak\"\n        },\n        \"appMenu\": {\n            \"quit\": \"$t(common.quit)\",\n            \"settings\": \"$t(common.setting, {\\\"count\\\": 2})\",\n            \"collapseSidebar\": \"tolestu alboko barra\",\n            \"expandSidebar\": \"zabaldu alboko barra\",\n            \"goBack\": \"atzera\",\n            \"goForward\": \"aurrera\",\n            \"manageServers\": \"kudeatu zerbitzariak\",\n            \"privateModeOff\": \"itzali modu pribatua\",\n            \"privateModeOn\": \"aktibatu modu pribatua\",\n            \"selectServer\": \"aukeratu zerbitzaria\",\n            \"version\": \"bertsioa {{version}}\",\n            \"openBrowserDevtools\": \"ireki nabigatzailearen garapen tresnak\",\n            \"commandPalette\": \"ireki komando-paleta\",\n            \"noMusicFolder\": \"ez da musika karpetarik hautatu\",\n            \"selectMusicFolder\": \"aukeratu musika karpeta\",\n            \"multipleMusicFolders\": \"{{count}} musika karpeta aukeratuta\"\n        },\n        \"manageServers\": {\n            \"url\": \"URLa\",\n            \"username\": \"erabiltzaile-izena\",\n            \"title\": \"kudeatu zerbitzariak\",\n            \"serverDetails\": \"zerbitzariaren xehetasunak\",\n            \"editServerDetailsTooltip\": \"editatu zerbitzariaren xehetasunak\",\n            \"removeServer\": \"kendu zerbitzaria\"\n        },\n        \"contextMenu\": {\n            \"addFavorite\": \"$t(action.addToFavorites)\",\n            \"addLast\": \"$t(player.addLast)\",\n            \"addNext\": \"$t(player.addNext)\",\n            \"addToFavorites\": \"$t(action.addToFavorites)\",\n            \"addToPlaylist\": \"$t(action.addToPlaylist)\",\n            \"createPlaylist\": \"$t(action.createPlaylist)\",\n            \"deletePlaylist\": \"$t(action.deletePlaylist)\",\n            \"deselectAll\": \"$t(action.deselectAll)\",\n            \"download\": \"deskargatu\",\n            \"moveToNext\": \"$t(action.moveToNext)\",\n            \"moveToBottom\": \"$t(action.moveToBottom)\",\n            \"moveToTop\": \"$t(action.moveToTop)\",\n            \"play\": \"$t(player.play)\",\n            \"playSimilarSongs\": \"$t(player.playSimilarSongs)\",\n            \"removeFromFavorites\": \"$t(action.removeFromFavorites)\",\n            \"removeFromPlaylist\": \"$t(action.removeFromPlaylist)\",\n            \"removeFromQueue\": \"$t(action.removeFromQueue)\",\n            \"setRating\": \"$t(action.setRating)\",\n            \"playShuffled\": \"$t(player.shuffle)\",\n            \"numberSelected\": \"{{count}} hautatuta\",\n            \"shareItem\": \"partekatu elementua\",\n            \"goToAlbum\": \"joan $t(entity.album, {\\\"count\\\": 1})-(e)ra\",\n            \"goToAlbumArtist\": \"joan albumera\",\n            \"showDetails\": \"informazioa lortu\",\n            \"moveItems\": \"$t(action.moveItems)\"\n        },\n        \"fullscreenPlayer\": {\n            \"config\": {\n                \"opacity\": \"opakotasuna\",\n                \"synchronized\": \"sinkronizatuta\",\n                \"unsynchronized\": \"sinkronizatu gabe\",\n                \"dynamicIsImage\": \"gaitu atzeko planoaren irudia\",\n                \"followCurrentLyric\": \"jarraitu uneko letra\",\n                \"lyricSize\": \"letraren tamaina\",\n                \"dynamicBackground\": \"atzeko plano dinamikoa\",\n                \"dynamicImageBlur\": \"irudiaren lausotze tamaina\",\n                \"lyricAlignment\": \"letraren lerrokatzea\",\n                \"showLyricMatch\": \"erakutsi letren bat-etortzea\",\n                \"showLyricProvider\": \"erakutsi letra hornitzailea\",\n                \"lyricOffset\": \"letra-desplazamendua (ms)\",\n                \"lyricGap\": \"letra hutsunea\",\n                \"useImageAspectRatio\": \"erabili irudiaren aspektu-erlazioa\"\n            },\n            \"lyrics\": \"letrak\",\n            \"related\": \"erlazionatuta\",\n            \"upNext\": \"hurrengoa\",\n            \"visualizer\": \"bistaratzailea\",\n            \"noLyrics\": \"ez da letrarik aurkitu\"\n        },\n        \"genreList\": {\n            \"title\": \"$t(entity.genre, {\\\"count\\\": 2})\",\n            \"showAlbums\": \"erakutsi $t(entity.genre, {\\\"count\\\": 1}) $t(entity.album, {\\\"count\\\": 2})\",\n            \"showTracks\": \"erakutsi $t(entity.genre, {\\\"count\\\": 1}) $t(entity.track, {\\\"count\\\": 2})\"\n        },\n        \"globalSearch\": {\n            \"title\": \"komandoak\",\n            \"commands\": {\n                \"goToPage\": \"joan orrira\",\n                \"searchFor\": \"bilatu {{query}}\",\n                \"serverCommands\": \"zerbitzariaren komandoak\"\n            }\n        },\n        \"home\": {\n            \"title\": \"$t(common.home)\",\n            \"mostPlayed\": \"gehien entzundakoak\",\n            \"newlyAdded\": \"azken aldian gehitutako argitalpenak\",\n            \"recentlyPlayed\": \"azken aldian entzundakoak\",\n            \"recentlyReleased\": \"azken aldian argitaratutak\",\n            \"explore\": \"arakatu zure liburutegitik\",\n            \"genres\": \"$t(entity.genre, {\\\"count\\\": 2})\"\n        },\n        \"playlistList\": {\n            \"title\": \"$t(entity.playlist, {\\\"count\\\": 2})\"\n        },\n        \"setting\": {\n            \"advanced\": \"aurreratua\",\n            \"generalTab\": \"orokorra\",\n            \"playbackTab\": \"erreprodukzioa\",\n            \"windowTab\": \"leihoa\",\n            \"hotkeysTab\": \"laster-teklak\",\n            \"cache\": \"katxea\",\n            \"application\": \"aplikazioa\",\n            \"theme\": \"gaia\",\n            \"sidebar\": \"alboko barra\",\n            \"exportImport\": \"inportatu/esportatu\",\n            \"scrobble\": \"scrobble\",\n            \"audio\": \"audioa\",\n            \"lyrics\": \"letrak\",\n            \"discord\": \"discord\",\n            \"playerFilters\": \"erreproduzitzailearen iragazkiak\",\n            \"updates\": \"eguneraketa\",\n            \"queryBuilder\": \"kontsulta-sortzailea\",\n            \"controls\": \"kontrolak\",\n            \"remote\": \"urrunekoa\",\n            \"lyricsDisplay\": \"erakutsi letrak\"\n        },\n        \"sidebar\": {\n            \"albumArtists\": \"$t(entity.albumArtist, {\\\"count\\\": 2})\",\n            \"albums\": \"$t(entity.album, {\\\"count\\\": 2})\",\n            \"artists\": \"$t(entity.artist, {\\\"count\\\": 2})\",\n            \"folders\": \"$t(entity.folder, {\\\"count\\\": 2})\",\n            \"genres\": \"$t(entity.genre, {\\\"count\\\": 2})\",\n            \"home\": \"$t(common.home)\",\n            \"playlists\": \"$t(entity.playlist, {\\\"count\\\": 2})\",\n            \"search\": \"$t(common.search)\",\n            \"settings\": \"$t(common.setting, {\\\"count\\\": 2})\",\n            \"tracks\": \"$t(entity.track, {\\\"count\\\": 2})\",\n            \"myLibrary\": \"nire liburutegia\",\n            \"nowPlaying\": \"orain erreproduzitzen\",\n            \"shared\": \"partekatutako $t(entity.playlist, {\\\"count\\\": 2})\",\n            \"favorites\": \"$t(entity.favorite, {\\\"count\\\": 2})\",\n            \"radio\": \"$t(entity.radioStation, {\\\"count\\\": 2})\"\n        },\n        \"trackList\": {\n            \"title\": \"$t(entity.track, {\\\"count\\\": 2})\",\n            \"genreTracks\": \"\\\"{{genre}}\\\" $t(entity.track, {\\\"count\\\": 2})\",\n            \"artistTracks\": \"{{artist}}-(r)en abestiak\"\n        },\n        \"albumArtistDetail\": {\n            \"about\": \"{{artist}}-(r)i buruz\",\n            \"relatedArtists\": \"erlazionatutako $t(entity.artist, {\\\"count\\\": 2})\",\n            \"topSongs\": \"abesti nagusiak\",\n            \"topSongsFrom\": \"{{title}}-(a)ren abesti nagusiak\",\n            \"viewAll\": \"ikusi guztiak\",\n            \"viewAllTracks\": \"ikusi $t(entity.track, {\\\"count\\\": 2}) guztiak\",\n            \"appearsOn\": \"agertzen da hemen\",\n            \"recentReleases\": \"azken argitalpenak\",\n            \"viewDiscography\": \"ikusi diskografia\",\n            \"groupingTypeAll\": \"argitalpen mota guztiak\",\n            \"groupingTypePrimary\": \"argitalpen mota nagusiak\"\n        },\n        \"itemDetail\": {\n            \"copyPath\": \"kopiatu bidea arbelean\",\n            \"openFile\": \"erakutsi pista fitxategi-kudeatzailean\",\n            \"copiedPath\": \"bidea behar bezala kopiatu da\"\n        },\n        \"playlist\": {\n            \"reorder\": \"berrantolaketa IDaren arabera ordenatzean bakarrik gaituta dago\"\n        },\n        \"folderList\": {\n            \"title\": \"$t(entity.folder, {\\\"count\\\": 2})\"\n        },\n        \"favorites\": {\n            \"title\": \"$t(entity.favorite, {\\\"count\\\": 2})\"\n        },\n        \"radioList\": {\n            \"title\": \"irrati-kateak\"\n        }\n    },\n    \"releaseType\": {\n        \"primary\": {\n            \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n            \"other\": \"bestelakoa\",\n            \"ep\": \"ep\"\n        },\n        \"secondary\": {\n            \"compilation\": \"konpilazioa\",\n            \"audiobook\": \"audioliburua\",\n            \"interview\": \"elkarrizketa\",\n            \"remix\": \"nahasketa\",\n            \"djMix\": \"dj nahasketa\"\n        }\n    },\n    \"datetime\": {\n        \"minuteShort\": \"m\",\n        \"secondShort\": \"s\",\n        \"hourShort\": \"h\",\n        \"dayShort\": \"d\"\n    },\n    \"queryBuilder\": {\n        \"customTags\": \"etiketa pertsonalizatutak\",\n        \"standardTags\": \"etiketa estandarrak\"\n    },\n    \"filterOperator\": {\n        \"is\": \"da\",\n        \"contains\": \"dauka\",\n        \"notContains\": \"ez dauka\",\n        \"startsWith\": \"honekin hasten da\",\n        \"endsWith\": \"honekin amaitzen da\",\n        \"isNot\": \"ez da\"\n    },\n    \"visualizer\": {\n        \"general\": \"Orokorra\",\n        \"mode\": \"Modua\",\n        \"vertical\": \"Bertikala\",\n        \"horizontal\": \"Horizontala\",\n        \"position\": \"Posizioa\",\n        \"level\": \"Maila\",\n        \"remove\": \"Kendu\",\n        \"custom\": \"Pertsonalizatua\",\n        \"builtIn\": \"Barneratua\",\n        \"colors\": \"Koloreak\",\n        \"gradient\": \"Gradientea\",\n        \"fft\": \"FFT\",\n        \"sensitivity\": \"Sentikortasuna\",\n        \"smoothing\": \"Leuntzea\",\n        \"gravity\": \"Grabitatea\",\n        \"radial\": \"Erradiala\",\n        \"radius\": \"Erradioa\",\n        \"mirror\": \"Ispilua\",\n        \"options\": {\n            \"colorMode\": {\n                \"gradient\": \"Gradientea\",\n                \"barIndex\": \"Barra-indizea\",\n                \"barLevel\": \"Barra-maila\"\n            },\n            \"gradient\": {\n                \"classic\": \"Klasikoa\",\n                \"prism\": \"Prisma\",\n                \"rainbow\": \"Ostadarra\",\n                \"orangered\": \"Laranja-gorria\"\n            },\n            \"weightingFilter\": {\n                \"none\": \"Bat ere ez\",\n                \"a\": \"A\",\n                \"b\": \"B\",\n                \"c\": \"C\",\n                \"d\": \"D\",\n                \"z\": \"Z\"\n            },\n            \"mode\": {\n                \"0\": \"[0] Maiztasun Diskretuak\",\n                \"1\": \"[1] 1/24 oktaba / 240 banda\",\n                \"2\": \"[2] 1/12 oktaba / 120 banda\",\n                \"3\": \"[3] 1/8 oktaba / 80 banda\",\n                \"4\": \"[4] 1/6ko oktaba / 60 banda\",\n                \"5\": \"[5] 1/4 oktaba / 40 banda\",\n                \"6\": \"[6] 1/3 oktaba / 30 banda\",\n                \"7\": \"[7] Oktaba erdi / 20 banda\",\n                \"8\": \"[8] Oktaba osoa / 10 banda\",\n                \"10\": \"[10] Lerroa / Azalera grafikoa\"\n            },\n            \"frequencyScale\": {\n                \"none\": \"Bat ere ez\",\n                \"linear\": \"Eskala Lineala\",\n                \"bark\": \"Bark Eskala\",\n                \"mel\": \"Mel Eskala\"\n            }\n        },\n        \"opacity\": \"Opakotasuna\",\n        \"minimumFrequency\": \"Gutxieneko Maiztasuna\",\n        \"maximumFrequency\": \"Gehienezko Maiztasuna\",\n        \"frequencyScale\": \"Maiztasun Eskala\",\n        \"weightingFilter\": \"Ponderazio-iragazkia\",\n        \"minimumDecibels\": \"Gutxieneko Dezibelioak\",\n        \"maximumDecibels\": \"Gehienezko Dezibelioak\",\n        \"linearAmplitude\": \"Anplitude Lineala\",\n        \"linearBoost\": \"Bultzada Lineala\",\n        \"showPeaks\": \"Erakutsi Gailurrak\",\n        \"configCopied\": \"Konfigurazioa arbelean kopiatu da\",\n        \"configCopyFailed\": \"Konfigurazioa kopiatzeak huts egin du\",\n        \"configPasted\": \"Konfigurazioa behar bezala aplikatu da\",\n        \"configPasteFailed\": \"Konfigurazioa aplikatzeak huts egin du. Mesedez, egiaztatu formatua.\",\n        \"configPasteReadFailed\": \"Arbelatik irakurtzeak huts egin du\",\n        \"colorMode\": \"Kolore Modua\",\n        \"fftSize\": \"FFT tamaina\",\n        \"frequencyRangeAndScaling\": \"Maiztasun-tartea eta eskalatzea\",\n        \"showScaleY\": \"Erakutsi Y Eskala\",\n        \"pasteGradientPlaceholder\": \"Itsatsi JSON gradientea hemen...\",\n        \"pasteGradient\": \"Itsatsi Gradientea\",\n        \"addColor\": \"Gehitu Kolorea\",\n        \"colorStops\": \"Kolore Geldialdiak\",\n        \"gradientNamePlaceholder\": \"Gradientearen Izena\",\n        \"gradientName\": \"Gradientearen Izena\",\n        \"addCustomGradient\": \"Gehitu Gradiente Pertsonalizatua\",\n        \"customGradients\": \"Gradiente Pertsonalizatuak\",\n        \"maxFPS\": \"FPS maximoak\",\n        \"channelLayout\": \"Kanalaren Diseinua\",\n        \"lineWidth\": \"Lerroaren Zabalera\",\n        \"presetNamePlaceholder\": \"Sartu aurrezarpenaren izena\",\n        \"presetName\": \"Aurrezarpenaren Izena\",\n        \"applyConfiguration\": \"Aplikatu konfigurazioa\",\n        \"pasteFromClipboard\": \"Itsatsi Arbeletik\",\n        \"pasteConfigurationPlaceholder\": \"Itsatsi JSON konfigurazioa hemen...\",\n        \"pasteConfiguration\": \"Itsatsi Konfigurazioa\",\n        \"copyConfiguration\": \"Kopiatu Konfigurazioa\",\n        \"updatePreset\": \"Aurrezarpena Eguneratu\",\n        \"saveAsPreset\": \"Aurrezarpen gisa gorde\",\n        \"applyPreset\": \"Aurrezarpena Aplikatu\",\n        \"selectPreset\": \"Aukeratu Aurrezarpena\",\n        \"presets\": \"Aurrezarpenak\"\n    }\n}\n"
  },
  {
    "path": "src/i18n/locales/fa.json",
    "content": "{\n    \"player\": {\n        \"repeat_all\": \"تکرار همه\",\n        \"stop\": \"توقف\",\n        \"repeat\": \"تکرار\",\n        \"skip\": \"رد کن\",\n        \"toggleFullscreenPlayer\": \"تغییر به پخش‌کنندهٔ تمام‌صفحه\",\n        \"skip_back\": \"برو عقب\",\n        \"shuffle\": \"پخش تصادفی\",\n        \"repeat_off\": \"تکرار غیرفعال\",\n        \"pause\": \"ایست\",\n        \"unfavorite\": \"حذف از موردعلاقه‌ها\",\n        \"shuffle_off\": \"پخش تصادفی غیر فعال\",\n        \"skip_forward\": \"برو جلو\",\n        \"queue_moveToTop\": \"جابجا کردن انتخاب شده به پایین\",\n        \"queue_clear\": \"خالی کردن صف\",\n        \"queue_remove\": \"حذف انتخاب شده\",\n        \"addLast\": \"افزودن به پایان\",\n        \"next\": \"پسین\",\n        \"play\": \"پخش\",\n        \"playbackSpeed\": \"تندی پخش\",\n        \"playRandom\": \"پخش تصادفی\",\n        \"previous\": \"پیشین\",\n        \"mute\": \"بی‌صدا کردن\",\n        \"playbackFetchCancel\": \"دارد طول می‌کشد... برای لفو کردن اعلان را ببندید\",\n        \"playbackFetchInProgress\": \"بارگذاری قطعه‌ها…\",\n        \"queue_moveToBottom\": \"جابجا کردن انتخاب شده به بالا\",\n        \"addNext\": \"افزودن به پسین\",\n        \"favorite\": \"مورد علاقه\",\n        \"playSimilarSongs\": \"پخش آهنگ‌های همگون\",\n        \"playbackFetchNoResults\": \"هیچ آهنگی پیدا نشد\",\n        \"viewQueue\": \"دیدن صف\",\n        \"muted\": \"بی‌صدا\"\n    },\n    \"action\": {\n        \"editPlaylist\": \"ویرایش $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"goToPage\": \"برو به صفحهٔ\",\n        \"moveToTop\": \"انتقال به بالا\",\n        \"clearQueue\": \"خالی کردن صف\",\n        \"addToFavorites\": \"افزودن به $t(entity.favorite, {\\\"count\\\": 2})\",\n        \"addToPlaylist\": \"افزودن به $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"createPlaylist\": \"ساخت $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"removeFromPlaylist\": \"حذف از $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"viewPlaylists\": \"نمایش $t(entity.playlist, {\\\"count\\\": 2})\",\n        \"refresh\": \"$t(common.refresh)\",\n        \"deletePlaylist\": \"حذف $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"removeFromQueue\": \"حذف از صف\",\n        \"deselectAll\": \"لغو انتخاب همه\",\n        \"moveToBottom\": \"انتقال به پایین\",\n        \"setRating\": \"تعیین امتیاز\",\n        \"toggleSmartPlaylistEditor\": \"تغییر ویرایشگر $t(entity.smartPlaylist)\",\n        \"removeFromFavorites\": \"حذف از $t(entity.favorite, {\\\"count\\\": 2})\",\n        \"openIn\": {\n            \"lastfm\": \"باز کردن در Last.fm\",\n            \"musicbrainz\": \"باز کردن در MusicBranz\"\n        },\n        \"moveToNext\": \"جابجا کردن به بعدی\"\n    },\n    \"setting\": {\n        \"hotkey_skipBackward\": \"برو عقب\",\n        \"audioDevice_description\": \"دستگاه صوتی را برای پخش انتخاب کنید (فقط پخش‌کنندهٔ تحت وب)\",\n        \"hotkey_playbackPause\": \"pause\",\n        \"hotkey_volumeUp\": \"زیاد کردن صدا\",\n        \"playButtonBehavior_optionPlay\": \"$t(player.play)\",\n        \"lyricFetch\": \"دریافت متن ترانه از اینترنت\",\n        \"enableRemote_description\": \"کنترل از راه دور سرویس‌دهنده را فعال کنید تا به دستگاه‌های دیگر اجازهٔ مدیریت اپلیکیشن را بدهید\",\n        \"mpvExecutablePath_description\": \"تعیین مسیر فایل اجرایی MPV\",\n        \"sampleRate\": \"sample rate\",\n        \"replayGainMode_optionNone\": \"$t(common.none)\",\n        \"hotkey_rate1\": \"امتیاز ۱ ستاره\",\n        \"hotkey_skipForward\": \"برو جلو\",\n        \"disableLibraryUpdateOnStartup\": \"غیرفعال کردن بررسی آخرین نسخه در آغاز به کار برنامه\",\n        \"discordApplicationId_description\": \"the application id for {{discord}} rich presence (defaults to {{defaultId}})\",\n        \"playButtonBehavior_optionAddLast\": \"$t(player.addLast)\",\n        \"hotkey_playbackPlay\": \"پخش\",\n        \"hotkey_volumeDown\": \"کم کردن صدا\",\n        \"audioPlayer_description\": \"پخش‌کنندهٔ صدا را برای پخش انتخاب کنید\",\n        \"hotkey_globalSearch\": \"جست و جوی سراسری\",\n        \"exitToTray_description\": \"خروج از اپلیکیشن به system tray\",\n        \"replayGainMode_optionAlbum\": \"$t(entity.album, {\\\"count\\\": 1})\",\n        \"discordUpdateInterval_description\": \"فاصلهٔ بین هر به روزرسانی به ثانیه (حداقل ۱۵ ثانیه)\",\n        \"audioExclusiveMode\": \"حالت اختصاصی صدا\",\n        \"remotePassword\": \"رمز عبور کنترل از راه دور\",\n        \"language_description\": \"زبان اپلیکیشن را معین می‌کند $t(common.restartRequired)\",\n        \"hotkey_rate3\": \"امتیاز ۳ ستاره\",\n        \"font\": \"قلم\",\n        \"replayGainMode_optionTrack\": \"$t(entity.track, {\\\"count\\\": 1})\",\n        \"hotkey_toggleFullScreenPlayer\": \"تغییر به پخش‌کنندهٔ تمام‌صفحه\",\n        \"hotkey_localSearch\": \"جست و جو در صفحه\",\n        \"hotkey_toggleQueue\": \"تغییر صف\",\n        \"hotkey_rate5\": \"امتیاز ۵ ستاره\",\n        \"hotkey_playbackPrevious\": \"قطعهٔ قبل\",\n        \"hotkey_toggleShuffle\": \"تغییر شافل\",\n        \"mpvExecutablePath\": \"مسیر اجرای MPV\",\n        \"audioDevice\": \"دستگاه صوتی\",\n        \"hotkey_rate2\": \"امتیاز ۲ ستاره\",\n        \"playButtonBehavior_description\": \"رفتار پیش‌فرض دکمهٔ پخش را هنگامی که آهنگی به صف افزوده می‌شود را معین می‌کند\",\n        \"exitToTray\": \"خروج به tray\",\n        \"hotkey_rate4\": \"امتیاز ۴ ستاره\",\n        \"enableRemote\": \"فعال کردن کنترل از راه دور سرویس‌دهنده\",\n        \"showSkipButton_description\": \"نمایش یا مخفی کردن دکمهٔ رد کردن روی نوار پخش‌کننده\",\n        \"playButtonBehavior\": \"رفتار دکمهٔ پخش\",\n        \"playbackStyle_optionNormal\": \"عادی\",\n        \"hotkey_toggleRepeat\": \"تغییر تکرار\",\n        \"fontType\": \"نوع قلم\",\n        \"hotkey_playbackNext\": \"قطعهٔ بعد\",\n        \"playButtonBehavior_optionAddNext\": \"$t(player.addNext)\",\n        \"lyricFetch_description\": \"دریافت متن ترانه از منابع اینترنتی\",\n        \"customFontPath\": \"مسیر قلم سفارشی\",\n        \"audioPlayer\": \"پخش‌کنندهٔ صدا\",\n        \"hotkey_rate0\": \"حذف امتیاز\",\n        \"discordApplicationId\": \"{{discord}} application id\",\n        \"hotkey_volumeMute\": \"بستن صدا\",\n        \"showSkipButton\": \"نمایش دکمهٔ رد کردن\",\n        \"customFontPath_description\": \"مسیر قلم سفارشی را برای استفاده در اپلیکیشن مشخص کنید\",\n        \"gaplessAudio_optionWeak\": \"ضعیف (توصیه شده)\",\n        \"hotkey_playbackStop\": \"توقف\",\n        \"font_description\": \"قلم مورد استفادهٔ اپلیکیشن را معین می‌کند\",\n        \"accentColor_description\": \"رنگ شاخص را برای نرم‌افزار مشخص می‌کند\",\n        \"applicationHotkeys\": \"کلیدهای میان‌بر نرم‌افزار\",\n        \"accentColor\": \"رنگ شاخص\",\n        \"albumBackgroundBlur\": \"اندازه‌ی مبهمی نگاره‌ی پس‌زمینه‌ی آلبوم\",\n        \"albumBackgroundBlur_description\": \"مقدار مبهمی‌ای که روی نگاره‌ی پس‌زمینه‌ی آلبوم اعمال می‌شود را تنظیم می‌کند\",\n        \"albumBackground\": \"نگاره‌ی پس‌زمینه‌ی آلبوم\",\n        \"albumBackground_description\": \"یک نگاره‌ی پس‌زمینه برای صفحات آلبوم دارای نگار آلبوم هستند، می‌افزاید\",\n        \"artistConfiguration\": \"پیکربندی صفحه‌ی هنرمند آلبوم\",\n        \"applicationHotkeys_description\": \"پیکربندی کلیدهای میان‌بر نرم‌افزار. برای تنظیم یک کلید میان‌بر عمومی مربع چک را فعال کنید (فقط پخش‌کننده‌ی میزکار)\",\n        \"clearCache\": \"پاک‌سازی کَش مرورگر\",\n        \"clearQueryCache\": \"پاک‌سازی کَش فیشین\",\n        \"clearCacheSuccess\": \"با موفقیت کَش پاک شد\",\n        \"artistConfiguration_description\": \"پیکربندی اینکه چه آیتمی‌هایی و در چه ترتیبی در صفحه‌ی هنرمند آلبوم نمایش داده شوند\",\n        \"buttonSize\": \"اندازه‌ی دکمه‌ی پخش نوار\",\n        \"contextMenu\": \"پیکربندی فهرست زمینه (کلیک راست)\",\n        \"buttonSize_description\": \"اندازه‌ی دکمه‌های پخش نوار\",\n        \"audioExclusiveMode_description\": \"حالت اختصاصی خروجی را فعال می‌کند. در این حالت، سامانه معمولاً قفل است و فقط mpv می‌تواند خروجی صدا دهد\",\n        \"clearQueryCache_description\": \"یک 'پاک‌سازی نرم' از فیشین. این فهرست‌های پخش و فراداده‌ی قطعه‌ها را تازه می‌کند و متن شعرهای ذخیره شده را بازنشانی می‌کند. پیکربندی‌ها، اعتبارنامه‌های سرویس‌دهنده و نگاره‌های کَش شده حفظ می‌شوند\",\n        \"clearCache_description\": \"یک 'پاک‌سازی سخت' فیشین. افزون بر پاک‌سازی کَش فیشین، کَش مرورگر هم تهی می‌شود (نگاره‌های ذخیره شده و باقی دارایی‌ها). اعتبارنامه‌ها و پیکربندی‌ها حفظ می‌شوند\",\n        \"contextMenu_description\": \"به شما اجازه می‌دهد که آیتم‌های نمایش داده شده در فهرستی که وقتی روی یک آیتم کلیک راست می‌کنید پدیدار می‌شود، را پنهان کنید. آیتم‌هایی که منتخب نیستند پنهان می‌شوند\",\n        \"customCssEnable_description\": \"اجازه دادن برای نوشتن css سفارشی\",\n        \"translationApiKey\": \"کلید API ترجمه\",\n        \"webAudio_description\": \"از صدای وب بهره‌مند می‌شود. این قابلیت‌های پیشرفته‌ای مانند گین بازپخش (replygain) را فعال می‌کند. غیرفعال کنید اگر غیر از این را تجربه می‌کنید\",\n        \"windowBarStyle_description\": \"گزینش سبک نوار پنجره\",\n        \"translationApiKey_description\": \"کلید API برای ترجمه (پشتیبانی فقط برای نقطه‌ی پایانی سرویس‌دهنده‌ی جهانی)\",\n        \"theme\": \"تم\",\n        \"hotkey_togglePreviousSongFavorite\": \"تغییر وضعیت برای مورد علاقه‌ی $t(common.previousSong)\",\n        \"transcode_description\": \"رمزگردانی به فرمت‌های گوناگون را فعال می‌کند\",\n        \"transcodeBitrate\": \"نرخ انتقال رمزگردانی\",\n        \"startMinimized\": \"پنهان‌شده آغاز کن\",\n        \"theme_description\": \"تم مورد استفاده در نرم‌افزار را می‌گزیند\",\n        \"themeLight\": \"تم (روشن)\",\n        \"transcodeBitrate_description\": \"نرخ انتقال برای رمزگردانی را انتخاب می‌کند. 0 بدان معناست سرور آن را انتخاب کند\",\n        \"transcodeFormat\": \"فرمت رمزگردانی\",\n        \"transcodeFormat_description\": \"فرمت رمزگردانی را انتخاب می‌کند. برای اینکه سرور آن را انتخاب کند، خالی بگذارید\",\n        \"customCssEnable\": \"فعال کردن css سفارشی\",\n        \"translationTargetLanguage\": \"زبان هدف ترجمه\",\n        \"hotkey_toggleCurrentSongFavorite\": \"تغییر وضعیت مورد علاقه برای $t(common.currentSong)\",\n        \"themeDark_description\": \"تم تاریک را برای استفاده‌ی نرم‌افزار می‌گزیند\",\n        \"volumeWheelStep_description\": \"اندازه‌ای از حجم صدا را در زمان اسکرول کردن روی نوار لغزنده تغییر داده شود\",\n        \"trayEnabled\": \"نمایش سینی\",\n        \"trayEnabled_description\": \"نمایش/پنهان کردن آیکون/فهرست در سینی. اگر غیرفعال باشد، کوچک کردن/خروج به سینی را نیز غیرفعال می‌کند\",\n        \"useSystemTheme_description\": \"از روشنی یا تاریکی که سیستم تعریف کرده است، پیروی می‌کند\",\n        \"crossfadeDuration\": \"زمان محو کردن گذار قطعه به قطعه‌ی بعدی\",\n        \"themeLight_description\": \"تم روشن را برای استفاده‌ی نرم‌افزار می‌گزیند\",\n        \"volumeWidth\": \"عرض نوار لغزنده‌ی حجم صدا\",\n        \"crossfadeStyle_description\": \"شیوه‌ی crossfade که می‌خواهید پخش‌کننده از آن استفاده کند را انتخاب کنید\",\n        \"startMinimized_description\": \"نرم‌افزار را در سینی اجرا کن\",\n        \"volumeWidth_description\": \"عرضی که نوار لغزنده‌ی حجم صدا داشته باشد\",\n        \"themeDark\": \"تم (تاریک)\",\n        \"useSystemTheme\": \"استفاده از تم سیستم\",\n        \"volumeWheelStep\": \"گام چرخ حجم صدا\",\n        \"webAudio\": \"استفاده از صدای وب\",\n        \"windowBarStyle\": \"سبک نوار پنجره\",\n        \"crossfadeDuration_description\": \"زمان افکت crossfade را مشخص می‌کند\"\n    },\n    \"common\": {\n        \"backward\": \"به عقب\",\n        \"increase\": \"افزایش\",\n        \"rating\": \"امتیاز\",\n        \"bpm\": \"bpm\",\n        \"refresh\": \"تازه‌سازی\",\n        \"unknown\": \"ناشناخته\",\n        \"areYouSure\": \"مطمئنید؟\",\n        \"edit\": \"ویرایش\",\n        \"favorite\": \"موردعلاقه\",\n        \"left\": \"چپ\",\n        \"save\": \"ذخیره\",\n        \"right\": \"راست\",\n        \"currentSong\": \"فعلی $t(entity.track, {\\\"count\\\": 1})\",\n        \"collapse\": \"بستن\",\n        \"trackNumber\": \"قطعه\",\n        \"descending\": \"نزولی\",\n        \"add\": \"افزودن\",\n        \"gap\": \"فاصله\",\n        \"ascending\": \"صعودی\",\n        \"dismiss\": \"رد\",\n        \"year\": \"سال\",\n        \"manage\": \"مدیریت\",\n        \"limit\": \"محدود\",\n        \"minimize\": \"کمینه\",\n        \"modified\": \"ویراسته شده\",\n        \"duration\": \"مدت\",\n        \"name\": \"نام\",\n        \"maximize\": \"بیشینه\",\n        \"decrease\": \"کم کردن\",\n        \"ok\": \"باشه\",\n        \"description\": \"شرح\",\n        \"configure\": \"تنظیم\",\n        \"path\": \"مسیر\",\n        \"center\": \"وسط\",\n        \"no\": \"خیر\",\n        \"owner\": \"مالک\",\n        \"enable\": \"فعال\",\n        \"clear\": \"خالی\",\n        \"forward\": \"جلو\",\n        \"delete\": \"حذف\",\n        \"cancel\": \"لغو\",\n        \"forceRestartRequired\": \"برای اعمال تغییرها دوباره راه‌اندازی کنید… اعلان را برای راه‌اندازی دوباره ببندید\",\n        \"version\": \"نسخه\",\n        \"title\": \"عنوان\",\n        \"filter_one\": \"پالایش\",\n        \"filter_other\": \"پالایش\",\n        \"filters\": \"پالایش\",\n        \"create\": \"ساختن\",\n        \"bitrate\": \"بیت‌ریت\",\n        \"saveAndReplace\": \"ذخیره و جایگزین\",\n        \"action_one\": \"عملیات\",\n        \"action_other\": \"عملیات\",\n        \"playerMustBePaused\": \"پخش‌کننده باید متوقف شود\",\n        \"confirm\": \"تایید\",\n        \"resetToDefault\": \"بازنشانی به پیش‌فرض\",\n        \"home\": \"خانه\",\n        \"comingSoon\": \"به زودی…\",\n        \"reset\": \"بازنشانی\",\n        \"channel_one\": \"کانال\",\n        \"channel_other\": \"کانال\",\n        \"disable\": \"غیرفعال\",\n        \"sortOrder\": \"ترتیب\",\n        \"none\": \"هیچ\",\n        \"menu\": \"منو\",\n        \"restartRequired\": \"راه‌اندازی دوباره لازم است\",\n        \"previousSong\": \"$t(entity.track, {\\\"count\\\": 1}) پیشین\",\n        \"noResultsFromQuery\": \"جست‌وجو نتیجه‌ای نداشت\",\n        \"quit\": \"خروج\",\n        \"expand\": \"گسترش\",\n        \"search\": \"جست‌وجو\",\n        \"saveAs\": \"ذخیره کن با اسم\",\n        \"disc\": \"دیسک\",\n        \"yes\": \"بله\",\n        \"random\": \"تصادفی\",\n        \"size\": \"حجم\",\n        \"biography\": \"زندگی‌نامه\",\n        \"note\": \"توجه\",\n        \"albumGain\": \"گین آلبوم\",\n        \"close\": \"بستن\",\n        \"albumPeak\": \"اوج آلبوم\",\n        \"mbid\": \"شناسه‌ی MusicBrainz\",\n        \"reload\": \"بارگذاری مجدد\",\n        \"setting_one\": \"پیکربندی\",\n        \"setting_other\": \"\",\n        \"trackGain\": \"گین قطعه\",\n        \"trackPeak\": \"اوج قطعه\",\n        \"translation\": \"ترجمه\",\n        \"preview\": \"پیش‌نمایش\",\n        \"share\": \"اشتراک‌گذاری\",\n        \"codec\": \"کدک\"\n    },\n    \"error\": {\n        \"remotePortWarning\": \"برای تعیین port تازه، سرویس دهنده را دوباره راه‌اندازی کنید\",\n        \"playbackError\": \"هنگام پخش خطایی رخ داد\",\n        \"remotePortError\": \"هنگام تعیین port سرویس دهنده خطایی رخ داد\",\n        \"serverRequired\": \"سرویس‌دهنده ضروری است\",\n        \"authenticationFailed\": \"احراز هویت شکست خورد\",\n        \"apiRouteError\": \"درخواست منتقل نشد\",\n        \"genericError\": \"خطایی رخ داد\",\n        \"credentialsRequired\": \"باید وارد شوید\",\n        \"sessionExpiredError\": \"جلسه شما منقضی شده است\",\n        \"remoteEnableError\": \"هنگام $t(common.enable) سرویس دهنده خطای رخ داد\",\n        \"serverNotSelectedError\": \"سرویس‌دهنده‌ای انتخاب نشده\",\n        \"remoteDisableError\": \"هنگام $t(common.disable) سرویس دهنده خطایی رخ داد\",\n        \"mpvRequired\": \"وجود MPV ضروری است\",\n        \"audioDeviceFetchError\": \"هنگام دسترسی به دستگاه صوتی خطایی رخ داد\",\n        \"localFontAccessDenied\": \"دسترسی به فونت‌های محلی پذیرفته نشد\",\n        \"loginRateError\": \"تلاش‌های بسیار برای ورود انجام داده‌اید،‌لطفاً بعد از چند ثانیه دوباره امتحان کنید\",\n        \"networkError\": \"خطای شبکه رخ داد\",\n        \"badAlbum\": \"شما این صفحه را می‌بینید چون‌که این آهنگ قسمتی از یک آلبوم نیست. شما احتمالا این مسأله را به این خاطر می‌بینید که آهنگی در پوشه‌ی سطح بالای آهنگ‌هایتان دارید. جلی‌فین فقط قطعه‌هایی را گروه‌بندی می‌کند که در یک پوشه قرار دارند\",\n        \"invalidServer\": \"سرویس‌دهنده‌ی نامعتبر\",\n        \"openError\": \"نمی‌توان پرونده را باز کرد\",\n        \"endpointNotImplementedError\": \"نقطه‌ی پایان {{endpoint}} برای {{serverType}} قرار داده نشده است\",\n        \"systemFontError\": \"خطایی هنگام تلاش برای دریافت فونت‌های سیستم رخ داد\"\n    },\n    \"filter\": {\n        \"mostPlayed\": \"بیشتر پخش شده\",\n        \"comment\": \"نظر\",\n        \"playCount\": \"تعداد پخش\",\n        \"recentlyUpdated\": \"به تازگی به روز شده\",\n        \"channels\": \"$t(common.channel_other)\",\n        \"recentlyPlayed\": \"به تازگی پخش شده\",\n        \"isRated\": \"امتیاز داده شده است\",\n        \"owner\": \"$t(common.owner)\",\n        \"title\": \"عنوان\",\n        \"rating\": \"امتیاز\",\n        \"search\": \"جست‌وجو\",\n        \"bitrate\": \"بیت‌ریت\",\n        \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n        \"recentlyAdded\": \"به تازگی افزوده شده\",\n        \"note\": \"توجه\",\n        \"name\": \"نام\",\n        \"dateAdded\": \"تاریخ افزوده شدن\",\n        \"releaseDate\": \"تاریخ انتشار\",\n        \"albumCount\": \"$t(entity.album, {\\\"count\\\": 2}) عدد\",\n        \"path\": \"مسیر\",\n        \"favorited\": \"موردعلاقه\",\n        \"albumArtist\": \"$t(entity.albumArtist, {\\\"count\\\": 1})\",\n        \"isRecentlyPlayed\": \"به تازگی پخش شده است\",\n        \"isFavorited\": \"موردعلاقه است\",\n        \"bpm\": \"bpm\",\n        \"releaseYear\": \"سال انتشار\",\n        \"id\": \"id\",\n        \"disc\": \"دیسک\",\n        \"biography\": \"زندگی‌نامه\",\n        \"songCount\": \"تعداد ترانه\",\n        \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n        \"duration\": \"مدت\",\n        \"isPublic\": \"عمومی است\",\n        \"random\": \"تصادفی\",\n        \"lastPlayed\": \"به تازگی پخش شده\",\n        \"toYear\": \"تا سال\",\n        \"fromYear\": \"از سال\",\n        \"criticRating\": \"امتیاز منتقدین\",\n        \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n        \"trackNumber\": \"قطعه\",\n        \"communityRating\": \"رتبه بندی جامعه\",\n        \"isCompilation\": \"مخلوط است\"\n    },\n    \"form\": {\n        \"deletePlaylist\": {\n            \"title\": \"حذف $t(entity.playlist, {\\\"count\\\": 1})\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) حذف شد\",\n            \"input_confirm\": \"برای تایید، نام $t(entity.playlist, {\\\"count\\\": 1}) را وارد کنید\"\n        },\n        \"createPlaylist\": {\n            \"input_description\": \"$t(common.description)\",\n            \"title\": \"ساخت $t(entity.playlist, {\\\"count\\\": 1})\",\n            \"input_public\": \"عمومی\",\n            \"input_name\": \"$t(common.name)\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) ساخته شد\",\n            \"input_owner\": \"$t(common.owner)\"\n        },\n        \"addServer\": {\n            \"title\": \"افزودن سرویس دهنده\",\n            \"input_username\": \"نام کاربری\",\n            \"input_url\": \"نشانی\",\n            \"input_password\": \"رمز عبور\",\n            \"input_name\": \"نام سرویس‌دهنده\",\n            \"success\": \"سرویس‌دهنده افزوده شد\",\n            \"input_savePassword\": \"ذخیرهٔ رمز\",\n            \"error_savePassword\": \"هنگام ذخیره رمز خطایی رخ داد\",\n            \"ignoreCors\": \"نادیده گرفتن هسته‌ها ($t(common.restartRequired))\",\n            \"input_legacyAuthentication\": \"فعال‌سازی احراز هویت سنتی\",\n            \"ignoreSsl\": \"نادیده گرفتن ssl ($t(common.restartRequired))\"\n        },\n        \"addToPlaylist\": {\n            \"success\": \"$t(entity.song, {\\\"count\\\": 2}) به {{numOfPlaylists}}$t(entity.playlist, {\\\"count\\\": 2}) افزوده شد\",\n            \"title\": \"افزودن به $t(entity.playlist, {\\\"count\\\": 1})\",\n            \"input_playlists\": \"$t(entity.playlist, {\\\"count\\\": 2})\",\n            \"input_skipDuplicates\": \"پرش از تکراری‌ها\"\n        },\n        \"lyricSearch\": {\n            \"input_name\": \"$t(common.name)\",\n            \"input_artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n            \"title\": \"جست‌وجو در متن شعر\"\n        },\n        \"editPlaylist\": {\n            \"title\": \"ویرایش $t(entity.playlist, {\\\"count\\\": 1})\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) با موفقیت بروزرسانی شد\",\n            \"publicJellyfinNote\": \"جلی‌فین به دلیلی این‌که فهرست پخش عمومی‌ست یا خصوصی را فاش نمی‌کند. اگر می‌خواهید این عمومی باقی بماند، لطفاٌ ورودی پیش‌رو را منتخب داشته باشید\"\n        },\n        \"queryEditor\": {\n            \"input_optionMatchAny\": \"همخوانی داشتن هر کدام\",\n            \"input_optionMatchAll\": \"همخوانی داشتن همه\"\n        },\n        \"shareItem\": {\n            \"expireInvalid\": \"انقضا باید در آینده باشد\",\n            \"description\": \"بازنمود\",\n            \"setExpiration\": \"تنظیم انقضا\",\n            \"success\": \"پیوند اشتراک‌گذاری در کلیپ‌بورد کپی شد (یا اینجا را کلیک کنید تا باز شود)\",\n            \"allowDownloading\": \"اجازه دادن بارگیری\",\n            \"createFailed\": \"ناکامی در ساخت پیوند اشتراک‌گذاری (آیا اشتراک‌گذاری فعال است؟)\"\n        },\n        \"updateServer\": {\n            \"success\": \"سرویس‌دهنده با موفقیت بروزرسانی شد\",\n            \"title\": \"بروزرسانی سرویس‌دهنده\"\n        }\n    },\n    \"entity\": {\n        \"genre_one\": \"ژانر\",\n        \"genre_other\": \"ژانرها\",\n        \"playlistWithCount_one\": \"{{count}} فهرست پخش\",\n        \"playlistWithCount_other\": \"{{count}} فهرست پخش\",\n        \"playlist_one\": \"فهرست پخش\",\n        \"playlist_other\": \"فهرست‌های پخش\",\n        \"artist_one\": \"هنرمند\",\n        \"artist_other\": \"هنرمندان\",\n        \"folderWithCount_one\": \"{{count}} پوشه\",\n        \"folderWithCount_other\": \"{{count}} پوشه\",\n        \"albumArtist_one\": \"هنرمند آلبوم\",\n        \"albumArtist_other\": \"هنرمندان آلبوم\",\n        \"track_one\": \"قطعه\",\n        \"track_other\": \"قطعه‌ها\",\n        \"albumArtistCount_one\": \"{{count}} هنرمند آلبوم\",\n        \"albumArtistCount_other\": \"{{count}} هنرمند آلبوم\",\n        \"albumWithCount_one\": \"{{count}} آلبوم\",\n        \"albumWithCount_other\": \"{{count}} آلبوم\",\n        \"favorite_one\": \"موردعلاقه\",\n        \"favorite_other\": \"موردعلاقه\",\n        \"artistWithCount_one\": \"{{count}} هنرمند\",\n        \"artistWithCount_other\": \"{{count}} هنرمند\",\n        \"folder_one\": \"پوشه\",\n        \"folder_other\": \"پوشه‌ها\",\n        \"smartPlaylist\": \"$t(entity.playlist, {\\\"count\\\": 1}) هوشمند\",\n        \"album_one\": \"آلبوم\",\n        \"album_other\": \"آلبوم‌ها\",\n        \"genreWithCount_one\": \"{{count}} ژانر\",\n        \"genreWithCount_other\": \"{{count}} ژانر\",\n        \"trackWithCount_one\": \"{{count}} قطعه\",\n        \"trackWithCount_other\": \"{{count}} قطعه\",\n        \"play_one\": \"{{count}} بار پخش\",\n        \"play_other\": \"{{count}} بار پخش\",\n        \"song_one\": \"آهنگ\",\n        \"song_other\": \"آهنگ‌ها\"\n    },\n    \"page\": {\n        \"albumList\": {\n            \"title\": \"$t(entity.album, {\\\"count\\\": 2})\",\n            \"artistAlbums\": \"آلبوم‌های {{artist}}\",\n            \"genreAlbums\": \"\\\"{{genre}}\\\" $t(entity.album, {\\\"count\\\": 2})\"\n        },\n        \"appMenu\": {\n            \"settings\": \"$t(common.setting, {\\\"count\\\": 2})\",\n            \"selectServer\": \"گزینش سرویس‌دهنده\",\n            \"expandSidebar\": \"گسترش نوار کناری\",\n            \"collapseSidebar\": \"فروکش نوار کناری\",\n            \"goBack\": \"بازگشت\",\n            \"openBrowserDevtools\": \"باز کردن ابزارهای توسعه مرورگر\",\n            \"quit\": \"$t(common.quit)\",\n            \"goForward\": \"پیش رفتن\",\n            \"manageServers\": \"مدیریت سرویس‌دهنده‌ها\",\n            \"version\": \"نسخه‌ی {{version}}\"\n        },\n        \"albumArtistDetail\": {\n            \"appearsOn\": \"مشاهده می‌شود در\",\n            \"about\": \"درباره‌ی {{artist}}\",\n            \"recentReleases\": \"عرضه‌های اخیر\",\n            \"viewAllTracks\": \"نمایش همه‌ی $t(entity.track, {\\\"count\\\": 2})\",\n            \"topSongsFrom\": \"قطعه‌های برتر از {{title}}\",\n            \"viewAll\": \"نمایش همه\",\n            \"viewDiscography\": \"نمایش کاتالوگ\",\n            \"relatedArtists\": \"$t(entity.artist, {\\\"count\\\": 2}) مربوطه\",\n            \"topSongs\": \"قطعه‌های برتر\"\n        },\n        \"contextMenu\": {\n            \"addFavorite\": \"$t(action.addToFavorites)\",\n            \"addLast\": \"$t(player.addLast)\",\n            \"addNext\": \"$t(player.addNext)\",\n            \"addToFavorites\": \"$t(action.addToFavorites)\",\n            \"numberSelected\": \"{{count}} تا انتخاب شده\",\n            \"play\": \"$t(player.play)\",\n            \"removeFromFavorites\": \"$t(action.removeFromFavorites)\",\n            \"deselectAll\": \"$t(action.deselectAll)\",\n            \"download\": \"بارگیری\",\n            \"shareItem\": \"اشتراک‌گذاری آیتم\",\n            \"removeFromPlaylist\": \"$t(action.removeFromPlaylist)\",\n            \"showDetails\": \"دریافت داده\",\n            \"playSimilarSongs\": \"$t(player.playSimilarSongs)\",\n            \"removeFromQueue\": \"$t(action.removeFromQueue)\",\n            \"playShuffled\": \"$t(player.shuffle)\",\n            \"addToPlaylist\": \"$t(action.addToPlaylist)\",\n            \"createPlaylist\": \"$t(action.createPlaylist)\",\n            \"moveToBottom\": \"$t(action.moveToBottom)\",\n            \"moveToTop\": \"$t(action.moveToTop)\",\n            \"setRating\": \"$t(action.setRating)\",\n            \"deletePlaylist\": \"$t(action.deletePlaylist)\",\n            \"moveToNext\": \"$t(action.moveToNext)\"\n        },\n        \"fullscreenPlayer\": {\n            \"related\": \"موارد مربوطه\",\n            \"visualizer\": \"تجسم یافته\",\n            \"config\": {\n                \"dynamicImageBlur\": \"اندازه مبهمی نگاره\",\n                \"dynamicIsImage\": \"فعال‌سازی نگاره به عنوان پس‌زمینه\",\n                \"lyricOffset\": \"انحراف متن شعر (میلی‌ثانیه)\",\n                \"unsynchronized\": \"همگام نشده\",\n                \"dynamicBackground\": \"پس‌زمینه پویا\",\n                \"followCurrentLyric\": \"دنبال کردن متن شعر کنونی\",\n                \"lyricAlignment\": \"هم‌ترازی متن شعر\",\n                \"lyricGap\": \"فاصله‌ی متن شعر\",\n                \"showLyricProvider\": \"نمایش فراهم‌گر متن شعر\",\n                \"useImageAspectRatio\": \"استفاده از نسبت نمای نگاره\",\n                \"lyricSize\": \"اندازه‌ی متن شعر\",\n                \"opacity\": \"شفافی\",\n                \"showLyricMatch\": \"نمایش همخوانی متن شعر\",\n                \"synchronized\": \"همگام شده\"\n            },\n            \"noLyrics\": \"هیچ متن شعری پیدا نشد\",\n            \"lyrics\": \"متن شعر\",\n            \"upNext\": \"در ادامه\"\n        },\n        \"home\": {\n            \"mostPlayed\": \"بیشترین پخش‌شده‌ها\",\n            \"title\": \"$t(common.home)\",\n            \"explore\": \"در کتاب‌خانه‌ی خود کاوش کنید\",\n            \"newlyAdded\": \"عرضه‌های تازه افزوده شده\",\n            \"recentlyPlayed\": \"تازه پخش شده‌ها\"\n        },\n        \"playlist\": {\n            \"reorder\": \"مرتب کردن دوباره زمانی فقط زمانی فعال شود که مرتب‌سازی بر اساس شناسه است\"\n        },\n        \"setting\": {\n            \"advanced\": \"پیشرفته\",\n            \"windowTab\": \"پنجره\",\n            \"generalTab\": \"همگانی\",\n            \"hotkeysTab\": \"کلیدهای میان‌بر\",\n            \"playbackTab\": \"پخش\"\n        },\n        \"sidebar\": {\n            \"genres\": \"$t(entity.genre, {\\\"count\\\": 2})\",\n            \"playlists\": \"$t(entity.playlist, {\\\"count\\\": 2})\",\n            \"search\": \"$t(common.search)\",\n            \"albumArtists\": \"$t(entity.albumArtist, {\\\"count\\\": 2})\",\n            \"albums\": \"$t(entity.album, {\\\"count\\\": 2})\",\n            \"folders\": \"$t(entity.folder, {\\\"count\\\": 2})\",\n            \"artists\": \"$t(entity.artist, {\\\"count\\\": 2})\",\n            \"home\": \"$t(common.home)\",\n            \"nowPlaying\": \"پخش کنونی\",\n            \"tracks\": \"$t(entity.track, {\\\"count\\\": 2})\",\n            \"settings\": \"$t(common.setting, {\\\"count\\\": 2})\",\n            \"shared\": \"$t(entity.playlist, {\\\"count\\\": 2}) اشتراک‌گذاری شده\"\n        },\n        \"albumDetail\": {\n            \"moreFromArtist\": \"موارد بیشتر از این $t(entity.artist, {\\\"count\\\": 1})\",\n            \"moreFromGeneric\": \"موارد بیشتر از {{item}}\",\n            \"released\": \"عرضه شده\"\n        },\n        \"manageServers\": {\n            \"title\": \"مدیریت سرویس‌دهنده‌ها\",\n            \"url\": \"آدرس\",\n            \"serverDetails\": \"ریزگان سرویس‌دهنده\",\n            \"removeServer\": \"حذف سرویس‌دهنده\",\n            \"username\": \"نام کاربری\",\n            \"editServerDetailsTooltip\": \"ویرایش ریزگان سرویس‌دهنده\"\n        },\n        \"genreList\": {\n            \"showAlbums\": \"نمایش $t(entity.genre, {\\\"count\\\": 1}) $t(entity.album, {\\\"count\\\": 2})\",\n            \"title\": \"$t(entity.genre, {\\\"count\\\": 2})\",\n            \"showTracks\": \"نمایش $t(entity.genre, {\\\"count\\\": 1}) $t(entity.track, {\\\"count\\\": 2})\"\n        },\n        \"globalSearch\": {\n            \"commands\": {\n                \"goToPage\": \"رفتن به صفحه‌ی\",\n                \"searchFor\": \"جست‌و‌جو برای {{query}}\",\n                \"serverCommands\": \"فرمان‌های سرویس‌دهنده\"\n            },\n            \"title\": \"فرمان‌ها\"\n        },\n        \"playlistList\": {\n            \"title\": \"$t(entity.playlist, {\\\"count\\\": 2})\"\n        },\n        \"trackList\": {\n            \"title\": \"$t(entity.track, {\\\"count\\\": 2})\",\n            \"artistTracks\": \"قطعه‌های {{artist}}\",\n            \"genreTracks\": \"$t(entity.track, {\\\"count\\\": 2}) \\\"{{genre}}\\\"\"\n        },\n        \"albumArtistList\": {\n            \"title\": \"$t(entity.albumArtist, {\\\"count\\\": 2})\"\n        },\n        \"itemDetail\": {\n            \"copyPath\": \"کپی کردن مسیر در کلیپ‌بورد\",\n            \"copiedPath\": \"مسیر با موفقیت کپی شد\",\n            \"openFile\": \"نمایش قطعه در مدیر پرونده\"\n        }\n    },\n    \"table\": {\n        \"column\": {\n            \"size\": \"$t(common.size)\",\n            \"lastPlayed\": \"آخرین بار پخش شده\",\n            \"discNumber\": \"دیسک\",\n            \"songCount\": \"$t(entity.track, {\\\"count\\\": 2})\",\n            \"title\": \"عنوان\",\n            \"trackNumber\": \"قطعه\",\n            \"favorite\": \"مورد علاقه\",\n            \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n            \"comment\": \"دیدگاه\",\n            \"playCount\": \"تعداد پخش\",\n            \"rating\": \"امتیاز\",\n            \"path\": \"مسیر\",\n            \"releaseYear\": \"سال\",\n            \"dateAdded\": \"تاریخ افزوده شدن\",\n            \"releaseDate\": \"تاریخ عرضه\"\n        },\n        \"config\": {\n            \"general\": {\n                \"followCurrentSong\": \"آهنگ کنونی را دنبال کن\",\n                \"displayType\": \"نوع نمایش\",\n                \"itemSize\": \"اندازه‌ی آیتم (px)\",\n                \"size\": \"$t(common.size)\",\n                \"tableColumns\": \"ستون‌های جدول\",\n                \"autoFitColumns\": \"تطبیق دادن ستون‌ها به شیوه‌ی خودکار\",\n                \"gap\": \"$t(common.gap)\",\n                \"itemGap\": \"فاصله‌ی آیتم (px)\"\n            },\n            \"label\": {\n                \"playCount\": \"تعداد پخش\",\n                \"dateAdded\": \"تاریخ افزوده شدن\",\n                \"discNumber\": \"شماره‌ی دیسک\",\n                \"lastPlayed\": \"آخرین بار پخش شده\",\n                \"actions\": \"$t(common.action_other)\"\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "src/i18n/locales/fi.json",
    "content": "{\n    \"common\": {\n        \"size\": \"koko\",\n        \"search\": \"etsi\",\n        \"sortOrder\": \"järjestys\",\n        \"setting_one\": \"asetus\",\n        \"setting_other\": \"asetukset\",\n        \"title\": \"otsikko\",\n        \"trackNumber\": \"raita\",\n        \"action_one\": \"toiminto\",\n        \"action_other\": \"toiminnot\",\n        \"add\": \"lisää\",\n        \"areYouSure\": \"oletko varma?\",\n        \"ascending\": \"nouseva\",\n        \"backward\": \"takaperin\",\n        \"bitrate\": \"bittinopeus\",\n        \"channel_one\": \"kanava\",\n        \"channel_other\": \"kanavat\",\n        \"collapse\": \"luhista\",\n        \"comingSoon\": \"tulossa pian…\",\n        \"configure\": \"konfiguroi\",\n        \"confirm\": \"hyväksy\",\n        \"disable\": \"poista käytöstä\",\n        \"disc\": \"levy\",\n        \"dismiss\": \"hylkää\",\n        \"favorite\": \"suosikki\",\n        \"filter_one\": \"suodatin\",\n        \"filter_other\": \"suodattimet\",\n        \"filters\": \"suodattimet\",\n        \"forceRestartRequired\": \"käynnistä uudelleen ottaaksesi muutokset käyttöön… sulje ilmoitus käynnistääksesi uudelleen\",\n        \"gap\": \"väli\",\n        \"home\": \"koti\",\n        \"left\": \"vasen\",\n        \"limit\": \"raja\",\n        \"manage\": \"hallitse\",\n        \"menu\": \"valikko\",\n        \"minimize\": \"minimoi\",\n        \"modified\": \"muokattu\",\n        \"name\": \"nimi\",\n        \"no\": \"ei\",\n        \"none\": \"ei mitään\",\n        \"noResultsFromQuery\": \"kysely ei tuottanut tuloksia\",\n        \"note\": \"huomautus\",\n        \"ok\": \"ok\",\n        \"owner\": \"omistaja\",\n        \"path\": \"polku\",\n        \"preview\": \"esikatsele\",\n        \"previousSong\": \"edellinen $t(entity.track, {\\\"count\\\": 1})\",\n        \"resetToDefault\": \"palauta oletusarvoihin\",\n        \"restartRequired\": \"vaatii uudelleenkäynnistyksen\",\n        \"right\": \"oikea\",\n        \"save\": \"tallenna\",\n        \"saveAndReplace\": \"tallenna ja korvaa\",\n        \"saveAs\": \"tallenna nimellä\",\n        \"unknown\": \"tuntematon\",\n        \"version\": \"versio\",\n        \"year\": \"vuosi\",\n        \"yes\": \"kyllä\",\n        \"close\": \"sulje\",\n        \"descending\": \"laskeva\",\n        \"biography\": \"biografia\",\n        \"cancel\": \"peruuta\",\n        \"bpm\": \"bpm\",\n        \"decrease\": \"pienennä\",\n        \"center\": \"keskitä\",\n        \"clear\": \"tyhjennä\",\n        \"codec\": \"koodekki\",\n        \"create\": \"luo\",\n        \"description\": \"kuvaus\",\n        \"currentSong\": \"nykyinen $t(entity.track, {\\\"count\\\": 1})\",\n        \"delete\": \"poista\",\n        \"duration\": \"kesto\",\n        \"edit\": \"muokkaa\",\n        \"enable\": \"ota käyttöön\",\n        \"expand\": \"laajenna\",\n        \"increase\": \"lisää\",\n        \"forward\": \"eteenpäin\",\n        \"maximize\": \"maksimoi\",\n        \"mbid\": \"MusicBrainz ID\",\n        \"share\": \"jaa\",\n        \"random\": \"satunnainen\",\n        \"reload\": \"lataa uudelleen\",\n        \"quit\": \"poistu\",\n        \"rating\": \"arvostelu\",\n        \"refresh\": \"virkistä\",\n        \"reset\": \"nollaa\",\n        \"playerMustBePaused\": \"soittimen täytyy olla pysäytetty\",\n        \"translation\": \"käännös\",\n        \"albumGain\": \"albumin vahvistus (gain)\",\n        \"albumPeak\": \"albumin huippu (peak)\",\n        \"trackGain\": \"raidan vahvistus (gain)\",\n        \"trackPeak\": \"kappaleen huippu (peak)\",\n        \"additionalParticipants\": \"muut osallistujat\",\n        \"tags\": \"tägit\",\n        \"newVersion\": \"uusi versio on asennettu ({{version}})\",\n        \"viewReleaseNotes\": \"katsele julkaisutietoja\",\n        \"bitDepth\": \"bittisyvyys\",\n        \"sampleRate\": \"näytteenottotaajuus\",\n        \"private\": \"yksityinen\",\n        \"public\": \"julkinen\",\n        \"explicitStatus\": \"eksplisiittinen tila\",\n        \"recordLabel\": \"levy-yhtiö\",\n        \"releaseType\": \"julkaisun tyyppi\",\n        \"explicit\": \"eksplisiittinen\",\n        \"clean\": \"puhdas\",\n        \"countSelected\": \"{{count}} valittuna\",\n        \"doNotShowAgain\": \"älä näytä uudelleen\",\n        \"view\": \"katso\",\n        \"example\": \"esimerkki\",\n        \"externalLinks\": \"ulkoiset linkit\",\n        \"faster\": \"nopeammin\",\n        \"filter_single\": \"yksi\",\n        \"filter_multiple\": \"useampi\",\n        \"mood\": \"mieliala\",\n        \"noFilters\": \"suodattimia ei ole määritetty\",\n        \"retry\": \"yritä uudelleen\",\n        \"rename\": \"nimeä uudelleen\",\n        \"slower\": \"hitaammin\",\n        \"sort\": \"järjestä\",\n        \"gridRows\": \"ruudukon rivejä\",\n        \"tableColumns\": \"taulukon sarakkeita\",\n        \"itemsMore\": \"{{count}} lisää\"\n    },\n    \"entity\": {\n        \"album_one\": \"albumi\",\n        \"album_other\": \"albumit\",\n        \"albumArtist_one\": \"albumin artisti\",\n        \"albumArtist_other\": \"albumin artistit\",\n        \"artistWithCount_one\": \"{{count}} artisti\",\n        \"artistWithCount_other\": \"{{count}} artistia\",\n        \"playlist_one\": \"soittolista\",\n        \"playlist_other\": \"soittolistat\",\n        \"playlistWithCount_one\": \"{{count}} soittolista\",\n        \"playlistWithCount_other\": \"{{count}} soittolistaa\",\n        \"albumArtistCount_one\": \"{{count}} albumin artisti\",\n        \"albumArtistCount_other\": \"{{count}} albumin artistia\",\n        \"albumWithCount_one\": \"{{count}} albumi\",\n        \"albumWithCount_other\": \"{{count}} albumia\",\n        \"artist_one\": \"artisti\",\n        \"artist_other\": \"artistit\",\n        \"favorite_one\": \"suosikki\",\n        \"favorite_other\": \"suosikit\",\n        \"folder_one\": \"kansio\",\n        \"folder_other\": \"kansiot\",\n        \"folderWithCount_one\": \"{{count}} kansio\",\n        \"folderWithCount_other\": \"{{count}} kansiota\",\n        \"genre_one\": \"genre\",\n        \"genre_other\": \"genret\",\n        \"genreWithCount_one\": \"{{count}} genre\",\n        \"genreWithCount_other\": \"{{count}} genreä\",\n        \"smartPlaylist\": \"älykäs $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"track_one\": \"raita\",\n        \"track_other\": \"raidat\",\n        \"trackWithCount_one\": \"{{count}} raita\",\n        \"trackWithCount_other\": \"{{count}} raitaa\",\n        \"play_one\": \"{{count}} toisto\",\n        \"play_other\": \"{{count}} toistoa\",\n        \"song_one\": \"kappale\",\n        \"song_other\": \"kappaleet\",\n        \"radioStation_one\": \"radioasema\",\n        \"radioStation_other\": \"radioasemaa\",\n        \"radioStationWithCount_one\": \"{{count}} radioasema\",\n        \"radioStationWithCount_other\": \"{{count}} radioasemaa\"\n    },\n    \"action\": {\n        \"clearQueue\": \"tyhjennä jono\",\n        \"createPlaylist\": \"luo $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"deselectAll\": \"poista kaikkien valinta\",\n        \"editPlaylist\": \"muokkaa $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"removeFromQueue\": \"poista jonosta\",\n        \"viewPlaylists\": \"katsele $t(entity.playlist, {\\\"count\\\": 2})\",\n        \"openIn\": {\n            \"lastfm\": \"Avaa Last.fm:ssä\",\n            \"musicbrainz\": \"Avaa MusicBrainz:ssä\"\n        },\n        \"goToPage\": \"mene sivulle\",\n        \"moveToBottom\": \"siirrä alimmaksi\",\n        \"moveToTop\": \"siirry ylös\",\n        \"addToFavorites\": \"lisää kohteeseen $t(entity.favorite, {\\\"count\\\": 2})\",\n        \"addToPlaylist\": \"lisää kohteeseen $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"refresh\": \"$t(common.refresh)\",\n        \"removeFromFavorites\": \"poista kohteesta $t(entity.favorite, {\\\"count\\\": 2})\",\n        \"toggleSmartPlaylistEditor\": \"kytke $t(entity.smartPlaylist) editori\",\n        \"deletePlaylist\": \"poista $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"removeFromPlaylist\": \"poista kohteesta $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"setRating\": \"aseta arvostelu\",\n        \"moveToNext\": \"siirry seuraavaan\",\n        \"selectRangeOfItems\": \"valitse useita peräkkäisiä kohteita\",\n        \"goToCurrent\": \"siirry nykyiseen kohteeseen\",\n        \"createRadioStation\": \"luo $t(entity.radioStation, {\\\"count\\\": 1})\",\n        \"deleteRadioStation\": \"poista $t(entity.radioStation, {\\\"count\\\": 1})\",\n        \"selectAll\": \"valitse kaikki\",\n        \"downloadStarted\": \"aloitettiin lataamaan {{count}} kohdetta\",\n        \"moveUp\": \"siirrä ylöspäin\",\n        \"moveDown\": \"siirrä alaspäin\",\n        \"holdToMoveToTop\": \"pidä pohjassa siirtääksesi ylimmäksi\",\n        \"holdToMoveToBottom\": \"pidä pohjassa siirtääksesi alimmaksi\",\n        \"moveItems\": \"siirrä kohteet\",\n        \"shuffle\": \"sekoita\",\n        \"shuffleAll\": \"sekoita kaikki\",\n        \"shuffleSelected\": \"sekoita valitut\",\n        \"viewMore\": \"katso lisää\",\n        \"openApplicationDirectory\": \"avaa ohjelman kansio\",\n        \"addOrRemoveFromSelection\": \"lisää tai poista valinnasta\"\n    },\n    \"error\": {\n        \"remoteEnableError\": \"virhe tapahtui yrittäessä $t(common.enable) etäpalvelinta\",\n        \"remotePortError\": \"virhe tapahtui etäpalvelimen porttia määrittäessä\",\n        \"serverNotSelectedError\": \"palvelinta ei ole valittu\",\n        \"remoteDisableError\": \"virhe tapahtui yrittäessä $t(common.disable) etäpalvelinta\",\n        \"serverRequired\": \"palvelin vaadittu\",\n        \"systemFontError\": \"virhe tapahtui yrittäessä hakea järjestelmän fontteja\",\n        \"sessionExpiredError\": \"istuntosi on vanhentunut\",\n        \"genericError\": \"tapahtui virhe\",\n        \"invalidServer\": \"virheellinen palvelin\",\n        \"audioDeviceFetchError\": \"äänentoistolaitteita haettaessa tapahtui virhe\",\n        \"authenticationFailed\": \"tunnistautuminen epäonnistui\",\n        \"badAlbum\": \"näet tämän sivun koska tämä kappale ei ole osa albumia. Näet tämän todennäköisesti jos kappaleesi on päämusiikkikansiosi juuressa. Jellyfin ryhmittää kappaleet vain jos ne ovat kansiossa\",\n        \"apiRouteError\": \"pyynnön reititys epäonnistui\",\n        \"credentialsRequired\": \"käyttäjätunnuksia vaaditaan\",\n        \"loginRateError\": \"liian monta kirjautumisyritystä, kokeile muutaman sekuntin päästä uudestaan\",\n        \"mpvRequired\": \"MPV vaadittu\",\n        \"networkError\": \"verkkoyhteysvirhe\",\n        \"openError\": \"tiedostoa ei voitu avata\",\n        \"localFontAccessDenied\": \"paikallisiin fontteihin pääsy on kielletty\",\n        \"playbackError\": \"mediaa toistaessa tapahtui virhe\",\n        \"remotePortWarning\": \"käynnistä palvelin uudestaan ottaaksesi uuden portin käyttöön\",\n        \"endpointNotImplementedError\": \"päätepiste {{endpoint}} ei ole toteutettu {{serverType}} varten\",\n        \"badValue\": \"kelpaamaton optio \\\"{{value}}\\\". tätä arvoa ei ole enää olemassa\",\n        \"notificationDenied\": \"luvat ilmouilmoituksia varten evättiin. tällä asetuksella ei ole vaikutusta\",\n        \"invalidJson\": \"virheellinen JSON\",\n        \"multipleServerSaveQueueError\": \"soittojonossa on yksi tai useampi kappale, jotka eivät ole nykyiseltä palvelimelta. tätä ei ole tuettu\",\n        \"noNetwork\": \"palvelin ei ole käytettävissä\",\n        \"noNetworkDescription\": \"ei voida yhdistää palvelimeen\",\n        \"serverLockSingleServer\": \"lukitussa tilassa sallitaan vain yksi palvelin\",\n        \"settingsSyncError\": \"rendererin ja pääprosessin asetukset eivät täsmää. Käynnistä sovellus uudelleen, jotta muutokset otetaan käyttöön\",\n        \"playbackPausedDueToError\": \"toisto tauotettiin virheen takia\",\n        \"saveQueueFailed\": \"jonon tallentaminen epäonnistui\"\n    },\n    \"filter\": {\n        \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n        \"albumArtist\": \"$t(entity.albumArtist, {\\\"count\\\": 1})\",\n        \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n        \"biography\": \"biografia\",\n        \"bitrate\": \"bittinopeus\",\n        \"bpm\": \"lyöntiä minuutissa (bpm)\",\n        \"channels\": \"$t(common.channel, {\\\"count\\\": 2})\",\n        \"title\": \"otsikko\",\n        \"playCount\": \"toistomäärä\",\n        \"dateAdded\": \"lisätty päivänä\",\n        \"lastPlayed\": \"viimeksi toistettu\",\n        \"mostPlayed\": \"eniten toistettu\",\n        \"isRecentlyPlayed\": \"on äskettäin toistettu\",\n        \"rating\": \"arvostelu\",\n        \"recentlyAdded\": \"äskettäin lisätty\",\n        \"recentlyUpdated\": \"äskettäin päivitetty\",\n        \"releaseDate\": \"julkaisupäivä\",\n        \"toYear\": \"vuoteen\",\n        \"releaseYear\": \"julkaisuvuosi\",\n        \"search\": \"haku\",\n        \"trackNumber\": \"raita\",\n        \"isPublic\": \"on julkinen\",\n        \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n        \"favorited\": \"suosikeissa\",\n        \"fromYear\": \"vuodelta\",\n        \"isRated\": \"on arvosteltu\",\n        \"recentlyPlayed\": \"äskettäin toistetut\",\n        \"albumCount\": \"$t(entity.album, {\\\"count\\\": 2}) määrä\",\n        \"disc\": \"levy\",\n        \"duration\": \"kesto\",\n        \"id\": \"tunnus\",\n        \"random\": \"satunnainen\",\n        \"isFavorited\": \"on suosikeissa\",\n        \"isCompilation\": \"on osa kokoelmaa\",\n        \"comment\": \"kommentti\",\n        \"communityRating\": \"yhteisön arvostelu\",\n        \"criticRating\": \"kriitikon arvostelu\",\n        \"name\": \"nimi\",\n        \"note\": \"muistiinpano\",\n        \"owner\": \"$t(common.owner)\",\n        \"path\": \"polku\",\n        \"songCount\": \"kappalemäärä\",\n        \"explicitStatus\": \"$t(common.explicitStatus)\",\n        \"matchAnd\": \"ja\",\n        \"matchOr\": \"tai\",\n        \"sortName\": \"järjestä nimen mukaan\"\n    },\n    \"form\": {\n        \"addServer\": {\n            \"input_legacyAuthentication\": \"käytä vanhaa kirjautumistapaa\",\n            \"ignoreCors\": \"ohita CORS ($t(common.restartRequired))\",\n            \"input_name\": \"palvelimen nimi\",\n            \"ignoreSsl\": \"ohita SSL ($t(common.restartRequired))\",\n            \"input_savePassword\": \"tallenna salasana\",\n            \"input_url\": \"url-osoite\",\n            \"title\": \"lisää palvelin\",\n            \"error_savePassword\": \"salasanaa tallentaessa tapahtui virhe\",\n            \"input_password\": \"salasana\",\n            \"input_username\": \"käyttäjänimi\",\n            \"success\": \"palvelin lisätty onnistuneesti\",\n            \"input_preferInstantMix\": \"suosi pika-miksausta\",\n            \"input_preferInstantMixDescription\": \"käytä vain pika-miksausta saadaksesi samankaltaisia kappaleita. käytännöllinen jos sinulla on lisäosia, jotka muuttavat tätä käytöstä\",\n            \"input_preferRemoteUrl\": \"suosi julkista url-osoitetta\",\n            \"input_remoteUrl\": \"julkinen url-osoite\",\n            \"input_remoteUrlPlaceholder\": \"valinnainen: julkinen url-osoite ulkoisille toiminnoille\"\n        },\n        \"createPlaylist\": {\n            \"input_public\": \"julkinen\",\n            \"input_name\": \"$t(common.name)\",\n            \"input_owner\": \"$t(common.owner)\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) luotu onnistuneesti\",\n            \"title\": \"luo $t(entity.playlist, {\\\"count\\\": 1})\",\n            \"input_description\": \"$t(common.description)\"\n        },\n        \"addToPlaylist\": {\n            \"input_skipDuplicates\": \"ohita kaksoiskappaleet\",\n            \"success\": \"$t(entity.trackWithCount, {\\\"count\\\": {{message}} }) lisätty $t(entity.playlistWithCount, {\\\"count\\\": {{numOfPlaylists}} })\",\n            \"title\": \"lisää soittolistalle $t(entity.playlist, {\\\"count\\\": 1})\",\n            \"input_playlists\": \"$t(entity.playlist, {\\\"count\\\": 2})\",\n            \"create\": \"luo $t(entity.playlist, {\\\"count\\\": 1}) {{playlist}}\",\n            \"searchOrCreate\": \"hae $t(entity.playlist, {\\\"count\\\": 2}) tai tyyppiä luodaksesi uuden\"\n        },\n        \"updateServer\": {\n            \"success\": \"palvelin on päivitetty onnistuneesti\",\n            \"title\": \"päivitä palvelin\"\n        },\n        \"deletePlaylist\": {\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) poistettu onnistuneesti\",\n            \"title\": \"poista $t(entity.playlist, {\\\"count\\\": 1})\",\n            \"input_confirm\": \"kirjoita soittolistan $t(entity.playlist, {\\\"count\\\": 1}) nimi vahvistaaksesi\"\n        },\n        \"editPlaylist\": {\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) päivitetty onnistuneesti\",\n            \"title\": \"muokkaa $t(entity.playlist, {\\\"count\\\": 1})\",\n            \"publicJellyfinNote\": \"Jellyfin ei jostain syystä kerro onko soittolista julkinen vai ei. Jos haluat sen pysyvän julkisena, pidä seuraava valinta valittuna\",\n            \"editNote\": \"manuaalisia muokkauksia ei suositella suurille soittolistoille. haluatko varmasti hyväksyä riskin, että nykyinen soittolista ylikirjoitetaan ja tietoja voi hävitä?\"\n        },\n        \"lyricSearch\": {\n            \"input_artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n            \"input_name\": \"$t(common.name)\",\n            \"title\": \"sanojen haku\"\n        },\n        \"shareItem\": {\n            \"createFailed\": \"jaon luonti epäonnistui (onko jako päällä?)\",\n            \"allowDownloading\": \"salli lataus\",\n            \"description\": \"kuvaus\",\n            \"setExpiration\": \"aseta vanheneminen\",\n            \"success\": \"jakolinkki kopioitu leikepöydälle (tai klikkaa tästä avataksesi)\",\n            \"expireInvalid\": \"vanhetumisen pitää olla tulevaisuudessa\",\n            \"copyToClipboard\": \"Kopioi leikepöydälle: Ctrl+C, Enter\",\n            \"successMustClick\": \"jako luotu onnistuneesti. paina tästä avataksesi\"\n        },\n        \"queryEditor\": {\n            \"input_optionMatchAny\": \"sovita joku\",\n            \"input_optionMatchAll\": \"sovita kaikki\",\n            \"title\": \"kyselyeditori\",\n            \"addRuleGroup\": \"lisää sääntöryhmä\",\n            \"removeRuleGroup\": \"poista sääntöryhmä\",\n            \"resetToDefault\": \"palauta oletukset\",\n            \"clearFilters\": \"poista suodattimet\"\n        },\n        \"privateMode\": {\n            \"enabled\": \"yksityinen tila käytössä, toistotila on nyt piilotettu ulkoisilta integraatioilta\",\n            \"disabled\": \"yksityinen tila poissa käytössä, toistotila on nyt näkyvillä ulkoisille integraatioille\",\n            \"title\": \"yksityinen tila\"\n        },\n        \"largeFetchConfirmation\": {\n            \"title\": \"lisää kohteet jonoon\",\n            \"description\": \"lisää kaikki suodatetun näkymän kohteet\"\n        },\n        \"createRadioStation\": {\n            \"success\": \"radiokanava luotu onnistuneesti\",\n            \"title\": \"luo radiokanava\",\n            \"input_homepageUrl\": \"kotisivun osoite\",\n            \"input_name\": \"nimi\",\n            \"input_streamUrl\": \"suoratoisto-osoite\"\n        },\n        \"lyricsExport\": {\n            \"export\": \"vie sanoitukset\",\n            \"input_synced\": \"vie ajastetut sanoitukset\",\n            \"input_offset\": \"$t(setting.lyricOffset)\"\n        },\n        \"saveQueue\": {\n            \"success\": \"toistojono tallennettu palvelimelle\"\n        },\n        \"shuffleAll\": {\n            \"title\": \"soita satunnainen\",\n            \"input_genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n            \"input_limit\": \"kuinka monta kappaletta?\",\n            \"input_minYear\": \"vuodesta\",\n            \"input_maxYear\": \"vuoteen\",\n            \"input_played\": \"toiston suodatin\",\n            \"input_played_optionAll\": \"kaikki raidat\",\n            \"input_played_optionUnplayed\": \"vain toistamattomat raidat\",\n            \"input_played_optionPlayed\": \"vain toistetut raidat\"\n        }\n    },\n    \"setting\": {\n        \"clearCacheSuccess\": \"välimuisti on tyhjennetty onnistuneesti\",\n        \"artistConfiguration_description\": \"valise näytettävät asiat ja niiden järjestys albumin artistin sivulla\",\n        \"audioDevice\": \"äänilaite\",\n        \"clearQueryCache_description\": \"feishinin 'pehmeä tyhjennys'. tämä tyhjentää soittolistat, raitojen metadatat ja tallennetut sanoitukset. asetukset, palvelimien käyttäjätunnukset ja välimuistissa olevat kuvat säilyvät\",\n        \"crossfadeDuration\": \"ristihäivytyksen kesto\",\n        \"audioPlayer_description\": \"valitse toistossa käytettävä soitin\",\n        \"buttonSize\": \"soittimen palkin nappien koko\",\n        \"buttonSize_description\": \"soittimen palkin nappien koko\",\n        \"clearCache\": \"tyhjennä selaimen välimuisti\",\n        \"clearQueryCache\": \"tyhjennä feishinin välimuisti\",\n        \"crossfadeDuration_description\": \"aseta ristihäivytystehosteen kesto\",\n        \"applicationHotkeys_description\": \"aseta sovelluksen pikanäppäimet. vaihda valintaruutua asettaaksesi valinta globaaliksi pikanäppäimeksi (vain työpöydällä)\",\n        \"crossfadeStyle_description\": \"valitse soittimessa käytettävän ristihäivytyksen tyyli\",\n        \"contextMenu_description\": \"mahdollistaa sinun piilottaa asiat, jotka näytetään valikossa klikatessasi objektia hiiren väärällä painikkella. poistetut valinnat piilotetaan\",\n        \"customCssEnable_description\": \"mahdollista oman css:n kirjoittaminen\",\n        \"accentColor\": \"korostusväri\",\n        \"customCssEnable\": \"käytä omaa css:ää\",\n        \"albumBackgroundBlur_description\": \"säätää albumin taustakuvan sumennuksen määrää\",\n        \"audioExclusiveMode_description\": \"käytä yksinomaista ulostulotilaa. Tässä tilassa järjestelmä on yleensä lukittuna ja vain mpv voi tuottaa ääntä\",\n        \"albumBackgroundBlur\": \"albumin taustakuvan sumennuksen koko\",\n        \"clearCache_description\": \"feishinin 'kova tyhjennys'. feishinin välimuistin lisäksi tyhjennä selaimen välimuisti (tallennetut kuvat ja muut kohteet). palvelimien käyttäjättunnukset ja asetukset säilyvät\",\n        \"audioExclusiveMode\": \"äänen yksinomainen tila\",\n        \"audioPlayer\": \"soitin\",\n        \"contextMenu\": \"kontekstivalikon (hiiren väärä näppäin) asetukset\",\n        \"accentColor_description\": \"aseta sovelluksen korostusväri\",\n        \"albumBackground_description\": \"lisää taustakuva albumin sivuille, jotka sisältävät albumin kuvitusta\",\n        \"artistConfiguration\": \"albumin artistin sivun hallinta\",\n        \"audioDevice_description\": \"valitse toistossa käytettävä äänilaite (vain verkkosoittimessa)\",\n        \"applicationHotkeys\": \"sovelluksen pikanäppäimet\",\n        \"albumBackground\": \"albumin taustakuva\",\n        \"customCss\": \"oma css\",\n        \"customFontPath_description\": \"asettaa polun mukautetulle fontille jota sovellus käyttää\",\n        \"homeConfiguration\": \"koti sivun muokkaus\",\n        \"homeConfiguration_description\": \"määritä mitä osioita näkyy, ja missä järjestyksessä, koti sivulla\",\n        \"gaplessAudio_optionWeak\": \"heikko (suositus)\",\n        \"hotkey_browserBack\": \"selain takaisin\",\n        \"hotkey_playbackPlay\": \"toista\",\n        \"hotkey_playbackPlayPause\": \"toista / tauko\",\n        \"hotkey_playbackPrevious\": \"edellinen ääniraita\",\n        \"hotkey_rate3\": \"arvostelu 3 tähteä\",\n        \"hotkey_playbackStop\": \"lopeta\",\n        \"hotkey_rate4\": \"arvostelu 4 tähteä\",\n        \"hotkey_rate1\": \"arvostelu 1 tähti\",\n        \"hotkey_rate2\": \"arvostelu 2 tähteä\",\n        \"hotkey_unfavoriteCurrentSong\": \"poista suosikeista $t(common.currentSong)\",\n        \"fontType_description\": \"sisäänrakennettu fontti valitsee yhden feishinin tuomista fonteista. järjestelmän fontti antaa sinun valita minkä tahansa käyttöjärjestelmään asennetun fontin. mukautettu antaa sinun tuoda oman fontin\",\n        \"fontType_optionBuiltIn\": \"sisäänrakennettu fontti\",\n        \"fontType_optionSystem\": \"järjestelmän fontti\",\n        \"fontType_optionCustom\": \"mukautettu fontti\",\n        \"hotkey_favoriteCurrentSong\": \"lisää suosikiksi $t(common.currentSong)\",\n        \"hotkey_favoritePreviousSong\": \"lisää suosikiksi $t(common.previousSong)\",\n        \"hotkey_rate5\": \"arvostelu 5 tähteä\",\n        \"hotkey_skipBackward\": \"ohita taaksepäin\",\n        \"hotkey_skipForward\": \"ohita eteenpäin\",\n        \"font\": \"kirjaisin\",\n        \"font_description\": \"asettaa fontin jota sovellus käyttää\",\n        \"discordApplicationId\": \"{{discord}} sovelluksen tunnus\",\n        \"hotkey_globalSearch\": \"globaali haku\",\n        \"hotkey_playbackNext\": \"seuraava ääniraita\",\n        \"hotkey_browserForward\": \"selain eteenpäin\",\n        \"hotkey_playbackPause\": \"tauko\",\n        \"hotkey_localSearch\": \"hae sivulta\",\n        \"customFontPath\": \"mukautetun fontin polku\",\n        \"fontType\": \"fonttityyppi\",\n        \"hotkey_unfavoritePreviousSong\": \"poista suosikeista $t(common.previousSong)\",\n        \"customCss_description\": \"mukautettu CSS-sisältö. Huomautus: content- ja etä-URL-osoitteet ovat estettyjä ominaisuuksia. Esikatselu sisällöstäsi on alla. Lisäkenttiä, joita et ole määrittänyt, on näkyvissä puhdistuksen vuoksi\",\n        \"customCssNotice\": \"Varoitus: vaikka jonkinlainen puhdistus onkin tehty (url()- ja content:-komentojen estäminen), mukautetun css:n käyttäminen voi silti aiheuttaa riskejä muuttamalla käyttöliittymää\",\n        \"disableLibraryUpdateOnStartup\": \"poista uusimman version tarkistus käynnistyksen yhteydessä käytöstä\",\n        \"discordIdleStatus\": \"näytä rich presencen käyttämätön tila\",\n        \"discordIdleStatus_description\": \"kun käytössä, päivitä tila kun soitin on käyttämättömänä\",\n        \"discordUpdateInterval_description\": \"päivitysväli sekunnteina (vähintään 15 sekunttia)\",\n        \"discordRichPresence_description\": \"ota toiston tila käyttöön {{discord}}n rich presence-toiminnossa. Kuvakkeiden avaimet ovat {{icon}}, {{playing}} ja {{paused}}\",\n        \"discordUpdateInterval\": \"{{discord}} rich presencen päivitysväli\",\n        \"enableRemote\": \"aktivoi etäohjauspalvelin\",\n        \"externalLinks_description\": \"ottaa ulkoiset linkit (Last.fm, MusicBrainz) artistien/albumien sivuilla\",\n        \"exitToTray\": \"sulje tehtäväpalkkiin\",\n        \"discordApplicationId_description\": \"{{discord}}n ohjelma-ID rich presenceä varten (oletuksena {{defaultId}})\",\n        \"enableRemote_description\": \"aktivoi etäohjauspalvelimen, jolla muut laitteet voivat ohjata sovellusta\",\n        \"externalLinks\": \"näytä ulkoiset linkit\",\n        \"exitToTray_description\": \"sovellus suljetaan tehtäväpalkkiin\",\n        \"discordListening_description\": \"näytä status kuuntelee pelaa sijaan\",\n        \"discordListening\": \"näytä status kuuntelee\",\n        \"playButtonBehavior_optionPlayShuffled\": \"$t(player.shuffle)\",\n        \"playButtonBehavior_optionAddNext\": \"$t(player.addNext)\",\n        \"lastfmApiKey_description\": \"API-avain {{lastfm}}:lle. tarvitaan kansikuvia varten\",\n        \"passwordStore_description\": \"mitä salasanojen/avaimien tallennusta käytetään. muuta tätä, jos sinulla on ongelmia salasanojen tallennuksessa\",\n        \"homeFeature_description\": \"ohjaa näytetäänkö suuri esittelykaruselli kotisivulla\",\n        \"hotkey_rate0\": \"arvostelun tyhjennys\",\n        \"hotkey_togglePreviousSongFavorite\": \"vaihda $t(common.previousSong) suosikkiasetus\",\n        \"imageAspectRatio_description\": \"jos käytössä, kansikuvat näytetään niiden alkuperäisellä kuvasuhteella. jos kuvasuhde ei ole 1:1, jäljelle jäävä tila jää tyhjäksi\",\n        \"language_description\": \"asettaa sovelluksen kielen $t(common.restartRequired)\",\n        \"lyricFetch\": \"hae sanoitukset internetistä\",\n        \"lyricFetchProvider_description\": \"valitse lähteet sanoituksien hakua varten. lähteiden järjestys on se järjestys, jossa ne tiedustellaan\",\n        \"minimumScrobblePercentage\": \"pienin skrobblauksen kesto (prosenttia)\",\n        \"mpvExecutablePath\": \"mpv:n suoritettavan tiedoston polku\",\n        \"mpvExecutablePath_description\": \"asettaa mpv:n suoritettavan tiedoston polun. ollessa tyhjä, käytetään oletuspolkua\",\n        \"mpvExtraParameters_help\": \"yksi per rivi\",\n        \"playButtonBehavior_optionPlay\": \"$t(player.play)\",\n        \"globalMediaHotkeys\": \"globaalit median pikanäppäimet\",\n        \"globalMediaHotkeys_description\": \"ota käyttöön tai poista käytöstä järjestelmän median pikanäppäinten käyttö toiston hallintaa\",\n        \"hotkey_toggleCurrentSongFavorite\": \"vaihda $t(common.currentSong) suosikkiasetus\",\n        \"imageAspectRatio\": \"käytä alkuperäistä kansikuvan kuvasuhdetta\",\n        \"lyricOffset_description\": \"siirrä sanoituksia valitun ajan millisekuntteina\",\n        \"minimizeToTray\": \"pienennä ilmaisinalueelle\",\n        \"gaplessAudio_description\": \"asettaa tauottoman toiston asetukset mpv:hen\",\n        \"hotkey_volumeDown\": \"äänenvoimakkuuden vähentäminen\",\n        \"hotkey_zoomIn\": \"lähennä\",\n        \"lyricFetch_description\": \"hae sanoitukset eri lähteistä internetissä\",\n        \"lyricFetchProvider\": \"lähteet sanoituksia varten\",\n        \"lyricOffset\": \"sanotuksien siirto (ms)\",\n        \"followLyric\": \"seuraa lyriikoita\",\n        \"followLyric_description\": \"vieritä lyriikat tämänhetkiseen paikkaan\",\n        \"hotkey_toggleQueue\": \"vaihda jono\",\n        \"minimumScrobblePercentage_description\": \"vähimmäisprosentti kappaleesta, joka on soitettava ennen kuin se skrobblataan\",\n        \"minimumScrobbleSeconds\": \"pienin skrobblaus (sekunttia)\",\n        \"minimumScrobbleSeconds_description\": \"vähimmäisaika kappaleesta, joka on soitettava ennen kuin se skrobblataan\",\n        \"passwordStore\": \"salasanojen/avaimien tallennus\",\n        \"hotkey_volumeUp\": \"äänenvoimakkuuden lisääminen\",\n        \"hotkey_toggleShuffle\": \"vaihda sekoitus\",\n        \"hotkey_volumeMute\": \"mykistäminen\",\n        \"lastfmApiKey\": \"{{lastfm}} API-avain\",\n        \"minimizeToTray_description\": \"pienennä sovellus ilmaisinalueelle\",\n        \"playButtonBehavior_optionAddLast\": \"$t(player.addLast)\",\n        \"hotkey_zoomOut\": \"loitonna\",\n        \"homeFeature\": \"kodin esittelykaruselli\",\n        \"hotkey_toggleFullScreenPlayer\": \"vaihda kokonäytön toistin\",\n        \"hotkey_toggleRepeat\": \"vaihda kertaus\",\n        \"gaplessAudio\": \"tauoton toisto\",\n        \"transcodeFormat_description\": \"valitsee transkoodattavan formaatin. jätä tyhjäksi palvelimen valintaa varten\",\n        \"replayGainMode_optionNone\": \"$t(common.none)\",\n        \"replayGainMode_optionTrack\": \"$t(entity.track, {\\\"count\\\": 1})\",\n        \"themeDark\": \"teema (tumma)\",\n        \"translationApiKey_description\": \"API-avain käännöstä varten (tukee vain globaalia palvelun palvelupistettä)\",\n        \"playbackStyle_description\": \"valitse toiston tyyli, jota käytetään soittimessa\",\n        \"transcode_description\": \"ottaa transkoodaksen käyttöön eri formaateille\",\n        \"transcodeBitrate\": \"transkoodattava bittinopeus\",\n        \"translationApiProvider\": \"käännös-API:n palveluntarjoaja\",\n        \"trayEnabled_description\": \"näytä/piilota järjestelmäpalkin kuvake/valikko. jos poistettu käytöstä, myös pienennä/sulje järjestelmäpalkkiin -toiminto poistetaan käytöstä\",\n        \"windowBarStyle_description\": \"valitse ikkunapalkin tyyli\",\n        \"webAudio\": \"käytä web-ääntä\",\n        \"windowBarStyle\": \"ikkunapalkin tyyli\",\n        \"zoom\": \"zoomausprosentti\",\n        \"playbackStyle\": \"toiston tyyli\",\n        \"remotePassword\": \"kauko-ohjauspalvelimen salasana\",\n        \"remoteUsername_description\": \"asettaa käyttäjänimen kauko-ohjauspalvelimelle. jos sekä käyttäjätunnus, että salasana ovat tyhjänä, todennus poistetaan käytöstä\",\n        \"skipPlaylistPage\": \"ohita soittolistojen sivu\",\n        \"themeDark_description\": \"asettaa tumman teeman käytettäväksi sovelluksessa\",\n        \"playbackStyle_optionCrossFade\": \"ristivaihto\",\n        \"playbackStyle_optionNormal\": \"normaali\",\n        \"playButtonBehavior\": \"toistopainikkeen toiminta\",\n        \"playButtonBehavior_description\": \"asettaa toistopainikkeen oletustoiminnan lisättäessä kappaleita jonoon\",\n        \"remotePort\": \"kauko-ohjauspalvelimen portti\",\n        \"replayGainMode\": \"{{ReplayGain}} tila\",\n        \"sampleRate_description\": \"valitse käytettävä näytteenottotaajuus, jos valittu näytetaajuus poikkeaa nykyisen median taajuudesta. arvo, joka on alle 8 000, käyttää oletustaajuutta\",\n        \"skipDuration\": \"ohituksen kesto\",\n        \"sidePlayQueueStyle_description\": \"asettaa tyylin sivupalkin toistojonolle\",\n        \"sidePlayQueueStyle_optionAttached\": \"liitetty\",\n        \"sidePlayQueueStyle_optionDetached\": \"irrotettu\",\n        \"startMinimized_description\": \"käynnistä sovellus järjestelmäpalkissa\",\n        \"theme\": \"teema\",\n        \"useSystemTheme_description\": \"seuraa järjestelmän määrittämää asetusta vaalealle tai tummalle asetukselle\",\n        \"remoteUsername\": \"kauko-ohjauspalvelimen käyttäjänimi\",\n        \"remotePort_description\": \"asettaa kauko-ohjauspalvelimen portin\",\n        \"remotePassword_description\": \"asettaa kauko-ohjauspalvelimen salasanan. Nämä tunnukset siirretään oletuksena turvattomasti, joten sinun kuuluisi käyttää uniikkia salasanaa, josta et välitä\",\n        \"replayGainClipping\": \"{{ReplayGain}} leikkaus\",\n        \"replayGainClipping_description\": \"Estää {{ReplayGain}}n aiheuttaman leikkauksen laskemalla vahvistusta automaatisesti\",\n        \"replayGainFallback\": \"{{ReplayGain}} palautus\",\n        \"replayGainMode_optionAlbum\": \"$t(entity.album, {\\\"count\\\": 1})\",\n        \"replayGainPreamp\": \"{{ReplayGain}} esivahvistus (dB)\",\n        \"scrobble_description\": \"skrobblaa toistot mediapalvelimellesi\",\n        \"replayGainPreamp_description\": \"säätää esivahvistuksen määrää {{ReplayGain}} arvoon\",\n        \"showSkipButtons\": \"näytä ohituspainikkeet\",\n        \"showSkipButtons_description\": \"näytä tai piilota soitinpalkin ohituspainikkeet\",\n        \"showSkipButton\": \"näytä ohituspainikkeet\",\n        \"showSkipButton_description\": \"näytä tai piilota soitinpalkin ohituspainikkeet\",\n        \"sidebarPlaylistList\": \"sivupakin soittolistojen lista\",\n        \"skipDuration_description\": \"asettaa ohitettavan ajan käytettäessä soitinpalkin ohituspainikkeita\",\n        \"volumeWidth\": \"äänenvoimakkuuden säätimen leveys\",\n        \"sidebarCollapsedNavigation_description\": \"näytä tai piilota navigointi romautetussa sivupalkissa\",\n        \"sidebarConfiguration\": \"sivupalkin asetukset\",\n        \"sidebarConfiguration_description\": \"valitse kohteet ja niiden järjestys sivupalkissa\",\n        \"volumeWidth_description\": \"äänenvoimakkuuden säätimen leveys\",\n        \"playerbarOpenDrawer\": \"toistipalkin kokoruudun kytkin\",\n        \"playerbarOpenDrawer_description\": \"sallii toistopalkin klikkaamisen avaamaan kokonäytön soittimen\",\n        \"replayGainFallback_description\": \"asetettava vahvistus desibelinä (dB), jos tiedostolla ei ole {{ReplayGain}} tageja\",\n        \"replayGainMode_description\": \"säätää äänenvoimmakkuutta {{ReplayGain}} arvojen mukaisesti tiedoston metadatasta\",\n        \"sampleRate\": \"näytteenottotaajuus\",\n        \"savePlayQueue\": \"tallenna toistojono\",\n        \"savePlayQueue_description\": \"tallenna toistojono, kun sovellus suljetaan ja avaa se uudestaan, kun sovellus avataan\",\n        \"scrobble\": \"skrobblaus\",\n        \"sidebarCollapsedNavigation\": \"sivupalkin (romautettu) navigointi\",\n        \"sidebarPlaylistList_description\": \"näytä tai piilota soittolistojen lista sivupalkissa\",\n        \"sidePlayQueueStyle\": \"sivupalkin jonon tyyli\",\n        \"skipPlaylistPage_description\": \"navigoidessa soittolistaan, mene soittolistan kappaleiden listaan oletussivun sijaan\",\n        \"theme_description\": \"asettaa ohjelmassa käytettävän teeman\",\n        \"themeLight\": \"teema (vaalea)\",\n        \"themeLight_description\": \"asettaa vaalean teeman käytettäväksi sovelluksessa\",\n        \"transcodeBitrate_description\": \"valitsee transkoodattavan bittinopeuden. 0 tarkoittaa palvelimen valintaa\",\n        \"transcodeFormat\": \"transkoodattava formaatti\",\n        \"translationApiProvider_description\": \"palveluntarjoajan API käännöstä varten\",\n        \"translationApiKey\": \"käännöksen API-avain\",\n        \"translationTargetLanguage\": \"käännöksen kohdekieli\",\n        \"translationTargetLanguage_description\": \"kohdekieli käännöstä varten\",\n        \"trayEnabled\": \"näytä järjestelmäpalkki\",\n        \"volumeWheelStep_description\": \"äänenvoimakkuuden muutoksen suuruus rullattaessa hiiren rullalla äänenvoimakkuuden säätimen päällä\",\n        \"zoom_description\": \"asettaa sovelluksen zoomausprosentin\",\n        \"webAudio_description\": \"käytä web-ääntä. tämä mahdollistaa edistyneet ominaisuudet, kuten replaygainin. poista käytöstä, jos koet ongelmia\",\n        \"startMinimized\": \"käynnistä pienennettynä\",\n        \"useSystemTheme\": \"käytä järjestelmän teemaa\",\n        \"volumeWheelStep\": \"äänenvoimakkuusrullan askel\",\n        \"discordServeImage\": \"jaa {{discord}} kuvat palvelimelta\",\n        \"discordServeImage_description\": \"jaa kansikuvat {{discord}}n rich presenceä varten suoraan palvelimelta. saatavilla vain Jellyfinille ja Navidromelle\",\n        \"musicbrainz_description\": \"näytä linkit MusicBrainz sivulle artistin/albumin sivuilla, jos MusicBrainz ID löytyy\",\n        \"lastfm\": \"näytä last.fm linkit\",\n        \"lastfm_description\": \"näytä linkit Last.fm sivulle artistin/albumin sivuilla\",\n        \"musicbrainz\": \"näytä MusicBrainz linkit\",\n        \"neteaseTranslation\": \"Ota NetEasen käännökset käyttöön\",\n        \"neteaseTranslation_description\": \"Käytöss ollessa noutaa ja näyttää käännetyt sanat NetEasesta, jos ne ovat saatavilla\",\n        \"preferLocalLyrics_description\": \"suosi paikallisia sanoituksia ulkoisten sijasta, kun saatavilla\",\n        \"preferLocalLyrics\": \"suosi paikallisia sanoituksia\",\n        \"discordPausedStatus\": \"näytä rich presence tauotettuna\",\n        \"discordPausedStatus_description\": \"ollessak käytössä, status näyttää milloin soitin on tautotettuna\",\n        \"preservePitch\": \"säilytä sävelkorkeus\",\n        \"preservePitch_description\": \"säilytä sävelkorkeus toistonopeutta muokatessa\",\n        \"artistBackground\": \"artistin taustakuva\",\n        \"artistBackground_description\": \"lisää taustakuvan artistin sivuille, jotak sisältävät artistin kuvia\",\n        \"artistBackgroundBlur\": \"artistin taustakuvan kuvan sumennuksen koko\",\n        \"artistBackgroundBlur_description\": \"säätää artistin taustakuvaan käytettävän sumennuksen määrää\",\n        \"crossfadeStyle\": \"ristihäivytyksen tyylli\",\n        \"releaseChannel_optionBeta\": \"beeta\",\n        \"releaseChannel_optionLatest\": \"viimeisin\",\n        \"releaseChannel\": \"julkaisulinja\",\n        \"releaseChannel_description\": \"valitse vakaiden ja beetaversioiden välillä automaattisille päivityksille\",\n        \"discordDisplayType_artistname\": \"artistin nimi / artistien nimet\",\n        \"autoDJ\": \"auto DJ\",\n        \"autoDJ_description\": \"lisää automaattisesti samanlaisia kappaleita jonoon\",\n        \"autoDJ_itemCount\": \"kohteiden määrä\",\n        \"autoDJ_itemCount_description\": \"jonoon lisättäväksi yritettyjen kohteiden määrä, kun auto DJ on käytössä\",\n        \"autoDJ_timing\": \"ajastus\"\n    },\n    \"page\": {\n        \"itemDetail\": {\n            \"copiedPath\": \"polku on kopioitu onnistuneesti\",\n            \"copyPath\": \"kopioi reitti leikepöytälle\",\n            \"openFile\": \"näytä kappale tiedostonhallinnassa\"\n        },\n        \"albumArtistList\": {\n            \"title\": \"$t(entity.albumArtist, {\\\"count\\\": 2})\"\n        },\n        \"albumDetail\": {\n            \"moreFromArtist\": \"siirrä kohteesta $t(entity.artist, {\\\"count\\\": 1})\",\n            \"moreFromGeneric\": \"listää kohteesta {{item}}\",\n            \"released\": \"julkaistu\"\n        },\n        \"albumList\": {\n            \"artistAlbums\": \"artistin {{artist}} albumit\",\n            \"genreAlbums\": \"\\\"{{genre}}\\\"$t(entity.album, {\\\"count\\\": 2})\",\n            \"title\": \"$t(entity.album, {\\\"count\\\": 2})\"\n        },\n        \"appMenu\": {\n            \"goBack\": \"mene takaisin\",\n            \"openBrowserDevtools\": \"avaa selaimen kehitystyökalut\",\n            \"quit\": \"$t(common.quit)\",\n            \"selectServer\": \"valitse palvelin\",\n            \"settings\": \"$t(common.setting, {\\\"count\\\": 2})\",\n            \"expandSidebar\": \"laajenna sivupalkki\",\n            \"goForward\": \"mene eteenpäin\",\n            \"manageServers\": \"hallitse palvelimia\",\n            \"collapseSidebar\": \"kutista sivupalkki\",\n            \"version\": \"versio {{version}}\",\n            \"privateModeOff\": \"käännä yksityinen tila pois käytöstä\",\n            \"privateModeOn\": \"käännä yksityinen tila käyttöön\",\n            \"commandPalette\": \"avaa komentopaletti\",\n            \"selectMusicFolder\": \"valitse musiikkikansio\",\n            \"noMusicFolder\": \"musiikkikansiota ei ole valittu\",\n            \"multipleMusicFolders\": \"{{count}} musiikkikansio(ta) valittu\"\n        },\n        \"contextMenu\": {\n            \"playSimilarSongs\": \"$t(player.playSimilarSongs)\",\n            \"addNext\": \"$t(player.addNext)\",\n            \"addToFavorites\": \"$t(action.addToFavorites)\",\n            \"addToPlaylist\": \"$t(action.addToPlaylist)\",\n            \"createPlaylist\": \"$t(action.createPlaylist)\",\n            \"deletePlaylist\": \"$t(action.deletePlaylist)\",\n            \"deselectAll\": \"$t(action.deselectAll)\",\n            \"removeFromPlaylist\": \"$t(action.removeFromPlaylist)\",\n            \"setRating\": \"$t(action.setRating)\",\n            \"playShuffled\": \"$t(player.shuffle)\",\n            \"numberSelected\": \"{{count}} valittuna\",\n            \"play\": \"$t(player.play)\",\n            \"download\": \"lataa\",\n            \"moveToBottom\": \"$t(action.moveToBottom)\",\n            \"moveToTop\": \"$t(action.moveToTop)\",\n            \"removeFromFavorites\": \"$t(action.removeFromFavorites)\",\n            \"shareItem\": \"jaa kohde\",\n            \"showDetails\": \"lisätietoa\",\n            \"addFavorite\": \"$t(action.addToFavorites)\",\n            \"addLast\": \"$t(player.addLast)\",\n            \"moveToNext\": \"$t(action.moveToNext)\",\n            \"removeFromQueue\": \"$t(action.removeFromQueue)\",\n            \"goToAlbum\": \"mene $t(entity.album, {\\\"count\\\": 1})\",\n            \"goToAlbumArtist\": \"mene $t(entity.albumArtist, {\\\"count\\\": 1})\",\n            \"moveItems\": \"$t(action.moveItems)\",\n            \"goTo\": \"mene\"\n        },\n        \"sidebar\": {\n            \"albumArtists\": \"$t(entity.albumArtist, {\\\"count\\\": 2})\",\n            \"albums\": \"$t(entity.album, {\\\"count\\\": 2})\",\n            \"settings\": \"$t(common.setting, {\\\"count\\\": 2})\",\n            \"shared\": \"$t(entity.playlist, {\\\"count\\\": 2}) jaettu\",\n            \"tracks\": \"$t(entity.track, {\\\"count\\\": 2})\",\n            \"artists\": \"$t(entity.artist, {\\\"count\\\": 2})\",\n            \"folders\": \"$t(entity.folder, {\\\"count\\\": 2})\",\n            \"genres\": \"$t(entity.genre, {\\\"count\\\": 2})\",\n            \"home\": \"$t(common.home)\",\n            \"nowPlaying\": \"nyt soi\",\n            \"playlists\": \"$t(entity.playlist, {\\\"count\\\": 2})\",\n            \"search\": \"$t(common.search)\",\n            \"myLibrary\": \"oma kirjasto\",\n            \"collections\": \"kokoelmat\",\n            \"favorites\": \"$t(entity.favorite, {\\\"count\\\": 2})\",\n            \"radio\": \"$t(entity.radioStation, {\\\"count\\\": 2})\"\n        },\n        \"setting\": {\n            \"generalTab\": \"yleinen\",\n            \"windowTab\": \"ikkuna\",\n            \"hotkeysTab\": \"pikanäppäimet\",\n            \"playbackTab\": \"toisto\",\n            \"advanced\": \"edistyneet\",\n            \"analytics\": \"tilastot\",\n            \"updates\": \"päivitä\",\n            \"cache\": \"välimuisti\",\n            \"application\": \"aplikaatio\",\n            \"queryBuilder\": \"kyselynrakentaja\",\n            \"theme\": \"teema\",\n            \"controls\": \"säätimet\",\n            \"sidebar\": \"sivupalkki\",\n            \"remote\": \"kauko-ohjain\",\n            \"exportImport\": \"tuo/vie\",\n            \"scrobble\": \"scrobblata\",\n            \"audio\": \"audio\",\n            \"lyrics\": \"sanat\",\n            \"lyricsDisplay\": \"sanojen näyttö\",\n            \"transcoding\": \"transkoodaus\",\n            \"discord\": \"discord\",\n            \"logger\": \"lokittaja\",\n            \"playerFilters\": \"soittimen suodattimet\"\n        },\n        \"fullscreenPlayer\": {\n            \"upNext\": \"seuraavaksi\",\n            \"visualizer\": \"visualisaattori\",\n            \"noLyrics\": \"sanoja ei löytynyt\",\n            \"config\": {\n                \"showLyricMatch\": \"näytä sanojen yhteneväisyys\",\n                \"showLyricProvider\": \"näytä sanojen tarjoaja\",\n                \"lyricGap\": \"sanojen rako\",\n                \"synchronized\": \"synkronoitu\",\n                \"lyricSize\": \"sanojen koko\",\n                \"opacity\": \"läpinäkyvyys\",\n                \"unsynchronized\": \"synkronoimaton\",\n                \"useImageAspectRatio\": \"käytä kuvan kuvasuhdetta\",\n                \"dynamicBackground\": \"liikkuva tausta\",\n                \"dynamicImageBlur\": \"kuvan sumennuksen koko\",\n                \"dynamicIsImage\": \"käytä taustakuvaa\",\n                \"lyricOffset\": \"sanojen kompensointi (ms)\",\n                \"followCurrentLyric\": \"seuraa nykyisiä sanoja\",\n                \"lyricAlignment\": \"sanojen kohdistus\"\n            },\n            \"lyrics\": \"sanat\",\n            \"related\": \"liittyvät\"\n        },\n        \"genreList\": {\n            \"showAlbums\": \"näytä $t(entity.genre, {\\\"count\\\": 1}) $t(entity.album, {\\\"count\\\": 2})\",\n            \"showTracks\": \"näytä $t(entity.genre, {\\\"count\\\": 1}) $t(entity.track, {\\\"count\\\": 2})\",\n            \"title\": \"$t(entity.genre, {\\\"count\\\": 2})\"\n        },\n        \"globalSearch\": {\n            \"commands\": {\n                \"searchFor\": \"hae {{query}}\",\n                \"serverCommands\": \"palvelimen komennot\",\n                \"goToPage\": \"mene sivulle\"\n            },\n            \"title\": \"komennot\"\n        },\n        \"home\": {\n            \"explore\": \"tutki kirjastotasi\",\n            \"recentlyPlayed\": \"hiljattain soitetut\",\n            \"title\": \"$t(common.home)\",\n            \"mostPlayed\": \"eniten soitetut\",\n            \"newlyAdded\": \"hiljattain lisätyt julkaisut\",\n            \"recentlyReleased\": \"hiljattain julkaistu\",\n            \"genres\": \"$t(entity.genre, {\\\"count\\\": 2})\"\n        },\n        \"albumArtistDetail\": {\n            \"about\": \"{{artist}}\",\n            \"viewDiscography\": \"katsele diskografiaa\",\n            \"relatedArtists\": \"liittyvät $t(entity.artist, {\\\"count\\\": 2})\",\n            \"appearsOn\": \"esiintyy\",\n            \"topSongs\": \"parhaat kappaleet\",\n            \"topSongsFrom\": \"parhaat kappaleet albumilta {{title}}\",\n            \"recentReleases\": \"hiljattaiset julkaisut\",\n            \"viewAll\": \"katsele kaikkia\",\n            \"viewAllTracks\": \"katsele kaikkia $t(entity.track, {\\\"count\\\": 2})\",\n            \"favoriteSongs\": \"suosikki kappaleet\",\n            \"groupingTypeAll\": \"kaikki julkaisun tyypit\",\n            \"groupingTypePrimary\": \"ensisijaiset tyypin julkaisut\",\n            \"topSongsCommunity\": \"yhteisö\",\n            \"topSongsPersonal\": \"henkilökohtainen\",\n            \"favoriteSongsFrom\": \"suosikkikappale {{title}}:sta\"\n        },\n        \"playlistList\": {\n            \"title\": \"$t(entity.playlist, {\\\"count\\\": 2})\"\n        },\n        \"manageServers\": {\n            \"title\": \"hallitse palvelimia\",\n            \"serverDetails\": \"palvelimen lisätiedot\",\n            \"url\": \"URL\",\n            \"username\": \"käyttäjänimi\",\n            \"editServerDetailsTooltip\": \"muokkaa palvelimen lisätietoja\",\n            \"removeServer\": \"etäpalvelin\"\n        },\n        \"playlist\": {\n            \"reorder\": \"uudelleenjärjestely mahdollista vain, kun järjestellään id:n mukaan\"\n        },\n        \"trackList\": {\n            \"artistTracks\": \"artistin {{artist}} kappaleet\",\n            \"genreTracks\": \"\\\"{{genre}}\\\" $t(entity.track, {\\\"count\\\": 2})\",\n            \"title\": \"$t(entity.track, {\\\"count\\\": 2})\"\n        },\n        \"radioList\": {\n            \"title\": \"radiokanavat\"\n        },\n        \"releasenotes\": {\n            \"commitsSinceStable\": \"muutokset {{stable}} verrattuna\",\n            \"noNewCommits\": \"ei uusia muutoksia tällä välillä\",\n            \"noStableReleaseToCompare\": \"vertailukelpoista vakaata versiota ei löytynyt\"\n        },\n        \"favorites\": {\n            \"title\": \"$t(entity.favorite, {\\\"count\\\": 2})\"\n        },\n        \"windowBar\": {\n            \"paused\": \"(Tauotettu) \",\n            \"privateMode\": \"(Yksityinen tila)\"\n        },\n        \"folderList\": {\n            \"title\": \"$t(entity.folder, {\\\"count\\\": 2})\"\n        },\n        \"collections\": {\n            \"overrideExisting\": \"syrjäytä olemassa olevat\",\n            \"saveAsCollection\": \"tallenna kokoelmana\"\n        }\n    },\n    \"player\": {\n        \"addLast\": \"viimeinen\",\n        \"addNext\": \"seuraava\",\n        \"favorite\": \"suosikki\",\n        \"queue_moveToTop\": \"siirrä valittu alas\",\n        \"queue_remove\": \"poista valittu\",\n        \"repeat\": \"kertaus\",\n        \"previous\": \"edellinen\",\n        \"queue_clear\": \"tyhjennä jono\",\n        \"skip\": \"ohita\",\n        \"skip_forward\": \"ohita eteenpäin\",\n        \"stop\": \"pysäytä\",\n        \"skip_back\": \"ohita taaksepäin\",\n        \"unfavorite\": \"poista suosikeista\",\n        \"playbackFetchNoResults\": \"kappaleita ei löytynyt\",\n        \"queue_moveToBottom\": \"siittä valittu ylös\",\n        \"pause\": \"tauota\",\n        \"playbackSpeed\": \"toistonopeus\",\n        \"repeat_all\": \"kertaa kaikki\",\n        \"playbackFetchCancel\": \"tämä vie aikaa... sulje ilmoitus peruaksesi\",\n        \"mute\": \"mykistä\",\n        \"shuffle\": \"soita (sekoitettuna)\",\n        \"next\": \"seuraava\",\n        \"play\": \"toista\",\n        \"playbackFetchInProgress\": \"ladataan kappaleita…\",\n        \"viewQueue\": \"katsele jonoa\",\n        \"muted\": \"mykistetty\",\n        \"playRandom\": \"toista satunnainen\",\n        \"playSimilarSongs\": \"toista samanlaisia kappaleita\",\n        \"repeat_off\": \"kertaus pois päältä\",\n        \"shuffle_off\": \"sekoitus pois päältä\",\n        \"toggleFullscreenPlayer\": \"vaihda kokoruudun soittimeen\",\n        \"addLastShuffled\": \"viimeinen (sekoitettu)\",\n        \"addNextShuffled\": \"seuraava (sekoitettu)\",\n        \"albumRadio\": \"albumiradio\",\n        \"artistRadio\": \"artistiradio\",\n        \"holdToShuffle\": \"pidä sekoittaaksesi\",\n        \"lyrics\": \"sanat\",\n        \"restoreQueueFromServer\": \"palauta tiedustelu palvelimelta\",\n        \"saveQueueToServer\": \"tallenna tiedustelu palvelimelle\",\n        \"trackRadio\": \"raitaradio\",\n        \"sleepTimer\": \"uniajastin\",\n        \"sleepTimer_endOfSong\": \"nykyisen kappaleen loppu\",\n        \"sleepTimer_minutes\": \"{{count}} min\",\n        \"sleepTimer_hours\": \"{{count}} t\",\n        \"sleepTimer_custom\": \"mukautettu\",\n        \"sleepTimer_off\": \"pois\",\n        \"sleepTimer_timeRemaining\": \"{{time}} jäljellä\",\n        \"sleepTimer_setCustom\": \"aseta ajastin\",\n        \"sleepTimer_cancel\": \"peruuta ajastin\"\n    },\n    \"table\": {\n        \"config\": {\n            \"general\": {\n                \"gap\": \"$t(common.gap)\",\n                \"size\": \"$t(common.size)\",\n                \"autoFitColumns\": \"sovita sarakkeet\",\n                \"followCurrentSong\": \"seuraa nykyistä kappaletta\",\n                \"displayType\": \"näytön tyyppi\",\n                \"itemGap\": \"kohteiden väli (px)\",\n                \"itemSize\": \"kohteiden koko (px)\",\n                \"tableColumns\": \"taulukon sarakkeet\"\n            },\n            \"label\": {\n                \"channels\": \"$t(common.channel_other)\",\n                \"trackNumber\": \"raidan numero\",\n                \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n                \"actions\": \"$t(common.action_other)\",\n                \"codec\": \"$t(common.codec)\",\n                \"dateAdded\": \"lisäyspäivämäärä\",\n                \"owner\": \"$t(common.owner)\",\n                \"path\": \"$t(common.path)\",\n                \"albumArtist\": \"$t(entity.albumArtist, {\\\"count\\\": 1})\",\n                \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n                \"discNumber\": \"levyn numero\",\n                \"duration\": \"$t(common.duration)\",\n                \"favorite\": \"$t(common.favorite)\",\n                \"lastPlayed\": \"viimeksi soitettu\",\n                \"note\": \"$t(common.note)\",\n                \"titleCombined\": \"$t(common.title) (yhdistetty)\",\n                \"rowIndex\": \"rivin indeksi\",\n                \"biography\": \"$t(common.biography)\",\n                \"bitrate\": \"$t(common.bitrate)\",\n                \"bpm\": \"$t(common.bpm)\",\n                \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n                \"playCount\": \"toistojen lukumäärä\",\n                \"rating\": \"$t(common.rating)\",\n                \"releaseDate\": \"julkaisupäivämäärä\",\n                \"size\": \"$t(common.size)\",\n                \"songCount\": \"$t(entity.track, {\\\"count\\\": 2})\",\n                \"title\": \"$t(common.title)\",\n                \"year\": \"$t(common.year)\"\n            },\n            \"view\": {\n                \"table\": \"taulukko\",\n                \"grid\": \"ruudukko\",\n                \"list\": \"lista\"\n            }\n        },\n        \"column\": {\n            \"releaseYear\": \"vuosi\",\n            \"bpm\": \"bpm\",\n            \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n            \"biography\": \"biografia\",\n            \"dateAdded\": \"lisäyspäivämäärä\",\n            \"album\": \"albumi\",\n            \"albumArtist\": \"albumin artisti\",\n            \"lastPlayed\": \"viimeksi toistettu\",\n            \"path\": \"polku\",\n            \"size\": \"$t(common.size)\",\n            \"songCount\": \"$t(entity.track, {\\\"count\\\": 2})\",\n            \"title\": \"nimi\",\n            \"trackNumber\": \"raita\",\n            \"codec\": \"$t(common.codec)\",\n            \"comment\": \"kommentti\",\n            \"albumCount\": \"$t(entity.album, {\\\"count\\\": 2})\",\n            \"bitrate\": \"bittinopeus\",\n            \"channels\": \"$t(common.channel_other)\",\n            \"discNumber\": \"levy\",\n            \"favorite\": \"suosikki\",\n            \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n            \"playCount\": \"toistoja\",\n            \"rating\": \"arvostelu\",\n            \"releaseDate\": \"julkaisupäivämäärä\"\n        }\n    },\n    \"releaseType\": {\n        \"primary\": {\n            \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n            \"broadcast\": \"lähetys\",\n            \"ep\": \"EP\",\n            \"other\": \"muu\",\n            \"single\": \"single\"\n        },\n        \"secondary\": {\n            \"audiobook\": \"änikirja\",\n            \"audioDrama\": \"kuunnelma\",\n            \"compilation\": \"kokoomateos\",\n            \"djMix\": \"DJ mix\",\n            \"demo\": \"demo\",\n            \"fieldRecording\": \"kenttä-äänitys\",\n            \"interview\": \"haastattelu\",\n            \"live\": \"live\",\n            \"mixtape\": \"mixtape\",\n            \"remix\": \"remiksi\",\n            \"soundtrack\": \"elokuvamusiikki\",\n            \"spokenWord\": \"puhetta\"\n        }\n    },\n    \"datetime\": {\n        \"minuteShort\": \"m\",\n        \"secondShort\": \"s\",\n        \"hourShort\": \"t\",\n        \"dayShort\": \"p\"\n    },\n    \"filterOperator\": {\n        \"after\": \"jälkeen\",\n        \"afterDate\": \"jälkeen (päivän)\",\n        \"before\": \"ennen\",\n        \"beforeDate\": \"ennen (päivää)\",\n        \"contains\": \"sisältää\",\n        \"endsWith\": \"loppuu\",\n        \"inPlaylist\": \"on\",\n        \"inTheLast\": \"on viimeisenä\",\n        \"inTheRange\": \"on välillä\",\n        \"inTheRangeDate\": \"on valitulla aikavälillä (päivä)\",\n        \"is\": \"on\",\n        \"isNot\": \"ei ole\",\n        \"isGreaterThan\": \"enemmän kuin\",\n        \"isLessThan\": \"vähemmän kuin\",\n        \"matchesRegex\": \"vastaa säännöllistä lausetta (regex)\",\n        \"notContains\": \"ei sisällä\",\n        \"notInPlaylist\": \"ei ole\",\n        \"notInTheLast\": \"ei ole viimeisenä\",\n        \"startsWith\": \"alkaa\"\n    },\n    \"queryBuilder\": {\n        \"standardTags\": \"standardi tagit\",\n        \"customTags\": \"mukautetut tagit\"\n    }\n}\n"
  },
  {
    "path": "src/i18n/locales/fr.json",
    "content": "{\n    \"player\": {\n        \"repeat_all\": \"répèter tout\",\n        \"stop\": \"stop\",\n        \"repeat\": \"répéter\",\n        \"queue_remove\": \"effacer la sélection\",\n        \"playRandom\": \"lecture aléatoire\",\n        \"skip\": \"sauter\",\n        \"previous\": \"précédent\",\n        \"toggleFullscreenPlayer\": \"plein écran\",\n        \"skip_back\": \"reculer\",\n        \"favorite\": \"favori\",\n        \"next\": \"suivant\",\n        \"shuffle\": \"lecture (mélangé)\",\n        \"playbackFetchNoResults\": \"aucun titre trouvé\",\n        \"playbackFetchInProgress\": \"chargement des titres…\",\n        \"addNext\": \"prochain\",\n        \"playbackSpeed\": \"vitesse de lecture\",\n        \"playbackFetchCancel\": \"cela prend du temps… fermez la notification pour annuler\",\n        \"play\": \"lecture\",\n        \"repeat_off\": \"répétition désactivée\",\n        \"queue_clear\": \"effacer la file d'attente\",\n        \"muted\": \"en sourdine\",\n        \"queue_moveToTop\": \"déplacer la sélection vers le bas\",\n        \"queue_moveToBottom\": \"déplacer la sélection vers le haut\",\n        \"shuffle_off\": \"aléatoire désactivé\",\n        \"addLast\": \"dernier\",\n        \"mute\": \"muet\",\n        \"skip_forward\": \"avancer\",\n        \"pause\": \"pause\",\n        \"unfavorite\": \"retirer des favoris\",\n        \"playSimilarSongs\": \"jouer des titres similaires\",\n        \"viewQueue\": \"voir la file d'attente\",\n        \"addLastShuffled\": \"dernier (mélangé)\",\n        \"addNextShuffled\": \"prochain (mélangé)\",\n        \"holdToShuffle\": \"maintenir pour mélanger\",\n        \"lyrics\": \"paroles\",\n        \"restoreQueueFromServer\": \"restaurer la file d'attente depuis le serveur\",\n        \"saveQueueToServer\": \"enregistrer la file d'attente sur le serveur\",\n        \"artistRadio\": \"radio de l'artiste\",\n        \"trackRadio\": \"radio du titre\",\n        \"sleepTimer\": \"minuterie de veille\",\n        \"sleepTimer_endOfSong\": \"fin du titre en cours\",\n        \"sleepTimer_minutes\": \"{{count}} min\",\n        \"sleepTimer_hours\": \"{{count}} h\",\n        \"sleepTimer_custom\": \"personnalisé\",\n        \"sleepTimer_off\": \"éteint\",\n        \"sleepTimer_timeRemaining\": \"{{time}} restante(s)\",\n        \"sleepTimer_setCustom\": \"définir le minuteur\",\n        \"sleepTimer_cancel\": \"annuler le minuteur\",\n        \"albumRadio\": \"radio d'album\"\n    },\n    \"action\": {\n        \"editPlaylist\": \"éditer $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"goToPage\": \"aller à la page\",\n        \"moveToTop\": \"déplacer en haut\",\n        \"clearQueue\": \"vider la file d'attente\",\n        \"addToFavorites\": \"ajouter aux $t(entity.favorite, {\\\"count\\\": 2})\",\n        \"addToPlaylist\": \"ajouter à $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"createPlaylist\": \"créer $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"removeFromPlaylist\": \"supprimer des $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"viewPlaylists\": \"voir $t(entity.playlist, {\\\"count\\\": 2})\",\n        \"refresh\": \"$t(common.refresh)\",\n        \"deletePlaylist\": \"supprimer de $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"removeFromQueue\": \"retirer de la file d'attente\",\n        \"deselectAll\": \"désélectionner tout\",\n        \"moveToBottom\": \"déplacer en bas\",\n        \"setRating\": \"noter\",\n        \"toggleSmartPlaylistEditor\": \"basculer l'éditeur de $t(entity.smartPlaylist)\",\n        \"removeFromFavorites\": \"retirer des $t(entity.favorite, {\\\"count\\\": 2})\",\n        \"openIn\": {\n            \"lastfm\": \"Ouvrir dans Last.fm\",\n            \"musicbrainz\": \"Ouvrir dans MusicBrainz\"\n        },\n        \"moveToNext\": \"passer au suivant\",\n        \"downloadStarted\": \"téléchargement de {{count}} éléments en cours\",\n        \"moveItems\": \"déplacer les entrées\",\n        \"shuffle\": \"mélanger\",\n        \"shuffleAll\": \"mélanger tout\",\n        \"shuffleSelected\": \"mélanger la sélection\",\n        \"viewMore\": \"voir plus\",\n        \"moveUp\": \"monter\",\n        \"moveDown\": \"descendre\",\n        \"holdToMoveToTop\": \"maintenir pour déplacer en haut\",\n        \"holdToMoveToBottom\": \"maintenir pour déplacer en bas\",\n        \"createRadioStation\": \"créer $t(entity.radioStation, {\\\"count\\\": 1})\",\n        \"deleteRadioStation\": \"supprimer $t(entity.radioStation, {\\\"count\\\": 1})\",\n        \"addOrRemoveFromSelection\": \"ajouter ou supprimer de la sélection\",\n        \"selectRangeOfItems\": \"sélectionner une plage d'entrées\",\n        \"selectAll\": \"tout sélectionner\",\n        \"openApplicationDirectory\": \"ouvrir le répertoire de l'application\",\n        \"goToCurrent\": \"aller à la piste en cours\"\n    },\n    \"common\": {\n        \"backward\": \"en arrière\",\n        \"increase\": \"augmenter\",\n        \"rating\": \"note\",\n        \"bpm\": \"BPM\",\n        \"refresh\": \"rafraichir\",\n        \"unknown\": \"inconnu\",\n        \"areYouSure\": \"êtes-vous sûr ?\",\n        \"edit\": \"éditer\",\n        \"favorite\": \"favori\",\n        \"left\": \"gauche\",\n        \"save\": \"enregistrer\",\n        \"right\": \"droite\",\n        \"currentSong\": \"$t(entity.track, {\\\"count\\\": 1}) actuelle\",\n        \"collapse\": \"réduire\",\n        \"trackNumber\": \"piste\",\n        \"descending\": \"décroisant\",\n        \"add\": \"ajouter\",\n        \"gap\": \"écart\",\n        \"ascending\": \"croissant\",\n        \"dismiss\": \"rejeter\",\n        \"year\": \"année\",\n        \"manage\": \"gérer\",\n        \"limit\": \"limite\",\n        \"minimize\": \"minimiser\",\n        \"modified\": \"modifié\",\n        \"duration\": \"durée\",\n        \"name\": \"nom\",\n        \"maximize\": \"agrandir\",\n        \"decrease\": \"diminuer\",\n        \"ok\": \"ok\",\n        \"description\": \"description\",\n        \"configure\": \"configurer\",\n        \"path\": \"chemin\",\n        \"center\": \"centre\",\n        \"no\": \"non\",\n        \"owner\": \"propriétaire\",\n        \"enable\": \"activer\",\n        \"clear\": \"vider\",\n        \"forward\": \"avancer\",\n        \"delete\": \"supprimer\",\n        \"cancel\": \"annuler\",\n        \"forceRestartRequired\": \"redémarrer pour appliquer les changements… fermer la notification pour redémarrer\",\n        \"setting\": \"paramètre\",\n        \"setting_one\": \"paramètre\",\n        \"setting_many\": \"paramètres\",\n        \"setting_other\": \"paramètres\",\n        \"version\": \"version\",\n        \"title\": \"titre\",\n        \"filter_one\": \"filtre\",\n        \"filter_many\": \"filtres\",\n        \"filter_other\": \"filtres\",\n        \"filters\": \"filtres\",\n        \"create\": \"créer\",\n        \"bitrate\": \"débit binaire\",\n        \"saveAndReplace\": \"enregistrer et remplacer\",\n        \"action_one\": \"action\",\n        \"action_many\": \"actions\",\n        \"action_other\": \"actions\",\n        \"playerMustBePaused\": \"le lecteur doit être en pause\",\n        \"confirm\": \"confirmer\",\n        \"resetToDefault\": \"réinitialiser par défaut\",\n        \"home\": \"accueil\",\n        \"comingSoon\": \"prochainement…\",\n        \"reset\": \"réinitialiser\",\n        \"channel_one\": \"canal\",\n        \"channel_many\": \"canaux\",\n        \"channel_other\": \"canaux\",\n        \"disable\": \"désactiver\",\n        \"sortOrder\": \"ordre\",\n        \"none\": \"aucun\",\n        \"menu\": \"menu\",\n        \"restartRequired\": \"redémarrage requis\",\n        \"previousSong\": \"$t(entity.track, {\\\"count\\\": 1}) précédente\",\n        \"noResultsFromQuery\": \"la requête n'a retourné aucun résultat\",\n        \"quit\": \"quitter\",\n        \"expand\": \"étendre\",\n        \"search\": \"recherche\",\n        \"saveAs\": \"enregistrer sous\",\n        \"disc\": \"disque\",\n        \"yes\": \"oui\",\n        \"random\": \"aléatoire\",\n        \"size\": \"taille\",\n        \"biography\": \"biographie\",\n        \"note\": \"note\",\n        \"albumGain\": \"gain de l'album\",\n        \"albumPeak\": \"crête de l'album\",\n        \"close\": \"fermer\",\n        \"mbid\": \"Identifiant MusicBrainz\",\n        \"preview\": \"aperçu\",\n        \"share\": \"partager\",\n        \"reload\": \"recharger\",\n        \"trackGain\": \"gain de la piste\",\n        \"trackPeak\": \"crête de la piste\",\n        \"codec\": \"codec\",\n        \"translation\": \"traduction\",\n        \"additionalParticipants\": \"participants additionnels\",\n        \"tags\": \"tags\",\n        \"newVersion\": \"une nouvelle version vient d'être installée ({{version}})\",\n        \"viewReleaseNotes\": \"voir la note de version\",\n        \"sampleRate\": \"taux d'échantillonnage\",\n        \"bitDepth\": \"format d'échantillonnage\",\n        \"explicitStatus\": \"statut explicite\",\n        \"explicit\": \"explicite\",\n        \"clean\": \"propre\",\n        \"private\": \"privé\",\n        \"public\": \"publique\",\n        \"recordLabel\": \"label de discographie\",\n        \"releaseType\": \"type de sortie\",\n        \"doNotShowAgain\": \"ne plus afficher\",\n        \"externalLinks\": \"liens externe\",\n        \"faster\": \"plus rapide\",\n        \"slower\": \"ralentir\",\n        \"sort\": \"trier\",\n        \"gridRows\": \"lignes de la grille\",\n        \"tableColumns\": \"colonnes du tableau\",\n        \"itemsMore\": \"plus {{count}}\",\n        \"view\": \"vue\",\n        \"noFilters\": \"aucun filtre configuré\",\n        \"countSelected\": \"{{count}} sélectionnée\",\n        \"example\": \"exemple\",\n        \"mood\": \"humeur\",\n        \"retry\": \"réessayer\",\n        \"filter_single\": \"unique\",\n        \"filter_multiple\": \"multiple\",\n        \"rename\": \"renommer\",\n        \"newVersionAvailable\": \"une nouvelle version est disponible\"\n    },\n    \"error\": {\n        \"remotePortWarning\": \"redémarrer le serveur pour appliquer le nouveau port\",\n        \"systemFontError\": \"une erreur s’est produite lors de la tentative d’obtenir les polices système\",\n        \"playbackError\": \"une erreur s'est produite lors de la tentative de lecture du média\",\n        \"endpointNotImplementedError\": \"l'endpoint {{endpoint}} n'est pas implémenté pour {{serverType}}\",\n        \"remotePortError\": \"une erreur s'est produite lors de la tentative de définir le port du serveur distant\",\n        \"serverRequired\": \"serveur requis\",\n        \"authenticationFailed\": \"l'authentification a échoué\",\n        \"apiRouteError\": \"incapable d’acheminer la demande\",\n        \"genericError\": \"une erreur s'est produite\",\n        \"credentialsRequired\": \"identifiants requis\",\n        \"sessionExpiredError\": \"votre session a expiré\",\n        \"remoteEnableError\": \"une erreur s'est produite lors de la tentative de $t(common.enable) le serveur distant\",\n        \"localFontAccessDenied\": \"accès refusé aux polices locales\",\n        \"serverNotSelectedError\": \"aucun serveur sélectionné\",\n        \"remoteDisableError\": \"une erreur s'est produite lors de la tentative de $t(common.disable) le serveur distant\",\n        \"mpvRequired\": \"MPV requis\",\n        \"audioDeviceFetchError\": \"une erreur s’est produite lors de la tentative d’obtenir les périphériques audio\",\n        \"invalidServer\": \"serveur invalide\",\n        \"loginRateError\": \"trop de tentative de connexion, merci de réessayer dans quelques secondes\",\n        \"openError\": \"impossible d'ouvrir le fichier\",\n        \"networkError\": \"une erreur de réseau est survenue\",\n        \"badAlbum\": \"vous voyez cette page parce que ce titre ne fait pas parti d'un album. vous rencontrez probablement cette erreur si vous avez un titre à la racine de votre dossier musique. Jellyfin gère les chansons uniquement si elles sont dans un sous-dossier, qui est lui-même dans un dossier \\\"Musique(s)\\\"\",\n        \"badValue\": \"option {{value}} invalide. cette valeur n'existe plus\",\n        \"notificationDenied\": \"les autorisations pour les notifications ont été refusées. ce paramètre n'a aucun effet\",\n        \"multipleServerSaveQueueError\": \"la file d'attente contient un ou plusieurs morceaux qui ne proviennent pas du serveur actuel. Ceci n'est pas prise en charge\",\n        \"saveQueueFailed\": \"échec de l'enregistrement de la file d'attente\",\n        \"settingsSyncError\": \"des incohérences ont été détectées entre les paramètres du moteur de rendu et ceux du processus principal. redémarrez l'application pour appliquer les modifications\",\n        \"noNetwork\": \"serveur indisponible\",\n        \"noNetworkDescription\": \"impossible de se connecter à ce serveur\",\n        \"invalidJson\": \"JSON invalide\",\n        \"serverLockSingleServer\": \"un seul serveur est autorisé quand le serveur est verrouillé\",\n        \"playbackPausedDueToError\": \"la lecture a été suspendue en raison d'une erreur\"\n    },\n    \"filter\": {\n        \"mostPlayed\": \"les plus joués\",\n        \"playCount\": \"nombre d'écoutes\",\n        \"isCompilation\": \"est une compilation\",\n        \"recentlyPlayed\": \"récemment joué\",\n        \"isRated\": \"est noté\",\n        \"title\": \"titre\",\n        \"rating\": \"note\",\n        \"search\": \"recherche\",\n        \"bitrate\": \"bitrate binaire\",\n        \"recentlyAdded\": \"ajout récent\",\n        \"note\": \"note\",\n        \"name\": \"nom\",\n        \"dateAdded\": \"date d'ajout\",\n        \"releaseDate\": \"date de sortie\",\n        \"communityRating\": \"note de la communauté\",\n        \"path\": \"chemin\",\n        \"favorited\": \"favori\",\n        \"isRecentlyPlayed\": \"a été joué récemment\",\n        \"isFavorited\": \"est favori\",\n        \"bpm\": \"BPM\",\n        \"releaseYear\": \"année de sortie\",\n        \"disc\": \"disque\",\n        \"biography\": \"biographie\",\n        \"songCount\": \"nombre de titre\",\n        \"duration\": \"durée\",\n        \"random\": \"aléatoire\",\n        \"lastPlayed\": \"écouté récemment\",\n        \"toYear\": \"à l'année\",\n        \"fromYear\": \"depuis l'année\",\n        \"criticRating\": \"note des critiques\",\n        \"trackNumber\": \"piste\",\n        \"albumArtist\": \"$t(entity.albumArtist, {\\\"count\\\": 1})\",\n        \"comment\": \"commentaire\",\n        \"recentlyUpdated\": \"mis à jour récemment\",\n        \"channels\": \"$t(common.channel, {\\\"count\\\": 2})\",\n        \"owner\": \"$t(common.owner)\",\n        \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n        \"albumCount\": \"$t(entity.album, {\\\"count\\\": 2}) total\",\n        \"id\": \"id\",\n        \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n        \"isPublic\": \"est public\",\n        \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n        \"explicitStatus\": \"$t(common.explicitStatus)\",\n        \"sortName\": \"tri par nom\",\n        \"matchAnd\": \"et\",\n        \"matchOr\": \"ou\"\n    },\n    \"page\": {\n        \"sidebar\": {\n            \"nowPlaying\": \"lecture en cours\",\n            \"playlists\": \"$t(entity.playlist, {\\\"count\\\": 2})\",\n            \"search\": \"$t(common.search)\",\n            \"tracks\": \"$t(entity.track, {\\\"count\\\": 2})\",\n            \"albums\": \"$t(entity.album, {\\\"count\\\": 2})\",\n            \"genres\": \"$t(entity.genre, {\\\"count\\\": 2})\",\n            \"folders\": \"$t(entity.folder, {\\\"count\\\": 2})\",\n            \"settings\": \"$t(common.setting, {\\\"count\\\": 2})\",\n            \"home\": \"$t(common.home)\",\n            \"artists\": \"$t(entity.artist, {\\\"count\\\": 2})\",\n            \"albumArtists\": \"$t(entity.albumArtist, {\\\"count\\\": 2})\",\n            \"shared\": \"$t(entity.playlist, {\\\"count\\\": 2}) partagée\",\n            \"myLibrary\": \"Bibliothèque\",\n            \"favorites\": \"$t(entity.favorite, {\\\"count\\\": 2})\",\n            \"radio\": \"$t(entity.radioStation, {\\\"count\\\": 2})\",\n            \"collections\": \"collections\"\n        },\n        \"fullscreenPlayer\": {\n            \"config\": {\n                \"showLyricMatch\": \"afficher la correspondance des paroles\",\n                \"dynamicBackground\": \"arrière-plan dynamique\",\n                \"synchronized\": \"synchronisé\",\n                \"followCurrentLyric\": \"suivre les paroles\",\n                \"showLyricProvider\": \"afficher la source des paroles\",\n                \"unsynchronized\": \"désynchronisé\",\n                \"lyricAlignment\": \"alignement des paroles\",\n                \"useImageAspectRatio\": \"utiliser le ratio de l'image\",\n                \"opacity\": \"opacité\",\n                \"lyricSize\": \"taille des paroles\",\n                \"lyricGap\": \"espacement des lettres\",\n                \"dynamicIsImage\": \"activer l'image d'arrière-plan\",\n                \"dynamicImageBlur\": \"intensité du flou sur l'image d'arrière-plan\",\n                \"lyricOffset\": \"décalage des paroles (ms)\"\n            },\n            \"upNext\": \"à suivre\",\n            \"lyrics\": \"paroles\",\n            \"related\": \"similaire\",\n            \"visualizer\": \"visualisateur\",\n            \"noLyrics\": \"aucune parole trouvée\"\n        },\n        \"appMenu\": {\n            \"selectServer\": \"sélectionner le serveur\",\n            \"manageServers\": \"gérer les serveurs\",\n            \"expandSidebar\": \"développer la barre latérale\",\n            \"collapseSidebar\": \"réduire la barre latérale\",\n            \"openBrowserDevtools\": \"ouvrir les outils de développement du navigateur\",\n            \"goBack\": \"retour arrière\",\n            \"goForward\": \"avancer\",\n            \"version\": \"version {{version}}\",\n            \"settings\": \"$t(common.setting, {\\\"count\\\": 2})\",\n            \"quit\": \"$t(common.quit)\",\n            \"privateModeOff\": \"désactiver le mode privé\",\n            \"privateModeOn\": \"activer le mode privé\",\n            \"commandPalette\": \"ouvrir la palette de commandes\",\n            \"selectMusicFolder\": \"sélectionner le dossier musique\",\n            \"noMusicFolder\": \"aucun dossier musique de sélectionner\",\n            \"multipleMusicFolders\": \"{{count}} dossiers musique sélectionnés\"\n        },\n        \"home\": {\n            \"mostPlayed\": \"Les plus joués\",\n            \"newlyAdded\": \"Ajoutés récemment\",\n            \"explore\": \"Explorer depuis la bibliothèque\",\n            \"recentlyPlayed\": \"Joués récemment\",\n            \"title\": \"$t(common.home)\",\n            \"recentlyReleased\": \"Sortis récemment\",\n            \"genres\": \"$t(entity.genre, {\\\"count\\\": 2})\"\n        },\n        \"albumDetail\": {\n            \"moreFromArtist\": \"plus de $t(entity.artist, {\\\"count\\\": 1})\",\n            \"moreFromGeneric\": \"plus de {{item}}\",\n            \"released\": \"publié\"\n        },\n        \"setting\": {\n            \"generalTab\": \"général\",\n            \"hotkeysTab\": \"raccourcis\",\n            \"windowTab\": \"fenêtre\",\n            \"playbackTab\": \"lecture\",\n            \"advanced\": \"avancé\",\n            \"analytics\": \"analytique\",\n            \"updates\": \"mise à jour\",\n            \"cache\": \"cache\",\n            \"application\": \"application\",\n            \"queryBuilder\": \"constructeur de requêtes\",\n            \"theme\": \"thème\",\n            \"controls\": \"contrôles\",\n            \"sidebar\": \"barre latérale\",\n            \"remote\": \"distant\",\n            \"exportImport\": \"importer/exporter\",\n            \"scrobble\": \"scrobble\",\n            \"audio\": \"audio\",\n            \"lyrics\": \"paroles\",\n            \"transcoding\": \"transcodage\",\n            \"discord\": \"discord\",\n            \"logger\": \"journaliseur\",\n            \"playerFilters\": \"filtres du lecteur\",\n            \"lyricsDisplay\": \"affichage des paroles\"\n        },\n        \"globalSearch\": {\n            \"commands\": {\n                \"serverCommands\": \"commandes du serveur\",\n                \"goToPage\": \"aller à la page\",\n                \"searchFor\": \"recherche pour {{query}}\"\n            },\n            \"title\": \"commandes\"\n        },\n        \"contextMenu\": {\n            \"numberSelected\": \"{{count}} sélectionné\",\n            \"addToPlaylist\": \"$t(action.addToPlaylist)\",\n            \"addToFavorites\": \"$t(action.addToFavorites)\",\n            \"setRating\": \"$t(action.setRating)\",\n            \"moveToTop\": \"$t(action.moveToTop)\",\n            \"deletePlaylist\": \"$t(action.deletePlaylist)\",\n            \"moveToBottom\": \"$t(action.moveToBottom)\",\n            \"createPlaylist\": \"$t(action.createPlaylist)\",\n            \"removeFromPlaylist\": \"$t(action.removeFromPlaylist)\",\n            \"removeFromFavorites\": \"$t(action.removeFromFavorites)\",\n            \"addNext\": \"$t(player.addNext)\",\n            \"deselectAll\": \"$t(action.deselectAll)\",\n            \"addLast\": \"$t(player.addLast)\",\n            \"addFavorite\": \"$t(action.addToFavorites)\",\n            \"play\": \"$t(player.play)\",\n            \"removeFromQueue\": \"$t(action.removeFromQueue)\",\n            \"shareItem\": \"partager un élément\",\n            \"playSimilarSongs\": \"$t(player.playSimilarSongs)\",\n            \"showDetails\": \"obtenir des informations\",\n            \"download\": \"télécharger\",\n            \"playShuffled\": \"$t(player.shuffle)\",\n            \"moveToNext\": \"$t(action.moveToNext)\",\n            \"goToAlbumArtist\": \"aller à l'$t(entity.albumArtist, {\\\"count\\\": 1})\",\n            \"goToAlbum\": \"aller à l'$t(entity.album, {\\\"count\\\": 1})\",\n            \"moveItems\": \"$t(action.moveItems)\",\n            \"goTo\": \"aller à\"\n        },\n        \"albumArtistList\": {\n            \"title\": \"$t(entity.albumArtist, {\\\"count\\\": 2})\"\n        },\n        \"genreList\": {\n            \"title\": \"$t(entity.genre, {\\\"count\\\": 2})\",\n            \"showAlbums\": \"afficher $t(entity.genre, {\\\"count\\\": 1}) $t(entity.album, {\\\"count\\\": 2})\",\n            \"showTracks\": \"afficher $t(entity.genre, {\\\"count\\\": 1}) $t(entity.track, {\\\"count\\\": 2})\"\n        },\n        \"trackList\": {\n            \"title\": \"$t(entity.track, {\\\"count\\\": 2})\",\n            \"artistTracks\": \"pistes par {{artist}}\",\n            \"genreTracks\": \"\\\"{{genre}}\\\" $t(entity.track, {\\\"count\\\": 2})\"\n        },\n        \"playlistList\": {\n            \"title\": \"$t(entity.playlist, {\\\"count\\\": 2})\"\n        },\n        \"albumList\": {\n            \"title\": \"$t(entity.album, {\\\"count\\\": 2})\",\n            \"artistAlbums\": \"albums par {{artist}}\",\n            \"genreAlbums\": \"\\\"{{genre}}\\\" $t(entity.album, {\\\"count\\\": 2})\"\n        },\n        \"albumArtistDetail\": {\n            \"about\": \"À propos de {{artist}}\",\n            \"appearsOn\": \"apparaît sur\",\n            \"topSongsFrom\": \"meilleurs titres de {{title}}\",\n            \"viewAll\": \"voir tout\",\n            \"viewAllTracks\": \"voir tout $t(entity.track, {\\\"count\\\": 2})\",\n            \"recentReleases\": \"sorties récentes\",\n            \"viewDiscography\": \"voir la discographie\",\n            \"relatedArtists\": \"$t(entity.artist, {\\\"count\\\": 2}) similaires\",\n            \"topSongs\": \"meilleurs titres\",\n            \"groupingTypeAll\": \"toutes les types de sortie\",\n            \"favoriteSongs\": \"titres préférées\",\n            \"groupingTypePrimary\": \"types de parution principale\",\n            \"topSongsCommunity\": \"communauté\",\n            \"topSongsPersonal\": \"personnel\",\n            \"favoriteSongsFrom\": \"titres favori de {{title}}\"\n        },\n        \"itemDetail\": {\n            \"copyPath\": \"copier le chemin dans le presse-papiers\",\n            \"openFile\": \"afficher la piste dans le gestionnaire de fichiers\",\n            \"copiedPath\": \"chemin copié avec succès\"\n        },\n        \"playlist\": {\n            \"reorder\": \"la réorganisation n'est possible que lors du tri par identifiant\"\n        },\n        \"manageServers\": {\n            \"serverDetails\": \"détails du serveur\",\n            \"removeServer\": \"retirer le serveur\",\n            \"url\": \"URL du serveur\",\n            \"title\": \"gérer les serveurs\",\n            \"username\": \"nom d'utilisateur\",\n            \"editServerDetailsTooltip\": \"modifier les détails du serveur\"\n        },\n        \"favorites\": {\n            \"title\": \"$t(entity.favorite, {\\\"count\\\": 2})\"\n        },\n        \"folderList\": {\n            \"title\": \"$t(entity.folder, {\\\"count\\\": 2})\"\n        },\n        \"radioList\": {\n            \"title\": \"stations radio\"\n        },\n        \"releasenotes\": {\n            \"commitsSinceStable\": \"commits depuis {{stable}}\",\n            \"noNewCommits\": \"pas de nouveaux commits dans cette plage\",\n            \"noStableReleaseToCompare\": \"aucune version stable disponible avec laquelle comparer\"\n        },\n        \"windowBar\": {\n            \"paused\": \"(Pause) \",\n            \"privateMode\": \"(Mode Privé)\"\n        },\n        \"collections\": {\n            \"overrideExisting\": \"ignorer l'existant\",\n            \"saveAsCollection\": \"enregistrer comme collection\"\n        }\n    },\n    \"setting\": {\n        \"audioDevice_description\": \"sélectionnez le périphérique audio à utiliser pour la lecture\",\n        \"audioExclusiveMode_description\": \"activer le mode de sortie exclusif. Dans ce mode, le système est généralement verrouillé et seul mpv pourra émettre de l'audio\",\n        \"audioPlayer_description\": \"sélectionnez le lecteur audio à utiliser pour la lecture\",\n        \"crossfadeDuration_description\": \"définit la durée du fondu enchaîné\",\n        \"audioDevice\": \"périphérique audio\",\n        \"accentColor\": \"couleur d'accentuation\",\n        \"accentColor_description\": \"définit la couleur d'accentuation de l'application\",\n        \"applicationHotkeys\": \"raccourcis clavier de l'application\",\n        \"crossfadeDuration\": \"durée du fondu enchaîné\",\n        \"audioPlayer\": \"lecteur audio\",\n        \"applicationHotkeys_description\": \"configurer les raccourcis clavier de l'application. cocher la case pour définir comme raccourci clavier global (bureau uniquement)\",\n        \"crossfadeStyle_description\": \"sélectionnez le style du fondu enchaîné à utiliser pour le lecteur audio\",\n        \"customFontPath\": \"chemin de police personnalisé\",\n        \"customFontPath_description\": \"définit le chemin de police personnalisé pour l'application\",\n        \"remotePort_description\": \"définit le port du serveur de contrôle à distance\",\n        \"hotkey_skipBackward\": \"reculer\",\n        \"hotkey_playbackPause\": \"pause\",\n        \"hotkey_volumeUp\": \"monter le volume\",\n        \"discordIdleStatus_description\": \"si activé, met à jour le statut pendant que le lecteur est inactif\",\n        \"showSkipButtons\": \"afficher les boutons suivants et précédents\",\n        \"minimumScrobblePercentage\": \"durée minimal du scobble (pourcentage)\",\n        \"lyricFetch\": \"récupérer les paroles depuis internet\",\n        \"scrobble\": \"scrobble\",\n        \"enableRemote_description\": \"activer le serveur de contrôle à distance qui permet à d'autres appareils de contrôler l'application\",\n        \"fontType_optionSystem\": \"police système\",\n        \"mpvExecutablePath_description\": \"définit le chemin vers l'exécutable mpv, si vide, le chemin par défaut sera utilisé\",\n        \"hotkey_favoriteCurrentSong\": \"ajouter la $t(common.currentSong) aux favoris\",\n        \"sampleRate\": \"taux d'échantillonnage\",\n        \"sampleRate_description\": \"sélectionne le taux d'échantillonnage de sortie à utilisé si la fréquence d'échantillonnage choisie est différente de celle du média en cours. une valeur inférieure à 8000 utilisera la fréquence par défaut\",\n        \"hotkey_zoomIn\": \"zoom avant\",\n        \"scrobble_description\": \"scrobbler les lectures à votre serveur multimédia\",\n        \"hotkey_browserForward\": \"avancer (navigateur)\",\n        \"discordUpdateInterval\": \"intervalle de mise à jour du statut d'activité {{discord}}\",\n        \"fontType_optionBuiltIn\": \"police intégrée\",\n        \"hotkey_playbackPlayPause\": \"lecture / pause\",\n        \"hotkey_rate1\": \"noter 1 étoile\",\n        \"hotkey_skipForward\": \"avancer\",\n        \"disableLibraryUpdateOnStartup\": \"désactive la recherche de mise à jour au démarrage\",\n        \"gaplessAudio\": \"audio sans interruption\",\n        \"minimizeToTray_description\": \"réduit l'application vers la barre d'état système\",\n        \"hotkey_playbackPlay\": \"lecture\",\n        \"hotkey_togglePreviousSongFavorite\": \"basculer $t(common.previousSong) dans les favoris\",\n        \"hotkey_volumeDown\": \"baisser le volume\",\n        \"hotkey_unfavoritePreviousSong\": \"retirer $t(common.previousSong) des favoris\",\n        \"globalMediaHotkeys\": \"touches multimédias globales\",\n        \"hotkey_globalSearch\": \"recherche globale\",\n        \"gaplessAudio_description\": \"définit les paramètres d'audio sans interruption pour mpv\",\n        \"remoteUsername_description\": \"définit le nom d'utilisateur du serveur de contrôle à distance. si le nom d'utilisateur et le mot de passe sont vides, l'authentification sera désactivée\",\n        \"exitToTray_description\": \"quitte l'application vers la barre d'état système\",\n        \"followLyric_description\": \"faire défiler les paroles jusqu'à la position actuelle de lecture\",\n        \"hotkey_favoritePreviousSong\": \"ajouter la $t(common.previousSong) aux favoris\",\n        \"lyricOffset\": \"décalage des paroles (ms)\",\n        \"discordUpdateInterval_description\": \"temps en seconde entre chaque mise à jour (minimum de 15 secondes)\",\n        \"fontType_optionCustom\": \"police personnalisée\",\n        \"remotePassword\": \"mot de passe du serveur de contrôle à distance\",\n        \"lyricFetchProvider\": \"fournisseur depuis lequel récupérer les paroles\",\n        \"language_description\": \"définit la langue de l'application ($t(common.restartRequired))\",\n        \"playbackStyle_optionCrossFade\": \"fondu enchaîné\",\n        \"hotkey_rate3\": \"noter 3 étoiles\",\n        \"font\": \"police\",\n        \"hotkey_toggleFullScreenPlayer\": \"basculer en lecture plein écran\",\n        \"hotkey_localSearch\": \"recherche dans la page\",\n        \"hotkey_toggleQueue\": \"basculer la file d'attente\",\n        \"remotePassword_description\": \"définit le mot de passe du serveur de contrôle à distance. Ces identifiants sont par défaut transmises de façon non sécurisées, donc vous devriez utiliser un mot de passe unique dont vous n'avez pas grand-chose à faire\",\n        \"hotkey_rate5\": \"noter 5 étoiles\",\n        \"hotkey_playbackPrevious\": \"piste précédente\",\n        \"showSkipButtons_description\": \"affiche ou masque les boutons suivants et précédents de la barre de lecture\",\n        \"playbackStyle\": \"style de lecture\",\n        \"hotkey_toggleShuffle\": \"activer/désactiver la lecture aléatoire\",\n        \"playbackStyle_description\": \"sélectionnez le style de lecture à utiliser pour le lecteur audio\",\n        \"discordRichPresence_description\": \"active l'état de lecture dans le statut d'activité {{discord}}. Les images clés sont : {{icon}}, {{playing}}, et {{paused}}\",\n        \"mpvExecutablePath\": \"chemin de l'exécutable mpv\",\n        \"hotkey_rate2\": \"noter 2 étoiles\",\n        \"playButtonBehavior_description\": \"définit le comportement par défaut du bouton lecture, lors de l'ajout de titres à la file d'attente\",\n        \"minimumScrobblePercentage_description\": \"le pourcentage minimum du titre qui doit être écouté avant qu’elle ne soit scrobblée\",\n        \"exitToTray\": \"quitter vers la barre d'état système\",\n        \"hotkey_rate4\": \"noter 4 étoiles\",\n        \"enableRemote\": \"activer le serveur de contrôle à distance\",\n        \"showSkipButton_description\": \"affiche ou masque les boutons suivants et précédents de la barre de lecture\",\n        \"savePlayQueue\": \"sauvegarder la file d'attente\",\n        \"minimumScrobbleSeconds_description\": \"la durée minimale en secondes du titre qui doit être écouté avant qu’elle ne soit scrobblée\",\n        \"fontType_description\": \"Police intégrée vous permet de sélectionner l'une des polices fournies par feishin. Police système vous permet de sélectionner une des polices fournies par votre système d'exploitation. Police personnalisée vous permet d'importer votre propre police\",\n        \"playButtonBehavior\": \"comportement du bouton lecture\",\n        \"playbackStyle_optionNormal\": \"normale\",\n        \"hotkey_toggleRepeat\": \"activer/désactiver la répétition\",\n        \"lyricOffset_description\": \"décale les paroles du nombre de millisecondes spécifié\",\n        \"fontType\": \"type de police\",\n        \"remotePort\": \"port du serveur de contrôle à distance\",\n        \"hotkey_playbackNext\": \"piste suivante\",\n        \"lyricFetch_description\": \"récupère les paroles depuis diverses sources d'internet\",\n        \"lyricFetchProvider_description\": \"sélectionnez les fournisseurs auprès desquels récupérer les paroles\",\n        \"globalMediaHotkeys_description\": \"active ou désactive l'utilisation des touches multimédias de votre système pour contrôler la lecture\",\n        \"followLyric\": \"suivre les paroles en cours\",\n        \"discordIdleStatus\": \"afficher l'état d'inactivité dans le statut de l'activité\",\n        \"hotkey_zoomOut\": \"zoom arrière\",\n        \"hotkey_unfavoriteCurrentSong\": \"retirer $t(common.currentSong) des favoris\",\n        \"hotkey_rate0\": \"effacer la note\",\n        \"hotkey_volumeMute\": \"couper le son\",\n        \"hotkey_toggleCurrentSongFavorite\": \"basculer $t(common.currentSong) dans les favoris\",\n        \"remoteUsername\": \"nom d'utilisateur du serveur de contrôle à distance\",\n        \"hotkey_browserBack\": \"revenir en arrière (navigateur)\",\n        \"showSkipButton\": \"afficher les boutons suivants et précédents\",\n        \"minimizeToTray\": \"réduire vers la barre d'état système\",\n        \"gaplessAudio_optionWeak\": \"faible (recommandée)\",\n        \"minimumScrobbleSeconds\": \"scrobble minimum (secondes)\",\n        \"hotkey_playbackStop\": \"stop\",\n        \"font_description\": \"définit la police à utiliser pour l'application\",\n        \"savePlayQueue_description\": \"sauvegarde la file d'attente quand l'application est fermée et la restaure quand l'application est ouverte\",\n        \"sidebarCollapsedNavigation_description\": \"affiche ou masque les boutons de navigation dans la barre latérale réduite\",\n        \"sidebarConfiguration\": \"configuration de la barre latérale\",\n        \"sidebarConfiguration_description\": \"sélectionnez les éléments et l'ordre dans lequel ils seront affichés dans la barre latérale\",\n        \"sidebarPlaylistList\": \"listes de lecture de la barre latérale\",\n        \"sidebarCollapsedNavigation\": \"boutons de navigation de la barre latérale (réduite)\",\n        \"skipDuration\": \"durée de l'avance rapide\",\n        \"sidePlayQueueStyle_optionAttached\": \"attaché\",\n        \"sidePlayQueueStyle\": \"style de la file d'attente latérale\",\n        \"sidebarPlaylistList_description\": \"affiche ou masque les listes de lecture de la barre latérale\",\n        \"sidePlayQueueStyle_description\": \"définit le style de la file d'attente\",\n        \"sidePlayQueueStyle_optionDetached\": \"détaché\",\n        \"volumeWheelStep_description\": \"la valeur de volume à modifier lors du défilement de la molette de la souris sur le curseur de volume\",\n        \"theme_description\": \"définit le thème à utiliser pour l'application\",\n        \"skipDuration_description\": \"définit le durée du saut rapide, lors de l'utilisation des boutons avancer/reculer de la barre de lecture\",\n        \"themeLight\": \"thème (clair)\",\n        \"zoom\": \"pourcentage de zoom\",\n        \"themeDark_description\": \"définit le thème sombre à utiliser pour l'application\",\n        \"themeLight_description\": \"définit le thème clair à utiliser pour l'application\",\n        \"zoom_description\": \"définit le pourcentage de zoom de l'application\",\n        \"theme\": \"thème\",\n        \"skipPlaylistPage_description\": \"lors de la navigation dans une liste de lecture, aller directement vers la liste des titres, au lieu de la page par défaut\",\n        \"volumeWheelStep\": \"pas de la molette de volume\",\n        \"windowBarStyle\": \"style de la barre de la fenêtre\",\n        \"useSystemTheme_description\": \"suivre les préférences du système (mode clair ou sombre)\",\n        \"skipPlaylistPage\": \"sauter la page de listes de lecture\",\n        \"themeDark\": \"thème (sombre)\",\n        \"windowBarStyle_description\": \"sélectionner le style de la barre de la fenêtre\",\n        \"useSystemTheme\": \"utiliser le thème du système\",\n        \"discordApplicationId_description\": \"l'identifiant de l'application pour le statut d'activité {{discord}} (par défaut à {{defaultId}})\",\n        \"audioExclusiveMode\": \"mode de sortie audio exclusif\",\n        \"discordApplicationId\": \"identifiant d'application {{discord}}\",\n        \"playButtonBehavior_optionPlay\": \"$t(player.play)\",\n        \"replayGainMode_optionNone\": \"$t(common.none)\",\n        \"playButtonBehavior_optionAddLast\": \"$t(player.addLast)\",\n        \"replayGainMode_optionAlbum\": \"$t(entity.album, {\\\"count\\\": 1})\",\n        \"replayGainMode_optionTrack\": \"$t(entity.track, {\\\"count\\\": 1})\",\n        \"playButtonBehavior_optionAddNext\": \"$t(player.addNext)\",\n        \"replayGainMode_description\": \"ajuste le gain du volume selon les valeurs de {{ReplayGain}} enregistrées dans les métadonnées du fichier\",\n        \"replayGainFallback\": \"valeur de repli de {{ReplayGain}}\",\n        \"replayGainClipping_description\": \"empêcher la distorsion causée par {{ReplayGain}} en réduisant automatiquement le gain\",\n        \"replayGainPreamp\": \"préamplificateur (dB) de {{ReplayGain}}\",\n        \"replayGainClipping\": \"distorsion du {{ReplayGain}}\",\n        \"replayGainMode\": \"mode de {{ReplayGain}}\",\n        \"replayGainFallback_description\": \"gain en dB à appliquer si le fichier n'a pas de tags de {{ReplayGain}}\",\n        \"replayGainPreamp_description\": \"ajuste le gain de préampli appliqué aux valeurs {{ReplayGain}}\",\n        \"clearQueryCache\": \"vide le cache de feishin\",\n        \"clearCache\": \"vider le cache du navigateur\",\n        \"buttonSize_description\": \"la taille des boutons de la barre de lecture\",\n        \"clearQueryCache_description\": \"un 'nettoyage léger' de feishin. cela actualisera les listes de lecture, les métadonnées des titres, et réinitialisera les paroles enregistrées. les paramètres, identifiants du serveur et images mises en cache seront conservés\",\n        \"clearCache_description\": \"un 'nettoyage complet' de feishin. en plus de vider le cache de feishin, vide le cache du navigateur (images sauvegardées et autres ressources). les identifiants serveurs et paramètres sont conservés\",\n        \"buttonSize\": \"taille des boutons de la barre de lecture\",\n        \"clearCacheSuccess\": \"cache vidé avec succès\",\n        \"externalLinks_description\": \"activer l'affichage de liens externes (Last.fm, MusicBrainz) sur les pages d'artiste/album\",\n        \"startMinimized_description\": \"démarrer l'application dans la barre d'état système\",\n        \"externalLinks\": \"afficher les liens externes\",\n        \"homeConfiguration\": \"configuration de la page d'accueil\",\n        \"homeFeature\": \"carrousel de la page d'accueil\",\n        \"homeFeature_description\": \"contrôle l’affichage du carrousel principal sur la page d’accueil\",\n        \"imageAspectRatio\": \"utiliser le rapport hauteur/largeur natif de la pochette d'album\",\n        \"imageAspectRatio_description\": \"si cette option est activée, les pochettes d'album seront affichées en utilisant leur rapport hauteur/largeur natif. pour les pochettes qui n'ont pas un rapport 1:1 (carré), l'espace restant sera vide\",\n        \"mpvExtraParameters_help\": \"un par ligne\",\n        \"passwordStore_description\": \"quel gestionnaire de mots de passe/secret utiliser. modifiez ceci si vous rencontrez des problèmes pour stocker les mots de passe\",\n        \"passwordStore\": \"gestionnaire de mots de passe/secrets\",\n        \"homeConfiguration_description\": \"configurer quels éléments sont affichés sur la page d'accueil, et dans quel ordre\",\n        \"startMinimized\": \"démarrer l'application en mode réduit\",\n        \"transcode_description\": \"permet le transcodage vers différents formats\",\n        \"transcodeBitrate_description\": \"sélectionne le débit binaire du transcodage. 0 signifie que le serveur choisit\",\n        \"transcodeFormat_description\": \"sélectionne le format du transcodage. laisser vide pour laisser le serveur décider\",\n        \"volumeWidth\": \"largeur de la barre de volume\",\n        \"volumeWidth_description\": \"la largeur de la barre de volume\",\n        \"customCssEnable\": \"active le css personnalisé\",\n        \"customCssEnable_description\": \"permet l'écriture de css personnalisé\",\n        \"customCssNotice\": \"Attention : bien qu'il y ait un certain assainissement (blocage de url() et de content :), l'utilisation de css personnalisé peut toujours présenter des risques en modifiant l'interface\",\n        \"customCss\": \"css personnalisé\",\n        \"webAudio\": \"utiliser l'audio web\",\n        \"transcodeBitrate\": \"débit binaire du transcodage\",\n        \"transcodeFormat\": \"format de transcodage\",\n        \"webAudio_description\": \"utiliser l'audio web. cela permet d'utiliser des fonctions avancées comme le replaygain. désactivez si cela cause des problèmes\",\n        \"artistConfiguration\": \"configuration de la page d'artiste d'album\",\n        \"artistConfiguration_description\": \"configurer les éléments et l'ordre à afficher, sur la page d'artiste d'album\",\n        \"contextMenu\": \"configuration du menu contextuel (clic droit)\",\n        \"contextMenu_description\": \"permet de masquer les éléments qui s'affichent dans le menu lorsque vous cliquez droit sur une entrée. les éléments qui ne sont pas cochés seront masqués\",\n        \"albumBackground\": \"image d'arrière-plan de l'album\",\n        \"albumBackground_description\": \"ajoute une image d'arrière-plan pour les pages d'album contenant une pochette d'album\",\n        \"albumBackgroundBlur_description\": \"ajuste le niveau de flou appliqué à l'image d'arrière-plan de l'album\",\n        \"playButtonBehavior_optionPlayShuffled\": \"$t(player.shuffle)\",\n        \"playerbarOpenDrawer\": \"basculement en plein écran de la barre de lecture\",\n        \"playerbarOpenDrawer_description\": \"permet de cliquer sur la barre de lecture pour ouvrir le lecteur plein écran\",\n        \"translationApiProvider\": \"fournisseur d'API de traduction\",\n        \"discordListening\": \"afficher le status en \\\"écoute\\\"\",\n        \"discordListening_description\": \"afficher le statut comme étant en écoute au lieu de jouer\",\n        \"translationApiKey_description\": \"clé API pour la traduction (point de terminaison global uniquement)\",\n        \"translationTargetLanguage\": \"langue cible de traduction\",\n        \"trayEnabled\": \"afficher la barre d’état système\",\n        \"translationApiProvider_description\": \"fournisseur d'API pour la traduction\",\n        \"customCss_description\": \"contenu css personnalisé. Remarque : les propriétés 'content' et les URL distantes ne sont pas autorisées. Un aperçu de votre contenu est affiché ci-dessous. Des champs supplémentaires que vous n'avez pas définis sont présents en raison d'assainissement\",\n        \"translationApiKey\": \"clé API de traduction\",\n        \"translationTargetLanguage_description\": \"langue cible pour la traduction\",\n        \"trayEnabled_description\": \"afficher/masquer l’icône/le menu dans la barre d’état système. si désactivé, désactive également la réduction/fermeture vers la barre d’état système\",\n        \"albumBackgroundBlur\": \"intensité du flou de l'image d'arrière-plan de l'album\",\n        \"lastfmApiKey\": \"clé API {{lastfm}}\",\n        \"lastfmApiKey_description\": \"la clé API pour {{lastfm}}. requise pour la pochette d'album\",\n        \"discordServeImage\": \"servir l'image {{discord}} depuis le serveur\",\n        \"discordServeImage_description\": \"partage la pochette d'album pour le statut d'activité {{discord}} depuis le serveur directement (disponible uniquement pour Jellyfin et Navidrome). {{discord}} utilise un bot pour récupérer les images, votre serveur doit donc être accessible depuis internet\",\n        \"lastfm\": \"afficher les liens last.fm\",\n        \"musicbrainz_description\": \"affiche les liens vers MusicBrainz sur les pages artiste/album, lorsque l'identifiant MusicBrainz existe\",\n        \"lastfm_description\": \"affiche les liens vers last.fm sur les pages artiste/album\",\n        \"musicbrainz\": \"affiche les liens MusicBrainz\",\n        \"neteaseTranslation\": \"Activer les traductions NetEase\",\n        \"neteaseTranslation_description\": \"si activé, récupère et affiche les paroles traduites de NetEase si elles sont disponibles\",\n        \"preferLocalLyrics_description\": \"privilégier les paroles locales aux paroles distantes lorsqu'elles sont disponibles\",\n        \"preferLocalLyrics\": \"privilégier les paroles locales\",\n        \"discordPausedStatus_description\": \"si activé, le statut affichera lorsque la lecture est en pause\",\n        \"discordPausedStatus\": \"afficher le statut d’activité même en pause\",\n        \"preservePitch\": \"préserver la hauteur\",\n        \"preservePitch_description\": \"préserver la hauteur lors du changement de la vitesse de lecture\",\n        \"discordDisplayType\": \"type d'affichage du statut {{discord}}\",\n        \"discordDisplayType_description\": \"modifie ce que vous écoutez dans votre statut\",\n        \"discordDisplayType_songname\": \"nom du titre\",\n        \"discordDisplayType_artistname\": \"nom(s) d’artiste\",\n        \"hotkey_navigateHome\": \"aller à l'accueil\",\n        \"preventSleepOnPlayback_description\": \"empêcher l’écran de s’éteindre pendant la lecture de la musique\",\n        \"preventSleepOnPlayback\": \"empêche la mise en veille lors de la lecture\",\n        \"discordLinkType\": \"lien du statut d'activité {{discord}}\",\n        \"discordLinkType_description\": \"ajoute des liens externes vers {{lastfm}} ou {{musicbrainz}} aux champs titre et artiste du statut d'activité {{discord}}. {{musicbrainz}} est la méthode la plus précise, mais nécessite des balises et ne fournit pas de liens vers les artistes, tandis que {{lastfm}} devrait toujours fournir un lien. aucune requête réseau supplémentaire n'est effectuée\",\n        \"discordLinkType_none\": \"$t(common.none)\",\n        \"discordLinkType_mbz_lastfm\": \"{{musicbrainz}} avec {{lastfm}} comme solution de repli\",\n        \"artistBackground\": \"image d'arrière-plan de l'artiste\",\n        \"artistBackground_description\": \"ajoute une image d'arrière-plan pour les pages d'artiste contenant une image de l'artiste\",\n        \"artistBackgroundBlur\": \"intensité du flou sur l'image d'arrière-plan de l'artiste\",\n        \"artistBackgroundBlur_description\": \"ajuste la quantité de flou appliquée à l'image d'arrière-plan de l'artiste\",\n        \"releaseChannel_optionLatest\": \"dernière\",\n        \"releaseChannel_optionBeta\": \"bêta\",\n        \"releaseChannel\": \"canal de diffusion\",\n        \"releaseChannel_description\": \"choisissez entre les versions stable, bêta ou alpha (nocturne) pour les mises à jour automatiques\",\n        \"mediaSession\": \"activer media session\",\n        \"mediaSession_description\": \"active l'intégration de Media Session, affichant les contrôles multimédias et les métadonnées dans la superposition du volume du système et sur l'écran de verrouillage\",\n        \"enableAutoTranslation_description\": \"activer la traduction automatiquement lorsque les paroles sont chargées\",\n        \"enableAutoTranslation\": \"activer la traduction automatique\",\n        \"exportImportSettings_control_description\": \"exporter et importer les paramètres en JSON\",\n        \"exportImportSettings_control_exportText\": \"paramètres d'exportation\",\n        \"exportImportSettings_control_importText\": \"paramètres d'importation\",\n        \"exportImportSettings_control_title\": \"paramètres d'importation / exportation\",\n        \"exportImportSettings_destructiveWarning\": \"l'importation des paramètres est destructive, veuillez lire les informations ci-dessus avant de cliquer sur \\\"importer\\\" ci-dessous !\",\n        \"exportImportSettings_importBtn\": \"paramètres d'importation\",\n        \"exportImportSettings_importSuccess\": \"les paramètres ont été importés avec succès !\",\n        \"exportImportSettings_notValidJSON\": \"le fichier transmis n'est pas un JSON valide\",\n        \"exportImportSettings_offendingKeyError\": \"la clé \\\"{{offendingKey}}\\\" est incorrecte - {{reason}}\",\n        \"exportImportSettings_importModalTitle\": \"importer les paramètres de feishin\",\n        \"crossfadeStyle\": \"style du fondu enchaîné\",\n        \"discordRichPresence\": \"statut d'activité {{discord}} (rich presence)\",\n        \"language\": \"langage\",\n        \"notify_description\": \"afficher des notifications lors du changement du titre en cours\",\n        \"transcode\": \"activer le transcodage\",\n        \"notify\": \"activer les notifications de titre\",\n        \"analyticsDisable\": \"Désactiver l'analytique basée sur l'utilisation\",\n        \"analyticsDisable_description\": \"les données d'utilisation anonymisées sont envoyées au développeur afin de contribuer à l'amélioration de l'application\",\n        \"playerbarSlider\": \"barre de progression\",\n        \"playerbarSliderType_optionSlider\": \"pleine\",\n        \"playerbarSliderType_optionWaveform\": \"forme d'onde\",\n        \"playerbarWaveformAlign\": \"alignement de l'onde\",\n        \"playerbarWaveformAlign_optionTop\": \"haut\",\n        \"playerbarWaveformAlign_optionCenter\": \"centre\",\n        \"playerbarWaveformAlign_optionBottom\": \"bas\",\n        \"playerbarWaveformBarWidth\": \"largeur des barres de l'onde\",\n        \"playerbarWaveformGap\": \"espacement de l'onde\",\n        \"playerbarWaveformRadius\": \"rayon des barres de l'onde\",\n        \"showLyricsInSidebar_description\": \"un panneau sera attaché à la file d'attente, qui affichera les paroles\",\n        \"showLyricsInSidebar\": \"afficher les paroles dans la barre de lecture latérale\",\n        \"showVisualizerInSidebar_description\": \"un panneau sera ajouté à la barre de lecture latérale qui affiche le visualiseur\",\n        \"showVisualizerInSidebar\": \"afficher le visualiseur dans la barre de lecture latérale\",\n        \"audioFadeOnStatusChange\": \"fondu audio lors du basculement lecture/pause\",\n        \"audioFadeOnStatusChange_description\": \"active le fondu sortant et entrant lors du changement de statut lecture/pause\",\n        \"queryBuilder\": \"constructeur de requêtes\",\n        \"queryBuilderCustomFields_inputLabel\": \"label\",\n        \"queryBuilderCustomFields_inputTag\": \"tag\",\n        \"queryBuilderCustomFields\": \"champs personnalisé\",\n        \"queryBuilderCustomFields_description\": \"ajouter des champs personnalisés à utiliser dans les constructeurs de requêtes\",\n        \"autoDJ\": \"DJ auto\",\n        \"autoDJ_description\": \"ajouter automatiquement des titres similaire à la file d'attente\",\n        \"autoDJ_itemCount\": \"nombre d'entrée\",\n        \"autoDJ_itemCount_description\": \"le nombre d'entrées tentées d'être ajoutées à la file d'attente lorsque le DJ auto est activé\",\n        \"autoDJ_timing\": \"timing\",\n        \"autoDJ_timing_description\": \"le nombre de titres restant dans la file d'attente avant le déclenchement du DJ auto\",\n        \"followCurrentSong_description\": \"défiler automatiquement la file d'attente jusqu'au titre en cours\",\n        \"followCurrentSong\": \"suivre le titre en cours\",\n        \"logLevel\": \"niveau de journalisation\",\n        \"logLevel_description\": \"définit le niveau minimum de journalisation à afficher. le mode debug affiche tous les logs, le mode error n’affiche que les erreurs\",\n        \"logLevel_optionDebug\": \"débogage\",\n        \"logLevel_optionError\": \"erreur\",\n        \"logLevel_optionInfo\": \"info\",\n        \"logLevel_optionWarn\": \"avertissement\",\n        \"playerFilters\": \"filtrer les titres de la file d'attente\",\n        \"playerFilters_description\": \"exclure les titres de la file d'attente selon les critères suivants\",\n        \"playerbarSlider_description\": \"la forme d'onde n'est pas recommandée sur une connexion lente ou limitée\",\n        \"useThemeAccentColor\": \"utiliser la couleur d'accent du thème\",\n        \"useThemeAccentColor_description\": \"utiliser la couleur principale définie dans le thème sélectionné au lieu de la couleur d'accentuation personnalisée\",\n        \"artistReleaseTypeConfiguration\": \"configuration des sorties de l'artiste\",\n        \"artistReleaseTypeConfiguration_description\": \"configure quels types de sortie sont affichés et dans quel ordre, sur la page d'artiste d'album\",\n        \"mpvExtraParameters\": \"paramètres supplémentaires de mpv\",\n        \"mpvExtraParameters_description\": \"arguments supplémentaires à transmettre à mpv\",\n        \"pathReplace\": \"remplacement du chemin de fichier\",\n        \"pathReplace_description\": \"remplacez le chemin de fichier par défaut de votre serveur\",\n        \"pathReplace_optionRemovePrefix\": \"supprimer un prefix\",\n        \"pathReplace_optionAddPrefix\": \"ajouter un prefix\",\n        \"artistRadioCount_description\": \"définit le nombre de titres à récupérer pour les radio d'artiste/piste\",\n        \"artistRadioCount\": \"radio d'artiste/piste\",\n        \"imageResolution\": \"résolution d'image\",\n        \"imageResolution_description\": \"la résolution d'image utilisée dans l'application. définir une valeur à 0 utilisera la résolution native de l'image\",\n        \"imageResolution_optionTable\": \"tableau\",\n        \"imageResolution_optionItemCard\": \"carte\",\n        \"imageResolution_optionSidebar\": \"barre latérale\",\n        \"imageResolution_optionHeader\": \"en-tête\",\n        \"imageResolution_optionFullScreenPlayer\": \"lecteur en plein écran\",\n        \"showRatings_description\": \"contrôle si la notation à étoiles s'affiche dans l'interface\",\n        \"showRatings\": \"affiche la notation à étoiles\",\n        \"combinedLyricsAndVisualizer_description\": \"combine les paroles et le visualisateur dans le même panneau\",\n        \"combinedLyricsAndVisualizer\": \"combine les paroles et le visualisateur dans la barre latérale\",\n        \"analyticsEnable\": \"Envoyer des métriques d'utilisation\",\n        \"analyticsEnable_description\": \"Des métriques d'utilisation anonymisées sont envoyées au développeur pour aider à améliorer l'application\",\n        \"automaticUpdates\": \"Mises à jour automatiques\",\n        \"automaticUpdates_description\": \"Vérifie et installe les mises à jour automatiquement\",\n        \"releaseChannel_optionAlpha\": \"alpha (nocturne)\",\n        \"discordStateIcon\": \"afficher l’icône de lecture\",\n        \"discordStateIcon_description\": \"affiche une petite icône de lecture dans le statut d'activité. l'icône de pause est toujours affichée lorsque \\\"Afficher le statut d’activité même en pause\\\" est activé\",\n        \"homeFeatureStyle_description\": \"contrôle le style du carrousel de la page d’accueil\",\n        \"homeFeatureStyle\": \"style du carrousel de la page d’accueil\",\n        \"homeFeatureStyle_optionMultiple\": \"multiple\",\n        \"homeFeatureStyle_optionSingle\": \"simple\",\n        \"blurExplicitImages\": \"flouter les images explicites\",\n        \"blurExplicitImages_description\": \"les pochettes de titre et d'albums étiquetées comme explicites seront floutées\",\n        \"enableGridMultiSelect\": \"activer la sélection multiple dans la grille\",\n        \"enableGridMultiSelect_description\": \"si activé, permet la sélection de plusieurs entrées dans la vue en grille. si désactivé, cliquer sur un item de la grille mène vers la page de l'entrée\",\n        \"sidebarPlaylistSorting_description\": \"permet le tri manuel des listes de lecture dans la barre latérale en utilisant le glisser-déposer au lieu de l'ordre par défaut du serveur\",\n        \"sidebarPlaylistSorting\": \"tri des listes de lecture dans la barre latérale\",\n        \"sidebarPlaylistListFilterRegex_description\": \"masquer les listes de lecture dans la barre latérale correspondant à cette expression régulière\",\n        \"sidebarPlaylistListFilterRegex_placeholder\": \"ex. ^Mix Journalier*\",\n        \"sidebarPlaylistListFilterRegex\": \"filtre de liste de lecture en expression régulière\",\n        \"autosave\": \"sauvegarder automatiquement la file d'attente\",\n        \"autosave_description\": \"activez la sauvegarde automatique de la file d'attente sur votre serveur. Cette fonction est uniquement disponible avec Navidrome/Subsonic et ne permet pas d'utiliser une file d'attente mixte.\",\n        \"autosaveCount\": \"fréquence de sauvegarde automatique de la file d'attente\",\n        \"autosaveCount_description\": \"nombre de changement de piste avant la sauvegarde de la file d'attente. 1 (minimum) signifie chaque changement de titre\",\n        \"useThemePrimaryShade\": \"utiliser la teinte principale du thème\",\n        \"useThemePrimaryShade_description\": \"utiliser la teinte principale définie dans le thème sélectionné pour les variantes de couleur primaire\",\n        \"primaryShade\": \"teinte principale\",\n        \"primaryShade_description\": \"remplacer la teinte principale (0–9) utilisée pour les boutons, les liens et les autres éléments de couleur primaire\",\n        \"hotkey_listNavigateToPage\": \"naviguer vers la page de l'élément\",\n        \"hotkey_listPlayDefault\": \"lecture de la liste\",\n        \"hotkey_listPlayLast\": \"lire en dernier\",\n        \"hotkey_listPlayNext\": \"lire ensuite\",\n        \"hotkey_listPlayNow\": \"lire maintenant\",\n        \"playerItemConfiguration_description\": \"configurer les éléments affichés et leur ordre dans le lecteur plein écran\",\n        \"playerItemConfiguration\": \"configuration des éléments du lecteur\"\n    },\n    \"form\": {\n        \"deletePlaylist\": {\n            \"title\": \"supprimer de $t(entity.playlist, {\\\"count\\\": 1})\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) supprimée avec succès\",\n            \"input_confirm\": \"taper le nom de la $t(entity.playlist, {\\\"count\\\": 1}) pour confirmer\"\n        },\n        \"addServer\": {\n            \"title\": \"ajouter un serveur\",\n            \"input_username\": \"nom d'utilisateur\",\n            \"input_url\": \"URL\",\n            \"input_password\": \"mot de passe\",\n            \"input_legacyAuthentication\": \"activer l'authentification legacy\",\n            \"input_name\": \"nom du serveur\",\n            \"success\": \"serveur ajouté avec succès\",\n            \"input_savePassword\": \"enregister le mot de passe\",\n            \"ignoreSsl\": \"ignorer ssl $t(common.restartRequired)\",\n            \"ignoreCors\": \"ignorer cors $t(common.restartRequired)\",\n            \"error_savePassword\": \"une erreur s’est produite lors de la tentative de sauvegarde du mot de passe\",\n            \"input_preferInstantMix\": \"préférer le mix instantané\",\n            \"input_preferInstantMixDescription\": \"utiliser uniquement le mix instantané pour jouer des pistes similaires. utile si vous avez des plugins qui modifient ce comportement\",\n            \"input_preferRemoteUrl\": \"préférer une URL publique\",\n            \"input_remoteUrl\": \"URL publique\",\n            \"input_remoteUrlPlaceholder\": \"optionnel : URL publique pour les fonctionnalités externes\"\n        },\n        \"addToPlaylist\": {\n            \"success\": \"$t(entity.trackWithCount, {\\\"count\\\" : {{message}} }) ajouté à $t(entity.playlistWithCount, {\\\"count\\\" : {{numOfPlaylists}} })\",\n            \"title\": \"ajouter à $t(entity.playlist, {\\\"count\\\": 1})\",\n            \"input_skipDuplicates\": \"sauter les doublons\",\n            \"input_playlists\": \"$t(entity.playlist, {\\\"count\\\": 2})\",\n            \"create\": \"créer $t(entity.playlist, {\\\"count\\\": 1}) {{playlist}}\",\n            \"searchOrCreate\": \"rechercher $t(entity.playlist, {\\\"count\\\": 2}) ou tapez pour en créer une nouvelle\"\n        },\n        \"createPlaylist\": {\n            \"title\": \"créer une $t(entity.playlist, {\\\"count\\\": 1})\",\n            \"input_public\": \"publique\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) créée avec succès\",\n            \"input_description\": \"$t(common.description)\",\n            \"input_name\": \"$t(common.name)\",\n            \"input_owner\": \"$t(common.owner)\"\n        },\n        \"updateServer\": {\n            \"title\": \"mise à jour du serveur\",\n            \"success\": \"serveur mis à jour avec succès\"\n        },\n        \"queryEditor\": {\n            \"input_optionMatchAll\": \"correspondre à tous\",\n            \"input_optionMatchAny\": \"correspondre à n'importe quel\",\n            \"title\": \"éditeur de requête\",\n            \"addRuleGroup\": \"ajouter un groupe de règles\",\n            \"removeRuleGroup\": \"supprimer un groupe de règles\",\n            \"resetToDefault\": \"réinitialiser par défaut\",\n            \"clearFilters\": \"réinitialiser les filtres\"\n        },\n        \"editPlaylist\": {\n            \"title\": \"modifier $t(entity.playlist, {\\\"count\\\": 1})\",\n            \"publicJellyfinNote\": \"Jellyfin n'indique pas si une liste de lecture est publique ou non. Si vous souhaitez que cette liste de lecture reste publique, veuillez sélectionner l'entrée suivante\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) mis à jour avec succès\",\n            \"editNote\": \"les modifications manuelles ne sont pas recommandées pour les listes de lecture volumineuses. êtes-vous sûre d'accepter le risque d'une perte de données en écrasant la liste de lecture existante ?\"\n        },\n        \"lyricSearch\": {\n            \"title\": \"recherche de paroles\",\n            \"input_name\": \"$t(common.name)\",\n            \"input_artist\": \"$t(entity.artist, {\\\"count\\\": 1})\"\n        },\n        \"shareItem\": {\n            \"allowDownloading\": \"autoriser le téléchargement\",\n            \"description\": \"description\",\n            \"setExpiration\": \"définir une expiration\",\n            \"success\": \"lien de partage copié dans le presse-papier (ou cliquez ici pour ouvrir)\",\n            \"expireInvalid\": \"l'expiration doit être définie à une date ultérieure\",\n            \"createFailed\": \"échec de la création du lien de partage (le partage est-il activé ?)\",\n            \"copyToClipboard\": \"Copier vers le presse-papiers : Ctrl+C, Entrer\",\n            \"successMustClick\": \"partage créé avec succès. cliquez ici pour ouvrir\"\n        },\n        \"privateMode\": {\n            \"enabled\": \"le mode privé est activé, le statut de lecture est maintenant caché des intégrations externes\",\n            \"disabled\": \"le mode privé est désactivé, le statut de lecture est maintenant visible des intégrations externes\",\n            \"title\": \"mode privé\"\n        },\n        \"largeFetchConfirmation\": {\n            \"title\": \"ajouter des entrées à la file d'attente\",\n            \"description\": \"Cette action ajoutera toutes les entrées de la vue filtrée actuelle\"\n        },\n        \"shuffleAll\": {\n            \"title\": \"jouer aléatoirement\",\n            \"input_genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n            \"input_limit\": \"combien de titres ?\",\n            \"input_minYear\": \"à partir de l'année\",\n            \"input_maxYear\": \"à l'année\",\n            \"input_played\": \"filtre de lecture\",\n            \"input_played_optionAll\": \"toutes les pistes\",\n            \"input_played_optionUnplayed\": \"seulement les pistes non jouées\",\n            \"input_played_optionPlayed\": \"seulement les pistes jouées\"\n        },\n        \"createRadioStation\": {\n            \"success\": \"station radio créée avec succès\",\n            \"title\": \"créer une station radio\",\n            \"input_homepageUrl\": \"lien de la page d'accueil\",\n            \"input_name\": \"nom\",\n            \"input_streamUrl\": \"lien du flux en direct\"\n        },\n        \"saveQueue\": {\n            \"success\": \"file d'attente enregistrée sur le serveur\"\n        },\n        \"lyricsExport\": {\n            \"export\": \"exporter les paroles\",\n            \"input_synced\": \"exporter les paroles synchronisées\",\n            \"input_offset\": \"$t(setting.lyricOffset)\"\n        }\n    },\n    \"entity\": {\n        \"genre_one\": \"genre\",\n        \"genre_many\": \"genres\",\n        \"genre_other\": \"genres\",\n        \"playlistWithCount_one\": \"{{count}} liste de lecture\",\n        \"playlistWithCount_many\": \"{{count}} listes de lecture\",\n        \"playlistWithCount_other\": \"{{count}} listes de lecture\",\n        \"playlist_one\": \"liste de lecture\",\n        \"playlist_many\": \"listes de lecture\",\n        \"playlist_other\": \"listes de lecture\",\n        \"artist_one\": \"artiste\",\n        \"artist_many\": \"artistes\",\n        \"artist_other\": \"artistes\",\n        \"folderWithCount_one\": \"{{count}} dossier\",\n        \"folderWithCount_many\": \"{{count}} dossiers\",\n        \"folderWithCount_other\": \"{{count}} dossiers\",\n        \"albumArtist_one\": \"artiste d'album\",\n        \"albumArtist_many\": \"artistes d'albums\",\n        \"albumArtist_other\": \"artistes d'albums\",\n        \"track_one\": \"piste\",\n        \"track_many\": \"pistes\",\n        \"track_other\": \"pistes\",\n        \"albumArtistCount_one\": \"{{count}} artiste de l'album\",\n        \"albumArtistCount_many\": \"{{count}} artistes d'albums\",\n        \"albumArtistCount_other\": \"{{count}} artistes d'albums\",\n        \"albumWithCount_one\": \"{{count}} album\",\n        \"albumWithCount_many\": \"{{count}} albums\",\n        \"albumWithCount_other\": \"{{count}} albums\",\n        \"favorite_one\": \"favori\",\n        \"favorite_many\": \"favoris\",\n        \"favorite_other\": \"favoris\",\n        \"artistWithCount_one\": \"{{count}} artiste\",\n        \"artistWithCount_many\": \"{{count}} artistes\",\n        \"artistWithCount_other\": \"{{count}} artistes\",\n        \"folder_one\": \"dossier\",\n        \"folder_many\": \"dossiers\",\n        \"folder_other\": \"dossiers\",\n        \"smartPlaylist\": \"$t(entity.playlist, {\\\"count\\\": 1}) intelligente\",\n        \"album_one\": \"album\",\n        \"album_many\": \"albums\",\n        \"album_other\": \"albums\",\n        \"genreWithCount_one\": \"{{count}} genre\",\n        \"genreWithCount_many\": \"{{count}} genres\",\n        \"genreWithCount_other\": \"{{count}} genres\",\n        \"trackWithCount_one\": \"{{count}} piste\",\n        \"trackWithCount_many\": \"{{count}} pistes\",\n        \"trackWithCount_other\": \"{{count}} pistes\",\n        \"play_one\": \"{{count}} écoute\",\n        \"play_many\": \"{{count}} écoutes\",\n        \"play_other\": \"{{count}} écoutes\",\n        \"song_one\": \"titre\",\n        \"song_many\": \"titres\",\n        \"song_other\": \"titres\",\n        \"radioStation_one\": \"station radio\",\n        \"radioStation_many\": \"stations radio\",\n        \"radioStation_other\": \"stations radio\",\n        \"radioStationWithCount_one\": \"{{count}} station radio\",\n        \"radioStationWithCount_many\": \"{{count}} stations radio\",\n        \"radioStationWithCount_other\": \"{{count}} stations radio\"\n    },\n    \"table\": {\n        \"config\": {\n            \"general\": {\n                \"displayType\": \"type d'affichage\",\n                \"tableColumns\": \"colonnes de la liste\",\n                \"autoFitColumns\": \"ajuster automatiquement la largeur des colonnes\",\n                \"gap\": \"$t(common.gap)\",\n                \"size\": \"$t(common.size)\",\n                \"itemGap\": \"écart entre les éléments (en pixel)\",\n                \"itemSize\": \"taille des élements (en pixel)\",\n                \"followCurrentSong\": \"suivre le titre actuelle\",\n                \"advancedSettings\": \"paramètres avancés\",\n                \"autosize\": \"taille automatique\",\n                \"moveUp\": \"monter\",\n                \"moveDown\": \"descendre\",\n                \"pinToLeft\": \"épingler à gauche\",\n                \"pinToRight\": \"épingler à droite\",\n                \"alignLeft\": \"aligner à gauche\",\n                \"alignCenter\": \"centrer\",\n                \"alignRight\": \"aligner à droite\",\n                \"itemsPerRow\": \"entrées par ligne\",\n                \"size_default\": \"défaut\",\n                \"size_compact\": \"compacte\",\n                \"size_large\": \"large\",\n                \"pagination\": \"pagination\",\n                \"pagination_itemsPerPage\": \"entrées par page\",\n                \"pagination_infinite\": \"infini\",\n                \"pagination_paginate\": \"paginé\",\n                \"alternateRowColors\": \"alterner les couleurs des lignes\",\n                \"horizontalBorders\": \"bordures de ligne\",\n                \"rowHoverHighlight\": \"surligner les lignes au survol\",\n                \"verticalBorders\": \"bordure de colonne\",\n                \"showHeader\": \"affiche l'en-tête\"\n            },\n            \"view\": {\n                \"table\": \"liste\",\n                \"grid\": \"grille\",\n                \"list\": \"liste\",\n                \"detail\": \"détail\"\n            },\n            \"label\": {\n                \"releaseDate\": \"date de sortie\",\n                \"titleCombined\": \"$t(common.title) (combiné)\",\n                \"dateAdded\": \"date d'ajout\",\n                \"lastPlayed\": \"écouté récemment\",\n                \"trackNumber\": \"numéro de piste\",\n                \"rowIndex\": \"index de ligne\",\n                \"playCount\": \"nombre de lecture\",\n                \"discNumber\": \"disque n°\",\n                \"duration\": \"$t(common.duration)\",\n                \"bpm\": \"$t(common.bpm)\",\n                \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n                \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n                \"biography\": \"$t(common.biography)\",\n                \"channels\": \"$t(common.channel, {\\\"count\\\": 2})\",\n                \"bitrate\": \"$t(common.bitrate)\",\n                \"actions\": \"$t(common.action, {\\\"count\\\": 2})\",\n                \"favorite\": \"$t(common.favorite)\",\n                \"albumArtist\": \"$t(entity.albumArtist, {\\\"count\\\": 1})\",\n                \"rating\": \"$t(common.rating)\",\n                \"note\": \"$t(common.note)\",\n                \"owner\": \"$t(common.owner)\",\n                \"path\": \"$t(common.path)\",\n                \"title\": \"$t(common.title)\",\n                \"size\": \"$t(common.size)\",\n                \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n                \"year\": \"$t(common.year)\",\n                \"songCount\": \"$t(entity.track, {\\\"count\\\": 2})\",\n                \"codec\": \"$t(common.codec)\",\n                \"albumCount\": \"$t(entity.album, {\\\"count\\\": 2})\",\n                \"genreBadge\": \"$t(entity.genre, {\\\"count\\\": 1}) (badges)\",\n                \"image\": \"image\",\n                \"bitDepth\": \"$t(common.bitDepth)\",\n                \"sampleRate\": \"$t(common.sampleRate)\",\n                \"composer\": \"compositeur\",\n                \"titleArtist\": \"$t(common.title) (artiste)\",\n                \"albumGroup\": \"groupe d'album\"\n            }\n        },\n        \"column\": {\n            \"comment\": \"commentaire\",\n            \"album\": \"album\",\n            \"rating\": \"note\",\n            \"favorite\": \"favori\",\n            \"playCount\": \"lectures\",\n            \"releaseYear\": \"année\",\n            \"biography\": \"biographie\",\n            \"releaseDate\": \"date de sortie\",\n            \"bitrate\": \"débit binaire\",\n            \"title\": \"titre\",\n            \"bpm\": \"BPM\",\n            \"dateAdded\": \"date d'ajout\",\n            \"trackNumber\": \"piste\",\n            \"albumArtist\": \"artiste de l'album\",\n            \"path\": \"chemin\",\n            \"discNumber\": \"disque\",\n            \"albumCount\": \"$t(entity.album, {\\\"count\\\": 2})\",\n            \"lastPlayed\": \"écouté récemment\",\n            \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n            \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n            \"songCount\": \"$t(entity.track, {\\\"count\\\": 2})\",\n            \"channels\": \"$t(common.channel, {\\\"count\\\": 2})\",\n            \"size\": \"$t(common.size)\",\n            \"codec\": \"$t(common.codec)\",\n            \"owner\": \"propriétaire\",\n            \"bitDepth\": \"$t(common.bitDepth)\",\n            \"sampleRate\": \"$t(common.sampleRate)\"\n        }\n    },\n    \"dragDropZone\": {\n        \"error_oneFileOnly\": \"Veuillez ne sélectionner qu'un seul fichier\",\n        \"error_readingFile\": \"un problème est survenu lors de la lecture du fichier : {{errorMessage}}\",\n        \"mainText\": \"déposez un fichier ici\"\n    },\n    \"releaseType\": {\n        \"primary\": {\n            \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n            \"broadcast\": \"diffusion\",\n            \"ep\": \"ep\",\n            \"other\": \"autre\",\n            \"single\": \"single\"\n        },\n        \"secondary\": {\n            \"audiobook\": \"livre audio\",\n            \"audioDrama\": \"dramatique radio\",\n            \"compilation\": \"compilation\",\n            \"djMix\": \"mix dj\",\n            \"demo\": \"démo\",\n            \"fieldRecording\": \"prise de son en extérieur\",\n            \"interview\": \"interview\",\n            \"live\": \"live\",\n            \"mixtape\": \"mixtape\",\n            \"remix\": \"remix\",\n            \"soundtrack\": \"bande son\",\n            \"spokenWord\": \"spoken word\"\n        }\n    },\n    \"queryBuilder\": {\n        \"standardTags\": \"tags standard\",\n        \"customTags\": \"tags personnalisées\"\n    },\n    \"filterOperator\": {\n        \"after\": \"est après\",\n        \"afterDate\": \"est après (date)\",\n        \"before\": \"est avant\",\n        \"beforeDate\": \"est avant (date)\",\n        \"contains\": \"contient\",\n        \"endsWith\": \"se termine par\",\n        \"inPlaylist\": \"est dans\",\n        \"inTheLast\": \"est dans le dernier\",\n        \"inTheRange\": \"est dans la plage\",\n        \"inTheRangeDate\": \"est dans la plage (date)\",\n        \"is\": \"est\",\n        \"isNot\": \"n'est pas\",\n        \"isGreaterThan\": \"est plus grand que\",\n        \"isLessThan\": \"est plus petit que\",\n        \"matchesRegex\": \"correspond à l'expression régulière\",\n        \"notContains\": \"ne contient pas\",\n        \"notInPlaylist\": \"n'est pas dans\",\n        \"notInTheLast\": \"n'est pas dans le dernier\",\n        \"startsWith\": \"commence par\"\n    },\n    \"datetime\": {\n        \"minuteShort\": \"m\",\n        \"secondShort\": \"s\",\n        \"hourShort\": \"h\",\n        \"dayShort\": \"j\"\n    },\n    \"visualizer\": {\n        \"visualizerType\": \"type de visualisateur\",\n        \"cyclePresets\": \"cycle les préréglages\",\n        \"cycleTime\": \"temps de cycle (secondes)\",\n        \"includeAllPresets\": \"inclure tous les préréglages\",\n        \"ignoredPresets\": \"préréglages ignorés\",\n        \"selectedPresets\": \"préréglages sélectionné\",\n        \"randomizeNextPreset\": \"randomiser le préréglage suivant\",\n        \"blendTime\": \"temps de mélange\",\n        \"presets\": \"préréglages\",\n        \"selectPreset\": \"sélectionner un préréglage\",\n        \"applyPreset\": \"appliquer le préréglage\",\n        \"saveAsPreset\": \"enregistrer en tant que préréglage\",\n        \"updatePreset\": \"mettre à jour le préréglage\",\n        \"copyConfiguration\": \"copier la configuration\",\n        \"pasteConfiguration\": \"coller la configuration\",\n        \"pasteConfigurationPlaceholder\": \"coller ici la configuration JSON...\",\n        \"pasteFromClipboard\": \"coller depuis le presse-papier\",\n        \"applyConfiguration\": \"appliquer la configuration\",\n        \"configCopied\": \"configuration copiée dans le presse-papiers\",\n        \"configCopyFailed\": \"échec de la copie de la configuration\",\n        \"configPasted\": \"configuration appliquée avec succès\",\n        \"configPasteFailed\": \"échec de l'application de la configuration. Merci de vérifier le format.\",\n        \"configPasteReadFailed\": \"échec de la lecture du presse-papiers\",\n        \"presetName\": \"nom du préréglage\",\n        \"presetNamePlaceholder\": \"saisissez le nom du préréglage\",\n        \"general\": \"générale\",\n        \"mode\": \"mode\",\n        \"mode1To8\": \"Mode 1 - 8\",\n        \"mode10\": \"Mode 10\",\n        \"barSpace\": \"espacement des barres\",\n        \"lineWidth\": \"Largeur des traits\",\n        \"fillAlpha\": \"remplissage alpha\",\n        \"channelLayout\": \"disposition des canaux\",\n        \"maxFPS\": \"FPS Maximum\",\n        \"opacity\": \"opacité\",\n        \"customGradients\": \"dégradés personnalisés\",\n        \"addCustomGradient\": \"ajouter un dégradés personnalisés\",\n        \"gradientName\": \"nom du dégradé\",\n        \"gradientNamePlaceholder\": \"nom du dégradé\",\n        \"vertical\": \"verticale\",\n        \"horizontal\": \"horizontale\",\n        \"colorStops\": \"couleur d'arrêts\",\n        \"addColor\": \"ajouter un couleur\",\n        \"position\": \"position\",\n        \"level\": \"niveau\",\n        \"remove\": \"supprimer\",\n        \"pasteGradient\": \"coller le dégradé\",\n        \"pasteGradientPlaceholder\": \"coller ici le dégradé JSON...\",\n        \"custom\": \"personnalisé\",\n        \"builtIn\": \"intégré\",\n        \"colors\": \"couleurs\",\n        \"colorMode\": \"mode de couleur\",\n        \"gradient\": \"dégradé\",\n        \"gradientLeft\": \"dégradé gauche\",\n        \"gradientRight\": \"dégradé droite\",\n        \"smoothing\": \"lissage\",\n        \"frequencyRangeAndScaling\": \"plage de fréquence et mise à l'échelle\",\n        \"minimumFrequency\": \"fréquence minimum\",\n        \"maximumFrequency\": \"fréquence maximum\",\n        \"frequencyScale\": \"mise à l'échelle de fréquence\",\n        \"sensitivity\": \"sensibilité\",\n        \"weightingFilter\": \"filter de pondérage\",\n        \"minimumDecibels\": \"décibels minimum\",\n        \"maximumDecibels\": \"décibels maximum\",\n        \"linearAmplitude\": \"amplitude linéaire\",\n        \"linearBoost\": \"Augmentation Linéaire\",\n        \"peakBehavior\": \"Comportement des Crêtes\",\n        \"showPeaks\": \"afficher les crêtes\",\n        \"fft\": \"FFT\",\n        \"fftSize\": \"Taille de la FFT\",\n        \"fadePeaks\": \"adoucir les crêtes\",\n        \"peakLine\": \"Ligne de Crête\",\n        \"gravity\": \"gravité\",\n        \"peakFadeTime\": \"temps d'adoucissement des crêtes (ms)\",\n        \"peakHoldTime\": \"temps d'attente des crêtes (ms)\",\n        \"radialSpectrum\": \"Spectre Radial\",\n        \"radial\": \"Radial\",\n        \"radialInvert\": \"Inversion Radiale\",\n        \"spinSpeed\": \"Vitesse de Rotation\",\n        \"radius\": \"Rayon\",\n        \"reflexMirror\": \"Miroir Réflexe\",\n        \"reflexFit\": \"Ajustement du Réflexe\",\n        \"reflexRatio\": \"Rapport de Réflexe\",\n        \"reflexAlpha\": \"Alpha du Réflexe\",\n        \"reflexBrightness\": \"Luminosité du Réflexe\",\n        \"mirror\": \"Mirroir\",\n        \"miscellaneousSettings\": \"Paramètres divers\",\n        \"alphaBars\": \"Barres alpha\",\n        \"ansiBands\": \"Bandes ANSI\",\n        \"ledBars\": \"Barres LED\",\n        \"trueLeds\": \"True LEDs\",\n        \"roundBars\": \"Barres arrondies\",\n        \"lowResolution\": \"Basse Résolution\",\n        \"showFPS\": \"Afficher les FPS\",\n        \"showScaleX\": \"Afficher l’échelle X\",\n        \"noteLabels\": \"Étiquettes de notes\",\n        \"showScaleY\": \"Afficher l’échelle Y\",\n        \"options\": {\n            \"mode\": {\n                \"0\": \"[0] Fréquences discrètes\",\n                \"1\": \"[1] 1/24ᵉ octave / 240 bandes\",\n                \"2\": \"[2] 1/12ᵉ octave / 120 bandes\",\n                \"3\": \"[3] 1/8ᵉ octave / 80 bandes\",\n                \"4\": \"[4] 1/6ᵉ octave / 60 bandes\",\n                \"5\": \"[5] 1/4ᵉ octave / 40 bandes\",\n                \"6\": \"[6] 1/3ᵉ octave / 30 bandes\",\n                \"7\": \"[7] Demi-octave / 20 bandes\",\n                \"8\": \"[8] Octave complète / 10 bandes\",\n                \"10\": \"[10] Linéaire / Graphique en aires\"\n            },\n            \"colorMode\": {\n                \"gradient\": \"Dégradé\",\n                \"barIndex\": \"Indice de Barre\",\n                \"barLevel\": \"Niveau de Barre\"\n            },\n            \"gradient\": {\n                \"classic\": \"Classique\",\n                \"prism\": \"Prisme\",\n                \"rainbow\": \"Arc-en-ciel\",\n                \"steelblue\": \"Bleu Acier\",\n                \"orangered\": \"Orange rougeâtre\"\n            },\n            \"channelLayout\": {\n                \"single\": \"Simple\",\n                \"dualCombined\": \"Combiné Double\",\n                \"dualHorizontal\": \"Double Horizontale\",\n                \"dualVertical\": \"Double Verticale\"\n            },\n            \"frequencyScale\": {\n                \"none\": \"Aucun\",\n                \"bark\": \"Échelle Bark\",\n                \"linear\": \"Échelle Linéaire\",\n                \"log\": \"Échelle Logarithmique\",\n                \"mel\": \"Échelle Mel\"\n            },\n            \"weightingFilter\": {\n                \"none\": \"Aucun\",\n                \"a\": \"A\",\n                \"b\": \"B\",\n                \"c\": \"C\",\n                \"d\": \"D\",\n                \"z\": \"Z\"\n            }\n        },\n        \"lumiBars\": \"Lumi Bars\",\n        \"outlineBars\": \"Outline Bars\",\n        \"splitGradient\": \"Split Gradient\"\n    }\n}\n"
  },
  {
    "path": "src/i18n/locales/hu.json",
    "content": "{\n    \"action\": {\n        \"moveToNext\": \"ugrás a következőre\",\n        \"deletePlaylist\": \"$t(entity.playlist, {\\\"count\\\": 1}) törlése\",\n        \"removeFromFavorites\": \"eltávolítás innen $t(entity.favorite, {\\\"count\\\": 2})\",\n        \"setRating\": \"értékelés\",\n        \"viewPlaylists\": \"$t(entity.playlist, {\\\"count\\\": 2}) megtekintése\",\n        \"openIn\": {\n            \"lastfm\": \"Megnyitás Last.fm-ben\",\n            \"musicbrainz\": \"Megnyitás MusicBrainz-ben\"\n        },\n        \"clearQueue\": \"műsorlista kiürítése\",\n        \"createPlaylist\": \"$t(entity.playlist, {\\\"count\\\": 1}) létrehozása\",\n        \"deselectAll\": \"kijelölés törlése\",\n        \"editPlaylist\": \"$t(entity.playlist, {\\\"count\\\": 1}) szerkesztése\",\n        \"goToPage\": \"menj az oldalra\",\n        \"moveToBottom\": \"ugrás az aljára\",\n        \"moveToTop\": \"ugrás a tetejére\",\n        \"removeFromPlaylist\": \"eltávolítás innen $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"removeFromQueue\": \"eltávolítás a műsorlistáról\",\n        \"toggleSmartPlaylistEditor\": \"$t(entity.smartPlaylist) szerkesztője\",\n        \"addToFavorites\": \"$t(entity.favorite, {\\\"count\\\": 2}) kedvelése\",\n        \"addToPlaylist\": \"hozzáadás lejátszási listához: $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"refresh\": \"$t(common.refresh)\",\n        \"downloadStarted\": \"megkezdődött {{count}} elem letöltése\",\n        \"moveItems\": \"elemek mozgatása\",\n        \"shuffle\": \"keverés\",\n        \"shuffleAll\": \"összes keverése\",\n        \"shuffleSelected\": \"kiválasztottak keverése\",\n        \"viewMore\": \"további információ\",\n        \"moveUp\": \"ugrás fel\",\n        \"moveDown\": \"ugrás le\",\n        \"holdToMoveToTop\": \"hosszan nyomva felülre mozgat\",\n        \"holdToMoveToBottom\": \"hosszan nyomva lejjebb mozgat\",\n        \"selectAll\": \"összes kijelölése\",\n        \"deleteRadioStation\": \"$t(entity.radioStation, {\\\"count\\\": 1}) törlése\",\n        \"createRadioStation\": \"$t(entity.radioStation, {\\\"count\\\": 1}) létrehozása\",\n        \"openApplicationDirectory\": \"app könyvtár megnyitása\",\n        \"addOrRemoveFromSelection\": \"hozzáadás vagy eltávolítás a kiválasztásból\",\n        \"selectRangeOfItems\": \"válaszd ki a tartományt\"\n    },\n    \"common\": {\n        \"collapse\": \"összecsukás\",\n        \"currentSong\": \"jelenlegi: $t(entity.track, {\\\"count\\\": 1})\",\n        \"no\": \"nem\",\n        \"close\": \"bezárás\",\n        \"confirm\": \"rendben\",\n        \"create\": \"létrehozás\",\n        \"codec\": \"kodek\",\n        \"delete\": \"törlés\",\n        \"description\": \"leírás\",\n        \"comingSoon\": \"hamarosan…\",\n        \"decrease\": \"csökkenés\",\n        \"enable\": \"engedélyez\",\n        \"disable\": \"letiltás\",\n        \"disc\": \"lemez\",\n        \"modified\": \"módosult\",\n        \"forceRestartRequired\": \"a módosítások alkalmazásához újra kell indulnunk... zárd be az értesítést az újraindításhoz\",\n        \"home\": \"főoldal\",\n        \"name\": \"név\",\n        \"action_one\": \"művelet\",\n        \"action_other\": \"műveletek\",\n        \"add\": \"hozzáadás\",\n        \"albumGain\": \"album hangerőssége\",\n        \"albumPeak\": \"album hangcsúcs\",\n        \"areYouSure\": \"Biztos vagy benne?\",\n        \"ascending\": \"emelkedő\",\n        \"backward\": \"vissza\",\n        \"biography\": \"Életrajz\",\n        \"bitrate\": \"bitráta\",\n        \"cancel\": \"mégse\",\n        \"center\": \"közép\",\n        \"channel_one\": \"csatorna\",\n        \"channel_other\": \"csatornák\",\n        \"clear\": \"törlés\",\n        \"configure\": \"konfigurálás\",\n        \"descending\": \"csökkenő\",\n        \"dismiss\": \"figyelmen kívül hagyás\",\n        \"duration\": \"hossz\",\n        \"edit\": \"szerkesztés\",\n        \"expand\": \"megnyitás\",\n        \"favorite\": \"kedvenc\",\n        \"filter_one\": \"szűrő\",\n        \"filter_other\": \"szűrők\",\n        \"filters\": \"szűrők\",\n        \"forward\": \"előre\",\n        \"gap\": \"hézag\",\n        \"increase\": \"megnövelés\",\n        \"left\": \"bal\",\n        \"limit\": \"korlát\",\n        \"manage\": \"kezelés\",\n        \"maximize\": \"maximalizálás\",\n        \"menu\": \"menü\",\n        \"minimize\": \"minimalizálás\",\n        \"mbid\": \"MusicBrainz ID\",\n        \"noResultsFromQuery\": \"nincsenek találatok a keresett kifejezésre\",\n        \"note\": \"jegyzet\",\n        \"ok\": \"ok\",\n        \"owner\": \"tulajdonos\",\n        \"path\": \"elérési út\",\n        \"playerMustBePaused\": \"a lejátszónak szüneteltetve kell lennie\",\n        \"preview\": \"előnézet\",\n        \"previousSong\": \"előző $t(entity.track, {\\\"count\\\": 1})\",\n        \"quit\": \"kilépés\",\n        \"random\": \"véletlenszerű\",\n        \"refresh\": \"frissítés\",\n        \"reset\": \"visszaállítás\",\n        \"resetToDefault\": \"visszaállítás alapértelmezettre\",\n        \"right\": \"jobb\",\n        \"save\": \"mentés\",\n        \"search\": \"keresés\",\n        \"title\": \"cím\",\n        \"trackNumber\": \"sáv\",\n        \"unknown\": \"ismeretlen\",\n        \"version\": \"verzió\",\n        \"yes\": \"igen\",\n        \"none\": \"egyik sem\",\n        \"restartRequired\": \"újraindítás szükséges\",\n        \"setting_one\": \"beállítás\",\n        \"setting_other\": \"\",\n        \"translation\": \"fordítás\",\n        \"rating\": \"értékelés\",\n        \"reload\": \"újratöltés\",\n        \"share\": \"megosztás\",\n        \"sortOrder\": \"sorrend\",\n        \"saveAndReplace\": \"mentés és felülírás\",\n        \"saveAs\": \"mentés másként\",\n        \"size\": \"méret\",\n        \"year\": \"év\",\n        \"trackGain\": \"sáv erőssége\",\n        \"trackPeak\": \"sáv csúcsa\",\n        \"newVersion\": \"({{version}}) Új verzió telepítve\",\n        \"viewReleaseNotes\": \"kiadási jegyzetek megtekintése\",\n        \"bpm\": \"bpm\",\n        \"bitDepth\": \"bitmélység\",\n        \"additionalParticipants\": \"további közreműködök\",\n        \"private\": \"privát\",\n        \"public\": \"publikus\",\n        \"recordLabel\": \"lemezkiadó\",\n        \"explicit\": \"nyílt\",\n        \"clean\": \"tiszta\",\n        \"sampleRate\": \"mintavételi frekvencia\",\n        \"releaseType\": \"kiadás típusa\",\n        \"explicitStatus\": \"nyílt státusz\",\n        \"tags\": \"címkék\",\n        \"doNotShowAgain\": \"ne mutasd többet\",\n        \"externalLinks\": \"külső linkek\",\n        \"faster\": \"gyorsabban\",\n        \"slower\": \"lassabban\",\n        \"sort\": \"rendezés\",\n        \"gridRows\": \"rács sorok\",\n        \"tableColumns\": \"táblázat oszlopok\",\n        \"itemsMore\": \"{{count}} még több\",\n        \"view\": \"nézet\",\n        \"noFilters\": \"nincs konfigurált szűrő\",\n        \"countSelected\": \"{{count}} kiválasztott\",\n        \"retry\": \"újra\"\n    },\n    \"entity\": {\n        \"albumArtist_one\": \"Zenész\",\n        \"albumArtist_other\": \"Zenészek\",\n        \"albumArtistCount_one\": \"{{count}} album szerző\",\n        \"albumArtistCount_other\": \"{{count}} album szerzők\",\n        \"albumWithCount_one\": \"{{count}} album\",\n        \"albumWithCount_other\": \"{{count}} albumok\",\n        \"artist_one\": \"előadó\",\n        \"artist_other\": \"előadók\",\n        \"favorite_one\": \"kedvelés\",\n        \"favorite_other\": \"kedvelések\",\n        \"folder_one\": \"mappa\",\n        \"folder_other\": \"mappák\",\n        \"genreWithCount_one\": \"{{count}} műfaj\",\n        \"genreWithCount_other\": \"{{count}} műfajok\",\n        \"track_one\": \"sáv\",\n        \"track_other\": \"sávok\",\n        \"song_one\": \"dal\",\n        \"song_other\": \"dalok\",\n        \"album_one\": \"album\",\n        \"album_other\": \"albumok\",\n        \"smartPlaylist\": \"intelligens $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"artistWithCount_one\": \"{{count}} előadó\",\n        \"artistWithCount_other\": \"{{count}} előadók\",\n        \"playlist_one\": \"lejátszási lista\",\n        \"playlist_other\": \"lejátszási listák\",\n        \"playlistWithCount_one\": \"{{count}} lejátszási lista\",\n        \"playlistWithCount_other\": \"{{count}} lejátszási listák\",\n        \"folderWithCount_one\": \"{{count}} mappa\",\n        \"folderWithCount_other\": \"{{count}} mappák\",\n        \"genre_one\": \"műfaj\",\n        \"genre_other\": \"műfajok\",\n        \"play_one\": \"{{count}} lejátszás\",\n        \"play_other\": \"{{count}} lejátszások\",\n        \"trackWithCount_one\": \"{{count}} sáv\",\n        \"trackWithCount_other\": \"{{count}} sávok\",\n        \"radioStation_one\": \"rádió állomás\",\n        \"radioStation_other\": \"rádió állomások\",\n        \"radioStationWithCount_one\": \"{{count}} rádióállomás\",\n        \"radioStationWithCount_other\": \"{{count}} rádióállomások\"\n    },\n    \"error\": {\n        \"apiRouteError\": \"a kérést nem sikerült célba juttatni\",\n        \"audioDeviceFetchError\": \"hiba történt a hangeszközök lekérésekor\",\n        \"authenticationFailed\": \"sikertelen hitelesítés\",\n        \"credentialsRequired\": \"hitelesítési adatok szükségesek\",\n        \"localFontAccessDenied\": \"hozzáférés megtagadásra került a helyi betűtípusokhoz\",\n        \"networkError\": \"hálózati hibába ütköztünk\",\n        \"openError\": \"a fájl megnyitása sikertelen volt\",\n        \"playbackError\": \"hiba történt a média lejátszásakor\",\n        \"remoteEnableError\": \"hiba történt a távoli szerver műveletkor: $t(common.enable)\",\n        \"remotePortError\": \"hiba történt a távoli szerver PORT-jának beállításakor\",\n        \"remotePortWarning\": \"indítsd újra a szervert az új PORT használatához\",\n        \"genericError\": \"hiba történt\",\n        \"endpointNotImplementedError\": \"a(z) {{endpoint}} végpont nincs implementálva a következőhöz: {{serverType}}\",\n        \"badAlbum\": \"azért látod ezt az oldalt mert ez a zeneszám nem része egy albumnak. ez általában akkor történik amikor egy szám a zenekönyvtárad gyökerébe kerül. a Jellyfin csak mappákba rendezett számokat csoportosít\",\n        \"loginRateError\": \"túl sok bejelentkezési kísérlet, kérlek próbáld újra pár másodperc múlva\",\n        \"mpvRequired\": \"MPV szükséges\",\n        \"invalidServer\": \"érvénytelen szerver\",\n        \"remoteDisableError\": \"hiba történt a távoli szerver műveletkor: $t(common.disable)\",\n        \"sessionExpiredError\": \"a munkameneted lejárt\",\n        \"systemFontError\": \"hiba történt a rendszer betűtípusainak lekérésekor\",\n        \"serverRequired\": \"szerver szükséges\",\n        \"serverNotSelectedError\": \"nincs szerver kiválasztva\",\n        \"notificationDenied\": \"Az értesítések engedélyezése megtagadva. Ez a beállítás hatástalan\",\n        \"badValue\": \"érvénytelen opció \\\"{{value}}\\\". ez az érték már nem létezik\",\n        \"noNetwork\": \"Szerver nem elérhető\",\n        \"noNetworkDescription\": \"Nem tudok csatlakozni a szerverhez\",\n        \"saveQueueFailed\": \"műsorlista mentése sikertelen\",\n        \"settingsSyncError\": \"Eltéréseket találtam a leképző és a fő folyamat beállításai között. Indítsd újra az alkalmazást\",\n        \"multipleServerSaveQueueError\": \"a műsorlistában egy vagy több olyan dal található, amely nem az aktuális szerverről származik. Ez nem támogatott\"\n    },\n    \"filter\": {\n        \"albumCount\": \"$t(entity.album, {\\\"count\\\": 2}) darab\",\n        \"bitrate\": \"bitráta\",\n        \"comment\": \"megjegyzés\",\n        \"dateAdded\": \"hozzáadás ideje\",\n        \"duration\": \"hossz\",\n        \"fromYear\": \"évtől\",\n        \"isCompilation\": \"gyűjtemény\",\n        \"isRated\": \"értékelt\",\n        \"lastPlayed\": \"utoljára lejátszva\",\n        \"mostPlayed\": \"legtöbbször lejátszott\",\n        \"note\": \"megjegyzés\",\n        \"random\": \"véletlenszerű\",\n        \"rating\": \"értékelések\",\n        \"recentlyAdded\": \"nemrég hozzáadott\",\n        \"releaseDate\": \"megjelenési dátum\",\n        \"releaseYear\": \"megjelenés éve\",\n        \"songCount\": \"dal szám\",\n        \"title\": \"cím\",\n        \"disc\": \"lemez\",\n        \"criticRating\": \"kritikusok értékelése\",\n        \"communityRating\": \"közösségi értékelés\",\n        \"albumArtist\": \"$t(entity.albumArtist, {\\\"count\\\": 1})\",\n        \"biography\": \"életrajz\",\n        \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n        \"favorited\": \"kedvelt\",\n        \"isRecentlyPlayed\": \"mostanában lejátszott\",\n        \"name\": \"név\",\n        \"owner\": \"$t(common.owner)\",\n        \"id\": \"id\",\n        \"recentlyPlayed\": \"nemrég lejátszott\",\n        \"isFavorited\": \"kedvelt\",\n        \"search\": \"keresés\",\n        \"isPublic\": \"nyilvános\",\n        \"playCount\": \"lejátszások száma\",\n        \"recentlyUpdated\": \"nemrég módosult\",\n        \"path\": \"elérési út\",\n        \"toYear\": \"évhez\",\n        \"trackNumber\": \"sáv\",\n        \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n        \"bpm\": \"bpm\",\n        \"channels\": \"$t(common.channel_other)\",\n        \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n        \"explicitStatus\": \"$t(common.explicitStatus)\"\n    },\n    \"form\": {\n        \"addServer\": {\n            \"error_savePassword\": \"hiba történt a jelszó mentésekor\",\n            \"ignoreCors\": \"CORS figyelmen kívül hagyása $t(common.restartRequired)\",\n            \"ignoreSsl\": \"SSL figyelmen kívül hagyása $t(common.restartRequired)\",\n            \"input_password\": \"jelszó\",\n            \"input_url\": \"url\",\n            \"input_username\": \"felhasználónév\",\n            \"success\": \"szerver sikeresen hozzáadva\",\n            \"title\": \"szerver hozzáadása\",\n            \"input_name\": \"szervernév\",\n            \"input_savePassword\": \"jelszó mentése\",\n            \"input_legacyAuthentication\": \"klasszikus hitelesítés bekapcsolása\",\n            \"input_preferInstantMix\": \"az instant mixet részesítem előnyben\",\n            \"input_preferInstantMixDescription\": \"Az instant mix használata csak a hasonló dalokhoz javasolt. Hasznos, ha vannak olyan bővítmények, amelyek módosítják ezt a viselkedést\"\n        },\n        \"addToPlaylist\": {\n            \"input_skipDuplicates\": \"duplikátumok átugrása\",\n            \"input_playlists\": \"$t(entity.playlist, {\\\"count\\\": 2})\",\n            \"success\": \"hozzáadtuk ezt: $t(entity.trackWithCount, {\\\"count\\\": {{message}} }) a következőhöz: $t(entity.playlistWithCount, {\\\"count\\\": {{numOfPlaylists}} })\",\n            \"title\": \"hozzáadás a következőhöz: $t(entity.playlist, {\\\"count\\\": 1})\",\n            \"create\": \"létrehoz $t(entity.playlist, {\\\"count\\\": 1}) {{playlist}}\",\n            \"searchOrCreate\": \"keresés $t(entity.playlist, {\\\"count\\\": 2}) vagy új létrehozása\"\n        },\n        \"createPlaylist\": {\n            \"input_description\": \"$t(common.description)\",\n            \"input_name\": \"$t(common.name)\",\n            \"input_owner\": \"$t(common.owner)\",\n            \"input_public\": \"publikus\",\n            \"title\": \"$t(entity.playlist, {\\\"count\\\": 1}) létrehozása\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) sikeresen létrehozva\"\n        },\n        \"deletePlaylist\": {\n            \"input_confirm\": \"a megerősítéshez írd be a(z) $t(entity.playlist, {\\\"count\\\": 1}) nevét\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) sikeresen törölve\",\n            \"title\": \"$t(entity.playlist, {\\\"count\\\": 1}) törlése\"\n        },\n        \"editPlaylist\": {\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) sikeresen módosítva\",\n            \"publicJellyfinNote\": \"A Jellyfin valamiért nem teszi közzé, hogy egy lejátszási lista publikus-e vagy sem. Amennyiben azt szeretnéd, hogy publikus maradjon, válaszd ki az alábbi beviteli mezőt\",\n            \"title\": \"szerkesztés $t(entity.playlist, {\\\"count\\\": 1})\",\n            \"editNote\": \"A kézi szerkesztés nem ajánlott nagy lejátszási listák esetén. Biztosan vállalod a meglévő lejátszási lista felülírásával járó adatvesztés kockázatát?\"\n        },\n        \"lyricSearch\": {\n            \"input_artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n            \"input_name\": \"$t(common.name)\",\n            \"title\": \"dalszöveg kereső\"\n        },\n        \"queryEditor\": {\n            \"title\": \"lekérdezés szerkesztő\",\n            \"input_optionMatchAll\": \"összes egyezés\",\n            \"input_optionMatchAny\": \"bármelyik egyező\",\n            \"addRuleGroup\": \"szabálycsoport hozzáadás\",\n            \"removeRuleGroup\": \"szabálycsoport eltávolítás\",\n            \"resetToDefault\": \"alapértelmezettre visszaállítás\",\n            \"clearFilters\": \"szűrők törlése\"\n        },\n        \"shareItem\": {\n            \"allowDownloading\": \"letöltés engedélyezése\",\n            \"description\": \"leírás\",\n            \"setExpiration\": \"lejárat beállítása\",\n            \"success\": \"link másolva a vágólapra (vagy kattintson ide a megnyitáshoz)\",\n            \"expireInvalid\": \"A lejáratnak a jövőben kell lennie\",\n            \"createFailed\": \"a megosztás létrehozása sikertelen (a megosztás engedélyezve van?)\"\n        },\n        \"updateServer\": {\n            \"success\": \"Szerver sikeresen frissítve\",\n            \"title\": \"Szerver frissítés\"\n        },\n        \"privateMode\": {\n            \"enabled\": \"privát mód engedélyezve, a lejátszási állapot mostantól rejtve marad a külső integrációk elől\",\n            \"disabled\": \"A privát mód le van tiltva, a lejátszási állapot mostantól látható az engedélyezett külső integrációk számára\",\n            \"title\": \"Privát mód\"\n        },\n        \"largeFetchConfirmation\": {\n            \"title\": \"műsorlistához ad\",\n            \"description\": \"Ez a művelet hozzáadja az összes elemet az aktuális szűrt nézetben\"\n        },\n        \"shuffleAll\": {\n            \"title\": \"véletlenszerű lejátszás\",\n            \"input_genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n            \"input_limit\": \"Hány dal?\",\n            \"input_minYear\": \"ettől az évtől\",\n            \"input_maxYear\": \"eddig az évig\",\n            \"input_played_optionAll\": \"összes sáv\",\n            \"input_played\": \"csak szűrt zenék\",\n            \"input_played_optionUnplayed\": \"Csak a még nem lejátszottak\",\n            \"input_played_optionPlayed\": \"Csak a játszottak számok\"\n        },\n        \"createRadioStation\": {\n            \"success\": \"rádió állomás sikeresen létrehozva\",\n            \"title\": \"rádió állomás létrehozása\",\n            \"input_homepageUrl\": \"oldal url\",\n            \"input_name\": \"név\",\n            \"input_streamUrl\": \"stream url\"\n        },\n        \"saveQueue\": {\n            \"success\": \"mentett lejátszási műsorlista a szerverre\"\n        }\n    },\n    \"dragDropZone\": {\n        \"error_oneFileOnly\": \"Kérlek, csak 1 fájlt válassz\",\n        \"error_readingFile\": \"probléma merült fel a fájl olvasásakor: {{errorMessage}}\",\n        \"mainText\": \"húzd ide a fájlt\"\n    },\n    \"page\": {\n        \"albumArtistDetail\": {\n            \"about\": \"Az eladóról {{artist}}\",\n            \"appearsOn\": \"megjelenik\",\n            \"recentReleases\": \"legújabb kiadványok\",\n            \"viewDiscography\": \"Diszkográfia megtekintése\",\n            \"relatedArtists\": \"kapcsolódik $t(entity.artist, {\\\"count\\\": 2})\",\n            \"topSongs\": \"sláger dalok\",\n            \"topSongsFrom\": \"sláger dalok tőle {{title}}\",\n            \"viewAll\": \"mindet megtekint\",\n            \"viewAllTracks\": \"mindet megtekint $t(entity.track, {\\\"count\\\": 2})\"\n        },\n        \"albumArtistList\": {\n            \"title\": \"$t(entity.albumArtist, {\\\"count\\\": 2})\"\n        },\n        \"albumDetail\": {\n            \"moreFromArtist\": \"még több ettől $t(entity.artist, {\\\"count\\\": 1})\",\n            \"moreFromGeneric\": \"Még több {{item}}\",\n            \"released\": \"megjelent\"\n        },\n        \"albumList\": {\n            \"artistAlbums\": \"albumok tőle {{artist}}\",\n            \"genreAlbums\": \"\\\"{{genre}}\\\" $t(entity.album, {\\\"count\\\": 2})\",\n            \"title\": \"$t(entity.album, {\\\"count\\\": 2})\"\n        },\n        \"appMenu\": {\n            \"collapseSidebar\": \"oldalsáv\",\n            \"expandSidebar\": \"oldalsáv\",\n            \"goBack\": \"vissza\",\n            \"goForward\": \"előre\",\n            \"manageServers\": \"szerverek\",\n            \"privateModeOff\": \"Privát mód\",\n            \"privateModeOn\": \"Privát mód\",\n            \"openBrowserDevtools\": \"Fejlesztői eszközök\",\n            \"quit\": \"$t(common.quit)\",\n            \"selectServer\": \"Szerver választása\",\n            \"settings\": \"$t(common.setting, {\\\"count\\\": 2})\",\n            \"version\": \"verzió {{version}}\",\n            \"selectMusicFolder\": \"zene mappa kiválasztása\",\n            \"noMusicFolder\": \"nincs zene mappa kiválasztva\",\n            \"multipleMusicFolders\": \"{{count}} kiválasztott zene mappák\",\n            \"commandPalette\": \"Parancspaletta\"\n        },\n        \"manageServers\": {\n            \"title\": \"Szerverek kezelés\",\n            \"serverDetails\": \"szerver adatok\",\n            \"url\": \"URL\",\n            \"username\": \"felhasználónév\",\n            \"editServerDetailsTooltip\": \"Szerver adok szerkesztése\",\n            \"removeServer\": \"távoli szerver\"\n        },\n        \"contextMenu\": {\n            \"addFavorite\": \"$t(action.addToFavorites)\",\n            \"addLast\": \"$t(player.addLast)\",\n            \"addNext\": \"$t(player.addNext)\",\n            \"addToFavorites\": \"$t(action.addToFavorites)\",\n            \"addToPlaylist\": \"$t(action.addToPlaylist)\",\n            \"createPlaylist\": \"$t(action.createPlaylist)\",\n            \"deletePlaylist\": \"$t(action.deletePlaylist)\",\n            \"deselectAll\": \"$t(action.deselectAll)\",\n            \"download\": \"letöltés\",\n            \"moveToNext\": \"$t(action.moveToNext)\",\n            \"moveToBottom\": \"$t(action.moveToBottom)\",\n            \"moveToTop\": \"$t(action.moveToTop)\",\n            \"numberSelected\": \"{{count}} kiválasztott\",\n            \"play\": \"$t(player.play)\",\n            \"playSimilarSongs\": \"$t(player.playSimilarSongs)\",\n            \"removeFromFavorites\": \"$t(action.removeFromFavorites)\",\n            \"removeFromPlaylist\": \"$t(action.removeFromPlaylist)\",\n            \"removeFromQueue\": \"$t(action.removeFromQueue)\",\n            \"setRating\": \"$t(action.setRating)\",\n            \"playShuffled\": \"$t(player.shuffle)\",\n            \"shareItem\": \"Megosztás\",\n            \"goToAlbum\": \"menj az $t(entity.album, {\\\"count\\\": 1})\",\n            \"goToAlbumArtist\": \"menj a $t(entity.albumArtist, {\\\"count\\\": 1})\",\n            \"showDetails\": \"info\",\n            \"moveItems\": \"$t(action.moveItems)\",\n            \"goTo\": \"menj\"\n        },\n        \"fullscreenPlayer\": {\n            \"config\": {\n                \"dynamicBackground\": \"dinamikus háttér\",\n                \"dynamicImageBlur\": \"kép elmosódás mérete\",\n                \"dynamicIsImage\": \"háttérkép engedélyezése\",\n                \"followCurrentLyric\": \"kövesd az aktuális dalszöveget\",\n                \"lyricAlignment\": \"dalszöveg igazítás\",\n                \"lyricOffset\": \"dalszöveg eltolása (ms)\",\n                \"lyricGap\": \"Dalszöveg hézag\",\n                \"lyricSize\": \"Dalszöveg méret\",\n                \"opacity\": \"áttetszőség\",\n                \"showLyricMatch\": \"mutasd az egyező dalszöveget\",\n                \"showLyricProvider\": \"mutasd a dalszöveg szolgáltatót\",\n                \"synchronized\": \"szinkronizálva\",\n                \"unsynchronized\": \"szinkronizálatlan\",\n                \"useImageAspectRatio\": \"kép arányának használata\"\n            },\n            \"lyrics\": \"dalszöveg\",\n            \"related\": \"kapcsolódó\",\n            \"upNext\": \"következik\",\n            \"visualizer\": \"vizualizáló\",\n            \"noLyrics\": \"nem található dalszöveg\"\n        },\n        \"genreList\": {\n            \"showAlbums\": \"mutasd a $t(entity.genre, {\\\"count\\\": 1}) $t(entity.album, {\\\"count\\\": 2})\",\n            \"showTracks\": \"mutasd a $t(entity.genre, {\\\"count\\\": 1}) $t(entity.track, {\\\"count\\\": 2})\",\n            \"title\": \"$t(entity.genre, {\\\"count\\\": 2})\"\n        },\n        \"globalSearch\": {\n            \"commands\": {\n                \"goToPage\": \"menj az oldalra\",\n                \"searchFor\": \"keresd a {{query}}\",\n                \"serverCommands\": \"szerver parancsok\"\n            },\n            \"title\": \"parancsok\"\n        },\n        \"home\": {\n            \"explore\": \"fedezd fel könyvtárából\",\n            \"mostPlayed\": \"legtöbbet játszott\",\n            \"newlyAdded\": \"újonnan hozzáadott megjelenések\",\n            \"recentlyPlayed\": \"nemrég játszott\",\n            \"recentlyReleased\": \"nemrég megjelent\",\n            \"title\": \"$t(common.home)\",\n            \"genres\": \"$t(entity.genre, {\\\"count\\\": 2})\"\n        },\n        \"itemDetail\": {\n            \"copyPath\": \"másolja az útvonalat a vágólapra\",\n            \"copiedPath\": \"útvonal sikeresen másolva\",\n            \"openFile\": \"mutasd a sávot a fájlkezelőbe\"\n        },\n        \"playlist\": {\n            \"reorder\": \"átrendezés csak ID szerinti rendezés esetén engedélyezett\"\n        },\n        \"playlistList\": {\n            \"title\": \"$t(entity.playlist, {\\\"count\\\": 2})\"\n        },\n        \"setting\": {\n            \"advanced\": \"haladó\",\n            \"generalTab\": \"általános\",\n            \"hotkeysTab\": \"gyorsbillentyűk\",\n            \"windowTab\": \"ablak\",\n            \"playbackTab\": \"visszajátszás\",\n            \"analytics\": \"elemzés\",\n            \"updates\": \"frissítés\",\n            \"cache\": \"gyorsítótár\",\n            \"application\": \"applikáció\",\n            \"theme\": \"téma\",\n            \"controls\": \"irányítás\",\n            \"sidebar\": \"oldalsáv\",\n            \"remote\": \"távoli\",\n            \"exportImport\": \"import/export\",\n            \"scrobble\": \"scrobble\",\n            \"audio\": \"audio\",\n            \"lyrics\": \"dalszöveg\",\n            \"transcoding\": \"átkódolás\",\n            \"discord\": \"discord\",\n            \"queryBuilder\": \"lekérdezés-építő\",\n            \"playerFilters\": \"lejátszó szűrők\",\n            \"logger\": \"naplózó\"\n        },\n        \"sidebar\": {\n            \"albumArtists\": \"$t(entity.albumArtist, {\\\"count\\\": 2})\",\n            \"albums\": \"$t(entity.album, {\\\"count\\\": 2})\",\n            \"artists\": \"$t(entity.artist, {\\\"count\\\": 2})\",\n            \"folders\": \"$t(entity.folder, {\\\"count\\\": 2})\",\n            \"genres\": \"$t(entity.genre, {\\\"count\\\": 2})\",\n            \"home\": \"$t(common.home)\",\n            \"myLibrary\": \"Könyvtáram\",\n            \"nowPlaying\": \"most játszott\",\n            \"playlists\": \"$t(entity.playlist, {\\\"count\\\": 2})\",\n            \"search\": \"$t(common.search)\",\n            \"settings\": \"$t(common.setting, {\\\"count\\\": 2})\",\n            \"shared\": \"megosztott $t(entity.playlist, {\\\"count\\\": 2})\",\n            \"tracks\": \"$t(entity.track, {\\\"count\\\": 2})\",\n            \"favorites\": \"$t(entity.favorite, {\\\"count\\\": 2})\",\n            \"radio\": \"$t(entity.radioStation, {\\\"count\\\": 2})\"\n        },\n        \"trackList\": {\n            \"artistTracks\": \"dalok tőle {{artist}}\",\n            \"genreTracks\": \"\\\"{{genre}}\\\" $t(entity.track, {\\\"count\\\": 2})\",\n            \"title\": \"$t(entity.track, {\\\"count\\\": 2})\"\n        },\n        \"favorites\": {\n            \"title\": \"$t(entity.favorite, {\\\"count\\\": 2})\"\n        },\n        \"folderList\": {\n            \"title\": \"$t(entity.folder, {\\\"count\\\": 2})\"\n        },\n        \"radioList\": {\n            \"title\": \"rádió állomások\"\n        }\n    },\n    \"player\": {\n        \"addLast\": \"utolsónak\",\n        \"addNext\": \"következő\",\n        \"favorite\": \"kedvenc\",\n        \"mute\": \"némítás\",\n        \"muted\": \"némítva\",\n        \"next\": \"következő\",\n        \"play\": \"lejátszás\",\n        \"playbackFetchCancel\": \"Ez eltart egy ideig… zárja be az értesítést a megszakításhoz\",\n        \"playbackFetchInProgress\": \"dalok betöltése…\",\n        \"playbackFetchNoResults\": \"nem találhatóak dalok\",\n        \"playbackSpeed\": \"lejátszási sebesség\",\n        \"playRandom\": \"véletlenszerű lejátszás\",\n        \"playSimilarSongs\": \"hasonló dalok lejátszása\",\n        \"previous\": \"előző\",\n        \"queue_clear\": \"műsorlista kiürítése\",\n        \"queue_moveToBottom\": \"kiválasztott elem feljebb mozgatása\",\n        \"queue_moveToTop\": \"kiválasztott elem lejjebb mozgatása\",\n        \"queue_remove\": \"kiválasztott elem eltávolítása\",\n        \"repeat\": \"ismétlés\",\n        \"repeat_all\": \"összes ismétlése\",\n        \"repeat_off\": \"ismétlés kikapcsolva\",\n        \"shuffle\": \"kevert (lejátszás)\",\n        \"skip\": \"ugrás\",\n        \"skip_back\": \"visszaugrás\",\n        \"skip_forward\": \"előre ugrás\",\n        \"stop\": \"állj\",\n        \"toggleFullscreenPlayer\": \"teljes képernyős lejátszó bekapcsolása\",\n        \"unfavorite\": \"kedvencekből eltávolítás\",\n        \"pause\": \"szünet\",\n        \"viewQueue\": \"műsorlista megtekintése\",\n        \"shuffle_off\": \"kevert lejátszás ki\",\n        \"addLastShuffled\": \"végére (keverve)\",\n        \"addNextShuffled\": \"következő (keverve)\",\n        \"holdToShuffle\": \"tartsd lenyomva a keveréshez\",\n        \"lyrics\": \"dalszöveg\",\n        \"saveQueueToServer\": \"műsorlista mentése a szerverre\",\n        \"restoreQueueFromServer\": \"műsorlista visszaállítása a szerverről\"\n    },\n    \"releaseType\": {\n        \"primary\": {\n            \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n            \"broadcast\": \"sugárzás\",\n            \"ep\": \"ep\",\n            \"other\": \"más\",\n            \"single\": \"kislemez\"\n        },\n        \"secondary\": {\n            \"audiobook\": \"hangoskönyv\",\n            \"audioDrama\": \"rádió dráma\",\n            \"compilation\": \"összeállítás\",\n            \"djMix\": \"dj mix\",\n            \"demo\": \"demo\",\n            \"fieldRecording\": \"helyszíni felvétel\",\n            \"interview\": \"interjú\",\n            \"live\": \"élő\",\n            \"mixtape\": \"kazetta mix\",\n            \"remix\": \"remix\",\n            \"soundtrack\": \"filmzene\",\n            \"spokenWord\": \"beszélt szó\"\n        }\n    },\n    \"setting\": {\n        \"accentColor_description\": \"beállítja az alkalmazás kiemelő színét\",\n        \"accentColor\": \"kiemelő szín\",\n        \"albumBackground_description\": \"háttérképet ad hozzá az albumborítót tartalmazó albumoldalakhoz\",\n        \"albumBackground\": \"album háttérkép\",\n        \"albumBackgroundBlur_description\": \"beállítja az album háttérképre alkalmazott elmosódás mértékét\",\n        \"albumBackgroundBlur\": \"album háttérkép elmosódás mérete\",\n        \"applicationHotkeys_description\": \"alkalmazás gyorsbillentyűk konfigurálása. jelöld be a jelölőnégyzetet, ha globális gyorsbillentyűként szeretnéd beállítani (csak asztali számítógépen)\",\n        \"applicationHotkeys\": \"alkalmazás gyorsbillentyűk\",\n        \"artistBackground\": \"előadó háttérkép\",\n        \"artistBackground_description\": \"háttérképet ad hozzá a előadó oldalához, amely tartalmazza a előadó stílusát\",\n        \"artistBackgroundBlur\": \"előadó háttérkép elmosódás mértéke\",\n        \"artistBackgroundBlur_description\": \"beállítja az előadó háttérképére alkalmazott elmosódás mértékét\",\n        \"artistConfiguration\": \"album előadójának oldal konfigurációja\",\n        \"artistConfiguration_description\": \"konfigurálja, hogy mely elemek jelenjenek meg, és milyen sorrendben, az album előadó oldalán\",\n        \"audioDevice_description\": \"válaszd ki a lejátszáshoz használni kívánt hangeszközt (csak webes lejátszó esetén)\",\n        \"audioDevice\": \"hangeszköz\",\n        \"audioExclusiveMode_description\": \"engedélyezze az exkluzív kimeneti módot. Ebben a módban a rendszer általában le van zárva, és csak az mpv képes hangot kiadni\",\n        \"audioExclusiveMode\": \"exkluzív audio mód\",\n        \"audioPlayer_description\": \"válaszd ki a lejátszáshoz használni kívánt audiolejátszót\",\n        \"audioPlayer\": \"audiolejátszó\",\n        \"buttonSize_description\": \"a lejátszó sáv gombjainak mérete\",\n        \"buttonSize\": \"lejátszó sáv gomb méret\",\n        \"clearCache_description\": \"a Feishin „kemény törlése”. a Feishin cache-ének törlése mellett ürítse ki a böngésző cache-ét (mentett képek és egyéb eszközök). a szerver hitelesítő adatai és beállításai megmaradnak\",\n        \"clearCache\": \"böngésző gyorsítótár törlése\",\n        \"clearCacheSuccess\": \"gyorsítótár sikeresen törölve\",\n        \"clearQueryCache_description\": \"A Feishin „soft clear” funkciója. Ez frissíti a lejátszási listákat, a zeneszámok metaadatait és visszaállítja a mentett dalszövegeket. A beállítások, a szerver hitelesítő adatok és a gyorsítótárban tárolt képek megmaradnak\",\n        \"clearQueryCache\": \"Feishin gyorsítótár törlése\",\n        \"contextMenu_description\": \"lehetővé teszi, hogy elrejtsd azokat az elemeket, amelyek a menüben megjelennek, amikor jobb gombbal kattintasz egy elemre. A bejelölés nélküli elemek el lesznek rejtve\",\n        \"releaseChannel_optionBeta\": \"béta\",\n        \"releaseChannel_optionLatest\": \"legújabb\",\n        \"releaseChannel\": \"kiadási csatorna\",\n        \"customCss\": \"egyéni css\",\n        \"customCssEnable_description\": \"lehetővé teszi az egyéni css írását\",\n        \"customCssEnable\": \"egyéni css engedélyezése\",\n        \"customFontPath\": \"egyéni betűtípus elérési út\",\n        \"customCss_description\": \"egyéni css tartalom. Megjegyzés: a tartalom és a távoli URL-ek nem megengedett tulajdonságok. A tartalom előnézete az alábbiakban látható. A tisztítás miatt további mezők is megjelennek, amelyeket te nem állítottál be\",\n        \"customCssNotice\": \"Figyelem: bár van némi tisztítás (az url() és a content: használata nem engedélyezett), az egyéni css használata továbbra is kockázatot jelenthet, mivel megváltoztatja a felületet\",\n        \"customFontPath_description\": \"beállítja az alkalmazáshoz használandó egyéni betűtípus elérési útját\",\n        \"contextMenu\": \"kontextusmenü (jobb klikk) beállítás\",\n        \"crossfadeDuration_description\": \"beállítja áthúzás effekt időtartamát\",\n        \"crossfadeDuration\": \"áthúzás időtartam\",\n        \"crossfadeStyle\": \"áthúzás stílus\",\n        \"crossfadeStyle_description\": \"válaszd ki az audiolejátszóhoz használni kívánt áthúzás stílust\",\n        \"releaseChannel_description\": \"válassz a stabil kiadás vagy a béta kiadás közül az automatikus frissítésekhez\",\n        \"disableLibraryUpdateOnStartup\": \"új verziók ellenőrzését indításkor letiltása\",\n        \"discordDisplayType_artistname\": \"előadó név\",\n        \"discordDisplayType_description\": \"megváltoztatja, hogy mit hallgatsz az állapotodban\",\n        \"discordDisplayType_songname\": \"dal címe\",\n        \"discordApplicationId_description\": \"az alkalmazás {{discord}} rich presence ID-ja (alapértelmezés szerint {{defaultId}})\",\n        \"discordApplicationId\": \"{{discord}} applikáció id\",\n        \"discordDisplayType\": \"{{discord}} presence megjelenítési típus\",\n        \"discordIdleStatus_description\": \"ha engedélyezve van, frissítse az állapotot, amikor a lejátszó nem aktív\",\n        \"discordIdleStatus\": \"mutasd a rich presence üresjárati állapotát\",\n        \"discordLinkType_description\": \"külső linkeket ad hozzá a {{lastfm}} vagy {{musicbrainz}} mezőkhöz a {{discord}} rich presence dal és előadó mezőiben. A {{musicbrainz}} a legpontosabb, de címkéket igényel, de nem biztosít előadói linkeket, míg a {{lastfm}} mindig biztosít linket, de nem generál extra hálózati kéréseket\",\n        \"discordLinkType_mbz_lastfm\": \"{{musicbrainz}} {{lastfm}} tartalék megoldással\",\n        \"discordLinkType_none\": \"$t(common.none)\",\n        \"discordLinkType\": \"{{discord}} presence linkek\",\n        \"discordListening_description\": \"állapot megjelenítése hallgatásként a lejátszás helyett\",\n        \"discordListening\": \"állapot megjelenítése hallgatásként\",\n        \"discordPausedStatus_description\": \"ha engedélyezve van, a lejátszó szüneteltetésekor megjelenik az állapot\",\n        \"discordPausedStatus\": \"rich presence megjelenítése szüneteltetéskor\",\n        \"discordRichPresence\": \"{{discord}} rich presence\",\n        \"discordRichPresence_description\": \"engedélyezze a lejátszási állapotot a {{discord}} rich presence-ben. A kép kulcsok: {{icon}}, {{playing}} és {{paused}}\",\n        \"discordServeImage\": \"{{discord}} képek kiszolgálása a szerverről\",\n        \"discordServeImage_description\": \"megosztja a {{discord}} borítóképet a szerverről (csak Jellyfin és Navidrome esetén elérhető). A {{discord}} botot használ a képek letöltéséhez, ezért a szervernek elérhetőnek kell lennie a nyilvános interneten\",\n        \"discordUpdateInterval\": \"{{discord}} rich presence frissítési intervallum\",\n        \"discordUpdateInterval_description\": \"az egyes frissítések közötti idő másodpercben (minimum 15 másodperc)\",\n        \"enableAutoTranslation_description\": \"a dalszöveg betöltésekor automatikusan engedélyezze a fordítást\",\n        \"enableAutoTranslation\": \"automatikus fordítás engedélyezése\",\n        \"enableRemote_description\": \"lehetővé teszi egy távoli vezérlő szerver számára, hogy más eszközök vezéreljék az alkalmazást\",\n        \"enableRemote\": \"távoli vezérlő szerver engedélyezése\",\n        \"exitToTray_description\": \"kilépés az alkalmazásból a tálcára\",\n        \"exitToTray\": \"kilépés a tálcára\",\n        \"exportImportSettings_control_description\": \"Beállítások exportálása és importálása JSON-on keresztül\",\n        \"exportImportSettings_control_exportText\": \"beállítások exportálása\",\n        \"exportImportSettings_control_importText\": \"beállítások importálása\",\n        \"exportImportSettings_control_title\": \"Beállítások exportálása és importálása\",\n        \"exportImportSettings_destructiveWarning\": \"A beállítások importálása végleges, ezért kérlek, olvasd el a fenti információkat, mielőtt rákattintasz az alábbi „Importálás” gombra!\",\n        \"exportImportSettings_importBtn\": \"beállítások importálása\",\n        \"exportImportSettings_importModalTitle\": \"Feishin beállítások importálása\",\n        \"exportImportSettings_importSuccess\": \"A beállítások sikeresen importálva!\",\n        \"exportImportSettings_notValidJSON\": \"A megadott fájl nem érvényes JSON\",\n        \"exportImportSettings_offendingKeyError\": \"\\\"{{offendingKey}}\\\" helytelen - {{reason}}\",\n        \"externalLinks_description\": \"lehetővé teszi külső linkek (Last.fm, MusicBrainz) megjelenítését az előadó/album oldalakon\",\n        \"externalLinks\": \"külső linkek megjelenítése\",\n        \"followLyric_description\": \"görgess a dalszöveghez az aktuális lejátszási pozícióig\",\n        \"followLyric\": \"kövesd az aktuális dalszöveget\",\n        \"font_description\": \"beállítja az alkalmazáshoz használandó betűtípust\",\n        \"font\": \"betűtípus\",\n        \"fontType_description\": \"A beépített betűtípus a Feishin által biztosított betűtípusok közül választ. A rendszerbetűtípus lehetővé teszi az operációs rendszer által biztosított bármely betűtípus kiválasztását. Az egyéni beállítás lehetővé teszi saját betűtípus megadását\",\n        \"fontType_optionBuiltIn\": \"beépített betűtípus\",\n        \"fontType_optionCustom\": \"egyedi betűtípus\",\n        \"fontType_optionSystem\": \"rendszer betűtípus\",\n        \"fontType\": \"Font típusa\",\n        \"gaplessAudio_description\": \"Beállítja az MPV résmentes (hézagmentes) lejátszását\",\n        \"gaplessAudio_optionWeak\": \"gyenge (ajánlott)\",\n        \"gaplessAudio\": \"hézagmentes hang\",\n        \"globalMediaHotkeys_description\": \"engedélyezheted vagy letilthatod a rendszer média gyorsbillentyűinek használatát a lejátszás vezérléséhez\",\n        \"globalMediaHotkeys\": \"globális média gyorsbillentyűk\",\n        \"homeConfiguration_description\": \"beállíthatod, hogy mely elemek jelenjenek meg, és milyen sorrendben a kezdőlapon\",\n        \"homeConfiguration\": \"kezdőlap konfigurációja\",\n        \"homeFeature_description\": \"ellenőrzi, hogy megjelenjen-e a nagy, kiemelt csúszka a kezdőlapon\",\n        \"hotkey_favoriteCurrentSong\": \"kedvenc $t(common.currentSong)\",\n        \"hotkey_favoritePreviousSong\": \"kedvenc $t(common.previousSong)\",\n        \"hotkey_globalSearch\": \"globális keresés\",\n        \"hotkey_localSearch\": \"oldalon belüli keresés\",\n        \"hotkey_browserBack\": \"Vissza a böngészőben\",\n        \"hotkey_navigateHome\": \"Ugrás a kezdőlapra\",\n        \"hotkey_browserForward\": \"Ugrás előre\",\n        \"homeFeature\": \"Kiemelt tartalmak csúszkája a kezdőlapon\",\n        \"hotkey_playbackNext\": \"következő szám\",\n        \"hotkey_playbackPause\": \"szünet\",\n        \"hotkey_playbackPlay\": \"lejátszás\",\n        \"hotkey_playbackPlayPause\": \"lejátszás/szünet\",\n        \"hotkey_playbackPrevious\": \"előző szám\",\n        \"hotkey_playbackStop\": \"állj\",\n        \"hotkey_rate0\": \"értékelés törlés\",\n        \"hotkey_rate1\": \"értékelés 1 csillag\",\n        \"hotkey_rate2\": \"értékelés 2 csillag\",\n        \"hotkey_rate3\": \"értékelés 3 csillag\",\n        \"hotkey_rate4\": \"értékelés 4 csillag\",\n        \"hotkey_rate5\": \"értékelés 5 csillag\",\n        \"hotkey_skipBackward\": \"visszaugrás\",\n        \"hotkey_skipForward\": \"előre ugrás\",\n        \"hotkey_volumeDown\": \"hangerő le\",\n        \"hotkey_volumeMute\": \"elnémítás\",\n        \"hotkey_volumeUp\": \"hangerő fel\",\n        \"hotkey_toggleCurrentSongFavorite\": \"kedvencek $t(common.currentSong) közé\",\n        \"hotkey_toggleFullScreenPlayer\": \"teljes képernyős lejátszóra váltás\",\n        \"hotkey_togglePreviousSongFavorite\": \"kedvencek $t(common.previousSong) közé\",\n        \"hotkey_toggleQueue\": \"lejátszási sorra váltása\",\n        \"hotkey_toggleRepeat\": \"ismétlésre váltás\",\n        \"hotkey_toggleShuffle\": \"keverésre váltás\",\n        \"hotkey_zoomIn\": \"nagyítás\",\n        \"hotkey_zoomOut\": \"kicsinyítés\",\n        \"imageAspectRatio_description\": \"Ha engedélyezve van, a borítóképet natív képarányban jeleníti meg. Az 1:1-től eltérő képarányú borítók esetében a fennmaradó hely üresen marad\",\n        \"imageAspectRatio\": \"használja a borító eredeti képarányát\",\n        \"language\": \"nyelv\",\n        \"hotkey_unfavoriteCurrentSong\": \"kedvencekből eltávolítás $t(common.currentSong)\",\n        \"hotkey_unfavoritePreviousSong\": \"kedvencekből eltávolítás $t(common.previousSong)\",\n        \"lastfmApiKey\": \"{{lastfm}} API kulcs\",\n        \"lyricFetch_description\": \"dalszövegek lekérése különböző internetes forrásokból\",\n        \"lyricFetch\": \"dalszövegek letöltése az internetről\",\n        \"lastfm\": \"mutasd a last.fm linkeket\",\n        \"lastfmApiKey_description\": \"a {{lastfm}} API kulcsa. szükséges a borítóhoz\",\n        \"language_description\": \"beállítja az alkalmazás nyelvét ($t(common.restartRequired))\",\n        \"lastfm_description\": \"mutasd a Last.fm linkeket az előadó/album oldalakon\",\n        \"lyricFetchProvider_description\": \"válaszd ki azokat a szolgáltatókat, amelyekről a dalszövegeket szeretnéd letölteni. A szolgáltatók sorrendje megegyezik a lekérdezés sorrendjével\",\n        \"lyricFetchProvider\": \"Dalszöveg szolgáltatók\",\n        \"lyricOffset_description\": \"a szöveget a megadott ms értékkel eltolja\",\n        \"lyricOffset\": \"szövegeltolás (ms)\",\n        \"minimizeToTray\": \"minimalizálás a tálcára\",\n        \"minimizeToTray_description\": \"alkalmazás minimalizálása a tálcára\",\n        \"musicbrainz\": \"mutasd a MusicBrainz linkeket\",\n        \"playbackStyle_optionNormal\": \"normal\",\n        \"playbackStyle\": \"lejátszás stílusa\",\n        \"playButtonBehavior_description\": \"beállítja a lejátszás gomb alapértelmezett viselkedését, amikor dalokat adunk a műsorlistához\",\n        \"playButtonBehavior_optionAddLast\": \"$t(player.addLast)\",\n        \"playButtonBehavior_optionAddNext\": \"$t(player.addNext)\",\n        \"playButtonBehavior_optionPlay\": \"$t(player.play)\",\n        \"playButtonBehavior_optionPlayShuffled\": \"$t(player.shuffle)\",\n        \"playButtonBehavior\": \"lejátszás gomb viselkedése\",\n        \"minimumScrobblePercentage_description\": \"a szám lejátszásának minimális százaléka, amelynek el kell hangzania, mielőtt Scrobble-nak számít\",\n        \"minimumScrobblePercentage\": \"Minimális Scrobble arány (százalék)\",\n        \"minimumScrobbleSeconds\": \"Minimum Scrobble arány (mp)\",\n        \"mpvExecutablePath\": \"Mpv elérési útja\",\n        \"mpvExecutablePath_description\": \"beállítja az Mpv elérési útját. Ha üresen hagyod, az alapértelmezett elérési út lesz használva\",\n        \"minimumScrobbleSeconds_description\": \"a szám lejátszásának minimális hossza, amelynek el kell hangzania, mielőtt Scrobble-nak számít\",\n        \"mpvExtraParameters_help\": \"soronként\",\n        \"musicbrainz_description\": \"mutasd a MusicBrainz linkjeit az előadó/album oldalakon, ahol MusicBrainz ID létezik\",\n        \"neteaseTranslation_description\": \"Ha engedélyezve van, letölti és megjeleníti a NetEase-ről a lefordított dalszövegeket, ha azok rendelkezésre állnak\",\n        \"neteaseTranslation\": \"Engedélyezi a NetEase fordításokat\",\n        \"notify\": \"bekapcsolja a dal értesítéseket\",\n        \"notify_description\": \"értesítések megjelenítése az aktuális dal megváltoztatásakor\",\n        \"playbackStyle_description\": \"válaszd ki az lejátszóhoz használni kívánt lejátszási stílust\",\n        \"playerbarOpenDrawer_description\": \"lehetővé teszi a lejátszósávra kattintással a teljes képernyős lejátszó megnyitását\",\n        \"playerbarOpenDrawer\": \"lejátszósáv teljes képernyőre váltás\",\n        \"preferLocalLyrics_description\": \"ha elérhető, akkor a távoli dalszövegek helyett a helyi dalszövegeket részesítse előnyben\",\n        \"preferLocalLyrics\": \"előnybe részesített dalszöveg\",\n        \"preservePitch_description\": \"megőrzi a hangmagasságot a lejátszási sebesség módosításakor\",\n        \"preservePitch\": \"hangmagasság megőrzése\",\n        \"preventSleepOnPlayback_description\": \"megakadályozza, hogy a kijelző alvó módba kerüljön zene lejátszása közben\",\n        \"preventSleepOnPlayback\": \"megakadályozza az alvó módot lejátszás közben\",\n        \"remotePassword_description\": \"beállítja a távoli vezérlő szerver jelszavát. Ezek a hitelesítő adatok alapértelmezés szerint nem biztonságosan kerülnek továbbításra, ezért olyan egyedi jelszót kell használnod, amely nem fontosak neked\",\n        \"remotePassword\": \"távoli vezérlő szerver jelszava\",\n        \"remotePort_description\": \"beállítja a távoli vezérlő szerver portját\",\n        \"remotePort\": \"távoli vezérlő szerver portja\",\n        \"remoteUsername_description\": \"beállítja a távoli vezérlő szerver felhasználónevét. Ha mind a felhasználónév, mind a jelszó üres, a hitelesítés le lesz tiltva\",\n        \"remoteUsername\": \"távoli vezérlő szerver felhasználónév\",\n        \"passwordStore_description\": \"jelszó/titkos tároló kiválasztása. Módosítsd, ha problémát tapasztalsz a jelszavak tárolásánál\",\n        \"passwordStore\": \"jelszó/titkos tároló\",\n        \"playbackStyle_optionCrossFade\": \"áthúzás\",\n        \"replayGainMode_optionAlbum\": \"$t(entity.album, {\\\"count\\\": 1})\",\n        \"replayGainMode_optionNone\": \"$t(common.none)\",\n        \"replayGainMode_optionTrack\": \"$t(entity.track, {\\\"count\\\": 1})\",\n        \"replayGainMode\": \"{{ReplayGain}} mód\",\n        \"replayGainClipping_description\": \"A {{ReplayGain}} által okozott torzítás megelőzése az erősítés automatikus csökkentésével\",\n        \"replayGainClipping\": \"{{ReplayGain}} torzítás\",\n        \"replayGainMode_description\": \"A hangerő erősítés beállítása a fájl metaadataiban tárolt {{ReplayGain}} értékek szerint\",\n        \"replayGainFallback_description\": \"Az alkalmazandó erősítés dB-ben, ha a fájl nem tartalmaz {{ReplayGain}} címkét\",\n        \"sampleRate_description\": \"A kimeneti mintavételi frekvencia kiválasztása, ha a beállított frekvencia eltér a jelenlegi médiáétól. 8000 alatti érték esetén az alapértelmezett frekvencia kerül használatra\",\n        \"sampleRate\": \"mintavételi frekvencia\",\n        \"savePlayQueue_description\": \"A műsorlista mentése bezárásakor, és visszaállítása az alkalmazás megnyitásakor\",\n        \"replayGainPreamp\": \"{{ReplayGain}} előerősítés (dB)\",\n        \"replayGainFallback\": \"{{ReplayGain}} fallback\",\n        \"replayGainPreamp_description\": \"állítsd be a {{ReplayGain}} -re vonatkozó előerősítő értéket\",\n        \"scrobble\": \"Scrobble\",\n        \"showSkipButton_description\": \"a lejátszó sávon megjelenő átugrás gombok megjelenítése vagy elrejtése\",\n        \"showSkipButton\": \"mutasd az átugrás gombot\",\n        \"savePlayQueue\": \"műsorlista mentése\",\n        \"scrobble_description\": \"a lejátszás Scrobble-elése a médiaszerveredre\",\n        \"showSkipButtons_description\": \"a lejátszó sávon megjelenő átugrás gombok megjelenítése vagy elrejtése\",\n        \"showSkipButtons\": \"mutasd az átugrás gombot\",\n        \"sidebarConfiguration_description\": \"válaszd ki az oldalsávban megjelenő elemeket és azok sorrendjét\",\n        \"sidebarCollapsedNavigation_description\": \"Navigáció megjelenítése vagy elrejtése az összecsukott oldalsávban\",\n        \"sidebarCollapsedNavigation\": \"Összecsukott oldalsáv navigációja\",\n        \"sidebarConfiguration\": \"oldalsáv konfigurációja\",\n        \"theme\": \"téma\",\n        \"sidebarPlaylistList_description\": \"lejátszási lista megjelenítése vagy elrejtése az oldalsávban\",\n        \"sidebarPlaylistList\": \"oldalsáv lejátszási lista\",\n        \"sidePlayQueueStyle_description\": \"beállítja az oldalsó műsorlista stílusát\",\n        \"mediaSession_description\": \"lehetővé teszi a Windows Media Session integrációját, a médiavezérlők és metaadatok megjelenítését a rendszer hangerő-átfedésben és a zárolási képernyőn (csak Windows)\",\n        \"mediaSession\": \"média munkamenet engedélyezése\",\n        \"sidePlayQueueStyle\": \"oldalsó műsorlista stílus\",\n        \"skipDuration\": \"átugrás hossza\",\n        \"skipPlaylistPage_description\": \"lejátszási listához való navigáláskor az alapértelmezett oldal helyett a lejátszási lista dalainak listájára mutató oldalra lépjen\",\n        \"skipPlaylistPage\": \"lejátszási lista oldalának átugrása\",\n        \"sidePlayQueueStyle_optionAttached\": \"rögzítve\",\n        \"sidePlayQueueStyle_optionDetached\": \"leválasztva\",\n        \"skipDuration_description\": \"beállítja az átugrás hosszát a lejátszósáv ugrás gombjainak használatakor\",\n        \"startMinimized_description\": \"Az alkalmazás indítása a tálcán\",\n        \"themeDark\": \"téma (sötét)\",\n        \"themeLight\": \"téma (világos)\",\n        \"themeDark_description\": \"sötét téma beállítása\",\n        \"themeLight_description\": \"világos téma beállítása\",\n        \"translationApiKey\": \"fordítási api kulcs\",\n        \"translationApiProvider_description\": \"fordításhoz szükséges api szolgáltató\",\n        \"translationApiProvider\": \"fordítási api szolgáltató\",\n        \"translationApiKey_description\": \"fordításhoz szükséges api kulcs (csak globális szolgáltatás végpont)\",\n        \"transcode_description\": \"átkódolás más formátumba\",\n        \"transcodeBitrate_description\": \"bitráta választás az átkódoláshoz. A 0 azt jelenti, hogy a szerver választ\",\n        \"transcodeBitrate\": \"átkódolás bitráta\",\n        \"transcodeFormat_description\": \"formátum választás átkódoláshoz.Ha üresen hagyod, akkor a szerver választ\",\n        \"transcodeFormat\": \"átkódolás formátuma\",\n        \"transcode\": \"átkódolás engedélyezése\",\n        \"startMinimized\": \"indítás minimalizálva\",\n        \"theme_description\": \"Beállítja az alkalmazás témáját\",\n        \"translationTargetLanguage_description\": \"fordítás célnyelve\",\n        \"trayEnabled\": \"tálca megjelenítése\",\n        \"useSystemTheme_description\": \"A rendszer által megadott világos/sötét mód követése\",\n        \"translationTargetLanguage\": \"fordítás célnyelve\",\n        \"trayEnabled_description\": \"tálcaikon/menü megjelenítése/elrejtése (kikapcsolva: nincs tálcára küldés)\",\n        \"useSystemTheme\": \"rendszer téma használata\",\n        \"zoom\": \"nagyítási arány\",\n        \"webAudio\": \"web audio használata\",\n        \"windowBarStyle_description\": \"válaszd ki az címsor stílusát\",\n        \"windowBarStyle\": \"címsor\",\n        \"zoom_description\": \"beállítja az alkalmazás nagyítási arányát\",\n        \"volumeWheelStep_description\": \"A hangerő változásának mértéke az egérgörgő használatakor a hangerő sávon\",\n        \"volumeWheelStep\": \"hangerőgörgő lépés\",\n        \"volumeWidth\": \"hangerő sáv szélessége\",\n        \"webAudio_description\": \"Web Audio használata. Ez lehetővé teszi a fejlettebb funkciókat, például a ReplayGain-t. (Kapcsold ki, ha problémát tapasztalsz)\",\n        \"volumeWidth_description\": \"hangerő sáv szélessége\",\n        \"analyticsDisable_description\": \"Anonim használati adatok kerülnek elküldésre a fejlesztőnek, hogy segítsék az alkalmazás fejlesztését\",\n        \"analyticsDisable\": \"Használati alapú adatok küldésének kikapcsolása\",\n        \"playerbarSlider\": \"lejátszósáv csúszka\",\n        \"playerbarSliderType_optionSlider\": \"csűszka\",\n        \"playerbarSliderType_optionWaveform\": \"hullámforma\",\n        \"playerbarWaveformAlign\": \"hullámforma igazítás\",\n        \"playerbarWaveformAlign_optionTop\": \"felső\",\n        \"playerbarWaveformAlign_optionCenter\": \"középső\",\n        \"playerbarWaveformAlign_optionBottom\": \"alsó\",\n        \"playerbarWaveformBarWidth\": \"hullámforma oszlopszélesség\",\n        \"playerbarWaveformGap\": \"hullámforma oszlopköz\",\n        \"playerbarWaveformRadius\": \"hullámforma sugara\",\n        \"showLyricsInSidebar_description\": \"a csatolt műsorlistához egy panel kerül hozzáadásra, amelyen a dalszövegek jelennek meg\",\n        \"showLyricsInSidebar\": \"dalszövegek megjelenítése a lejátszó oldalsávban\",\n        \"showVisualizerInSidebar_description\": \"a lejátszó oldalsávjához egy panel kerül hozzáadásra, amely megjeleníti a vizualizáció\",\n        \"showVisualizerInSidebar\": \"vizualizáció megjelenítése a lejátszó oldalsávban\",\n        \"queryBuilder\": \"lekérdezés-építő\",\n        \"queryBuilderCustomFields_inputLabel\": \"címke\",\n        \"queryBuilderCustomFields_inputTag\": \"jelölés\",\n        \"queryBuilderCustomFields\": \"egyéni mezők\",\n        \"queryBuilderCustomFields_description\": \"egyéni mezők hozzáadása a lekérdezés-építőhöz\",\n        \"autoDJ\": \"auto DJ\",\n        \"autoDJ_timing\": \"időzítés\",\n        \"autoDJ_description\": \"hasonló dalokat automatikusan hozzáad a műsorlistához\",\n        \"autoDJ_itemCount\": \"elem szám\",\n        \"autoDJ_itemCount_description\": \"az auto DJ engedélyezésekor a műsorsorba felvenni kívánt elemek száma\",\n        \"autoDJ_timing_description\": \"az auto DJ elindulása előtt a műsorlistában maradt dalok száma\",\n        \"followCurrentSong_description\": \"automatikusan görgesse a műsorlistát az aktuálisan lejátszott dalra\",\n        \"followCurrentSong\": \"kövesd az aktuális dalt\",\n        \"logLevel\": \"naplózási szint\",\n        \"logLevel_description\": \"beállítja a megjelenítendő minimális naplószintet. A debug minden naplót megjeleníti, az error csak a hibákat\",\n        \"logLevel_optionDebug\": \"debug\",\n        \"logLevel_optionError\": \"error\",\n        \"logLevel_optionInfo\": \"info\",\n        \"logLevel_optionWarn\": \"figyelmeztetés\",\n        \"playerFilters\": \"Szűrje a dalokat a műsorlistából\",\n        \"playerFilters_description\": \"a következő kritériumok alapján kihagyja a dalokat a műsorlistából\",\n        \"playerbarSlider_description\": \"a hullámforma nem ajánlott lassú vagy korlátozott internetkapcsolat esetén\",\n        \"audioFadeOnStatusChange\": \"audio behúzás állapotváltozáskor\",\n        \"audioFadeOnStatusChange_description\": \"lehetővé teszi a lehúzást és a behúzást, amikor a lejátszás/szünet állapot megváltozik\",\n        \"useThemeAccentColor\": \"használd a téma kiemelő színét\",\n        \"useThemeAccentColor_description\": \"a kiválasztott témában meghatározott alapszínt használja az egyéni kiemelő szín helyett\"\n    },\n    \"table\": {\n        \"config\": {\n            \"label\": {\n                \"path\": \"$t(common.path)\",\n                \"playCount\": \"lejátszások száma\",\n                \"rating\": \"$t(common.rating)\",\n                \"releaseDate\": \"megjelenés dátuma\",\n                \"rowIndex\": \"sor index\",\n                \"size\": \"$t(common.size)\",\n                \"songCount\": \"$t(entity.track, {\\\"count\\\": 2})\",\n                \"title\": \"$t(common.title)\",\n                \"titleCombined\": \"$t(common.title) (kombinált)\",\n                \"trackNumber\": \"szám\",\n                \"year\": \"$t(common.year)\",\n                \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n                \"lastPlayed\": \"utoljára játszott\",\n                \"note\": \"$t(common.note)\",\n                \"owner\": \"$t(common.owner)\",\n                \"albumArtist\": \"$t(entity.albumArtist, {\\\"count\\\": 1})\",\n                \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n                \"biography\": \"$t(common.biography)\",\n                \"bitrate\": \"$t(common.bitrate)\",\n                \"bpm\": \"$t(common.bpm)\",\n                \"channels\": \"$t(common.channel_other)\",\n                \"codec\": \"$t(common.codec)\",\n                \"dateAdded\": \"hozzáadva\",\n                \"discNumber\": \"lemezszám\",\n                \"duration\": \"$t(common.duration)\",\n                \"favorite\": \"$t(common.favorite)\",\n                \"actions\": \"$t(common.action_other)\",\n                \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n                \"albumCount\": \"$t(entity.album, {\\\"count\\\": 2})\",\n                \"genreBadge\": \"$t(entity.genre, {\\\"count\\\": 1}) (jelvények)\",\n                \"image\": \"kép\",\n                \"bitDepth\": \"$t(common.bitDepth)\",\n                \"sampleRate\": \"$t(common.sampleRate)\"\n            },\n            \"view\": {\n                \"grid\": \"rács\",\n                \"list\": \"lista\",\n                \"table\": \"táblázat\"\n            },\n            \"general\": {\n                \"autoFitColumns\": \"oszlopok automatikus igazítása\",\n                \"followCurrentSong\": \"kövesd az jelenlegi dalt\",\n                \"displayType\": \"kijelző típusa\",\n                \"gap\": \"$t(common.gap)\",\n                \"size\": \"$t(common.size)\",\n                \"tableColumns\": \"táblázat oszlopai\",\n                \"itemGap\": \"elemek közötti távolság (px)\",\n                \"itemSize\": \"elem mérete (px)\",\n                \"advancedSettings\": \"speciális beállítások\",\n                \"autosize\": \"automatikus méret\",\n                \"moveUp\": \"felfelé\",\n                \"moveDown\": \"lefelé\",\n                \"pinToLeft\": \"balra tűz\",\n                \"pinToRight\": \"jobbra tűz\",\n                \"alignLeft\": \"igazítás balra\",\n                \"alignCenter\": \"igazítás középre\",\n                \"alignRight\": \"igazítás jobbra\",\n                \"itemsPerRow\": \"elemek soronként\",\n                \"size_default\": \"alapértelmezett\",\n                \"size_compact\": \"kompakt\",\n                \"size_large\": \"nagy\",\n                \"pagination\": \"oldalszámozás\",\n                \"pagination_itemsPerPage\": \"elemek oldalanként\",\n                \"pagination_infinite\": \"végtelen\",\n                \"pagination_paginate\": \"oldal számozva\",\n                \"alternateRowColors\": \"alternatív sor színek\",\n                \"horizontalBorders\": \"sorhatárok\",\n                \"rowHoverHighlight\": \"sor kiemelése egérrel\",\n                \"verticalBorders\": \"oszlophatárok\"\n            }\n        },\n        \"column\": {\n            \"albumCount\": \"$t(entity.album, {\\\"count\\\": 2})\",\n            \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n            \"biography\": \"életrajz\",\n            \"bitrate\": \"bitráta\",\n            \"bpm\": \"bpm\",\n            \"channels\": \"$t(common.channel_other)\",\n            \"codec\": \"$t(common.codec)\",\n            \"comment\": \"komment\",\n            \"dateAdded\": \"hozzáadva\",\n            \"discNumber\": \"lemez\",\n            \"favorite\": \"kedvenc\",\n            \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n            \"lastPlayed\": \"utoljára játszott\",\n            \"path\": \"elérési út\",\n            \"playCount\": \"lejátszások\",\n            \"rating\": \"értékelés\",\n            \"releaseDate\": \"megjelenés\",\n            \"releaseYear\": \"év\",\n            \"size\": \"$t(common.size)\",\n            \"songCount\": \"$t(entity.track, {\\\"count\\\": 2})\",\n            \"title\": \"cím\",\n            \"trackNumber\": \"sáv\",\n            \"album\": \"album\",\n            \"albumArtist\": \"album előadó\",\n            \"owner\": \"tulajdonos\",\n            \"bitDepth\": \"$t(common.bitDepth)\",\n            \"sampleRate\": \"$t(common.sampleRate)\"\n        }\n    },\n    \"queryBuilder\": {\n        \"standardTags\": \"általános címkék\",\n        \"customTags\": \"egyedi címkék\"\n    },\n    \"filterOperator\": {\n        \"after\": \"után\",\n        \"afterDate\": \"(dátum) után\",\n        \"before\": \"előtt\",\n        \"beforeDate\": \"(dátum) előtt\",\n        \"contains\": \"tartalmaz\",\n        \"inPlaylist\": \"benne\",\n        \"endsWith\": \"végződik\",\n        \"inTheLast\": \"elmúlt\",\n        \"inTheRange\": \"tartományban\",\n        \"inTheRangeDate\": \"(dátum) tartományban\",\n        \"isGreaterThan\": \"nagyobb mint\",\n        \"isLessThan\": \"kisebb mint\",\n        \"notContains\": \"nem tartalmazza\",\n        \"notInPlaylist\": \"nincs benne\",\n        \"startsWith\": \"kezdődik\",\n        \"notInTheLast\": \"nem az elmúlt\",\n        \"matchesRegex\": \"illeszkedik a regexre\",\n        \"is\": \"van\",\n        \"isNot\": \"nincs\"\n    },\n    \"datetime\": {\n        \"minuteShort\": \"perc\",\n        \"secondShort\": \"mp\",\n        \"hourShort\": \"óra\",\n        \"dayShort\": \"nap\"\n    }\n}\n"
  },
  {
    "path": "src/i18n/locales/id.json",
    "content": "{\n    \"action\": {\n        \"createPlaylist\": \"buat $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"toggleSmartPlaylistEditor\": \"ubah editor $t(entity.smartPlaylist)\",\n        \"goToPage\": \"pergi ke halaman\",\n        \"moveToTop\": \"pindah ke atas\",\n        \"addToPlaylist\": \"tambahkan ke $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"removeFromFavorites\": \"hapus dari $t(entity.favorite, {\\\"count\\\": 2})\",\n        \"removeFromPlaylist\": \"hapus dari $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"deselectAll\": \"batalkan pilih semua\",\n        \"editPlaylist\": \"ubah $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"moveToNext\": \"pindah ke berikutnya\",\n        \"refresh\": \"$t(common.refresh)\",\n        \"removeFromQueue\": \"hapus dari antrean\",\n        \"setRating\": \"setel penilaian\",\n        \"viewPlaylists\": \"lihat $t(entity.playlist, {\\\"count\\\": 2})\",\n        \"openIn\": {\n            \"lastfm\": \"Buka di Last.fm\",\n            \"musicbrainz\": \"Buka di MusicBrainz\"\n        },\n        \"addToFavorites\": \"tambahkan ke $t(entity.favorite, {\\\"count\\\": 2})\",\n        \"clearQueue\": \"kosongkan antrian\",\n        \"deletePlaylist\": \"hapus $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"moveToBottom\": \"pindah ke bawah\",\n        \"addOrRemoveFromSelection\": \"tambahkan atau hapus dari pilihan\",\n        \"selectRangeOfItems\": \"pilih rentang item\",\n        \"createRadioStation\": \"buat $t(entity.radioStation, {\\\"count\\\": 1})\",\n        \"deleteRadioStation\": \"hapus $t(entity.radioStation, {\\\"count\\\": 1})\",\n        \"selectAll\": \"pilih semua\",\n        \"downloadStarted\": \"memulai unduhan {{count}} item\",\n        \"moveUp\": \"pindahkan ke atas\",\n        \"moveDown\": \"pindahkan ke bawah\",\n        \"holdToMoveToTop\": \"tahan untuk memindahkan ke paling atas\",\n        \"holdToMoveToBottom\": \"tahan untuk memindahkan ke paling bawah\",\n        \"moveItems\": \"pindahkan item\",\n        \"shuffle\": \"acak\",\n        \"shuffleAll\": \"acak semua\",\n        \"shuffleSelected\": \"acak yang dipilih\",\n        \"viewMore\": \"lihat lebih banyak\",\n        \"openApplicationDirectory\": \"buka direktori aplikasi\",\n        \"goToCurrent\": \"pergi ke item saat ini\"\n    },\n    \"common\": {\n        \"clear\": \"bersihkan\",\n        \"action_other\": \"aksi\",\n        \"codec\": \"Koded\",\n        \"channel_other\": \"Saluran\",\n        \"duration\": \"durasi\",\n        \"create\": \"buat\",\n        \"center\": \"tengah\",\n        \"areYouSure\": \"apakah Anda yakin?\",\n        \"add\": \"tambah\",\n        \"albumGain\": \"perolehan album\",\n        \"albumPeak\": \"Puncak album\",\n        \"cancel\": \"batal\",\n        \"close\": \"Tutup\",\n        \"configure\": \"konfigurasi\",\n        \"currentSong\": \"lagu saat ini $t(entity.track, {\\\"count\\\": 1})\",\n        \"delete\": \"hapus\",\n        \"description\": \"deskripsi\",\n        \"edit\": \"ubah\",\n        \"biography\": \"biografi\",\n        \"confirm\": \"konfirmasi\",\n        \"descending\": \"menurun\",\n        \"disable\": \"nonaktifkan\",\n        \"disc\": \"disk\",\n        \"enable\": \"aktifkan\",\n        \"expand\": \"perbesar\",\n        \"favorite\": \"favorit\",\n        \"filter_other\": \"filter\",\n        \"filters\": \"filter\",\n        \"forceRestartRequired\": \"perlu restart untuk menerapkan perubahan... tutup pemberitahuan untuk memulai ulang\",\n        \"forward\": \"maju\",\n        \"gap\": \"jarak\",\n        \"home\": \"beranda\",\n        \"increase\": \"tingkatkan\",\n        \"left\": \"kiri\",\n        \"limit\": \"batasi\",\n        \"manage\": \"kelola\",\n        \"maximize\": \"maksimalkan\",\n        \"menu\": \"menu\",\n        \"minimize\": \"minimalisasi\",\n        \"modified\": \"dimodifikasi\",\n        \"mbid\": \"ID MusicBrainz\",\n        \"name\": \"nama\",\n        \"no\": \"tidak\",\n        \"none\": \"tidak ada\",\n        \"noResultsFromQuery\": \"permintaan tidak menghasilkan hasil\",\n        \"note\": \"catatan\",\n        \"ok\": \"oke\",\n        \"owner\": \"pemilik\",\n        \"playerMustBePaused\": \"pemain harus dijeda\",\n        \"preview\": \"Pratinjau\",\n        \"previousSong\": \"lagu sebelumnya $t(entity.track, {\\\"count\\\": 1})\",\n        \"quit\": \"keluar\",\n        \"random\": \"acak\",\n        \"rating\": \"penilaian\",\n        \"refresh\": \"segarkan\",\n        \"reload\": \"Muat Ulang\",\n        \"reset\": \"reset\",\n        \"resetToDefault\": \"reset ke default\",\n        \"restartRequired\": \"restart diperlukan\",\n        \"right\": \"kanan\",\n        \"save\": \"simpan\",\n        \"saveAndReplace\": \"simpan dan ganti\",\n        \"saveAs\": \"simpan sebagai\",\n        \"search\": \"cari\",\n        \"setting_other\": \"pengaturan\",\n        \"share\": \"Bagikan\",\n        \"size\": \"ukuran\",\n        \"sortOrder\": \"urutkan\",\n        \"title\": \"judul\",\n        \"trackNumber\": \"pista\",\n        \"trackGain\": \"Gain pista\",\n        \"trackPeak\": \"puncak lagu\",\n        \"unknown\": \"tidak dikenal\",\n        \"version\": \"versi\",\n        \"year\": \"tahun\",\n        \"yes\": \"ya\",\n        \"path\": \"path(jalur)\",\n        \"ascending\": \"menaik\",\n        \"bpm\": \"bpm\",\n        \"bitrate\": \"kecepatan bit\",\n        \"collapse\": \"lipat\",\n        \"comingSoon\": \"segera hadir…\",\n        \"decrease\": \"kurangi\",\n        \"dismiss\": \"abaikan\",\n        \"translation\": \"terjemahan\",\n        \"backward\": \"mundur\",\n        \"countSelected\": \"{{count}} dipilih\",\n        \"explicitStatus\": \"status eksplisit\",\n        \"additionalParticipants\": \"peserta tambahan\",\n        \"newVersion\": \"versi baru telah diinstal ({{version}})\",\n        \"viewReleaseNotes\": \"lihat catatan rilis\",\n        \"bitDepth\": \"kedalaman bit\",\n        \"doNotShowAgain\": \"jangan tampilkan ini lagi\",\n        \"view\": \"tampilan\",\n        \"example\": \"contoh\",\n        \"externalLinks\": \"tautan eksternal\",\n        \"faster\": \"lebih cepat\",\n        \"filter_single\": \"tunggal\",\n        \"filter_multiple\": \"multi\",\n        \"mood\": \"suasana\",\n        \"noFilters\": \"tidak ada filter yang dikonfigurasi\",\n        \"private\": \"pribadi\",\n        \"public\": \"publik\",\n        \"retry\": \"coba lagi\",\n        \"recordLabel\": \"label rekaman\",\n        \"releaseType\": \"jenis rilis\",\n        \"rename\": \"ganti nama\",\n        \"sampleRate\": \"laju sampel\",\n        \"slower\": \"lebih lambat\",\n        \"sort\": \"urutkan\",\n        \"tags\": \"tag\",\n        \"explicit\": \"eksplisit\",\n        \"clean\": \"bersih\",\n        \"gridRows\": \"baris kisi\",\n        \"tableColumns\": \"kolom tabel\",\n        \"itemsMore\": \"{{count}} lagi\"\n    },\n    \"entity\": {\n        \"album_other\": \"album\",\n        \"albumArtist_other\": \"artis album\",\n        \"albumArtistCount_other\": \"{{count}} artis album\",\n        \"albumWithCount_other\": \"{{count}} album\",\n        \"artist_other\": \"artis\",\n        \"artistWithCount_other\": \"{{count}} artis\",\n        \"favorite_other\": \"favorit\",\n        \"folder_other\": \"folder\",\n        \"folderWithCount_other\": \"{{count}} folder\",\n        \"genre_other\": \"genre\",\n        \"genreWithCount_other\": \"{{count}} genre\",\n        \"playlist_other\": \"daftar putar\",\n        \"play_other\": \"Putar {{count}}\",\n        \"playlistWithCount_other\": \"{{count}} daftar putar\",\n        \"smartPlaylist\": \"$t(entity.playlist, {\\\"count\\\": 1}) pintar\",\n        \"track_other\": \"pista\",\n        \"song_other\": \"lagu\",\n        \"trackWithCount_other\": \"{{count}} pista\",\n        \"radioStation_other\": \"stasiun radio\",\n        \"radioStationWithCount_other\": \"{{count}} stasiun radio\"\n    },\n    \"error\": {\n        \"apiRouteError\": \"tidak dapat mengarahkan permintaan\",\n        \"audioDeviceFetchError\": \"terjadi kesalahan saat mencoba mengambil perangkat audio\",\n        \"authenticationFailed\": \"autentikasi gagal\",\n        \"badAlbum\": \"Anda melihat halaman ini karena lagu ini tidak termasuk dalam album. Masalah ini bisa terjadi jika Anda memiliki lagu di tingkat atas folder musik Anda. Jellyfin hanya mengelompokkan lagu jika mereka berada di dalam folder\",\n        \"credentialsRequired\": \"kredensial diperlukan\",\n        \"endpointNotImplementedError\": \"endpoint {{endpoint}} tidak diimplementasikan untuk {{serverType}}\",\n        \"genericError\": \"terjadi kesalahan\",\n        \"invalidServer\": \"server tidak valid\",\n        \"localFontAccessDenied\": \"akses ke font lokal ditolak\",\n        \"loginRateError\": \"terlalu banyak percobaan login, coba beberapa detik lagi\",\n        \"mpvRequired\": \"MPV diperlukan\",\n        \"networkError\": \"terjadi kesalahan jaringan\",\n        \"openError\": \"Tidak dapat membuka file\",\n        \"playbackError\": \"terjadi kesalahan saat mencoba memutar media\",\n        \"remoteDisableError\": \"terjadi kesalahan saat mencoba $t(common.disable) server jarak jauh\",\n        \"remoteEnableError\": \"terjadi kesalahan saat mencoba $t(common.enable) server jarak jauh\",\n        \"remotePortError\": \"terjadi kesalahan saat mencoba mengatur port server jarak jauh\",\n        \"remotePortWarning\": \"restart server untuk menerapkan port baru\",\n        \"serverNotSelectedError\": \"tidak ada server yang dipilih\",\n        \"serverRequired\": \"server diperlukan\",\n        \"sessionExpiredError\": \"sesi Anda telah kedaluwarsa\",\n        \"systemFontError\": \"terjadi kesalahan saat mencoba mendapatkan font sistem\",\n        \"badValue\": \"opsi tidak valid \\\"{{value}}\\\". nilai ini sudah tidak ada\",\n        \"multipleServerSaveQueueError\": \"antrean putar memiliki satu atau lebih lagu yang bukan dari server saat ini. ini tidak didukung\",\n        \"noNetwork\": \"server tidak tersedia\",\n        \"noNetworkDescription\": \"tidak dapat terhubung ke server ini\",\n        \"notificationDenied\": \"izin untuk notifikasi ditolak. pengaturan ini tidak berpengaruh\",\n        \"saveQueueFailed\": \"gagal menyimpan antrean\",\n        \"settingsSyncError\": \"ditemukan ketidaksesuaian antara pengaturan di perender dan proses utama. mulai ulang aplikasi untuk menerapkan perubahan\",\n        \"invalidJson\": \"JSON tidak valid\",\n        \"serverLockSingleServer\": \"hanya satu server yang diizinkan ketika server dikunci\"\n    },\n    \"filter\": {\n        \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n        \"albumArtist\": \"$t(entity.albumArtist, {\\\"count\\\": 1})\",\n        \"albumCount\": \"Hitung $t(entity.album, {\\\"count\\\": 2})\",\n        \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n        \"biography\": \"biografi\",\n        \"bitrate\": \"bitrate\",\n        \"bpm\": \"lpm\",\n        \"channels\": \"$t(common.channel, {\\\"count\\\": 2})\",\n        \"comment\": \"komentar\",\n        \"communityRating\": \"penilaian komunitas\",\n        \"criticRating\": \"penilaian kritik\",\n        \"dateAdded\": \"tanggal ditambahkan\",\n        \"disc\": \"disk\",\n        \"duration\": \"durasi\",\n        \"favorited\": \"favorit\",\n        \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n        \"id\": \"id\",\n        \"isCompilation\": \"apakah ini kompilasi\",\n        \"isFavorited\": \"apakah ini favorit\",\n        \"isPublic\": \"apakah ini publik\",\n        \"isRated\": \"apakah ini terklasifikasi\",\n        \"isRecentlyPlayed\": \"baru saja diputar\",\n        \"lastPlayed\": \"terakhir diputar\",\n        \"mostPlayed\": \"paling sering diputar\",\n        \"name\": \"nama\",\n        \"note\": \"catatan\",\n        \"owner\": \"$t(common.owner)\",\n        \"playCount\": \"jumlah putar\",\n        \"random\": \"acak\",\n        \"rating\": \"penilaian\",\n        \"recentlyAdded\": \"baru saja ditambahkan\",\n        \"recentlyPlayed\": \"baru saja diputar\",\n        \"recentlyUpdated\": \"baru saja diperbarui\",\n        \"releaseDate\": \"tanggal rilis\",\n        \"releaseYear\": \"tahun rilis\",\n        \"search\": \"cari\",\n        \"songCount\": \"jumlah lagu\",\n        \"toYear\": \"hingga tahun\",\n        \"trackNumber\": \"nomor pista\",\n        \"fromYear\": \"dari tahun\",\n        \"title\": \"judul\",\n        \"path\": \"path(jalur)\",\n        \"sortName\": \"nama pengurutan\",\n        \"explicitStatus\": \"$t(common.explicitStatus)\",\n        \"matchAnd\": \"dan\",\n        \"matchOr\": \"atau\"\n    },\n    \"form\": {\n        \"addServer\": {\n            \"error_savePassword\": \"terjadi kesalahan saat mencoba menyimpan kata sandi\",\n            \"ignoreCors\": \"abaikan cors ($t(common.restartRequired))\",\n            \"ignoreSsl\": \"abaikan ssl ($t(common.restartRequired))\",\n            \"input_legacyAuthentication\": \"izinkan autentikasi warisan\",\n            \"input_name\": \"nama server\",\n            \"input_password\": \"kata sandi\",\n            \"input_savePassword\": \"simpan kata sandi\",\n            \"input_url\": \"url\",\n            \"input_username\": \"nama pengguna\",\n            \"success\": \"server berhasil ditambahkan\",\n            \"title\": \"tambah server\",\n            \"input_preferInstantMix\": \"utamakan mix instan\",\n            \"input_preferInstantMixDescription\": \"hanya gunakan mix instan untuk mendapatkan lagu serupa. berguna jika Anda memiliki plugin yang memodifikasi perilaku ini\",\n            \"input_preferRemoteUrl\": \"utamakan URL publik\",\n            \"input_remoteUrl\": \"url publik\",\n            \"input_remoteUrlPlaceholder\": \"opsional: URL publik untuk fitur eksternal\"\n        },\n        \"addToPlaylist\": {\n            \"input_playlists\": \"$t(entity.playlist, {\\\"count\\\": 2})\",\n            \"input_skipDuplicates\": \"lewati duplikat\",\n            \"success\": \"ditambahkan $t(entity.trackWithCount, {\\\"count\\\": {{message}} }) ke $t(entity.playlistWithCount, {\\\"count\\\": {{numOfPlaylists}} })\",\n            \"title\": \"tambahkan ke $t(entity.playlist, {\\\"count\\\": 1})\",\n            \"create\": \"buat $t(entity.playlist, {\\\"count\\\": 1}) {{playlist}}\",\n            \"searchOrCreate\": \"cari $t(entity.playlist, {\\\"count\\\": 2}) atau ketik untuk membuat yang baru\"\n        },\n        \"createPlaylist\": {\n            \"input_description\": \"$t(common.description)\",\n            \"input_name\": \"$t(common.name)\",\n            \"input_owner\": \"$t(common.owner)\",\n            \"input_public\": \"publik\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) berhasil dibuat\",\n            \"title\": \"buat $t(entity.playlist, {\\\"count\\\": 1})\"\n        },\n        \"deletePlaylist\": {\n            \"input_confirm\": \"ketik nama $t(entity.playlist, {\\\"count\\\": 1}) untuk mengonfirmasi\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) berhasil dihapus\",\n            \"title\": \"hapus $t(entity.playlist, {\\\"count\\\": 1})\"\n        },\n        \"editPlaylist\": {\n            \"publicJellyfinNote\": \"Jellyfin entah bagaimana tidak menampilkan apakah playlist ini publik atau tidak. Jika Anda ingin playlist ini tetap publik, harap pilih entri berikut\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) berhasil diperbarui\",\n            \"title\": \"ubah $t(entity.playlist, {\\\"count\\\": 1})\",\n            \"editNote\": \"pengeditan manual tidak disarankan untuk playlist besar. apakah Anda yakin menerima risiko kehilangan data yang timbul akibat menimpa playlist yang ada?\"\n        },\n        \"lyricSearch\": {\n            \"input_artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n            \"input_name\": \"$t(common.name)\",\n            \"title\": \"cari lirik\"\n        },\n        \"queryEditor\": {\n            \"input_optionMatchAll\": \"cocokkan semua\",\n            \"input_optionMatchAny\": \"cocokkan salah satu\",\n            \"title\": \"editor kueri\",\n            \"addRuleGroup\": \"tambahkan grup aturan\",\n            \"removeRuleGroup\": \"hapus grup aturan\",\n            \"resetToDefault\": \"reset ke default\",\n            \"clearFilters\": \"hapus filter\"\n        },\n        \"shareItem\": {\n            \"allowDownloading\": \"Izinkan unduhan\",\n            \"description\": \"Deskripsi\",\n            \"setExpiration\": \"Atur masa berlaku\",\n            \"success\": \"Tautan berbagi berhasil disalin ke papan klip (atau klik di sini untuk membuka)\",\n            \"expireInvalid\": \"Masa berlaku harus di masa depan\",\n            \"createFailed\": \"Tidak dapat membuat sumber daya berbagi (Apakah berbagi diaktifkan?)\",\n            \"copyToClipboard\": \"Salin ke clipboard: Ctrl+C, Enter\",\n            \"successMustClick\": \"berbagi berhasil dibuat. klik di sini untuk membuka\"\n        },\n        \"updateServer\": {\n            \"success\": \"Server berhasil diperbarui\",\n            \"title\": \"perbarui server\"\n        },\n        \"largeFetchConfirmation\": {\n            \"title\": \"tambahkan item ke antrean\",\n            \"description\": \"Tindakan ini akan menambahkan semua item dalam tampilan terfilter saat ini\"\n        },\n        \"createRadioStation\": {\n            \"success\": \"stasiun radio berhasil dibuat\",\n            \"title\": \"buat stasiun radio\",\n            \"input_homepageUrl\": \"url beranda\",\n            \"input_name\": \"nama\",\n            \"input_streamUrl\": \"url streaming\"\n        },\n        \"lyricsExport\": {\n            \"export\": \"ekspor lirik\",\n            \"input_synced\": \"ekspor lirik tersinkron\",\n            \"input_offset\": \"$t(setting.lyricOffset)\"\n        },\n        \"saveQueue\": {\n            \"success\": \"antrean putar disimpan ke server\"\n        },\n        \"shuffleAll\": {\n            \"title\": \"putar acak\",\n            \"input_genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n            \"input_limit\": \"berapa banyak lagu?\",\n            \"input_minYear\": \"dari tahun\",\n            \"input_maxYear\": \"hingga tahun\",\n            \"input_played\": \"filter pemutaran\",\n            \"input_played_optionAll\": \"semua trek\",\n            \"input_played_optionUnplayed\": \"hanya trek yang belum diputar\",\n            \"input_played_optionPlayed\": \"hanya trek yang sudah diputar\"\n        },\n        \"privateMode\": {\n            \"enabled\": \"mode pribadi diaktifkan, status pemutaran kini disembunyikan dari integrasi eksternal\",\n            \"disabled\": \"mode pribadi dinonaktifkan, status pemutaran kini terlihat oleh integrasi eksternal yang diaktifkan\",\n            \"title\": \"mode pribadi\"\n        }\n    },\n    \"page\": {\n        \"albumArtistDetail\": {\n            \"about\": \"Tentang {{artist}}\",\n            \"recentReleases\": \"Rilis terbaru\",\n            \"viewDiscography\": \"Lihat diskografi\",\n            \"relatedArtists\": \"$t(entity.artist, {\\\"count\\\": 2}) serupa\",\n            \"topSongs\": \"Lagu terbaik\",\n            \"topSongsFrom\": \"Lagu terbaik dari {{title}}\",\n            \"viewAll\": \"Lihat semua\",\n            \"viewAllTracks\": \"Lihat semua $t(entity.track, {\\\"count\\\": 2})\",\n            \"appearsOn\": \"Tampil di\",\n            \"groupingTypeAll\": \"semua jenis rilis\",\n            \"groupingTypePrimary\": \"jenis rilis utama\",\n            \"favoriteSongs\": \"lagu favorit\",\n            \"topSongsCommunity\": \"komunitas\",\n            \"topSongsPersonal\": \"pribadi\",\n            \"favoriteSongsFrom\": \"lagu favorit dari {{title}}\"\n        },\n        \"albumArtistList\": {\n            \"title\": \"$t(entity.albumArtist, {\\\"count\\\": 2})\"\n        },\n        \"albumDetail\": {\n            \"moreFromArtist\": \"lebih banyak dari $t(entity.artist, {\\\"count\\\": 1}) ini\",\n            \"moreFromGeneric\": \"lebih banyak dari {{item}}\",\n            \"released\": \"dirilis\"\n        },\n        \"albumList\": {\n            \"artistAlbums\": \"album dari {{artist}}\",\n            \"genreAlbums\": \"\\\"{{genre}}\\\" $t(entity.album, {\\\"count\\\": 2})\",\n            \"title\": \"$t(entity.album, {\\\"count\\\": 2})\"\n        },\n        \"appMenu\": {\n            \"collapseSidebar\": \"perkecil sidebar\",\n            \"expandSidebar\": \"perluas sidebar\",\n            \"goBack\": \"kembali\",\n            \"goForward\": \"maju\",\n            \"manageServers\": \"kelola server\",\n            \"openBrowserDevtools\": \"buka alat pengembang browser\",\n            \"quit\": \"$t(common.quit)\",\n            \"selectServer\": \"pilih server\",\n            \"settings\": \"$t(common.setting, {\\\"count\\\": 2})\",\n            \"version\": \"versi {{version}}\",\n            \"commandPalette\": \"buka palet perintah\",\n            \"privateModeOff\": \"matikan mode pribadi\",\n            \"privateModeOn\": \"nyalakan mode pribadi\",\n            \"selectMusicFolder\": \"pilih folder musik\",\n            \"noMusicFolder\": \"tidak ada folder musik yang dipilih\",\n            \"multipleMusicFolders\": \"{{count}} folder musik dipilih\"\n        },\n        \"manageServers\": {\n            \"title\": \"kelola server\",\n            \"serverDetails\": \"detail server\",\n            \"url\": \"URL\",\n            \"username\": \"nama pengguna\",\n            \"editServerDetailsTooltip\": \"edit detail server\",\n            \"removeServer\": \"hapus server\"\n        },\n        \"contextMenu\": {\n            \"addFavorite\": \"$t(action.addToFavorites)\",\n            \"addLast\": \"$t(player.addLast)\",\n            \"addNext\": \"$t(player.addNext)\",\n            \"addToFavorites\": \"$t(action.addToFavorites)\",\n            \"addToPlaylist\": \"$t(action.addToPlaylist)\",\n            \"createPlaylist\": \"$t(action.createPlaylist)\",\n            \"deletePlaylist\": \"$t(action.deletePlaylist)\",\n            \"deselectAll\": \"$t(action.deselectAll)\",\n            \"download\": \"unduh\",\n            \"moveToNext\": \"$t(action.moveToNext)\",\n            \"moveToBottom\": \"$t(action.moveToBottom)\",\n            \"numberSelected\": \"{{count}} terpilih\",\n            \"playSimilarSongs\": \"$t(player.playSimilarSongs)\",\n            \"removeFromFavorites\": \"$t(action.removeFromFavorites)\",\n            \"removeFromPlaylist\": \"$t(action.removeFromPlaylist)\",\n            \"removeFromQueue\": \"$t(action.removeFromQueue)\",\n            \"setRating\": \"$t(action.setRating)\",\n            \"playShuffled\": \"$t(player.shuffle)\",\n            \"shareItem\": \"Bagikan item\",\n            \"showDetails\": \"Lihat detail\",\n            \"moveToTop\": \"$t(action.moveToTop)\",\n            \"play\": \"$t(player.play)\",\n            \"moveItems\": \"$t(action.moveItems)\",\n            \"goTo\": \"buka ke\",\n            \"goToAlbum\": \"buka ke $t(entity.album, {\\\"count\\\": 1})\",\n            \"goToAlbumArtist\": \"buka ke $t(entity.albumArtist, {\\\"count\\\": 1})\"\n        },\n        \"fullscreenPlayer\": {\n            \"config\": {\n                \"dynamicBackground\": \"latar belakang dinamis\",\n                \"dynamicImageBlur\": \"ukuran blur gambar\",\n                \"dynamicIsImage\": \"aktifkan gambar latar belakang\",\n                \"followCurrentLyric\": \"ikuti lirik saat ini\",\n                \"lyricAlignment\": \"penyelarasan lirik\",\n                \"lyricSize\": \"ukuran lirik\",\n                \"opacity\": \"opasitas\",\n                \"showLyricMatch\": \"tampilkan kecocokan lirik\",\n                \"showLyricProvider\": \"tampilkan penyedia lirik\",\n                \"synchronized\": \"sinkronisasi\",\n                \"unsynchronized\": \"tidak sinkronisasi\",\n                \"useImageAspectRatio\": \"gunakan rasio aspek gambar\",\n                \"lyricOffset\": \"offset lirik (ms)\",\n                \"lyricGap\": \"jarak lirik\"\n            },\n            \"lyrics\": \"lirik\",\n            \"related\": \"terkait\",\n            \"upNext\": \"berikutnya\",\n            \"noLyrics\": \"tanpa lirik\",\n            \"visualizer\": \"visualisasi\"\n        },\n        \"genreList\": {\n            \"showAlbums\": \"Tampilkan $t(entity.genre, {\\\"count\\\": 1}) $t(entity.album, {\\\"count\\\": 2})\",\n            \"showTracks\": \"Tampilkan $t(entity.genre, {\\\"count\\\": 1}) $t(entity.track, {\\\"count\\\": 2})\",\n            \"title\": \"$t(entity.genre, {\\\"count\\\": 2})\"\n        },\n        \"globalSearch\": {\n            \"commands\": {\n                \"goToPage\": \"pergi ke halaman\",\n                \"searchFor\": \"cari {{query}}\",\n                \"serverCommands\": \"perintah server\"\n            },\n            \"title\": \"perintah\"\n        },\n        \"home\": {\n            \"explore\": \"jelajahi dari pustaka Anda\",\n            \"mostPlayed\": \"paling banyak diputar\",\n            \"newlyAdded\": \"rilis baru ditambahkan\",\n            \"recentlyPlayed\": \"baru saja diputar\",\n            \"title\": \"$t(common.home)\",\n            \"genres\": \"$t(entity.genre, {\\\"count\\\": 2})\",\n            \"recentlyReleased\": \"baru dirilis\"\n        },\n        \"itemDetail\": {\n            \"copyPath\": \"Salin jalur ke papan klip\",\n            \"copiedPath\": \"Jalur berhasil disalin\",\n            \"openFile\": \"Tampilkan lagu di pengelola file\"\n        },\n        \"playlist\": {\n            \"reorder\": \"pengurutan ulang hanya diaktifkan saat mengurutkan berdasarkan id\"\n        },\n        \"playlistList\": {\n            \"title\": \"$t(entity.playlist, {\\\"count\\\": 2})\"\n        },\n        \"setting\": {\n            \"advanced\": \"Lanjutan\",\n            \"generalTab\": \"umum\",\n            \"hotkeysTab\": \"tombol pintasan\",\n            \"playbackTab\": \"pemutaran\",\n            \"windowTab\": \"jendela\",\n            \"analytics\": \"analitik\",\n            \"updates\": \"perbarui\",\n            \"cache\": \"cache\",\n            \"application\": \"aplikasi\",\n            \"queryBuilder\": \"pembuat kueri\",\n            \"theme\": \"tema\",\n            \"controls\": \"kontrol\",\n            \"sidebar\": \"bilah samping\",\n            \"remote\": \"jarak jauh\",\n            \"exportImport\": \"impor/ekspor\",\n            \"scrobble\": \"scrobble\",\n            \"audio\": \"audio\",\n            \"lyrics\": \"lirik\",\n            \"lyricsDisplay\": \"tampilan lirik\",\n            \"transcoding\": \"transcoding\",\n            \"discord\": \"discord\",\n            \"logger\": \"logger\",\n            \"playerFilters\": \"filter pemutar\"\n        },\n        \"sidebar\": {\n            \"albumArtists\": \"$t(entity.albumArtist, {\\\"count\\\": 2})\",\n            \"albums\": \"$t(entity.album, {\\\"count\\\": 2})\",\n            \"artists\": \"$t(entity.artist, {\\\"count\\\": 2})\",\n            \"folders\": \"$t(entity.folder, {\\\"count\\\": 2})\",\n            \"genres\": \"$t(entity.genre, {\\\"count\\\": 2})\",\n            \"home\": \"$t(common.home)\",\n            \"nowPlaying\": \"sedang diputar\",\n            \"playlists\": \"$t(entity.playlist, {\\\"count\\\": 2})\",\n            \"search\": \"$t(common.search)\",\n            \"settings\": \"$t(common.setting, {\\\"count\\\": 2})\",\n            \"shared\": \"berbagi $t(entity.playlist, {\\\"count\\\": 2})\",\n            \"tracks\": \"$t(entity.track, {\\\"count\\\": 2})\",\n            \"collections\": \"koleksi\",\n            \"favorites\": \"$t(entity.favorite, {\\\"count\\\": 2})\",\n            \"radio\": \"$t(entity.radioStation, {\\\"count\\\": 2})\",\n            \"myLibrary\": \"pustaka saya\"\n        },\n        \"trackList\": {\n            \"artistTracks\": \"lagu oleh {{artist}}\",\n            \"genreTracks\": \"\\\"{{genre}}\\\" $t(entity.track, {\\\"count\\\": 2})\",\n            \"title\": \"$t(entity.track, {\\\"count\\\": 2})\"\n        },\n        \"radioList\": {\n            \"title\": \"stasiun radio\"\n        },\n        \"favorites\": {\n            \"title\": \"$t(entity.favorite, {\\\"count\\\": 2})\"\n        },\n        \"windowBar\": {\n            \"paused\": \"(Dijeda) \",\n            \"privateMode\": \"(Mode pribadi)\"\n        },\n        \"folderList\": {\n            \"title\": \"$t(entity.folder, {\\\"count\\\": 2})\"\n        },\n        \"collections\": {\n            \"overrideExisting\": \"timpa yang ada\",\n            \"saveAsCollection\": \"simpan sebagai koleksi\"\n        },\n        \"releasenotes\": {\n            \"commitsSinceStable\": \"commit sejak {{stable}}\",\n            \"noNewCommits\": \"tidak ada commit baru dalam rentang ini\",\n            \"noStableReleaseToCompare\": \"tidak ada rilis stabil yang tersedia untuk dibandingkan\"\n        }\n    },\n    \"player\": {\n        \"addLast\": \"sebelumnya\",\n        \"favorite\": \"favorit\",\n        \"mute\": \"bisukan\",\n        \"muted\": \"terbisukan\",\n        \"next\": \"berikutnya\",\n        \"play\": \"putar\",\n        \"playbackFetchCancel\": \"ini memerlukan waktu... tutup pemberitahuan untuk membatalkan\",\n        \"playbackFetchInProgress\": \"memuat lagu…\",\n        \"playbackFetchNoResults\": \"tidak ada lagu ditemukan\",\n        \"playbackSpeed\": \"kecepatan pemutaran\",\n        \"playRandom\": \"putar acak\",\n        \"playSimilarSongs\": \"putar lagu serupa\",\n        \"previous\": \"sebelumnya\",\n        \"queue_clear\": \"bersihkan antrean\",\n        \"queue_moveToBottom\": \"pindahkan yang terpilih ke bawah\",\n        \"queue_moveToTop\": \"pindahkan yang terpilih ke atas\",\n        \"queue_remove\": \"hapus yang terpilih\",\n        \"repeat\": \"ulang\",\n        \"repeat_all\": \"ulang semua\",\n        \"repeat_off\": \"ulang dimatikan\",\n        \"shuffle\": \"putar (diacak)\",\n        \"shuffle_off\": \"acak dimatikan\",\n        \"skip\": \"lewati\",\n        \"skip_back\": \"mundur\",\n        \"skip_forward\": \"lewati maju\",\n        \"stop\": \"berhenti\",\n        \"toggleFullscreenPlayer\": \"aktifkan pemutar layar penuh\",\n        \"unfavorite\": \"bukan favorit\",\n        \"pause\": \"jeda\",\n        \"viewQueue\": \"lihat antrean\",\n        \"addNext\": \"berikutnya\",\n        \"addLastShuffled\": \"sebelumnya (diacak)\",\n        \"addNextShuffled\": \"berikutnya (diacak)\",\n        \"artistRadio\": \"radio artis\",\n        \"holdToShuffle\": \"tahan untuk mengacak\",\n        \"lyrics\": \"lirik\",\n        \"restoreQueueFromServer\": \"pulihkan antrean dari server\",\n        \"saveQueueToServer\": \"simpan antrean ke server\",\n        \"trackRadio\": \"radio trek\",\n        \"albumRadio\": \"radio album\",\n        \"sleepTimer\": \"pengatur waktu tidur\",\n        \"sleepTimer_endOfSong\": \"akhir lagu saat ini\",\n        \"sleepTimer_minutes\": \"{{count}} menit\",\n        \"sleepTimer_hours\": \"{{count}} jam\",\n        \"sleepTimer_custom\": \"kustom\",\n        \"sleepTimer_off\": \"mati\",\n        \"sleepTimer_timeRemaining\": \"{{time}} tersisa\",\n        \"sleepTimer_setCustom\": \"atur pengatur waktu\",\n        \"sleepTimer_cancel\": \"batalkan pengatur waktu\"\n    },\n    \"setting\": {\n        \"accentColor\": \"warna sorotan\",\n        \"accentColor_description\": \"menetapkan warna sorotan aplikasi\",\n        \"albumBackground\": \"gambar latar belakang album\",\n        \"albumBackground_description\": \"Tambahkan gambar latar belakang ke halaman album yang berisi sampul album\",\n        \"albumBackgroundBlur\": \"Ukuran blur gambar latar belakang album\",\n        \"albumBackgroundBlur_description\": \"Atur tingkat blur gambar latar belakang album\",\n        \"applicationHotkeys\": \"tombol pintasan aplikasi\",\n        \"applicationHotkeys_description\": \"menetapkan tombol pintasan aplikasi. centang untuk menjadikannya tombol pintasan global (desktop saja)\",\n        \"artistConfiguration\": \"Pengaturan halaman artis album\",\n        \"artistConfiguration_description\": \"Atur elemen apa yang ditampilkan dan urutannya di halaman artis album\",\n        \"audioDevice\": \"perangkat audio\",\n        \"audioDevice_description\": \"pilih perangkat audio yang digunakan untuk pemutaran\",\n        \"audioExclusiveMode\": \"mode audio eksklusif\",\n        \"audioExclusiveMode_description\": \"aktifkan mode audio eksklusif. Dalam mode ini, sistem biasanya diblokir, dan hanya mpv yang akan diizinkan untuk output audio\",\n        \"audioPlayer\": \"pemutar audio\",\n        \"audioPlayer_description\": \"pilih pemutar audio yang digunakan untuk pemutaran\",\n        \"buttonSize\": \"ukuran tombol bilah pemutaran\",\n        \"buttonSize_description\": \"ukuran tombol pada bilah pemutaran\",\n        \"webAudio_description\": \"Menggunakan audio web. Ini mengaktifkan fitur lanjutan seperti Replaygain. Nonaktifkan opsi ini jika Anda mengalami masalah\",\n        \"windowBarStyle\": \"gaya bilah jendela\",\n        \"windowBarStyle_description\": \"pilih gaya bilah jendela\",\n        \"zoom\": \"persentase zoom\",\n        \"zoom_description\": \"tentukan persentase zoom aplikasi\",\n        \"clearCache_description\": \"'Pembersihan keras' Feishin. Untuk membersihkan cache Feishin, kosongkan cache browser (gambar yang disimpan dan elemen lainnya). Kredensial dan pengaturan server tetap terjaga\",\n        \"clearQueryCache\": \"Bersihkan cache Feishin\",\n        \"clearQueryCache_description\": \"'Pembersihan lunak' Feishin. Ini akan menyegarkan daftar putar, metadata lagu, dan mengatur ulang lirik yang disimpan. Pengaturan, kredensial server, dan gambar cache tetap terjaga\",\n        \"clearCacheSuccess\": \"Cache berhasil dibersihkan\",\n        \"contextMenu\": \"Pengaturan menu konteks (klik kanan)\",\n        \"contextMenu_description\": \"Memungkinkan Anda menyembunyikan elemen yang ditampilkan dalam menu saat Anda klik kanan pada elemen. Elemen yang tidak dipilih akan disembunyikan\",\n        \"crossfadeDuration\": \"durasi crossfade\",\n        \"crossfadeDuration_description\": \"atur durasi efek crossfade\",\n        \"crossfadeStyle_description\": \"pilih gaya crossfade yang digunakan oleh pemutar audio\",\n        \"customCssEnable\": \"aktifkan CSS kustom\",\n        \"customCssEnable_description\": \"izinkan penulisan CSS kustom\",\n        \"customCssNotice\": \"Peringatan: meskipun ada beberapa sanitasi (melarang url() dan content:), menggunakan CSS kustom tetap dapat menimbulkan risiko dengan mengubah antarmuka\",\n        \"customCss\": \"css kustom\",\n        \"customCss_description\": \"konten CSS kustom. Catatan: properti content dan URL jarak jauh tidak diizinkan. Pratinjau konten Anda ditampilkan di bawah. Kolom tambahan yang tidak Anda atur ada karena sanitasi\",\n        \"customFontPath\": \"jalur font kustom\",\n        \"customFontPath_description\": \"tentukan jalur font kustom yang akan digunakan aplikasi\",\n        \"discordApplicationId\": \"ID aplikasi {{discord}}\",\n        \"discordApplicationId_description\": \"id aplikasi untuk rich presence {{discord}} (default: {{defaultId}})\",\n        \"discordIdleStatus\": \"tampilkan status tidak aktif dalam status aktivitas\",\n        \"discordIdleStatus_description\": \"ketika diaktifkan, memperbarui status saat pemutar tidak aktif\",\n        \"discordListening\": \"Tampilkan status sebagai mendengarkan\",\n        \"discordListening_description\": \"tampilkan status sebagai mendengarkan alih-alih bermain\",\n        \"discordRichPresence_description\": \"aktifkan status pemutaran di status aktivitas {{discord}}. Gambar tombol adalah: {{icon}}, {{playing}}, dan {{paused}}\",\n        \"discordUpdateInterval\": \"interval pembaruan status aktivitas {{discord}}\",\n        \"discordUpdateInterval_description\": \"waktu dalam detik antara setiap pembaruan (minimal 15 detik)\",\n        \"enableRemote\": \"aktifkan kontrol jarak jauh server\",\n        \"enableRemote_description\": \"aktifkan kontrol jarak jauh server untuk memungkinkan perangkat lain mengontrol aplikasi\",\n        \"externalLinks\": \"Tampilkan tautan eksternal\",\n        \"externalLinks_description\": \"Izinkan untuk menampilkan tautan eksternal (Last.fm, MusicBrainz) di halaman artis/album\",\n        \"exitToTray\": \"keluar ke baki\",\n        \"exitToTray_description\": \"keluar dari aplikasi ke baki sistem\",\n        \"followLyric\": \"ikuti lirik saat ini\",\n        \"followLyric_description\": \"gulir lirik ke posisi pemutaran saat ini\",\n        \"font\": \"font\",\n        \"font_description\": \"tentukan font yang digunakan aplikasi\",\n        \"fontType\": \"jenis font\",\n        \"fontType_description\": \"font bawaan memilih salah satu font yang disediakan oleh feishin. font sistem memungkinkan Anda memilih font apa pun yang disediakan oleh sistem operasi Anda. kustom memungkinkan Anda memberikan font Anda sendiri\",\n        \"fontType_optionBuiltIn\": \"font bawaan\",\n        \"fontType_optionCustom\": \"font kustom\",\n        \"fontType_optionSystem\": \"font sistem\",\n        \"gaplessAudio\": \"audio tanpa jeda\",\n        \"gaplessAudio_description\": \"tentukan pengaturan audio tanpa jeda untuk mpv\",\n        \"gaplessAudio_optionWeak\": \"lemah (disarankan)\",\n        \"globalMediaHotkeys\": \"tombol pintasan media global\",\n        \"globalMediaHotkeys_description\": \"aktifkan atau nonaktifkan penggunaan tombol pintasan sistem media untuk mengontrol pemutaran\",\n        \"homeConfiguration\": \"Pengaturan halaman beranda\",\n        \"homeConfiguration_description\": \"Mengatur elemen mana yang ditampilkan dan urutannya di halaman beranda\",\n        \"homeFeature\": \"Karusel fitur beranda\",\n        \"homeFeature_description\": \"Mengontrol apakah karusel besar fitur ditampilkan di halaman beranda\",\n        \"hotkey_browserBack\": \"mundur\",\n        \"hotkey_browserForward\": \"maju\",\n        \"hotkey_favoriteCurrentSong\": \"$t(common.currentSong) favorit\",\n        \"hotkey_favoritePreviousSong\": \"$t(common.previousSong) favorit\",\n        \"hotkey_globalSearch\": \"pencarian global\",\n        \"hotkey_localSearch\": \"pencarian di halaman\",\n        \"hotkey_playbackNext\": \"lagu berikutnya\",\n        \"hotkey_playbackPause\": \"jeda\",\n        \"hotkey_playbackPlay\": \"putar\",\n        \"hotkey_playbackPlayPause\": \"putar / jeda\",\n        \"hotkey_playbackPrevious\": \"lagu sebelumnya\",\n        \"hotkey_playbackStop\": \"berhenti\",\n        \"hotkey_rate0\": \"Bersihkan penilaian\",\n        \"hotkey_rate1\": \"beri penilaian 1 bintang\",\n        \"hotkey_rate2\": \"beri penilaian 2 bintang\",\n        \"hotkey_rate3\": \"beri penilaian 3 bintang\",\n        \"hotkey_rate4\": \"beri penilaian 4 bintang\",\n        \"hotkey_rate5\": \"beri penilaian 5 bintang\",\n        \"hotkey_skipBackward\": \"mundur\",\n        \"hotkey_skipForward\": \"lompat ke depan\",\n        \"hotkey_toggleCurrentSongFavorite\": \"ubah $t(common.currentSong) menjadi favorit\",\n        \"hotkey_toggleFullScreenPlayer\": \"ubah pemutar menjadi layar penuh\",\n        \"hotkey_togglePreviousSongFavorite\": \"ubah $t(common.previousSong) menjadi favorit\",\n        \"hotkey_toggleQueue\": \"ubah antrean\",\n        \"hotkey_toggleRepeat\": \"toggle ulangi\",\n        \"hotkey_toggleShuffle\": \"toggle acak\",\n        \"hotkey_unfavoriteCurrentSong\": \"$t(common.currentSong) tidak favorit\",\n        \"hotkey_unfavoritePreviousSong\": \"$t(common.previousSong) tidak favorit\",\n        \"hotkey_volumeDown\": \"turunkan volume\",\n        \"hotkey_volumeMute\": \"senyapkan volume\",\n        \"hotkey_volumeUp\": \"naikkan volume\",\n        \"hotkey_zoomIn\": \"perbesar\",\n        \"hotkey_zoomOut\": \"perkecil\",\n        \"imageAspectRatio\": \"Gunakan rasio aspek sampul asli\",\n        \"imageAspectRatio_description\": \"Jika diaktifkan, sampul akan ditampilkan dengan rasio aspek aslinya. Untuk seni yang tidak 1:1, ruang yang tersisa akan kosong\",\n        \"language_description\": \"menetapkan bahasa untuk aplikasi ($t(common.restartRequired))\",\n        \"lastfmApiKey\": \"Kunci API untuk {{lastfm}}\",\n        \"lastfmApiKey_description\": \"kunci API untuk {{lastfm}}. Diperlukan untuk sampul\",\n        \"lyricFetch\": \"cari lirik di Internet\",\n        \"lyricFetch_description\": \"mencari lirik dari berbagai sumber di Internet\",\n        \"lyricFetchProvider\": \"penyedia untuk mencari lirik\",\n        \"lyricFetchProvider_description\": \"pilih penyedia untuk mengambil lirik dari\",\n        \"lyricOffset\": \"geser lirik (ms)\",\n        \"lyricOffset_description\": \"geser lirik sebanyak jumlah milidetik yang ditentukan\",\n        \"minimizeToTray\": \"minimalkan ke baki\",\n        \"minimizeToTray_description\": \"minimalkan aplikasi ke baki sistem\",\n        \"minimumScrobblePercentage\": \"persentase durasi scrobble minimum\",\n        \"minimumScrobblePercentage_description\": \"persentase minimum lagu yang harus diputar sebelum melakukan scrobble\",\n        \"minimumScrobbleSeconds\": \"scrobble minimum (detik)\",\n        \"minimumScrobbleSeconds_description\": \"durasi minimum dalam detik dari lagu yang harus diputar sebelum melakukan scrobble\",\n        \"mpvExecutablePath_description\": \"tentukan jalur executable mpv. jika dibiarkan kosong, jalur default akan digunakan\",\n        \"mpvExtraParameters_help\": \"Satu per baris\",\n        \"passwordStore\": \"kata sandi/penyimpanan rahasia\",\n        \"passwordStore_description\": \"metode penyimpanan kata sandi/kunci rahasia yang akan digunakan. ubah opsi ini jika Anda mengalami masalah dalam menyimpan kata sandi\",\n        \"playbackStyle\": \"gaya pemutaran\",\n        \"playbackStyle_description\": \"pilih gaya pemutaran yang akan digunakan oleh pemutar audio\",\n        \"playbackStyle_optionCrossFade\": \"crossfade\",\n        \"playbackStyle_optionNormal\": \"normal\",\n        \"playButtonBehavior\": \"perilaku tombol putar\",\n        \"playButtonBehavior_description\": \"tentukan perilaku default tombol putar saat lagu ditambahkan ke antrean\",\n        \"playButtonBehavior_optionAddLast\": \"$t(player.addLast)\",\n        \"playButtonBehavior_optionAddNext\": \"$t(player.addNext)\",\n        \"playButtonBehavior_optionPlayShuffled\": \"$t(player.shuffle)\",\n        \"playerbarOpenDrawer\": \"Buka pemutar ke layar penuh\",\n        \"playerbarOpenDrawer_description\": \"Izinkan mengklik bilah pemutar untuk membuka pemutar di layar penuh\",\n        \"remotePassword\": \"kata sandi kontrol jarak jauh server\",\n        \"remotePassword_description\": \"tentukan kata sandi untuk kontrol jarak jauh server. Kredensial ini dikirimkan dengan tidak aman secara default, jadi Anda harus menggunakan kata sandi unik untuk menghindari masalah\",\n        \"remotePort\": \"port kontrol jarak jauh server\",\n        \"remotePort_description\": \"tentukan port untuk kontrol jarak jauh server\",\n        \"remoteUsername\": \"nama pengguna kontrol jarak jauh server\",\n        \"remoteUsername_description\": \"tentukan nama pengguna untuk kontrol jarak jauh server. jika nama pengguna dan kata sandi kosong, otentikasi akan dinonaktifkan\",\n        \"replayGainClipping\": \"potong {{ReplayGain}}\",\n        \"replayGainClipping_description\": \"mencegah pemotongan yang disebabkan oleh {{ReplayGain}} dengan menurunkan gain secara otomatis\",\n        \"replayGainFallback\": \"alternatif {{ReplayGain}}\",\n        \"replayGainFallback_description\": \"gain dalam dB yang akan diterapkan jika file tidak memiliki tag {{ReplayGain}}\",\n        \"replayGainMode\": \"mode {{ReplayGain}}\",\n        \"replayGainMode_description\": \"menyesuaikan volume gain sesuai dengan nilai {{ReplayGain}} yang disimpan dalam metadata file\",\n        \"replayGainMode_optionAlbum\": \"$t(entity.album, {\\\"count\\\": 1})\",\n        \"replayGainMode_optionNone\": \"$t(common.none)\",\n        \"replayGainMode_optionTrack\": \"$t(entity.track, {\\\"count\\\": 1})\",\n        \"replayGainPreamp\": \"preamplifier (dB) {{ReplayGain}}\",\n        \"replayGainPreamp_description\": \"menyesuaikan gain preamplifier yang diterapkan ke nilai {{ReplayGain}}\",\n        \"sampleRate_description\": \"pilih rasio sampel output yang akan digunakan jika frekuensi sampel yang dipilih berbeda dari media yang sedang diputar. nilai di bawah 8000 akan menggunakan frekuensi default\",\n        \"savePlayQueue_description\": \"menyimpan antrean pemutaran saat aplikasi ditutup dan mengembalikannya saat dibuka\",\n        \"scrobble\": \"scrobble\",\n        \"scrobble_description\": \"melakukan scrobble pemutaran di server media Anda\",\n        \"showSkipButton\": \"tampilkan tombol lompat\",\n        \"showSkipButton_description\": \"menampilkan atau menyembunyikan tombol lompat di bilah pemutar\",\n        \"showSkipButtons\": \"tampilkan tombol lompat\",\n        \"showSkipButtons_description\": \"menampilkan atau menyembunyikan tombol lompat di bilah pemutar\",\n        \"sidebarCollapsedNavigation\": \"navigasi sidebar (terlipat)\",\n        \"sidebarCollapsedNavigation_description\": \"tampilkan atau sembunyikan navigasi di sidebar yang terlipat\",\n        \"sidebarConfiguration\": \"pengaturan sidebar\",\n        \"sidebarConfiguration_description\": \"pilih elemen dan urutan tampilannya di sidebar\",\n        \"sidebarPlaylistList\": \"daftar putar sidebar\",\n        \"sidebarPlaylistList_description\": \"tampilkan atau sembunyikan daftar putar di sidebar\",\n        \"sidePlayQueueStyle\": \"gaya antrean pemutaran samping\",\n        \"sidePlayQueueStyle_description\": \"menetapkan gaya antrean pemutaran samping\",\n        \"sidePlayQueueStyle_optionAttached\": \"terpasang\",\n        \"sidePlayQueueStyle_optionDetached\": \"terpisah\",\n        \"skipDuration\": \"durasi lompat\",\n        \"skipDuration_description\": \"tentukan durasi untuk lompat saat menggunakan tombol lompat di bilah pemutar\",\n        \"skipPlaylistPage\": \"lompat halaman daftar putar\",\n        \"skipPlaylistPage_description\": \"saat menavigasi ke daftar putar, pergi ke halaman daftar lagu dari daftar putar alih-alih halaman default\",\n        \"startMinimized\": \"mulai dengan minimalkan\",\n        \"startMinimized_description\": \"mulai aplikasi di baki sistem\",\n        \"theme\": \"tema\",\n        \"theme_description\": \"tentukan tema yang digunakan oleh aplikasi\",\n        \"themeDark\": \"tema (gelap)\",\n        \"themeDark_description\": \"tentukan tema gelap yang digunakan oleh aplikasi\",\n        \"themeLight\": \"tema (terang)\",\n        \"themeLight_description\": \"tentukan tema terang yang digunakan oleh aplikasi\",\n        \"transcode_description\": \"mengaktifkan transkode ke berbagai format\",\n        \"transcodeBitrate\": \"bitrate untuk transkode\",\n        \"transcodeBitrate_description\": \"pilih bitrate untuk ditranskode. 0 berarti biarkan server yang memilih\",\n        \"transcodeFormat\": \"format untuk ditranskode\",\n        \"transcodeFormat_description\": \"pilih format untuk ditranskode. biarkan kosong agar server yang memutuskan\",\n        \"translationApiProvider\": \"penyedia API terjemahan\",\n        \"translationApiProvider_description\": \"penyedia API untuk terjemahan\",\n        \"translationApiKey\": \"kunci API terjemahan\",\n        \"translationApiKey_description\": \"kunci API untuk terjemahan (hanya endpoint layanan global)\",\n        \"translationTargetLanguage\": \"bahasa tujuan penerjemahan\",\n        \"translationTargetLanguage_description\": \"bahasa tujuan untuk penerjemahan\",\n        \"trayEnabled\": \"Tampilkan di area pemberitahuan\",\n        \"trayEnabled_description\": \"tampilkan/sembunyikan ikon/menu di area pemberitahuan. Jika dinonaktifkan, juga menonaktifkan meminimalkan/keluar ke baki\",\n        \"useSystemTheme\": \"gunakan tema sistem\",\n        \"useSystemTheme_description\": \"ikuti preferensi terang atau gelap yang ditetapkan oleh sistem\",\n        \"volumeWheelStep\": \"langkah roda volume\",\n        \"volumeWheelStep_description\": \"jumlah volume yang berubah saat menggulirkan roda mouse pada penggeser volume\",\n        \"volumeWidth\": \"Lebar penggeser volume\",\n        \"volumeWidth_description\": \"Lebar penggeser volume\",\n        \"webAudio\": \"gunakan audio web\",\n        \"clearCache\": \"Bersihkan cache browser\",\n        \"disableLibraryUpdateOnStartup\": \"nonaktifkan pemeriksaan versi baru saat startup\",\n        \"mpvExecutablePath\": \"jalur executable mpv\",\n        \"playButtonBehavior_optionPlay\": \"$t(player.play)\",\n        \"sampleRate\": \"rasio sampel\",\n        \"savePlayQueue\": \"simpan antrean pemutaran\",\n        \"autoDJ\": \"DJ otomatis\",\n        \"autoDJ_description\": \"tambahkan lagu serupa secara otomatis ke antrean\",\n        \"autoDJ_itemCount\": \"jumlah item\",\n        \"autoDJ_itemCount_description\": \"jumlah item yang dicoba ditambahkan ke antrean saat DJ otomatis diaktifkan\",\n        \"autoDJ_timing\": \"waktu\",\n        \"autoDJ_timing_description\": \"jumlah lagu yang tersisa dalam antrean sebelum DJ otomatis dipicu\",\n        \"useThemeAccentColor\": \"gunakan warna aksen tema\",\n        \"useThemeAccentColor_description\": \"gunakan warna utama yang ditentukan dalam tema yang dipilih alih-alih warna aksen kustom\",\n        \"analyticsDisable\": \"nonaktifkan analitik berbasis penggunaan\",\n        \"analyticsDisable_description\": \"data penggunaan yang dianonimkan dikirim ke pengembang untuk membantu meningkatkan aplikasi\",\n        \"artistBackground\": \"gambar latar belakang artis\",\n        \"artistBackground_description\": \"menambahkan gambar latar belakang untuk halaman artis yang memuat artwork artis\",\n        \"artistBackgroundBlur\": \"ukuran blur gambar latar belakang artis\",\n        \"artistBackgroundBlur_description\": \"menyesuaikan tingkat blur yang diterapkan pada gambar latar belakang artis\",\n        \"artistReleaseTypeConfiguration\": \"konfigurasi jenis rilis artis\",\n        \"artistReleaseTypeConfiguration_description\": \"konfigurasikan jenis rilis yang ditampilkan, dan urutannya, di halaman artis album\",\n        \"crossfadeStyle\": \"gaya crossfade\",\n        \"releaseChannel_optionBeta\": \"beta\",\n        \"releaseChannel_optionLatest\": \"terbaru\",\n        \"releaseChannel\": \"kanal rilis\",\n        \"releaseChannel_description\": \"pilih antara rilis stabil, beta, atau alpha (nightly) untuk pembaruan otomatis\",\n        \"discordDisplayType_artistname\": \"nama artis\",\n        \"discordDisplayType_description\": \"mengubah apa yang Anda dengarkan di status Anda\",\n        \"discordDisplayType_songname\": \"nama lagu\",\n        \"discordDisplayType\": \"jenis tampilan presence {{discord}}\",\n        \"discordLinkType_description\": \"menambahkan tautan eksternal ke {{lastfm}} atau {{musicbrainz}} pada kolom lagu dan artis di rich presence {{discord}}. {{musicbrainz}} paling akurat tetapi memerlukan tag dan tidak menyediakan tautan artis, sedangkan {{lastfm}} seharusnya selalu menyediakan tautan. tidak membuat permintaan jaringan tambahan\",\n        \"discordLinkType_mbz_lastfm\": \"{{musicbrainz}} dengan fallback {{lastfm}}\",\n        \"discordLinkType_none\": \"$t(common.none)\",\n        \"discordLinkType\": \"tautan presence {{discord}}\",\n        \"discordPausedStatus_description\": \"saat diaktifkan, status akan ditampilkan ketika pemutar dijeda\",\n        \"discordPausedStatus\": \"tampilkan rich presence saat dijeda\",\n        \"discordRichPresence\": \"rich presence {{discord}}\",\n        \"discordServeImage\": \"sajikan gambar {{discord}} dari server\",\n        \"discordServeImage_description\": \"bagikan cover art untuk rich presence {{discord}} dari server itu sendiri, hanya tersedia untuk Jellyfin dan Navidrome. {{discord}} menggunakan bot untuk mengambil gambar, jadi server Anda harus dapat dijangkau dari internet publik\",\n        \"enableAutoTranslation_description\": \"aktifkan terjemahan secara otomatis saat lirik dimuat\",\n        \"enableAutoTranslation\": \"aktifkan terjemahan otomatis\",\n        \"exportImportSettings_control_description\": \"ekspor dan impor pengaturan melalui JSON\",\n        \"exportImportSettings_control_exportText\": \"ekspor pengaturan\",\n        \"exportImportSettings_control_importText\": \"impor pengaturan\",\n        \"exportImportSettings_control_title\": \"impor / ekspor pengaturan\",\n        \"exportImportSettings_destructiveWarning\": \"mengimpor pengaturan bersifat destruktif, harap tinjau hal di atas sebelum mengklik \\\"impor\\\" di bawah!\",\n        \"exportImportSettings_importBtn\": \"impor pengaturan\",\n        \"exportImportSettings_importModalTitle\": \"impor pengaturan feishin\",\n        \"exportImportSettings_importSuccess\": \"pengaturan berhasil diimpor!\",\n        \"exportImportSettings_notValidJSON\": \"file yang diberikan bukan JSON yang valid\",\n        \"exportImportSettings_offendingKeyError\": \"\\\"{{offendingKey}}\\\" tidak benar - {{reason}}\",\n        \"followCurrentSong_description\": \"gulirkan antrean putar secara otomatis ke lagu yang sedang diputar\",\n        \"followCurrentSong\": \"ikuti lagu saat ini\",\n        \"homeFeatureStyle_description\": \"mengontrol gaya carousel unggulan beranda\",\n        \"homeFeatureStyle\": \"gaya carousel unggulan beranda\",\n        \"homeFeatureStyle_optionMultiple\": \"beberapa\",\n        \"homeFeatureStyle_optionSingle\": \"tunggal\",\n        \"hotkey_listNavigateToPage\": \"navigasi daftar ke halaman item\",\n        \"hotkey_listPlayDefault\": \"putar dari daftar\",\n        \"hotkey_listPlayLast\": \"putar terakhir dari daftar\",\n        \"hotkey_listPlayNext\": \"putar berikutnya dari daftar\",\n        \"hotkey_listPlayNow\": \"putar sekarang dari daftar\",\n        \"hotkey_navigateHome\": \"navigasi ke beranda\",\n        \"language\": \"bahasa\",\n        \"lastfm_description\": \"tampilkan tautan ke Last.fm pada halaman artis/album\",\n        \"lastfm\": \"tampilkan tautan last.fm\",\n        \"logLevel\": \"tingkat log\",\n        \"logLevel_description\": \"menetapkan tingkat log minimum untuk ditampilkan. debug menampilkan semua log, error hanya menampilkan error\",\n        \"logLevel_optionDebug\": \"debug\",\n        \"logLevel_optionError\": \"error\",\n        \"logLevel_optionInfo\": \"info\",\n        \"logLevel_optionWarn\": \"warn\",\n        \"mpvExtraParameters\": \"parameter tambahan mpv\",\n        \"mpvExtraParameters_description\": \"argumen tambahan untuk diteruskan ke mpv\",\n        \"musicbrainz_description\": \"tampilkan tautan ke MusicBrainz pada halaman artis/album, saat ID MusicBrainz tersedia\",\n        \"musicbrainz\": \"tampilkan tautan MusicBrainz\",\n        \"neteaseTranslation_description\": \"Saat diaktifkan, mengambil dan menampilkan lirik terjemahan dari NetEase jika tersedia\",\n        \"neteaseTranslation\": \"aktifkan terjemahan NetEase\",\n        \"notify\": \"aktifkan notifikasi lagu\",\n        \"notify_description\": \"tampilkan notifikasi saat mengganti lagu saat ini\",\n        \"pathReplace\": \"penggantian jalur file\",\n        \"pathReplace_description\": \"ganti filepath default server Anda\",\n        \"pathReplace_optionRemovePrefix\": \"hapus prefiks\",\n        \"pathReplace_optionAddPrefix\": \"tambahkan prefiks\",\n        \"playerFilters\": \"Filter lagu dari antrean\",\n        \"playerFilters_description\": \"abaikan lagu agar tidak ditambahkan ke antrean berdasarkan kriteria berikut\",\n        \"artistRadioCount_description\": \"menetapkan jumlah lagu yang diambil untuk radio artis dan radio trek\",\n        \"artistRadioCount\": \"jumlah radio artis/trek\",\n        \"imageResolution\": \"resolusi gambar\",\n        \"imageResolution_description\": \"resolusi untuk gambar yang digunakan di seluruh aplikasi. menggunakan nilai 0 akan kembali ke resolusi gambar asli\",\n        \"imageResolution_optionTable\": \"tabel\",\n        \"imageResolution_optionItemCard\": \"kartu item\",\n        \"imageResolution_optionSidebar\": \"bilah samping\",\n        \"imageResolution_optionHeader\": \"header\",\n        \"imageResolution_optionFullScreenPlayer\": \"pemutar layar penuh\",\n        \"playerbarSlider\": \"slider bilah pemutar\",\n        \"playerbarSlider_description\": \"waveform tidak direkomendasikan jika menggunakan koneksi internet yang lambat atau berbatas kuota\",\n        \"playerbarSliderType_optionSlider\": \"slider\",\n        \"playerbarSliderType_optionWaveform\": \"waveform\",\n        \"playerbarWaveformAlign\": \"perataan waveform\",\n        \"playerbarWaveformAlign_optionTop\": \"atas\",\n        \"playerbarWaveformAlign_optionCenter\": \"tengah\",\n        \"playerbarWaveformAlign_optionBottom\": \"bawah\",\n        \"playerbarWaveformBarWidth\": \"lebar batang waveform\",\n        \"playerbarWaveformGap\": \"jarak waveform\",\n        \"playerbarWaveformRadius\": \"radius waveform\",\n        \"preferLocalLyrics_description\": \"utamakan lirik lokal dibanding lirik jarak jauh jika tersedia\",\n        \"preferLocalLyrics\": \"utamakan lirik lokal\",\n        \"showLyricsInSidebar_description\": \"panel akan ditambahkan pada antrean putar terlampir yang menampilkan lirik\",\n        \"showLyricsInSidebar\": \"tampilkan lirik di bilah samping pemutar\",\n        \"showRatings_description\": \"mengontrol apakah fitur penilaian bintang muncul di antarmuka\",\n        \"showRatings\": \"tampilkan penilaian bintang\",\n        \"enableGridMultiSelect\": \"aktifkan pilihan multi kisi\",\n        \"enableGridMultiSelect_description\": \"saat diaktifkan, memungkinkan memilih beberapa item pada tampilan kisi. saat dinonaktifkan, mengklik gambar item kisi akan menavigasi ke halaman item\",\n        \"showVisualizerInSidebar_description\": \"panel akan ditambahkan pada bilah samping pemutar yang menampilkan visualizer\",\n        \"showVisualizerInSidebar\": \"tampilkan visualizer di bilah samping pemutar\",\n        \"combinedLyricsAndVisualizer_description\": \"gabungkan lirik dan visualizer ke dalam panel yang sama\",\n        \"combinedLyricsAndVisualizer\": \"gabungkan lirik dan visualizer di bilah samping pemutar\",\n        \"preservePitch_description\": \"mempertahankan pitch saat memodifikasi kecepatan pemutaran\",\n        \"preservePitch\": \"pertahankan pitch\",\n        \"audioFadeOnStatusChange\": \"fade audio saat status berubah\",\n        \"audioFadeOnStatusChange_description\": \"mengaktifkan fade out dan fade in saat status putar/jeda berubah\",\n        \"preventSleepOnPlayback_description\": \"cegah layar tidur saat musik diputar\",\n        \"preventSleepOnPlayback\": \"cegah tidur saat pemutaran\",\n        \"sidebarPlaylistSorting_description\": \"memungkinkan pengurutan playlist secara manual di bilah samping dengan drag dan drop alih-alih urutan default server\",\n        \"sidebarPlaylistSorting\": \"pengurutan playlist bilah samping\",\n        \"mediaSession_description\": \"mengaktifkan integrasi Media Session, menampilkan kontrol media dan metadata pada overlay volume sistem dan layar kunci\",\n        \"mediaSession\": \"aktifkan media session\",\n        \"transcode\": \"aktifkan transcoding\",\n        \"queryBuilder\": \"pembuat kueri\",\n        \"queryBuilderCustomFields_inputLabel\": \"label\",\n        \"queryBuilderCustomFields_inputTag\": \"tag\",\n        \"queryBuilderCustomFields\": \"kolom kustom\",\n        \"queryBuilderCustomFields_description\": \"tambahkan kolom kustom untuk digunakan di pembuat kueri\",\n        \"useThemePrimaryShade\": \"gunakan warna utama tema\",\n        \"useThemePrimaryShade_description\": \"gunakan warna utama yang ditentukan dalam tema yang dipilih untuk varian warna utama\",\n        \"primaryShade\": \"warna utama tema\",\n        \"primaryShade_description\": \"timpa warna utama (0–9) yang digunakan untuk tombol, tautan, dan elemen berwarna utama lainnya\",\n        \"analyticsEnable\": \"Kirim analitik berbasis penggunaan\",\n        \"analyticsEnable_description\": \"Data penggunaan yang dianonimkan dikirim ke pengembang untuk membantu meningkatkan aplikasi\",\n        \"automaticUpdates\": \"Pembaruan otomatis\",\n        \"automaticUpdates_description\": \"Periksa dan instal pembaruan secara otomatis\",\n        \"releaseChannel_optionAlpha\": \"alpha (nightly)\",\n        \"discordStateIcon\": \"tampilkan ikon sedang diputar\",\n        \"discordStateIcon_description\": \"tampilkan ikon kecil sedang diputar di status rich presence. ikon dijeda selalu ditampilkan ketika \\\"Tampilkan rich presence saat dijeda\\\" diaktifkan\",\n        \"blurExplicitImages\": \"buramkan gambar eksplisit\",\n        \"blurExplicitImages_description\": \"sampul album dan lagu yang ditandai sebagai eksplisit akan diburamkan\",\n        \"playerItemConfiguration_description\": \"konfigurasikan item apa yang ditampilkan, dan dalam urutan apa, pada pemutar layar penuh\",\n        \"playerItemConfiguration\": \"konfigurasi item pemutar\",\n        \"sidebarPlaylistListFilterRegex_description\": \"sembunyikan playlist di bilah sisi yang cocok dengan ekspresi reguler ini\",\n        \"sidebarPlaylistListFilterRegex_placeholder\": \"mis. ^Daily Mix.*\",\n        \"sidebarPlaylistListFilterRegex\": \"regex filter playlist\"\n    },\n    \"table\": {\n        \"column\": {\n            \"album\": \"album\",\n            \"albumArtist\": \"artis album\",\n            \"albumCount\": \"$t(entity.album, {\\\"count\\\": 2})\",\n            \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n            \"biography\": \"biografi\",\n            \"bitrate\": \"bitrate\",\n            \"bpm\": \"lpm\",\n            \"channels\": \"$t(common.channel, {\\\"count\\\": 2})\",\n            \"codec\": \"$t(common.codec)\",\n            \"comment\": \"komentar\",\n            \"dateAdded\": \"tanggal ditambahkan\",\n            \"discNumber\": \"nomor disk\",\n            \"favorite\": \"favorit\",\n            \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n            \"lastPlayed\": \"terakhir diputar\",\n            \"path\": \"jalur\",\n            \"playCount\": \"putaran\",\n            \"rating\": \"penilaian\",\n            \"releaseDate\": \"tanggal rilis\",\n            \"releaseYear\": \"tahun\",\n            \"size\": \"$t(common.size)\",\n            \"songCount\": \"$t(entity.track, {\\\"count\\\": 2})\",\n            \"title\": \"judul\",\n            \"trackNumber\": \"pista\",\n            \"bitDepth\": \"$t(common.bitDepth)\",\n            \"sampleRate\": \"$t(common.sampleRate)\",\n            \"owner\": \"pemilik\"\n        },\n        \"config\": {\n            \"general\": {\n                \"autoFitColumns\": \"sesuaikan kolom otomatis\",\n                \"followCurrentSong\": \"ikuti lagu saat ini\",\n                \"displayType\": \"tipe tampilan\",\n                \"gap\": \"$t(common.gap)\",\n                \"itemGap\": \"jarak antar elemen (px)\",\n                \"itemSize\": \"ukuran elemen (px)\",\n                \"size\": \"$t(common.size)\",\n                \"tableColumns\": \"kolom tabel\",\n                \"advancedSettings\": \"pengaturan lanjutan\",\n                \"autosize\": \"ukuran otomatis\",\n                \"moveUp\": \"pindahkan ke atas\",\n                \"moveDown\": \"pindahkan ke bawah\",\n                \"pinToLeft\": \"sematkan ke kiri\",\n                \"pinToRight\": \"sematkan ke kanan\",\n                \"alignLeft\": \"rata kiri\",\n                \"alignCenter\": \"rata tengah\",\n                \"alignRight\": \"rata kanan\",\n                \"itemsPerRow\": \"item per baris\",\n                \"size_default\": \"default\",\n                \"size_compact\": \"ringkas\",\n                \"size_large\": \"besar\",\n                \"pagination\": \"paginasi\",\n                \"pagination_itemsPerPage\": \"item per halaman\",\n                \"pagination_infinite\": \"tak terbatas\",\n                \"pagination_paginate\": \"berhalaman\",\n                \"alternateRowColors\": \"warna baris bergantian\",\n                \"horizontalBorders\": \"batas baris\",\n                \"rowHoverHighlight\": \"sorotan hover baris\",\n                \"showHeader\": \"tampilkan header\",\n                \"verticalBorders\": \"batas kolom\"\n            },\n            \"label\": {\n                \"actions\": \"$t(common.action, {\\\"count\\\": 2})\",\n                \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n                \"albumArtist\": \"$t(entity.albumArtist, {\\\"count\\\": 1})\",\n                \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n                \"biography\": \"$t(common.biography)\",\n                \"bitrate\": \"$t(common.bitrate)\",\n                \"bpm\": \"$t(common.bpm)\",\n                \"channels\": \"$t(common.channel, {\\\"count\\\": 2})\",\n                \"codec\": \"$t(common.codec)\",\n                \"dateAdded\": \"tanggal ditambahkan\",\n                \"discNumber\": \"nomor disk\",\n                \"duration\": \"$t(common.duration)\",\n                \"favorite\": \"$t(common.favorite)\",\n                \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n                \"lastPlayed\": \"terakhir diputar\",\n                \"note\": \"$t(common.note)\",\n                \"owner\": \"$t(common.owner)\",\n                \"path\": \"$t(common.path)\",\n                \"playCount\": \"jumlah putaran\",\n                \"rating\": \"$t(common.rating)\",\n                \"releaseDate\": \"tanggal rilis\",\n                \"rowIndex\": \"indeks baris\",\n                \"size\": \"$t(common.size)\",\n                \"songCount\": \"$t(entity.track, {\\\"count\\\": 2})\",\n                \"title\": \"$t(common.title)\",\n                \"titleCombined\": \"$t(common.title) (digabungkan)\",\n                \"trackNumber\": \"nomor pista\",\n                \"year\": \"$t(common.year)\",\n                \"albumCount\": \"$t(entity.album, {\\\"count\\\": 2})\",\n                \"bitDepth\": \"$t(common.bitDepth)\",\n                \"composer\": \"komposer\",\n                \"genreBadge\": \"$t(entity.genre, {\\\"count\\\": 1}) (lencana)\",\n                \"image\": \"gambar\",\n                \"sampleRate\": \"$t(common.sampleRate)\",\n                \"titleArtist\": \"$t(common.title) (artis)\",\n                \"albumGroup\": \"grup album\"\n            },\n            \"view\": {\n                \"table\": \"tabel\",\n                \"grid\": \"kisi\",\n                \"list\": \"daftar\",\n                \"detail\": \"detail\"\n            }\n        }\n    },\n    \"datetime\": {\n        \"minuteShort\": \"m\",\n        \"secondShort\": \"s\",\n        \"hourShort\": \"h\",\n        \"dayShort\": \"d\"\n    },\n    \"filterOperator\": {\n        \"after\": \"setelah\",\n        \"afterDate\": \"setelah (tanggal)\",\n        \"before\": \"sebelum\",\n        \"beforeDate\": \"sebelum (tanggal)\",\n        \"contains\": \"berisi\",\n        \"endsWith\": \"diakhiri dengan\",\n        \"inPlaylist\": \"berada di\",\n        \"inTheLast\": \"dalam kurun terakhir\",\n        \"inTheRange\": \"berada dalam rentang\",\n        \"inTheRangeDate\": \"berada dalam rentang (tanggal)\",\n        \"is\": \"adalah\",\n        \"isNot\": \"bukan\",\n        \"isGreaterThan\": \"lebih besar dari\",\n        \"isLessThan\": \"lebih kecil dari\",\n        \"matchesRegex\": \"cocok dengan regex\",\n        \"notContains\": \"tidak berisi\",\n        \"notInPlaylist\": \"tidak berada di\",\n        \"notInTheLast\": \"tidak dalam kurun terakhir\",\n        \"startsWith\": \"diawali dengan\"\n    },\n    \"queryBuilder\": {\n        \"standardTags\": \"tag standar\",\n        \"customTags\": \"tag kustom\"\n    },\n    \"releaseType\": {\n        \"primary\": {\n            \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n            \"broadcast\": \"siaran\",\n            \"ep\": \"ep\",\n            \"other\": \"lainnya\",\n            \"single\": \"single\"\n        },\n        \"secondary\": {\n            \"audiobook\": \"buku audio\",\n            \"audioDrama\": \"drama audio\",\n            \"compilation\": \"kompilasi\",\n            \"djMix\": \"dj mix\",\n            \"demo\": \"demo\",\n            \"fieldRecording\": \"rekaman lapangan\",\n            \"interview\": \"wawancara\",\n            \"live\": \"live\",\n            \"mixtape\": \"mixtape\",\n            \"remix\": \"remix\",\n            \"soundtrack\": \"soundtrack\",\n            \"spokenWord\": \"spoken word\"\n        }\n    },\n    \"dragDropZone\": {\n        \"error_oneFileOnly\": \"Harap pilih hanya 1 file\",\n        \"error_readingFile\": \"terjadi masalah saat membaca file: {{errorMessage}}\",\n        \"mainText\": \"jatuhkan file di sini\"\n    },\n    \"visualizer\": {\n        \"visualizerType\": \"Jenis Visualizer\",\n        \"cyclePresets\": \"Putar Preset\",\n        \"cycleTime\": \"Waktu Siklus (detik)\",\n        \"includeAllPresets\": \"Sertakan Semua Preset\",\n        \"ignoredPresets\": \"Preset yang Diabaikan\",\n        \"selectedPresets\": \"Preset yang Dipilih\",\n        \"randomizeNextPreset\": \"Acak Preset Berikutnya\",\n        \"blendTime\": \"Waktu Pencampuran\",\n        \"presets\": \"Preset\",\n        \"selectPreset\": \"Pilih Preset\",\n        \"applyPreset\": \"Terapkan Preset\",\n        \"saveAsPreset\": \"Simpan sebagai Preset\",\n        \"updatePreset\": \"Perbarui Preset\",\n        \"copyConfiguration\": \"Salin Konfigurasi\",\n        \"pasteConfiguration\": \"Tempel Konfigurasi\",\n        \"pasteConfigurationPlaceholder\": \"Tempel konfigurasi JSON di sini...\",\n        \"pasteFromClipboard\": \"Tempel dari Clipboard\",\n        \"applyConfiguration\": \"Terapkan Konfigurasi\",\n        \"configCopied\": \"Konfigurasi disalin ke clipboard\",\n        \"configCopyFailed\": \"Gagal menyalin konfigurasi\",\n        \"configPasted\": \"Konfigurasi berhasil diterapkan\",\n        \"configPasteFailed\": \"Gagal menerapkan konfigurasi. Harap periksa formatnya.\",\n        \"configPasteReadFailed\": \"Gagal membaca dari clipboard\",\n        \"presetName\": \"Nama Preset\",\n        \"presetNamePlaceholder\": \"Masukkan nama preset\",\n        \"general\": \"Umum\",\n        \"mode\": \"Mode\",\n        \"mode1To8\": \"Mode 1 - 8\",\n        \"mode10\": \"Mode 10\",\n        \"barSpace\": \"Jarak Batang\",\n        \"lineWidth\": \"Lebar Garis\",\n        \"fillAlpha\": \"Alpha Isian\",\n        \"channelLayout\": \"Tata Letak Kanal\",\n        \"maxFPS\": \"FPS Maksimum\",\n        \"opacity\": \"Opasitas\",\n        \"customGradients\": \"Gradien Kustom\",\n        \"addCustomGradient\": \"Tambah Gradien Kustom\",\n        \"gradientName\": \"Nama Gradien\",\n        \"gradientNamePlaceholder\": \"Nama Gradien\",\n        \"vertical\": \"Vertikal\",\n        \"horizontal\": \"Horizontal\",\n        \"colorStops\": \"Titik Warna\",\n        \"addColor\": \"Tambah Warna\",\n        \"position\": \"Posisi\",\n        \"level\": \"Level\",\n        \"remove\": \"Hapus\",\n        \"pasteGradient\": \"Tempel Gradien\",\n        \"pasteGradientPlaceholder\": \"Tempel JSON gradien di sini...\",\n        \"custom\": \"Kustom\",\n        \"builtIn\": \"Bawaan\",\n        \"colors\": \"Warna\",\n        \"colorMode\": \"Mode Warna\",\n        \"gradient\": \"Gradien\",\n        \"gradientLeft\": \"Gradien Kiri\",\n        \"gradientRight\": \"Gradien Kanan\",\n        \"fft\": \"FFT\",\n        \"fftSize\": \"Ukuran FFT\",\n        \"smoothing\": \"Penghalusan\",\n        \"frequencyRangeAndScaling\": \"Rentang frekuensi dan penskalaan\",\n        \"minimumFrequency\": \"Frekuensi Minimum\",\n        \"maximumFrequency\": \"Frekuensi Maksimum\",\n        \"frequencyScale\": \"Skala Frekuensi\",\n        \"sensitivity\": \"Sensitivitas\",\n        \"weightingFilter\": \"Filter Pembobotan\",\n        \"minimumDecibels\": \"Desibel Minimum\",\n        \"maximumDecibels\": \"Desibel Maksimum\",\n        \"linearAmplitude\": \"Amplitudo Linear\",\n        \"linearBoost\": \"Peningkatan Linear\",\n        \"peakBehavior\": \"Perilaku Puncak\",\n        \"showPeaks\": \"Tampilkan Puncak\",\n        \"fadePeaks\": \"Pudarkan Puncak\",\n        \"peakLine\": \"Garis Puncak\",\n        \"gravity\": \"Gravitasi\",\n        \"peakFadeTime\": \"Waktu Pudarnya Puncak (ms)\",\n        \"peakHoldTime\": \"Waktu Menahan Puncak (ms)\",\n        \"radialSpectrum\": \"Spektrum Radial\",\n        \"radial\": \"Radial\",\n        \"radialInvert\": \"Balik Radial\",\n        \"spinSpeed\": \"Kecepatan Putar\",\n        \"radius\": \"Jari-jari\",\n        \"reflexMirror\": \"Cermin Refleks\",\n        \"reflexFit\": \"Kesesuaian Refleks\",\n        \"reflexRatio\": \"Rasio Refleks\",\n        \"reflexAlpha\": \"Alpha Refleks\",\n        \"reflexBrightness\": \"Kecerahan Refleks\",\n        \"mirror\": \"Cermin\",\n        \"miscellaneousSettings\": \"Pengaturan Lain-lain\",\n        \"alphaBars\": \"Batang Alpha\",\n        \"ansiBands\": \"Pita ANSI\",\n        \"ledBars\": \"Batang LED\",\n        \"trueLeds\": \"LED Sebenarnya\",\n        \"lumiBars\": \"Batang Lumi\",\n        \"outlineBars\": \"Batang Outline\",\n        \"roundBars\": \"Batang Bulat\",\n        \"lowResolution\": \"Resolusi Rendah\",\n        \"splitGradient\": \"Pemisahan Gradien\",\n        \"showFPS\": \"Tampilkan FPS\",\n        \"showScaleX\": \"Tampilkan Skala X\",\n        \"noteLabels\": \"Label Nada\",\n        \"showScaleY\": \"Tampilkan Skala Y\",\n        \"options\": {\n            \"mode\": {\n                \"0\": \"Frekuensi Diskret\",\n                \"1\": \"1/24 oktaf / 240 pita\",\n                \"2\": \"1/12 oktaf / 120 pita\",\n                \"3\": \"1/8 oktaf / 80 pita\",\n                \"4\": \"1/6 oktaf / 60 pita\",\n                \"5\": \"1/4 oktaf / 40 pita\",\n                \"6\": \"1/3 oktaf / 30 pita\",\n                \"7\": \"Setengah oktaf / 20 pita\",\n                \"8\": \"Satu oktaf penuh / 10 pita\",\n                \"10\": \"Grafik garis / area\"\n            },\n            \"colorMode\": {\n                \"gradient\": \"Gradien\",\n                \"barIndex\": \"Indeks Batang\",\n                \"barLevel\": \"Level Batang\"\n            },\n            \"gradient\": {\n                \"classic\": \"Klasik\",\n                \"prism\": \"Prisma\",\n                \"rainbow\": \"Pelangi\",\n                \"steelblue\": \"Biru baja\",\n                \"orangered\": \"Oranye kemerahan\"\n            },\n            \"channelLayout\": {\n                \"single\": \"Tunggal\",\n                \"dualCombined\": \"Dua-Gabungan\",\n                \"dualHorizontal\": \"Dua-Horizontal\",\n                \"dualVertical\": \"Dua-Vertikal\"\n            },\n            \"frequencyScale\": {\n                \"none\": \"Tidak ada\",\n                \"bark\": \"Skala Bark\",\n                \"linear\": \"Skala Linear\",\n                \"log\": \"Skala Log\",\n                \"mel\": \"Skala Mel\"\n            },\n            \"weightingFilter\": {\n                \"none\": \"Tidak ada\",\n                \"a\": \"A\",\n                \"b\": \"B\",\n                \"c\": \"C\",\n                \"d\": \"D\",\n                \"z\": \"Z\"\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "src/i18n/locales/it.json",
    "content": "{\n    \"action\": {\n        \"editPlaylist\": \"modifica $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"goToPage\": \"vai alla pagina\",\n        \"clearQueue\": \"cancella la coda\",\n        \"addToFavorites\": \"aggiungi a $t(entity.favorite, {\\\"count\\\": 2})\",\n        \"addToPlaylist\": \"aggiungi a $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"createPlaylist\": \"crea $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"removeFromPlaylist\": \"rimuovi da $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"viewPlaylists\": \"visualizza $t(entity.playlist, {\\\"count\\\": 2})\",\n        \"refresh\": \"$t(common.refresh)\",\n        \"deletePlaylist\": \"elimina $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"removeFromQueue\": \"rimuovi dalla coda\",\n        \"deselectAll\": \"deseleziona tutto\",\n        \"setRating\": \"vota\",\n        \"toggleSmartPlaylistEditor\": \"attiva/disattiva editor $t(entity.smartPlaylist)\",\n        \"removeFromFavorites\": \"rimuovi da $t(entity.favorite, {\\\"count\\\": 2})\",\n        \"moveToTop\": \"sposta in cima\",\n        \"moveToBottom\": \"sposta in fondo\",\n        \"moveToNext\": \"passa al successivo\",\n        \"openIn\": {\n            \"lastfm\": \"Apri in Last.fm\",\n            \"musicbrainz\": \"Apri in MusicBrainz\"\n        },\n        \"addOrRemoveFromSelection\": \"aggiungi o rimuovi dalla selezione\",\n        \"selectRangeOfItems\": \"seleziona un intervallo di elementi\",\n        \"createRadioStation\": \"crea $t(entity.radioStation, {\\\"count\\\": 1})\",\n        \"deleteRadioStation\": \"rimuovi $t(entity.radioStation, {\\\"count\\\": 1})\",\n        \"selectAll\": \"seleziona tutto\",\n        \"downloadStarted\": \"download di {{count}} elementi iniziato\",\n        \"moveUp\": \"sposta sopra\",\n        \"moveDown\": \"sposta in basso\",\n        \"holdToMoveToTop\": \"tieni premuto per muovere in cima\",\n        \"holdToMoveToBottom\": \"tieni premuto per muoverlo in fondo\",\n        \"moveItems\": \"muovi elementi\",\n        \"shuffle\": \"casuale\",\n        \"viewMore\": \"mostra di più\"\n    },\n    \"common\": {\n        \"backward\": \"indietro\",\n        \"areYouSure\": \"sei sicurə?\",\n        \"add\": \"aggiungi\",\n        \"ascending\": \"crescente\",\n        \"bitrate\": \"bitrate\",\n        \"action_one\": \"azione\",\n        \"action_many\": \"azioni\",\n        \"action_other\": \"azioni\",\n        \"biography\": \"biografia\",\n        \"bpm\": \"bpm\",\n        \"center\": \"centrale\",\n        \"cancel\": \"annulla\",\n        \"channel_one\": \"canale\",\n        \"channel_many\": \"canali\",\n        \"channel_other\": \"canali\",\n        \"collapse\": \"collassa\",\n        \"configure\": \"configura\",\n        \"comingSoon\": \"a seguire…\",\n        \"increase\": \"incrementa\",\n        \"rating\": \"voto\",\n        \"refresh\": \"ricarica\",\n        \"unknown\": \"sconosciuto\",\n        \"edit\": \"modifica\",\n        \"favorite\": \"preferito\",\n        \"left\": \"sinistra\",\n        \"save\": \"salva\",\n        \"right\": \"destra\",\n        \"currentSong\": \"$t(entity.track, {\\\"count\\\": 1}) corrente\",\n        \"trackNumber\": \"traccia\",\n        \"descending\": \"decrescente\",\n        \"gap\": \"gap\",\n        \"dismiss\": \"dimetti\",\n        \"year\": \"anno\",\n        \"manage\": \"gestisci\",\n        \"limit\": \"limite\",\n        \"minimize\": \"minimizza\",\n        \"modified\": \"modificato\",\n        \"duration\": \"durata\",\n        \"name\": \"nome\",\n        \"maximize\": \"massimizza\",\n        \"decrease\": \"decrementa\",\n        \"ok\": \"ok\",\n        \"description\": \"descrizione\",\n        \"path\": \"percorso\",\n        \"no\": \"no\",\n        \"owner\": \"proprietariə\",\n        \"enable\": \"abilita\",\n        \"clear\": \"svuota\",\n        \"forward\": \"successivo\",\n        \"delete\": \"elimina\",\n        \"forceRestartRequired\": \"riavvia per applicare le modifiche... chiudi la notifica per riavviare\",\n        \"setting_one\": \"impostazione\",\n        \"setting_many\": \"impostazioni\",\n        \"setting_other\": \"impostazioni\",\n        \"version\": \"versione\",\n        \"title\": \"titolo\",\n        \"filter_one\": \"filtro\",\n        \"filter_many\": \"filtri\",\n        \"filter_other\": \"filtri\",\n        \"filters\": \"filtri\",\n        \"create\": \"crea\",\n        \"saveAndReplace\": \"salva e sovrascrivi\",\n        \"playerMustBePaused\": \"il player deve essere messo in pausa\",\n        \"confirm\": \"conferma\",\n        \"resetToDefault\": \"ripristina ai valori di default\",\n        \"home\": \"home\",\n        \"reset\": \"ripristina\",\n        \"disable\": \"disabilita\",\n        \"sortOrder\": \"ordine\",\n        \"none\": \"nessuno\",\n        \"menu\": \"menù\",\n        \"restartRequired\": \"riavvio richiesto\",\n        \"previousSong\": \"$t(entity.track, {\\\"count\\\": 1}) precedente\",\n        \"noResultsFromQuery\": \"la query non ha ritornato risultati\",\n        \"quit\": \"esci\",\n        \"expand\": \"espandi\",\n        \"search\": \"cerca\",\n        \"saveAs\": \"salva come\",\n        \"disc\": \"disco\",\n        \"yes\": \"si\",\n        \"random\": \"casuale\",\n        \"size\": \"dimensione\",\n        \"note\": \"nota\",\n        \"additionalParticipants\": \"partecipanti aggiuntivi\",\n        \"newVersion\": \"è stata installata una nuova versione ({{version}})\",\n        \"viewReleaseNotes\": \"mostra le note di rilascio\",\n        \"albumGain\": \"guadagno (gain) dell'album\",\n        \"albumPeak\": \"picco di volume dell'album\",\n        \"close\": \"chiudi\",\n        \"codec\": \"codec\",\n        \"mbid\": \"MusicBrainz ID\",\n        \"preview\": \"anteprima\",\n        \"reload\": \"aggiorna\",\n        \"share\": \"condividi\",\n        \"tags\": \"tags\",\n        \"trackGain\": \"normalizzazione (gain) del brano\",\n        \"trackPeak\": \"picco di volume del brano\",\n        \"translation\": \"traduzione\",\n        \"bitDepth\": \"bit depth (profondità di bit)\",\n        \"sampleRate\": \"sample rate (frequenza di campionamento)\",\n        \"countSelected\": \"{{count}} selezionati\",\n        \"doNotShowAgain\": \"non mostrarlo di nuovo\",\n        \"view\": \"visualizza\",\n        \"example\": \"esempio\",\n        \"externalLinks\": \"link esterni\",\n        \"faster\": \"più rapido\",\n        \"filter_single\": \"singolo\",\n        \"noFilters\": \"nessun filtro configurato\",\n        \"private\": \"privato\",\n        \"public\": \"pubblico\",\n        \"retry\": \"riprova\",\n        \"recordLabel\": \"registra etichetta\",\n        \"rename\": \"rinomina\",\n        \"sort\": \"ordina\",\n        \"explicit\": \"esplicito\",\n        \"clean\": \"pulisci\",\n        \"itemsMore\": \"ancora {{count}}\"\n    },\n    \"player\": {\n        \"repeat_all\": \"ripeti coda\",\n        \"stop\": \"ferma\",\n        \"repeat\": \"ripeti traccia\",\n        \"queue_remove\": \"rimuovi selezionati\",\n        \"playRandom\": \"riproduci casuale\",\n        \"skip\": \"salta\",\n        \"previous\": \"precedente\",\n        \"toggleFullscreenPlayer\": \"attiva/disattiva player a schermo intero\",\n        \"skip_back\": \"salta indietro\",\n        \"favorite\": \"preferito\",\n        \"next\": \"successivo\",\n        \"shuffle\": \"riproduzione casuale\",\n        \"playbackFetchNoResults\": \"nessuna canzone trovata\",\n        \"playbackFetchInProgress\": \"caricamento canzoni…\",\n        \"addNext\": \"successivo\",\n        \"playbackSpeed\": \"velocità di riproduzione\",\n        \"playbackFetchCancel\": \"ci sta mettendo un po'... chiudi la notifica per annullare\",\n        \"play\": \"riproduci\",\n        \"repeat_off\": \"non ripetere\",\n        \"pause\": \"pausa\",\n        \"queue_clear\": \"cancella coda\",\n        \"muted\": \"silenziato\",\n        \"unfavorite\": \"togli dai preferiti\",\n        \"queue_moveToTop\": \"sposta selezionati in fondo\",\n        \"queue_moveToBottom\": \"sposta selezionati in cima\",\n        \"shuffle_off\": \"non mescolare\",\n        \"addLast\": \"per ultima\",\n        \"mute\": \"silenzia\",\n        \"skip_forward\": \"salta avanti\",\n        \"playSimilarSongs\": \"riproduci brani simili\",\n        \"viewQueue\": \"visualizza coda\",\n        \"holdToShuffle\": \"tieni premuto per la riproduzione casuale\",\n        \"lyrics\": \"testi\",\n        \"restoreQueueFromServer\": \"ripristina coda dal server\",\n        \"saveQueueToServer\": \"salva coda sul server\",\n        \"trackRadio\": \"radio della traccia\",\n        \"sleepTimer_minutes\": \"{{count}} minuti\",\n        \"sleepTimer_hours\": \"{{count}} ore\",\n        \"sleepTimer_custom\": \"personalizzato\",\n        \"sleepTimer_off\": \"spento\",\n        \"sleepTimer_timeRemaining\": \"{{time}} rimanente\",\n        \"sleepTimer_setCustom\": \"imposta timer\",\n        \"sleepTimer_cancel\": \"cancella timer\"\n    },\n    \"setting\": {\n        \"crossfadeStyle_description\": \"seleziona lo stile dissolvenza da usare per il player audio\",\n        \"remotePort_description\": \"imposta la porta del server di controllo remoto\",\n        \"hotkey_skipBackward\": \"salta a precedente\",\n        \"volumeWheelStep_description\": \"la quantità di volume da cambiare quando si scorre la rotellina del mouse sullo slider del volume\",\n        \"audioDevice_description\": \"seleziona il dispositivo audio da usare per la riproduzione\",\n        \"theme_description\": \"imposta il tema da usare per l'applicazione\",\n        \"hotkey_playbackPause\": \"pausa\",\n        \"hotkey_volumeUp\": \"alza volume\",\n        \"skipDuration\": \"salta durata\",\n        \"discordIdleStatus_description\": \"quando è attivo, aggiorna lo stato mentre il player è inattivo\",\n        \"playButtonBehavior_optionPlay\": \"$t(player.play)\",\n        \"minimumScrobblePercentage\": \"durata minima scrobble (percentuale)\",\n        \"lyricFetch\": \"ottieni testi da internet\",\n        \"scrobble\": \"scrobble\",\n        \"skipDuration_description\": \"imposta la durata da saltare quando vengono usati i pulsanti di salto nella barra del player\",\n        \"enableRemote_description\": \"abilita il controllo remoto del server per permettere ad altri dispositivi di controllare l'applicazione\",\n        \"fontType_optionSystem\": \"font di sistema\",\n        \"mpvExecutablePath_description\": \"imposta il percorso dell'eseguibile mpv. se lasciato vuoto, verrà utilizzato il percorso predefinito\",\n        \"hotkey_favoriteCurrentSong\": \"$t(common.currentSong) preferita\",\n        \"sidebarConfiguration\": \"configurazione barra laterale\",\n        \"replayGainMode_optionNone\": \"$t(common.none)\",\n        \"hotkey_zoomIn\": \"ingrandisci layout\",\n        \"scrobble_description\": \"invia lo scrobble delle riproduzioni al tuo media server\",\n        \"audioExclusiveMode_description\": \"abilità modalità output esclusiva. In questa modalità il sistema è di solito chiuso fuori, e solo mpv potrà riprodurre audio\",\n        \"discordUpdateInterval\": \"intervallo aggiornamento stato attività {{discord}}\",\n        \"themeLight\": \"tema (chiaro)\",\n        \"fontType_optionBuiltIn\": \"font built-in\",\n        \"hotkey_playbackPlayPause\": \"riproduci / pausa\",\n        \"hotkey_rate1\": \"voto 1 stella\",\n        \"hotkey_skipForward\": \"salta a successivo\",\n        \"disableLibraryUpdateOnStartup\": \"disabilita il controllo di nuove versioni all'avvio\",\n        \"discordApplicationId_description\": \"l'application id per lo stato attività di {{discord}} ({{defaultId}} è il valore predefinito)\",\n        \"gaplessAudio\": \"audio gapless\",\n        \"playButtonBehavior_optionAddLast\": \"$t(player.addLast)\",\n        \"zoom\": \"percentuale zoom\",\n        \"minimizeToTray_description\": \"riduce a icona nella barra di sistema\",\n        \"hotkey_playbackPlay\": \"riproduci\",\n        \"hotkey_volumeDown\": \"abbassa volume\",\n        \"audioPlayer_description\": \"seleziona il player audio da usare per la riproduzione\",\n        \"globalMediaHotkeys\": \"tasti media globali\",\n        \"hotkey_globalSearch\": \"ricerca globale\",\n        \"gaplessAudio_description\": \"imposta l'audio gapless per mpv\",\n        \"remoteUsername_description\": \"imposta l'username del server di controllo remoto. Se username e password sono vuoti, l'autenticazione sarà disattivata\",\n        \"exitToTray_description\": \"riduce a icona nella barra di sistema all'uscita\",\n        \"followLyric_description\": \"scorre il testo alla posizione di riproduzione corrente\",\n        \"hotkey_favoritePreviousSong\": \"$t(common.previousSong) preferita\",\n        \"replayGainMode_optionAlbum\": \"$t(entity.album, {\\\"count\\\": 1})\",\n        \"lyricOffset\": \"offset testi (ms)\",\n        \"discordUpdateInterval_description\": \"il tempo in secondi tra ogni aggiornamento (minimo 15 secondi)\",\n        \"fontType_optionCustom\": \"font personalizzato\",\n        \"themeDark_description\": \"imposta il tema scuro da usare per l'applicazione\",\n        \"audioExclusiveMode\": \"modalità audio esclusiva\",\n        \"remotePassword\": \"password server controllo remoto\",\n        \"lyricFetchProvider\": \"providers da dove ottenere testi\",\n        \"language_description\": \"imposta la lingua dell'applicazione ($t(common.restartRequired))\",\n        \"playbackStyle_optionCrossFade\": \"dissolvenza\",\n        \"hotkey_rate3\": \"voto 3 stelle\",\n        \"font\": \"font\",\n        \"replayGainMode_optionTrack\": \"$t(entity.track, {\\\"count\\\": 1})\",\n        \"themeLight_description\": \"imposta il tema chiaro da usare per l'applicazione\",\n        \"hotkey_toggleFullScreenPlayer\": \"attiva/disattiva player a schermo intero\",\n        \"hotkey_localSearch\": \"ricerca in-pagina\",\n        \"hotkey_toggleQueue\": \"attiva/disattiva coda\",\n        \"zoom_description\": \"imposta la percentuale zoom per l'applicazione\",\n        \"remotePassword_description\": \"imposta la password del server di controllo remoto. Queste credenziali sono di default trasferite in modo non sicuro, quindi dovresti usare una password unica di cui non ti importa\",\n        \"hotkey_rate5\": \"voto 5 stelle\",\n        \"hotkey_playbackPrevious\": \"traccia precedente\",\n        \"crossfadeDuration_description\": \"imposta la durata dell'effetto di dissolvenza\",\n        \"playbackStyle\": \"stile riproduzione\",\n        \"hotkey_toggleShuffle\": \"attiva/disattiva mescolamento\",\n        \"theme\": \"tema\",\n        \"playbackStyle_description\": \"selezione lo stile di riproduzione da usare per il player audio\",\n        \"discordRichPresence_description\": \"abilita lo stato di riproduzione nello stato attività di {{discord}}. Le chiavi immagine sono: {{icon}}, {{playing}} e {{paused}}\",\n        \"mpvExecutablePath\": \"percorso eseguibile mpv\",\n        \"audioDevice\": \"device audio\",\n        \"hotkey_rate2\": \"voto 2 stelle\",\n        \"playButtonBehavior_description\": \"imposta il comportamente di default del pulsante di riproduzione quando viene aggiunta una canzone alla coda\",\n        \"minimumScrobblePercentage_description\": \"la minima percentuale di una canzone che deve essere riprodutta prima di eseguire lo scrobble\",\n        \"exitToTray\": \"riduci a icona all'uscita\",\n        \"hotkey_rate4\": \"voto 4 stelle\",\n        \"enableRemote\": \"abilita controllo remoto server\",\n        \"savePlayQueue\": \"salva coda di riproduzione\",\n        \"minimumScrobbleSeconds_description\": \"la minima durata in secondi di una canzone che deve essere riprodutta prima di eseguire lo scrobble\",\n        \"fontType_description\": \"Font built-in seleziona uno dei font forniti da feishin. Font di sistema ti permette di selezionare ogni font fornito dal tuo sistema operativo. Custom ti permette di fornire il tuo font\",\n        \"playButtonBehavior\": \"comportamento pulsante riproduzione\",\n        \"volumeWheelStep\": \"step rotellina volume\",\n        \"sidebarPlaylistList_description\": \"mostra o nascondi la lista delle playlist nella barra laterale\",\n        \"accentColor\": \"colore d'accento\",\n        \"accentColor_description\": \"imposta colore d'accento per l'applicazione\",\n        \"playbackStyle_optionNormal\": \"normale\",\n        \"windowBarStyle\": \"stile barra della finestra\",\n        \"hotkey_toggleRepeat\": \"attiva/disattiva ripeti\",\n        \"lyricOffset_description\": \"aumenta/dimuisce l'offset del testo di una specifica quantità di millisecondi\",\n        \"sidebarConfiguration_description\": \"seleziona gli elementi e l'ordine in cui appaiono nella barra laterale\",\n        \"fontType\": \"tipo font\",\n        \"remotePort\": \"porta del server di controllo remoto\",\n        \"applicationHotkeys\": \"tasti a scelta rapida applicazione\",\n        \"hotkey_playbackNext\": \"traccia successiva\",\n        \"useSystemTheme_description\": \"segui le preferenze del tema definite dal sistema\",\n        \"playButtonBehavior_optionAddNext\": \"$t(player.addNext)\",\n        \"lyricFetch_description\": \"ottieni testi da varie sorgenti internet\",\n        \"lyricFetchProvider_description\": \"seleziona i provider dai quali prendere i testi\",\n        \"globalMediaHotkeys_description\": \"attiva/disattiva l'uso dei tasti media globali per controllare la riproduzione\",\n        \"customFontPath\": \"percorso font personalizzato\",\n        \"followLyric\": \"segui testo corrente\",\n        \"crossfadeDuration\": \"durata dissolvenza\",\n        \"discordIdleStatus\": \"mostra lo stato attività di Discord quando non stai riproducendo\",\n        \"audioPlayer\": \"player audio\",\n        \"hotkey_zoomOut\": \"rimpicciolisci layout\",\n        \"hotkey_rate0\": \"rimuovi voto\",\n        \"discordApplicationId\": \"application id {{discord}}\",\n        \"applicationHotkeys_description\": \"configura tasti a scelta rapida dell'applicazione. attiva/disattiva la casella per impostare un tasto a scelta rapida globale (solo desktop)\",\n        \"hotkey_volumeMute\": \"silenzia volume\",\n        \"remoteUsername\": \"username server di controllo remoto\",\n        \"sidebarPlaylistList\": \"lista playlist nella barra laterale\",\n        \"minimizeToTray\": \"riduci a icona\",\n        \"themeDark\": \"tema (scuro)\",\n        \"customFontPath_description\": \"imposta il percorso al font personalizzato da usare per l'applicazione\",\n        \"gaplessAudio_optionWeak\": \"debole (raccomandato)\",\n        \"minimumScrobbleSeconds\": \"durata minima scrobble (secondi)\",\n        \"hotkey_playbackStop\": \"ferma\",\n        \"windowBarStyle_description\": \"seleziona lo stile della barra della finestra\",\n        \"font_description\": \"imposta il font da usare per l'applicazione\",\n        \"savePlayQueue_description\": \"salva la coda di riproduzione quando l'applicazione viene chiusa e ripristina quando l'applicazione viene riaperta\",\n        \"useSystemTheme\": \"usa il tema di sistema\",\n        \"replayGainMode_description\": \"aggiusta il volume secondo i valori {{ReplayGain}} salvati nei metadati del file\",\n        \"showSkipButtons\": \"mostra pulsanti per saltare\",\n        \"sampleRate\": \"frequenza di campionamento\",\n        \"sampleRate_description\": \"seleziona la frequenza di campionamento di output da utilizzare se quella selezionata è diversa da quella del file sorgente in riproduzione. Un valore inferiore a 8000 utilizzerà la frequenza predefinita\",\n        \"hotkey_togglePreviousSongFavorite\": \"imposta/rimuovi $t(common.previousSong) favorito\",\n        \"hotkey_unfavoritePreviousSong\": \"rimuovi $t(common.previousSong) dai preferiti\",\n        \"showSkipButton_description\": \"mostra o nascondi i pulsanti per saltare nella barra del player\",\n        \"hotkey_unfavoriteCurrentSong\": \"rimuovi $t(common.currentSong) dai preferiti\",\n        \"hotkey_toggleCurrentSongFavorite\": \"imposta/rimuovi $t(common.currentSong) favorito\",\n        \"showSkipButton\": \"mostra pulsanti per saltare\",\n        \"hotkey_browserForward\": \"Vai avanti di una pagina\",\n        \"hotkey_browserBack\": \"Torna indietro di una pagina\",\n        \"sidebarCollapsedNavigation_description\": \"mostra o nascondi la navigazione nella barra laterale collassata\",\n        \"replayGainClipping_description\": \"Previeni il clipping causato da {{ReplayGain}} abbassando automaticamente il gain\",\n        \"replayGainPreamp\": \"preamplificazione {{ReplayGain}} (dB)\",\n        \"sidePlayQueueStyle\": \"stile della coda di riproduzione laterale\",\n        \"showSkipButtons_description\": \"mostra o nascondi i pulsanti per saltare dalla barra di riproduzione\",\n        \"skipPlaylistPage_description\": \"quando si naviga in una playlist, si va alla pagina dell'elenco dei brani della playlist invece che alla pagina predefinita\",\n        \"sidePlayQueueStyle_description\": \"imposta lo stile della coda di riproduzione laterale\",\n        \"replayGainMode\": \"modalità {{ReplayGain}}\",\n        \"replayGainFallback_description\": \"gain in db da applicare se il file non possiede tag {{ReplayGain}}\",\n        \"replayGainPreamp_description\": \"aggiusta la preamplificazione del gain applicato sui valori {{ReplayGain}}\",\n        \"skipPlaylistPage\": \"Salta la pagina playlist\",\n        \"sidebarCollapsedNavigation\": \"navigazione con barra laterale (collassata)\",\n        \"clearCache_description\": \"pulitura \\\"forzata\\\" di feishin. Oltre a pulire la cache di feishin, elimina la cache del browser(immagini salvate e altri elementi). credenziali e impostazioni del server saranno mantenute\",\n        \"clearQueryCache\": \"pulisci cache di feishin\",\n        \"buttonSize_description\": \"Dimensione bottoni nella barra di riproduzione\",\n        \"clearCache\": \"pulisci la cache del browser\",\n        \"clearQueryCache_description\": \"\\\"leggera\\\" pulizia di feishin. verranno aggiornate le playlist, metadata delle tracce e i testi salvati. impostazioni, credenziali del server e le immagini salvate saranno mantenute\",\n        \"albumBackground\": \"immagine di sfondo dell'album\",\n        \"albumBackground_description\": \"aggiunge un'immagine di sfondo per le pagine degli album contenenti l'album art\",\n        \"albumBackgroundBlur\": \"intensità sfocatura immagine di sfondo dell'album\",\n        \"albumBackgroundBlur_description\": \"regola la quantità di sfocatura applicata all'immagine di sfondo dell'album\",\n        \"artistConfiguration\": \"configurazione della pagina artista dell’album\",\n        \"artistConfiguration_description\": \"configurare quali elementi vengono visualizzati, e in quale ordine, nella pagina dell'artista dell'album\",\n        \"buttonSize\": \"dimensione del bottone nella barra di riproduzione\",\n        \"clearCacheSuccess\": \"cache pulita correttamente\",\n        \"contextMenu\": \"configurazione menu contestuale (clic destro)\",\n        \"contextMenu_description\": \"consente di nascondere gli elementi che vengono visualizzati nel menu quando si fa clic destro su un elemento. gli oggetti non selezionati saranno nascosti\",\n        \"customCssEnable\": \"abilita css personalizzato\",\n        \"customCssEnable_description\": \"consente di scrivere css personalizzati\",\n        \"customCssNotice\": \"Attenzione: sebbene ci sia una certa sanitizzazione (vengono bloccati url() e content:), l’uso di css personalizzati può comunque comportare dei rischi modificando l’interfaccia\",\n        \"customCss\": \"css personalizzato\",\n        \"customCss_description\": \"contenuto CSS personalizzato. Nota: le proprietà content e gli URL remoti non sono consentiti. Di seguito è mostrata un’anteprima del tuo contenuto. Sono presenti anche altri campi non impostati da te a causa della sanitizzazione\",\n        \"discordPausedStatus\": \"mostra lo stato attività di Discord quando la riproduzione è in pausa\",\n        \"discordPausedStatus_description\": \"quando abilitato, verrà mostrato lo stato del lettore in standby/pausa (nessun brano in riproduzione)\",\n        \"discordListening\": \"mostra stato come in ascolto\",\n        \"discordListening_description\": \"mostra lo stato come in ascolto invece che in riproduzione\",\n        \"discordServeImage\": \"recupera le immagini di {{discord}} dal server\",\n        \"discordServeImage_description\": \"condividi la copertina per lo stato attività di {{discord}} direttamente dal server, disponibile solo per Jellyfin e Navidrome\",\n        \"externalLinks\": \"mostra link esterni\",\n        \"externalLinks_description\": \"consente di visualizzare link esterni (Last.fm, MusicBrainz) sulle pagine di artista/album\",\n        \"preferLocalLyrics\": \"utilizza i testi locali\",\n        \"preferLocalLyrics_description\": \"usa i testi locali anziché quelli online, quando disponibili\",\n        \"homeConfiguration\": \"configurazione della home page\",\n        \"homeConfiguration_description\": \"configura quali elementi vengono mostrati e in quale ordine nella home page\",\n        \"homeFeature\": \"carosello in evidenza nella home page\",\n        \"homeFeature_description\": \"controlla se mostrare il grande carosello in evidenza nella pagina principale\",\n        \"imageAspectRatio\": \"usa dimensioni originali(aspect ratio) della copertina\",\n        \"imageAspectRatio_description\": \"se abilitato, la copertina verrà mostrata utilizzando le dimesioni originali. per le immagini con rapporto diverso da 1:1, lo spazio residuo resterà vuoto\",\n        \"lastfm\": \"mostra links last.fm\",\n        \"lastfm_description\": \"mostra i link per Last.fm sulle pagine di artista/album\",\n        \"lastfmApiKey\": \"{{lastfm}} chiave API\",\n        \"lastfmApiKey_description\": \"chiave API per {{lastfm}}. necessaria per visualizzare le copertine\",\n        \"mpvExtraParameters_help\": \"uno per linea\",\n        \"musicbrainz\": \"mostra links MusicBrainz\",\n        \"musicbrainz_description\": \"mostra link a MusicBrainz sulle pagine degli artisti/album, se è disponibile un MusicBrainz ID\",\n        \"neteaseTranslation\": \"Abilita traduzioni di NetEase\",\n        \"neteaseTranslation_description\": \"Se abilitato, recupera e mostra i testi tradotti da NetEase, se disponibili\",\n        \"passwordStore\": \"Archivio di password/segreti\",\n        \"passwordStore_description\": \"specifica quale archivio di password e segreti utilizzare. modificalo in caso di problemi nel salvataggio delle credenziali\",\n        \"playButtonBehavior_optionPlayShuffled\": \"$t(player.shuffle)\",\n        \"sidePlayQueueStyle_optionAttached\": \"fissata\",\n        \"sidePlayQueueStyle_optionDetached\": \"sganciata\",\n        \"startMinimized\": \"avvia minimizzato\",\n        \"startMinimized_description\": \"avvia l'app nella barra di sistema\",\n        \"transcode_description\": \"abilita la transcodifica in formati diversi\",\n        \"playerbarOpenDrawer\": \"attiva/disattiva schermo intero\",\n        \"playerbarOpenDrawer_description\": \"consente di cliccare sulla barra del lettore per aprire il lettore a schermo intero\",\n        \"replayGainClipping\": \"clipping di {{ReplayGain}}\",\n        \"replayGainFallback\": \"metodo alternativo di {{ReplayGain}}\",\n        \"transcodeBitrate\": \"bitrate per la transcodifica\",\n        \"transcodeBitrate_description\": \"seleziona il bitrate per la transcodifica. 0 significa lasciare che sia il server a scegliere\",\n        \"transcodeFormat\": \"formato per la transcodifica\",\n        \"transcodeFormat_description\": \"seleziona il formato per la transcodifica. se vuoto viene decisco dal server\",\n        \"translationApiProvider\": \"translation api provider\",\n        \"translationApiProvider_description\": \"api provider for translation\",\n        \"translationApiKey\": \"chiave api translation\",\n        \"translationApiKey_description\": \"chiave api per la traduzione (supporta solo endpoint di servizio globali)\",\n        \"translationTargetLanguage\": \"lingua di destinazione della traduzione\",\n        \"translationTargetLanguage_description\": \"lingua di destinazione per la traduzione\",\n        \"trayEnabled\": \"Mostra icona app nella barra di sistema\",\n        \"trayEnabled_description\": \"mostra/nascondi icona app nella barra si sistema. se disabilitato, disattiva anche minimizza/chiudi nella barra di sistema\",\n        \"volumeWidth\": \"larghezza della barra del volume\",\n        \"webAudio\": \"use audio web\",\n        \"webAudio_description\": \"usa audio web. abilita funzionalità avanzate come ReplayGain. disabilita se riscontri problemi\",\n        \"preservePitch\": \"mantieni tono (pitch)\",\n        \"preservePitch_description\": \"mantiene il tono (pitch) durante la modifica della velocità di riproduzione\",\n        \"volumeWidth_description\": \"larghezza del cursore del volume\",\n        \"discordDisplayType_description\": \"modifica cosa stai ascoltando nel tuo stato\",\n        \"discordDisplayType_songname\": \"titolo traccia\",\n        \"discordDisplayType_artistname\": \"nome artisti\",\n        \"hotkey_navigateHome\": \"vai alla schermata iniziale\",\n        \"preventSleepOnPlayback\": \"non sospendere in riproduzione\",\n        \"preventSleepOnPlayback_description\": \"non sospendere il sistema quando la riproduzione è attiva\",\n        \"discordDisplayType\": \"stile dello stato su {{discord}}\",\n        \"discordLinkType\": \"link di attività {{discord}}\",\n        \"discordLinkType_description\": \"aggiunge collegamenti esterni a {{lastfm}} o {{musicbrainz}} ai campi del brano e dell'artista nell'attività {{discord}}. {{musicbrainz}} è il più accurato, ma richiede tag e non fornisce collegamenti dell'artista mentre {{lastfm}} dovrebbe sempre fornire un link. non rende richieste di rete extra\",\n        \"discordLinkType_none\": \"$t(common.none)\",\n        \"discordLinkType_mbz_lastfm\": \"{{musicbrainz}} con {{lastfm}} fallback\",\n        \"autoDJ\": \"auto DJ\",\n        \"autoDJ_description\": \"aggiungi automaticamente canzoni simili alla coda\",\n        \"autoDJ_itemCount\": \"conteggio elementi\",\n        \"analyticsDisable_description\": \"Alcuni dati anonimi sull'utilizzo vengono inviati allo sviluppatore per migliorare l'applicazione\",\n        \"artistBackground\": \"immagine dello sfondo dell'artista\",\n        \"releaseChannel_optionBeta\": \"beta\",\n        \"releaseChannel_optionLatest\": \"ultima\",\n        \"releaseChannel\": \"canale di rilascio\",\n        \"releaseChannel_description\": \"seleziona tra rilascio stabile, beta o alpha (nightly) per gli aggiornamenti automatici\",\n        \"discordRichPresence\": \"{{discord}} rich presence\",\n        \"releaseChannel_optionAlpha\": \"alpha (nightly)\",\n        \"automaticUpdates_description\": \"Controlla e installa aggiornamenti automaticamente\",\n        \"automaticUpdates\": \"Aggiornamenti automatici\",\n        \"exportImportSettings_notValidJSON\": \"il file passato non è un JSON valido\",\n        \"language\": \"lingua\",\n        \"logLevel_optionDebug\": \"debug\",\n        \"logLevel_optionError\": \"errore\",\n        \"logLevel_optionInfo\": \"info\",\n        \"pathReplace_optionRemovePrefix\": \"rimuovi prefisso\",\n        \"pathReplace_optionAddPrefix\": \"aggiungi prefisso\",\n        \"playerFilters\": \"Filtra canzoni dalla coda\",\n        \"imageResolution_optionHeader\": \"header\",\n        \"playerbarWaveformAlign_optionTop\": \"in cima\",\n        \"sidebarPlaylistListFilterRegex_placeholder\": \"es. ^Daily Mix.*\",\n        \"transcode\": \"abilita transcodifica\",\n        \"queryBuilderCustomFields_inputLabel\": \"etichetta\",\n        \"queryBuilderCustomFields_inputTag\": \"tag\",\n        \"queryBuilderCustomFields\": \"campi personalizzati\"\n    },\n    \"error\": {\n        \"remotePortWarning\": \"riavvia il server per applicare la nuova porta\",\n        \"systemFontError\": \"si è verificato un errore nell'otternere i font di sistema\",\n        \"playbackError\": \"si è verificato un errore nel provare a riprodurre il media\",\n        \"endpointNotImplementedError\": \"l'endpoint {{endpoint}} non è implementato per {{serverType}}\",\n        \"remotePortError\": \"si è verificato un errore nel provare a impostare la porta del server remoto\",\n        \"serverRequired\": \"server richiesto\",\n        \"authenticationFailed\": \"autenticazione fallita\",\n        \"apiRouteError\": \"impossibile indirizzare la richiesta\",\n        \"genericError\": \"si è verificato un errore\",\n        \"credentialsRequired\": \"credenziali richieste\",\n        \"sessionExpiredError\": \"la tua sessione è scaduta\",\n        \"remoteEnableError\": \"si è verificato un errore nel $t(common.enable) il server remoto\",\n        \"localFontAccessDenied\": \"accesso non consentito ai font locali\",\n        \"serverNotSelectedError\": \"nessun server selezionato\",\n        \"remoteDisableError\": \"si è verificato un errore nel $t(common.disable) il server remoto\",\n        \"mpvRequired\": \"MPV richiesto\",\n        \"audioDeviceFetchError\": \"si è verificato un errore nel provare ad ottenre i device audio\",\n        \"invalidServer\": \"server non valido\",\n        \"loginRateError\": \"troppi tentativi di accesso, per favore riprova tra qualche secondo\",\n        \"badAlbum\": \"stai visualizzando questa pagina perché questa canzone non fa parte di un album. probabilmente vedi questo messaggio perché hai una canzone posizionata direttamente nella cartella principale della tua libreria musicale. Jellyfin raggruppa le tracce solo se si trovano all’interno di una cartella\",\n        \"badValue\": \"opzione non valida \\\"{{value}}\\\". valore inesistente\",\n        \"networkError\": \"si è verificato un errore di rete\",\n        \"openError\": \"impossibile aprire il file\",\n        \"notificationDenied\": \"i permessi per le notifiche non sono stati concessi. questa configurazione non ha effetto\",\n        \"invalidJson\": \"JSON non valido\",\n        \"noNetwork\": \"server non disponibile\",\n        \"noNetworkDescription\": \"impossibile connettersi al server\"\n    },\n    \"filter\": {\n        \"mostPlayed\": \"più riprodotti\",\n        \"comment\": \"commento\",\n        \"playCount\": \"numero di riproduzioni\",\n        \"recentlyUpdated\": \"aggiornati recentemente\",\n        \"channels\": \"$t(common.channel, {\\\"count\\\": 2})\",\n        \"isCompilation\": \"è una compilation\",\n        \"recentlyPlayed\": \"riprodotti recentemente\",\n        \"isRated\": \"è valutato\",\n        \"owner\": \"$t(common.owner)\",\n        \"title\": \"titolo\",\n        \"rating\": \"voto\",\n        \"search\": \"cerca\",\n        \"bitrate\": \"bitrate\",\n        \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n        \"recentlyAdded\": \"aggiunti recentemente\",\n        \"note\": \"nota\",\n        \"name\": \"nome\",\n        \"dateAdded\": \"data aggiunta\",\n        \"releaseDate\": \"data di rilascio\",\n        \"albumCount\": \"numero $t(entity.album, {\\\"count\\\": 2})\",\n        \"communityRating\": \"voto della community\",\n        \"path\": \"percorso\",\n        \"favorited\": \"preferito\",\n        \"albumArtist\": \"$t(entity.albumArtist, {\\\"count\\\": 1})\",\n        \"isRecentlyPlayed\": \"è stato recentemente riprodotto\",\n        \"isFavorited\": \"è preferito\",\n        \"bpm\": \"bpm\",\n        \"releaseYear\": \"anno di rilascio\",\n        \"id\": \"id\",\n        \"disc\": \"disco\",\n        \"biography\": \"biografia\",\n        \"songCount\": \"conteggio canzoni\",\n        \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n        \"duration\": \"durata\",\n        \"isPublic\": \"è pubblico\",\n        \"random\": \"casuale\",\n        \"lastPlayed\": \"ultima riproduzione\",\n        \"toYear\": \"fino all'anno\",\n        \"fromYear\": \"dall'anno\",\n        \"criticRating\": \"voto della critica\",\n        \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n        \"trackNumber\": \"traccia\",\n        \"matchOr\": \"o\",\n        \"sortName\": \"ordina nome\"\n    },\n    \"page\": {\n        \"sidebar\": {\n            \"nowPlaying\": \"in riproduzione\",\n            \"playlists\": \"$t(entity.playlist, {\\\"count\\\": 2})\",\n            \"search\": \"$t(common.search)\",\n            \"tracks\": \"$t(entity.track, {\\\"count\\\": 2})\",\n            \"albums\": \"$t(entity.album, {\\\"count\\\": 2})\",\n            \"genres\": \"$t(entity.genre, {\\\"count\\\": 2})\",\n            \"folders\": \"$t(entity.folder, {\\\"count\\\": 2})\",\n            \"settings\": \"$t(common.setting, {\\\"count\\\": 2})\",\n            \"home\": \"$t(common.home)\",\n            \"artists\": \"$t(entity.artist, {\\\"count\\\": 2})\",\n            \"albumArtists\": \"$t(entity.albumArtist, {\\\"count\\\": 2})\",\n            \"myLibrary\": \"la mia libreria\",\n            \"shared\": \"condivisa $t(entity.playlist, {\\\"count\\\": 2})\",\n            \"collections\": \"collezioni\",\n            \"favorites\": \"$t(entity.favorite, {\\\"count\\\": 2})\",\n            \"radio\": \"$t(entity.radioStation, {\\\"count\\\": 2})\"\n        },\n        \"fullscreenPlayer\": {\n            \"config\": {\n                \"showLyricMatch\": \"mostra corrispondenza testi\",\n                \"dynamicBackground\": \"background dinamico\",\n                \"synchronized\": \"sincronizzato\",\n                \"followCurrentLyric\": \"segui testo corrente\",\n                \"opacity\": \"opacità\",\n                \"lyricSize\": \"dimensione testo\",\n                \"showLyricProvider\": \"mostra provider testi\",\n                \"unsynchronized\": \"non sinncronizzato\",\n                \"lyricAlignment\": \"allineamento testo\",\n                \"useImageAspectRatio\": \"usa le proporzioni dell'immagine\",\n                \"lyricGap\": \"gap testo\",\n                \"dynamicImageBlur\": \"intensità sfocatura immagine\",\n                \"dynamicIsImage\": \"abilita immagine di sfondo\",\n                \"lyricOffset\": \"ritardo testi (ms)\"\n            },\n            \"upNext\": \"successivamente\",\n            \"lyrics\": \"testi\",\n            \"related\": \"correlati\",\n            \"visualizer\": \"visualizzatore audio\",\n            \"noLyrics\": \"nessun testo trovato\"\n        },\n        \"appMenu\": {\n            \"selectServer\": \"seleziona server\",\n            \"version\": \"versione {{version}}\",\n            \"settings\": \"$t(common.setting, {\\\"count\\\": 2})\",\n            \"manageServers\": \"gestisci server\",\n            \"expandSidebar\": \"espandi barra laterale\",\n            \"collapseSidebar\": \"collassa barra laterale\",\n            \"openBrowserDevtools\": \"apri devtools browser\",\n            \"quit\": \"$t(common.quit)\",\n            \"goBack\": \"torna indietro\",\n            \"goForward\": \"vai avanti\",\n            \"privateModeOff\": \"disabilita modalità privata\",\n            \"privateModeOn\": \"abilita modalità privata\",\n            \"selectMusicFolder\": \"seleziona cartella con musica\",\n            \"noMusicFolder\": \"nessuna cartella con musica selezionata\",\n            \"multipleMusicFolders\": \"{{count}} cartelle con musica selezionate\"\n        },\n        \"contextMenu\": {\n            \"addToPlaylist\": \"$t(action.addToPlaylist)\",\n            \"addToFavorites\": \"$t(action.addToFavorites)\",\n            \"setRating\": \"$t(action.setRating)\",\n            \"moveToTop\": \"$t(action.moveToTop)\",\n            \"deletePlaylist\": \"$t(action.deletePlaylist)\",\n            \"moveToBottom\": \"$t(action.moveToBottom)\",\n            \"createPlaylist\": \"$t(action.createPlaylist)\",\n            \"removeFromPlaylist\": \"$t(action.removeFromPlaylist)\",\n            \"removeFromFavorites\": \"$t(action.removeFromFavorites)\",\n            \"addNext\": \"$t(player.addNext)\",\n            \"deselectAll\": \"$t(action.deselectAll)\",\n            \"addLast\": \"$t(player.addLast)\",\n            \"addFavorite\": \"$t(action.addToFavorites)\",\n            \"play\": \"$t(player.play)\",\n            \"numberSelected\": \"{{count}} selezionati\",\n            \"removeFromQueue\": \"$t(action.removeFromQueue)\",\n            \"download\": \"download\",\n            \"moveToNext\": \"$t(action.moveToNext)\",\n            \"playSimilarSongs\": \"$t(player.playSimilarSongs)\",\n            \"playShuffled\": \"$t(player.shuffle)\",\n            \"shareItem\": \"condividi elemento\",\n            \"showDetails\": \"mostra info\",\n            \"goToAlbum\": \"vai a $t(entity.album, {\\\"count\\\": 1})\",\n            \"goToAlbumArtist\": \"vai a $t(entity.albumArtist, {\\\"count\\\": 1})\",\n            \"moveItems\": \"$t(action.moveItems)\",\n            \"goTo\": \"vai a\"\n        },\n        \"home\": {\n            \"mostPlayed\": \"più riprodotti\",\n            \"newlyAdded\": \"nuovi rilasci aggiunti\",\n            \"title\": \"$t(common.home)\",\n            \"explore\": \"esplora dalla tua libreria\",\n            \"recentlyPlayed\": \"riprodotti recentemente\",\n            \"recentlyReleased\": \"appena pubblicato\",\n            \"genres\": \"$t(entity.genre, {\\\"count\\\": 2})\"\n        },\n        \"albumDetail\": {\n            \"moreFromArtist\": \"di più da questo $t(entity.artist, {\\\"count\\\": 1})\",\n            \"moreFromGeneric\": \"di più da {{item}}\",\n            \"released\": \"rilasciato\"\n        },\n        \"setting\": {\n            \"playbackTab\": \"riproduzione\",\n            \"generalTab\": \"generale\",\n            \"hotkeysTab\": \"tasti a scelta rapida\",\n            \"windowTab\": \"finestra\",\n            \"advanced\": \"avanzate\",\n            \"updates\": \"aggiorna\",\n            \"cache\": \"cache\",\n            \"application\": \"applicazione\",\n            \"theme\": \"tema\",\n            \"controls\": \"controlla\",\n            \"remote\": \"remoto\",\n            \"exportImport\": \"importa/esporta\",\n            \"scrobble\": \"scrobble\",\n            \"audio\": \"audio\",\n            \"lyrics\": \"testi\",\n            \"transcoding\": \"transcodifica\",\n            \"discord\": \"discord\",\n            \"lyricsDisplay\": \"mostra testi\"\n        },\n        \"albumArtistList\": {\n            \"title\": \"$t(entity.albumArtist, {\\\"count\\\": 2})\"\n        },\n        \"genreList\": {\n            \"title\": \"$t(entity.genre, {\\\"count\\\": 2})\",\n            \"showAlbums\": \"mostra $t(entity.genre, {\\\"count\\\": 1}) $t(entity.album, {\\\"count\\\": 2})\",\n            \"showTracks\": \"mostra $t(entity.genre, {\\\"count\\\": 1}) $t(entity.track, {\\\"count\\\": 2})\"\n        },\n        \"trackList\": {\n            \"title\": \"$t(entity.track, {\\\"count\\\": 2})\",\n            \"artistTracks\": \"tracce di {{artist}}\",\n            \"genreTracks\": \"\\\"{{genre}}\\\" $t(entity.track, {\\\"count\\\": 2})\"\n        },\n        \"globalSearch\": {\n            \"commands\": {\n                \"serverCommands\": \"comandi server\",\n                \"goToPage\": \"vai alla pagina\",\n                \"searchFor\": \"cerca per {{query}}\"\n            },\n            \"title\": \"comandi\"\n        },\n        \"playlistList\": {\n            \"title\": \"$t(entity.playlist, {\\\"count\\\": 2})\"\n        },\n        \"albumList\": {\n            \"title\": \"$t(entity.album, {\\\"count\\\": 2})\",\n            \"artistAlbums\": \"albums di {{artist}}\",\n            \"genreAlbums\": \"\\\"{{genre}}\\\" $t(entity.album, {\\\"count\\\": 2})\"\n        },\n        \"albumArtistDetail\": {\n            \"about\": \"Info {{artist}}\",\n            \"appearsOn\": \"compare su\",\n            \"recentReleases\": \"uscite recenti\",\n            \"viewDiscography\": \"mostra discografia\",\n            \"relatedArtists\": \"correlati $t(entity.artist, {\\\"count\\\": 2})\",\n            \"topSongs\": \"brani migliori\",\n            \"topSongsFrom\": \"brani migliori da {{title}}\",\n            \"viewAll\": \"mostra tutto\",\n            \"viewAllTracks\": \"mostra tutto $t(entity.track, {\\\"count\\\": 2})\",\n            \"favoriteSongs\": \"canzoni preferite\",\n            \"topSongsPersonal\": \"personale\",\n            \"favoriteSongsFrom\": \"canzoni preferite da {{title}}\"\n        },\n        \"manageServers\": {\n            \"title\": \"gestisci servers\",\n            \"serverDetails\": \"dettagli server\",\n            \"url\": \"URL\",\n            \"username\": \"nome utente\",\n            \"editServerDetailsTooltip\": \"modifica dettagli server\",\n            \"removeServer\": \"rimuovi server\"\n        },\n        \"itemDetail\": {\n            \"copyPath\": \"copia percorso negli appunti\",\n            \"copiedPath\": \"percorso copiato con successo\",\n            \"openFile\": \"mostra traccia nel gestore file\"\n        },\n        \"playlist\": {\n            \"reorder\": \"riordino abilitato solo quando si ordina per id\"\n        },\n        \"radioList\": {\n            \"title\": \"stazioni radio\"\n        },\n        \"folderList\": {\n            \"title\": \"$t(entity.folder, {\\\"count\\\": 2})\"\n        },\n        \"collections\": {\n            \"overrideExisting\": \"sovrascrivi esistente\",\n            \"saveAsCollection\": \"salva come collezione\"\n        }\n    },\n    \"form\": {\n        \"deletePlaylist\": {\n            \"title\": \"elimina $t(entity.playlist, {\\\"count\\\": 1})\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) eliminata correttamente\",\n            \"input_confirm\": \"digita il nome della $t(entity.playlist, {\\\"count\\\": 1}) per confermare\"\n        },\n        \"createPlaylist\": {\n            \"input_description\": \"$t(common.description)\",\n            \"title\": \"crea $t(entity.playlist, {\\\"count\\\": 1})\",\n            \"input_public\": \"publico\",\n            \"input_name\": \"$t(common.name)\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) creata con successo\",\n            \"input_owner\": \"$t(common.owner)\"\n        },\n        \"addServer\": {\n            \"title\": \"aggiungi server\",\n            \"input_username\": \"nome utente\",\n            \"input_url\": \"url\",\n            \"input_password\": \"password\",\n            \"input_legacyAuthentication\": \"abilita autenticazione legacy\",\n            \"input_name\": \"nome server\",\n            \"success\": \"server aggiunto con successo\",\n            \"input_savePassword\": \"salva password\",\n            \"ignoreSsl\": \"ignora ssl ($t(common.restartRequired))\",\n            \"ignoreCors\": \"ignora cors ($t(common.restartRequired))\",\n            \"error_savePassword\": \"si è verificato un errore quando si è provato a salvare la password\",\n            \"input_preferInstantMix\": \"preferisci mix istantaneo\",\n            \"input_preferInstantMixDescription\": \"usa solo mix istantaneo per ottenere canzoni simili. utile se si dispone di plugin che modificano questo comportamento\",\n            \"input_preferRemoteUrl\": \"preferisci url pubblico\",\n            \"input_remoteUrl\": \"url pubblico\"\n        },\n        \"addToPlaylist\": {\n            \"success\": \"aggiunto $t(entity.trackWithCount, {\\\"count\\\": {{message}} }) a $t(entity.playlistWithCount, {\\\"count\\\": {{numOfPlaylists}} })\",\n            \"title\": \"aggiungi a $t(entity.playlist, {\\\"count\\\": 1})\",\n            \"input_skipDuplicates\": \"salta duplicati\",\n            \"input_playlists\": \"$t(entity.playlist, {\\\"count\\\": 2})\",\n            \"create\": \"crea $t(entity.playlist, {\\\"count\\\": 1})\",\n            \"searchOrCreate\": \"cerca $t(entity.playlist, {\\\"count\\\": 2}) o digita per crearne una nuova\"\n        },\n        \"updateServer\": {\n            \"title\": \"aggiorna server\",\n            \"success\": \"server aggiornato con successo\"\n        },\n        \"queryEditor\": {\n            \"input_optionMatchAll\": \"soddisfa tutti\",\n            \"input_optionMatchAny\": \"soddisfa qualsiasi\",\n            \"title\": \"editor di query\",\n            \"resetToDefault\": \"ripristina predefinito\",\n            \"clearFilters\": \"rimuovi filtri\"\n        },\n        \"lyricSearch\": {\n            \"input_name\": \"$t(common.name)\",\n            \"input_artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n            \"title\": \"cerca testi\"\n        },\n        \"editPlaylist\": {\n            \"title\": \"modifica $t(entity.playlist, {\\\"count\\\": 1})\",\n            \"publicJellyfinNote\": \"Jellyfin non mostra se una playlist è pubblica o meno. Se vuoi che rimanga pubblica, assicurati di selezionare l’opzione seguente\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) aggiornato con successo\"\n        },\n        \"shareItem\": {\n            \"allowDownloading\": \"consentire il download\",\n            \"description\": \"descrizione\",\n            \"setExpiration\": \"imposta scadenza\",\n            \"success\": \"link di condivisione copiato negli appunti (o clicca qui per aprirlo)\",\n            \"expireInvalid\": \"la scadenza deve essere nel futuro\",\n            \"createFailed\": \"condivisione fallita (è abilitata la condivisione?)\"\n        },\n        \"privateMode\": {\n            \"enabled\": \"la modalità privata è abilitata: lo stato di riproduzione viene ora nascosto alle integrazioni esterne\",\n            \"disabled\": \"la modalità privata è disabilitata: lo stato di riproduzione è ora visibile alle integrazioni esterne abilitate\",\n            \"title\": \"modalità privata\"\n        },\n        \"largeFetchConfirmation\": {\n            \"title\": \"aggiungi elementi alla coda\"\n        },\n        \"createRadioStation\": {\n            \"success\": \"stazione radio creata con successo\",\n            \"title\": \"crea stazione radio\",\n            \"input_name\": \"nome\"\n        },\n        \"lyricsExport\": {\n            \"export\": \"esporta testi\",\n            \"input_synced\": \"esporta testi sincronizzati\",\n            \"input_offset\": \"$t(setting.lyricOffset)\"\n        },\n        \"shuffleAll\": {\n            \"input_limit\": \"quante canzoni?\",\n            \"input_minYear\": \"dall'anno\",\n            \"input_maxYear\": \"all'anno\",\n            \"input_played_optionAll\": \"tutte le tracce\",\n            \"input_played_optionUnplayed\": \"solo tracce non ancora riprodotte\",\n            \"input_played_optionPlayed\": \"solo tracce riprodotte\"\n        }\n    },\n    \"table\": {\n        \"config\": {\n            \"general\": {\n                \"displayType\": \"mostra tipo\",\n                \"gap\": \"$t(common.gap)\",\n                \"tableColumns\": \"tabella colonne\",\n                \"autoFitColumns\": \"adatta colonne automaticamente\",\n                \"size\": \"$t(common.size)\",\n                \"followCurrentSong\": \"segui il brano corrente\",\n                \"itemGap\": \"spaziatura tra gli elementi (px)\",\n                \"itemSize\": \"dimensione dell’elemento (px)\",\n                \"advancedSettings\": \"impostazioni avanzate\",\n                \"moveUp\": \"muovi sopra\",\n                \"moveDown\": \"muovi sotto\",\n                \"pinToLeft\": \"fissa a sinistra\",\n                \"pinToRight\": \"fissa a destra\",\n                \"alignLeft\": \"allinea a sinistra\",\n                \"alignCenter\": \"allina al centro\",\n                \"alignRight\": \"allinea a destra\",\n                \"itemsPerRow\": \"elementi per riga\",\n                \"size_default\": \"predefinito\",\n                \"size_compact\": \"compatto\",\n                \"size_large\": \"largo\",\n                \"pagination\": \"paginazione\",\n                \"pagination_itemsPerPage\": \"elementi per pagina\",\n                \"pagination_infinite\": \"infinita\",\n                \"pagination_paginate\": \"impaginato\"\n            },\n            \"view\": {\n                \"table\": \"tabella\",\n                \"grid\": \"griglia\",\n                \"list\": \"lista\",\n                \"detail\": \"dettaglio\"\n            },\n            \"label\": {\n                \"releaseDate\": \"data rilascio\",\n                \"title\": \"$t(common.title)\",\n                \"duration\": \"$t(common.duration)\",\n                \"titleCombined\": \"$t(common.title) (combinati)\",\n                \"dateAdded\": \"data aggiunta\",\n                \"size\": \"$t(common.size)\",\n                \"bpm\": \"$t(common.bpm)\",\n                \"lastPlayed\": \"ultima riproduzione\",\n                \"trackNumber\": \"numero traccia\",\n                \"rowIndex\": \"indice riga\",\n                \"rating\": \"$t(common.rating)\",\n                \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n                \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n                \"note\": \"$t(common.note)\",\n                \"biography\": \"$t(common.biography)\",\n                \"owner\": \"$t(common.owner)\",\n                \"path\": \"$t(common.path)\",\n                \"channels\": \"$t(common.channel, {\\\"count\\\": 2})\",\n                \"playCount\": \"numero riproduzioni\",\n                \"bitrate\": \"$t(common.bitrate)\",\n                \"actions\": \"$t(common.action, {\\\"count\\\": 2})\",\n                \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n                \"discNumber\": \"numero disco\",\n                \"favorite\": \"$t(common.favorite)\",\n                \"year\": \"$t(common.year)\",\n                \"albumArtist\": \"$t(entity.albumArtist, {\\\"count\\\": 1})\",\n                \"codec\": \"$t(common.codec)\",\n                \"songCount\": \"$t(entity.track, {\\\"count\\\": 2})\",\n                \"bitDepth\": \"$t(common.bitDepth)\",\n                \"albumCount\": \"$t(entity.album, {\\\"count\\\": 2})\",\n                \"composer\": \"compositore\",\n                \"image\": \"immagine\",\n                \"sampleRate\": \"$t(common.sampleRate)\",\n                \"titleArtist\": \"$t(common.title) (artista)\"\n            }\n        },\n        \"column\": {\n            \"comment\": \"commento\",\n            \"album\": \"album\",\n            \"rating\": \"voto\",\n            \"favorite\": \"preferito\",\n            \"playCount\": \"riproduzioni\",\n            \"albumCount\": \"$t(entity.album, {\\\"count\\\": 2})\",\n            \"releaseYear\": \"anno\",\n            \"lastPlayed\": \"ultima riproduzione\",\n            \"biography\": \"biografia\",\n            \"releaseDate\": \"data di rilascio\",\n            \"bitrate\": \"bitrate\",\n            \"title\": \"titolo\",\n            \"bpm\": \"bpm\",\n            \"dateAdded\": \"data aggiunta\",\n            \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n            \"songCount\": \"$t(entity.track, {\\\"count\\\": 2})\",\n            \"trackNumber\": \"traccia\",\n            \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n            \"albumArtist\": \"artista album\",\n            \"path\": \"percorso\",\n            \"discNumber\": \"disco\",\n            \"channels\": \"$t(common.channel, {\\\"count\\\": 2})\",\n            \"size\": \"$t(common.size)\",\n            \"codec\": \"$t(common.codec)\",\n            \"bitDepth\": \"$t(common.bitDepth)\",\n            \"sampleRate\": \"$t(common.sampleRate)\",\n            \"owner\": \"proprietario\"\n        }\n    },\n    \"entity\": {\n        \"genre_one\": \"genere\",\n        \"genre_many\": \"generi\",\n        \"genre_other\": \"generi\",\n        \"playlistWithCount_one\": \"{{count}} playlist\",\n        \"playlistWithCount_many\": \"{{count}} playlist\",\n        \"playlistWithCount_other\": \"{{count}} playlist\",\n        \"playlist_one\": \"playlist\",\n        \"playlist_many\": \"playlist\",\n        \"playlist_other\": \"playlist\",\n        \"artist_one\": \"artista\",\n        \"artist_many\": \"artisti\",\n        \"artist_other\": \"artisti\",\n        \"folderWithCount_one\": \"{{count}} cartella\",\n        \"folderWithCount_many\": \"{{count}} cartelle\",\n        \"folderWithCount_other\": \"{{count}} cartelle\",\n        \"albumArtist_one\": \"artista album\",\n        \"albumArtist_many\": \"artisti album\",\n        \"albumArtist_other\": \"artisti album\",\n        \"track_one\": \"traccia\",\n        \"track_many\": \"tracce\",\n        \"track_other\": \"tracce\",\n        \"albumArtistCount_one\": \"{{count}} artista album\",\n        \"albumArtistCount_many\": \"{{count}} artisti album\",\n        \"albumArtistCount_other\": \"{{count}} artisti album\",\n        \"albumWithCount_one\": \"{{count}} album\",\n        \"albumWithCount_many\": \"{{count}} album\",\n        \"albumWithCount_other\": \"{{count}} album\",\n        \"favorite_one\": \"preferito\",\n        \"favorite_many\": \"preferiti\",\n        \"favorite_other\": \"preferiti\",\n        \"artistWithCount_one\": \"{{count}} artista\",\n        \"artistWithCount_many\": \"{{count}} artisti\",\n        \"artistWithCount_other\": \"{{count}} artisti\",\n        \"folder_one\": \"cartella\",\n        \"folder_many\": \"cartelle\",\n        \"folder_other\": \"cartelle\",\n        \"smartPlaylist\": \"$t(entity.playlist, {\\\"count\\\": 1}) smart\",\n        \"album_one\": \"album\",\n        \"album_many\": \"album\",\n        \"album_other\": \"album\",\n        \"genreWithCount_one\": \"{{count}} genere\",\n        \"genreWithCount_many\": \"{{count}} generi\",\n        \"genreWithCount_other\": \"{{count}} generi\",\n        \"trackWithCount_one\": \"{{count}} traccia\",\n        \"trackWithCount_many\": \"{{count}} tracce\",\n        \"trackWithCount_other\": \"{{count}} tracce\",\n        \"play_one\": \"{{count}} riproduzione\",\n        \"play_many\": \"{{count}} riproduzioni\",\n        \"play_other\": \"{{count}} riproduzioni\",\n        \"song_one\": \"traccia\",\n        \"song_many\": \"tracce\",\n        \"song_other\": \"tracce\",\n        \"radioStation_one\": \"stazione radio\",\n        \"radioStation_many\": \"stazioni radio\",\n        \"radioStation_other\": \"stazioni radio\",\n        \"radioStationWithCount_one\": \"{{count}} stazione radio\",\n        \"radioStationWithCount_many\": \"{{count}} stazioni radio\",\n        \"radioStationWithCount_other\": \"{{count}} stazioni radio\"\n    },\n    \"datetime\": {\n        \"minuteShort\": \"m\",\n        \"secondShort\": \"s\",\n        \"hourShort\": \"o\",\n        \"dayShort\": \"g\"\n    },\n    \"filterOperator\": {\n        \"contains\": \"contiene\",\n        \"endsWith\": \"finisce con\",\n        \"inPlaylist\": \"è in\",\n        \"is\": \"è\",\n        \"isNot\": \"non è\",\n        \"isGreaterThan\": \"è più grande di\",\n        \"isLessThan\": \"è meno di\",\n        \"notContains\": \"non contiene\",\n        \"notInPlaylist\": \"non è in\",\n        \"startsWith\": \"inizia con\"\n    },\n    \"releaseType\": {\n        \"primary\": {\n            \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n            \"broadcast\": \"broadcast\",\n            \"ep\": \"ep\",\n            \"other\": \"altro\",\n            \"single\": \"singolo\"\n        },\n        \"secondary\": {\n            \"audiobook\": \"audiolibro\",\n            \"audioDrama\": \"audio drama\",\n            \"compilation\": \"compilation\",\n            \"interview\": \"intervista\",\n            \"live\": \"live\",\n            \"mixtape\": \"mixtape\",\n            \"remix\": \"remix\",\n            \"soundtrack\": \"soundtrack\"\n        }\n    },\n    \"queryBuilder\": {\n        \"customTags\": \"tag personalizzati\"\n    },\n    \"dragDropZone\": {\n        \"error_oneFileOnly\": \"Per favore seleziona solo 1 file\",\n        \"error_readingFile\": \"errore nella lettura del file: {{errorMessage}}\",\n        \"mainText\": \"rilascia un file qui\"\n    },\n    \"visualizer\": {\n        \"ignoredPresets\": \"Preset Ignorati\",\n        \"includeAllPresets\": \"Includi Tutti i Preset\",\n        \"selectedPresets\": \"Preset Selezionati\",\n        \"presets\": \"Preset\",\n        \"selectPreset\": \"Seleziona Preset\",\n        \"applyPreset\": \"Applica Preset\",\n        \"saveAsPreset\": \"Salva come Preset\",\n        \"updatePreset\": \"Aggiorna Preset\",\n        \"copyConfiguration\": \"Copia Configurazione\",\n        \"pasteConfiguration\": \"Incolla Configurazione\",\n        \"pasteConfigurationPlaceholder\": \"Incolla la configurazione JSON qui...\",\n        \"pasteFromClipboard\": \"Incolla dalla Clipboard\",\n        \"applyConfiguration\": \"Applica Configurazione\",\n        \"presetName\": \"Nome Preset\",\n        \"presetNamePlaceholder\": \"Inserisci il nome del preset\",\n        \"general\": \"Generale\",\n        \"mode\": \"Modalità\",\n        \"maxFPS\": \"Max FPS\",\n        \"opacity\": \"Opacità\",\n        \"customGradients\": \"Gradienti Personalizzati\",\n        \"gradientNamePlaceholder\": \"Nome Gradiente\",\n        \"vertical\": \"Verticale\",\n        \"horizontal\": \"Orizzontale\",\n        \"addColor\": \"Aggiungi Colore\",\n        \"position\": \"Posizione\",\n        \"level\": \"Livello\",\n        \"remove\": \"Rimuovi\",\n        \"pasteGradient\": \"Incolla Gradiente\",\n        \"custom\": \"Personalizzato\",\n        \"gradient\": \"Gradiente\"\n    }\n}\n"
  },
  {
    "path": "src/i18n/locales/ja.json",
    "content": "{\n    \"player\": {\n        \"repeat_all\": \"全曲リピート\",\n        \"stop\": \"停止\",\n        \"repeat\": \"リピート\",\n        \"queue_remove\": \"選択項目を削除\",\n        \"playRandom\": \"ランダム再生\",\n        \"skip\": \"スキップ\",\n        \"previous\": \"前へ\",\n        \"toggleFullscreenPlayer\": \"全画面プレーヤーに切り替える\",\n        \"skip_back\": \"前へスキップ\",\n        \"favorite\": \"お気に入り\",\n        \"next\": \"次へ\",\n        \"shuffle\": \"再生 (シャッフル)\",\n        \"playbackFetchNoResults\": \"曲が見つかりません\",\n        \"playbackFetchInProgress\": \"曲を読み込み中…\",\n        \"addNext\": \"次\",\n        \"playbackSpeed\": \"再生速度\",\n        \"playbackFetchCancel\": \"処理に時間がかかります… 通知を閉じるとキャンセルします\",\n        \"play\": \"再生\",\n        \"repeat_off\": \"リピート無効\",\n        \"queue_clear\": \"キューをクリア\",\n        \"muted\": \"ミュート中\",\n        \"unfavorite\": \"お気に入り解除\",\n        \"queue_moveToTop\": \"選択項目を一番下に移動\",\n        \"queue_moveToBottom\": \"選択項目を先頭に移動\",\n        \"shuffle_off\": \"シャッフル無効\",\n        \"addLast\": \"最後\",\n        \"mute\": \"ミュート\",\n        \"skip_forward\": \"次へスキップ\",\n        \"pause\": \"一時停止\",\n        \"playSimilarSongs\": \"似たような曲を再生する\",\n        \"viewQueue\": \"キューを表示\",\n        \"lyrics\": \"歌詞\",\n        \"restoreQueueFromServer\": \"サーバーからキューを復元\",\n        \"saveQueueToServer\": \"サーバーにキューを保存\",\n        \"addLastShuffled\": \"最後 (シャッフル)\",\n        \"addNextShuffled\": \"次 (シャッフル)\",\n        \"sleepTimer_minutes\": \"{{count}} 分\",\n        \"sleepTimer_hours\": \"{{count}} 時間\",\n        \"sleepTimer\": \"スリープタイマー\",\n        \"sleepTimer_endOfSong\": \"現在の曲の終わり\",\n        \"sleepTimer_custom\": \"カスタム\",\n        \"sleepTimer_off\": \"オフ\",\n        \"sleepTimer_timeRemaining\": \"残り {{time}}\",\n        \"sleepTimer_setCustom\": \"タイマーを設定\",\n        \"sleepTimer_cancel\": \"タイマーをキャンセル\",\n        \"holdToShuffle\": \"長押しでシャッフル\",\n        \"albumRadio\": \"アルバム・ラジオ\",\n        \"artistRadio\": \"アーティストラジオ\",\n        \"trackRadio\": \"ラジオを追跡する\"\n    },\n    \"setting\": {\n        \"crossfadeStyle_description\": \"オーディオプレーヤーが使用するクロスフェードのスタイルを選択します\",\n        \"remotePort_description\": \"リモートコントロール サーバーのポートを設定します\",\n        \"hotkey_skipBackward\": \"前にスキップ\",\n        \"replayGainMode_description\": \"ファイルのメタデータに保存されている {{ReplayGain}} 値に従って音量ゲインを調整します\",\n        \"volumeWheelStep_description\": \"音量スライダーでマウスホイールをスクロールしたときに変化する音量を設定します\",\n        \"audioDevice_description\": \"再生に使用するオーディオデバイスを選択します\",\n        \"theme_description\": \"アプリケーションに使用するテーマを設定します\",\n        \"hotkey_playbackPause\": \"一時停止\",\n        \"replayGainFallback\": \"{{ReplayGain}} フォールバック\",\n        \"sidebarCollapsedNavigation_description\": \"折りたたまれたサイドバーのナビゲーションを表示または非表示にします\",\n        \"hotkey_volumeUp\": \"音量を上げる\",\n        \"skipDuration\": \"スキップの長さ\",\n        \"discordIdleStatus_description\": \"有効にすると、プレーヤーがアイドル状態でもステータスを更新します\",\n        \"showSkipButtons\": \"スキップボタンを表示\",\n        \"playButtonBehavior_optionPlay\": \"$t(player.play)\",\n        \"minimumScrobblePercentage\": \"最小 Scrobble 時間 (%)\",\n        \"lyricFetch\": \"インターネットから歌詞を取得\",\n        \"scrobble\": \"Scrobble\",\n        \"skipDuration_description\": \"プレーヤーバーのスキップボタンでスキップする時間を設定します\",\n        \"enableRemote_description\": \"リモートコントロール サーバーを有効化し、他のデバイスからアプリケーションを制御できるようにします\",\n        \"fontType_optionSystem\": \"システムフォント\",\n        \"mpvExecutablePath_description\": \"MPV を実行するファイルパスを設定します。空のままにすると、デフォルトのパスが使用されます\",\n        \"replayGainClipping_description\": \"自動的にゲインを下げて {{ReplayGain}} によるクリッピングを防ぎます\",\n        \"replayGainPreamp\": \"{{ReplayGain}} プリアンプ (dB)\",\n        \"hotkey_favoriteCurrentSong\": \"$t(common.currentSong) をお気に入りに登録\",\n        \"sampleRate\": \"サンプルレート\",\n        \"sidePlayQueueStyle_optionAttached\": \"結合\",\n        \"sidebarConfiguration\": \"サイドバー設定\",\n        \"sampleRate_description\": \"選択したサンプル周波数が現在のメディアの周波数と異なる場合に使用する出力サンプルレートを選択します。8000 未満の値を指定するとデフォルトの周波数が使用されます\",\n        \"replayGainMode_optionNone\": \"$t(common.none)\",\n        \"replayGainClipping\": \"{{ReplayGain}} クリッピング\",\n        \"hotkey_zoomIn\": \"拡大\",\n        \"scrobble_description\": \"再生した音楽をメディアサーバーから Scrobble します\",\n        \"hotkey_browserForward\": \"ブラウザ 進む\",\n        \"audioExclusiveMode_description\": \"排他出力モードを有効にします。このモードでは、システムの他の出力がロックされ、MPV のみがオーディオを出力できるようになります\",\n        \"discordUpdateInterval\": \"{{discord}} Rich Presence の更新間隔\",\n        \"themeLight\": \"テーマ (ライト)\",\n        \"fontType_optionBuiltIn\": \"組み込みフォント\",\n        \"hotkey_playbackPlayPause\": \"再生 / 一時停止\",\n        \"hotkey_rate1\": \"1 つ星で評価\",\n        \"hotkey_skipForward\": \"次へスキップ\",\n        \"disableLibraryUpdateOnStartup\": \"起動時の新バージョンチェックを無効にします\",\n        \"discordApplicationId_description\": \"{{discord}} に Rich Presence ステータスを表示するためのアプリケーション ID (デフォルトは {{defaultId}} です)\",\n        \"sidePlayQueueStyle\": \"サイド再生キューの形式\",\n        \"gaplessAudio\": \"ギャップレス再生\",\n        \"playButtonBehavior_optionAddLast\": \"$t(player.addLast)\",\n        \"zoom\": \"ズーム率\",\n        \"minimizeToTray_description\": \"最小化ボタンが押された際、システムトレイに格納します\",\n        \"hotkey_playbackPlay\": \"再生\",\n        \"hotkey_togglePreviousSongFavorite\": \"$t(common.previousSong) のお気に入りを切り替え\",\n        \"hotkey_volumeDown\": \"音量を下げる\",\n        \"hotkey_unfavoritePreviousSong\": \"$t(common.previousSong) をお気に入りから解除\",\n        \"audioPlayer_description\": \"再生に使用するオーディオプレーヤーを選択します\",\n        \"globalMediaHotkeys\": \"グローバルメディアホットキー\",\n        \"hotkey_globalSearch\": \"グローバル検索\",\n        \"gaplessAudio_description\": \"MPV 向けのギャップレス再生を設定します\",\n        \"remoteUsername_description\": \"リモートコントロール サーバーのユーザ名を設定します。 ユーザー名とパスワードの両方が空の場合、認証は無効になります\",\n        \"exitToTray_description\": \"アプリケーション終了ボタンが押された際、システムトレイに格納します\",\n        \"followLyric_description\": \"現在の再生位置に歌詞をスクロールします\",\n        \"hotkey_favoritePreviousSong\": \"$t(common.previousSong) をお気に入りに登録\",\n        \"replayGainMode_optionAlbum\": \"$t(entity.album, {\\\"count\\\": 1})\",\n        \"lyricOffset\": \"歌詞のオフセット (ミリ秒)\",\n        \"discordUpdateInterval_description\": \"更新間隔 (秒単位、最小 15 秒)\",\n        \"fontType_optionCustom\": \"カスタムフォント\",\n        \"themeDark_description\": \"アプリケーションに使用するダークテーマを設定します\",\n        \"audioExclusiveMode\": \"オーディオ排他モード\",\n        \"remotePassword\": \"リモートコントロールサーバーのパスワード\",\n        \"lyricFetchProvider\": \"歌詞取得先\",\n        \"language_description\": \"アプリケーションの言語を設定します ($t(common.restartRequired))\",\n        \"playbackStyle_optionCrossFade\": \"クロスフェード\",\n        \"hotkey_rate3\": \"3 つ星で評価\",\n        \"font\": \"フォント\",\n        \"replayGainMode_optionTrack\": \"$t(entity.track, {\\\"count\\\": 1})\",\n        \"themeLight_description\": \"アプリケーションに使用するライトテーマを設定します\",\n        \"hotkey_toggleFullScreenPlayer\": \"全画面プレーヤーに切り替え\",\n        \"hotkey_localSearch\": \"ページ内検索\",\n        \"hotkey_toggleQueue\": \"キューの切り替え\",\n        \"zoom_description\": \"アプリケーションのズーム率を設定します\",\n        \"remotePassword_description\": \"リモートコントロール サーバーのパスワードを設定します。 ログイン情報はデフォルトでセキュアな通信がされないため、個人情報と関係ないランダムなパスワードを利用してください\",\n        \"hotkey_rate5\": \"5 つ星で評価\",\n        \"hotkey_playbackPrevious\": \"前のトラック\",\n        \"showSkipButtons_description\": \"プレーヤーバーのスキップボタンを表示または非表示にします\",\n        \"crossfadeDuration_description\": \"クロスフェード効果の時間を設定します\",\n        \"playbackStyle\": \"再生スタイル\",\n        \"hotkey_toggleShuffle\": \"シャッフルの切り替え\",\n        \"theme\": \"テーマ\",\n        \"playbackStyle_description\": \"オーディオプレーヤーに使用する再生スタイルを選択します\",\n        \"discordRichPresence_description\": \"{{discord}} Rich Presence で再生ステータスを有効にします。画像キー: {{icon}}, {{playing}}, {{paused}}\",\n        \"mpvExecutablePath\": \"MPV 実行ファイルパス\",\n        \"audioDevice\": \"オーディオデバイス\",\n        \"hotkey_rate2\": \"2 つ星で評価\",\n        \"playButtonBehavior_description\": \"キューに曲を追加するときの再生ボタンのデフォルトの動作を設定します\",\n        \"minimumScrobblePercentage_description\": \"Scrobble されるために必要な最短の再生時間 (%)\",\n        \"exitToTray\": \"終了時にシステムトレイに格納\",\n        \"hotkey_rate4\": \"4 つ星で評価\",\n        \"enableRemote\": \"リモートコントロール サーバーを有効化\",\n        \"showSkipButton_description\": \"プレーヤーバーのスキップボタンを表示または非表示にします\",\n        \"savePlayQueue\": \"再生キューを保存\",\n        \"minimumScrobbleSeconds_description\": \"Scrobble されるために必要な最短の再生時間 (秒)\",\n        \"skipPlaylistPage_description\": \"プレイリストに移動するときに、デフォルトページではなくプレイリストの曲リストページに移動します\",\n        \"fontType_description\": \"組み込みフォントの場合、Feishin が提供するフォントの中から 1 つ選択します。 システムフォントの場合、OS が提供する任意のフォントを選択できます。 カスタムフォントの場合、フォントファイルを自身で選択できます\",\n        \"playButtonBehavior\": \"再生ボタンの動作\",\n        \"volumeWheelStep\": \"音量ホイールステップ\",\n        \"sidebarPlaylistList_description\": \"サイドバーのプレイリストを表示または非表示にします\",\n        \"accentColor\": \"アクセントカラー\",\n        \"sidePlayQueueStyle_description\": \"サイド再生キューの形式を設定します\",\n        \"accentColor_description\": \"アプリケーションが利用するアクセントカラーを設定します\",\n        \"replayGainMode\": \"{{ReplayGain}} モード\",\n        \"playbackStyle_optionNormal\": \"通常\",\n        \"windowBarStyle\": \"ウィンドウバースタイル\",\n        \"replayGainFallback_description\": \"ファイルに {{ReplayGain}} タグがない場合に適用するゲイン (dB 単位)\",\n        \"replayGainPreamp_description\": \"{{ReplayGain}} の値に適用されるプリアンプゲインを調整します\",\n        \"hotkey_toggleRepeat\": \"リピートの切り替え\",\n        \"lyricOffset_description\": \"歌詞のオフセットをミリ秒単位で指定します\",\n        \"sidebarConfiguration_description\": \"サイドバーに表示する項目と順序を選択します\",\n        \"fontType\": \"フォントタイプ\",\n        \"remotePort\": \"リモートコントロールサーバーのポート\",\n        \"applicationHotkeys\": \"アプリケーションホットキー\",\n        \"hotkey_playbackNext\": \"次のトラック\",\n        \"useSystemTheme_description\": \"システム設定のライト/ダークテーマに従います\",\n        \"playButtonBehavior_optionAddNext\": \"$t(player.addNext)\",\n        \"lyricFetch_description\": \"様々なインターネットソースから歌詞を取得します\",\n        \"lyricFetchProvider_description\": \"歌詞を取得するプロバイダーを選択します\",\n        \"globalMediaHotkeys_description\": \"システムのメディアホットキーでの再生コントロールを有効/無効化します\",\n        \"customFontPath\": \"カスタムフォントパス\",\n        \"followLyric\": \"歌詞を再生位置に追従\",\n        \"crossfadeDuration\": \"クロスフェードの長さ\",\n        \"discordIdleStatus\": \"アイドル状態で Rich Presence ステータスを表示\",\n        \"sidePlayQueueStyle_optionDetached\": \"分離\",\n        \"audioPlayer\": \"オーディオプレーヤー\",\n        \"hotkey_zoomOut\": \"縮小\",\n        \"hotkey_unfavoriteCurrentSong\": \"$t(common.currentSong) をお気に入りから解除\",\n        \"hotkey_rate0\": \"評価をクリア\",\n        \"discordApplicationId\": \"{{discord}} アプリケーション ID\",\n        \"applicationHotkeys_description\": \"アプリケーションのホットキーを設定します。チェックボックスを切り替えて、グローバルホットキーとして設定します (デスクトップのみ)\",\n        \"hotkey_volumeMute\": \"音量をミュート\",\n        \"hotkey_toggleCurrentSongFavorite\": \"$t(common.currentSong) のお気に入りを切り替え\",\n        \"remoteUsername\": \"リモートコントロールサーバーのユーザー名\",\n        \"hotkey_browserBack\": \"ブラウザ 戻る\",\n        \"showSkipButton\": \"スキップボタンを表示\",\n        \"sidebarPlaylistList\": \"サイドバーのプレイリスト\",\n        \"minimizeToTray\": \"最小化時にシステムトレイに格納\",\n        \"skipPlaylistPage\": \"プレイリストページをスキップ\",\n        \"themeDark\": \"テーマ (ダーク)\",\n        \"sidebarCollapsedNavigation\": \"サイドバー (折りたたみ) ナビゲーション\",\n        \"customFontPath_description\": \"アプリケーションが使用するカスタムフォントへのパスを設定します\",\n        \"gaplessAudio_optionWeak\": \"弱 (推奨)\",\n        \"minimumScrobbleSeconds\": \"最小 Scrobble 時間 (秒)\",\n        \"hotkey_playbackStop\": \"停止\",\n        \"windowBarStyle_description\": \"ウィンドウバーのスタイルを選択します\",\n        \"font_description\": \"アプリケーションに使用するフォントを設定します\",\n        \"savePlayQueue_description\": \"アプリケーション終了時に再生キューを保存し、アプリケーション開始時に復元します\",\n        \"useSystemTheme\": \"システムテーマを使用\",\n        \"webAudio\": \"Web Audio を使用する\",\n        \"mediaSession_description\": \"Media Session の統合を有効にし、システムボリュームオーバーレイとロック画面にメディアコントロールとメタデータを表示します\",\n        \"mediaSession\": \"Media Session を有効にする\",\n        \"startMinimized_description\": \"システムトレイでアプリケーションを起動する\",\n        \"startMinimized\": \"最小化して起動する\",\n        \"translationApiKey_description\": \"翻訳用の API キー (グローバルサービスエンドポイントのみ)\",\n        \"translationApiKey\": \"翻訳 API キー\",\n        \"translationApiProvider_description\": \"翻訳用の API プロバイダー\",\n        \"translationApiProvider\": \"翻訳 API プロバイダー\",\n        \"translationTargetLanguage_description\": \"翻訳対象言語\",\n        \"translationTargetLanguage\": \"翻訳対象言語\",\n        \"trayEnabled_description\": \"トレイアイコン/メニューの表示/非表示。無効にすると、最小化/トレイへの終了も無効になります\",\n        \"trayEnabled\": \"トレイを表示する\",\n        \"volumeWidth_description\": \"音量スライダーの幅\",\n        \"volumeWidth\": \"音量スライダーの幅\",\n        \"webAudio_description\": \"Web Audio を使用します。これにより、リプレイゲインなどの高度な機能が有効になります。問題が発生した場合は無効にしてください\",\n        \"mpvExtraParameters_help\": \"1 行に 1 つずつ\",\n        \"musicbrainz_description\": \"MusicBrainz ID が存在するアーティストとアルバムページに MusicBrainz へのリンクを表示します\",\n        \"musicbrainz\": \"MusicBrainz のリンクを表示\",\n        \"neteaseTranslation_description\": \"有効にすると、利用可能な場合は NetEase から翻訳された歌詞を取得して表示します\",\n        \"neteaseTranslation\": \"NetEase 翻訳歌詞を有効にする\",\n        \"passwordStore_description\": \"使用するパスワード / シークレットストア。パスワードの保存に問題がある場合はこれを変更してください\",\n        \"passwordStore\": \"パスワード / シークレットストア\",\n        \"playerbarOpenDrawer_description\": \"プレーヤーバーをクリックすると全画面プレーヤーが開きます\",\n        \"preferLocalLyrics_description\": \"利用可能な場合は、リモート歌詞よりもローカル歌詞を優先します\",\n        \"preferLocalLyrics\": \"ローカル歌詞を優先する\",\n        \"preservePitch_description\": \"再生速度を変更してもピッチを保持します\",\n        \"preservePitch\": \"ピッチを保持する\",\n        \"preventSleepOnPlayback_description\": \"音楽の再生中にディスプレイがスリープ状態にならないようにします\",\n        \"preventSleepOnPlayback\": \"再生中のスリープを防止する\",\n        \"lastfmApiKey_description\": \"{{lastfm}} の API キー。カバーアートの表示に必要です\",\n        \"imageAspectRatio\": \"ネイティブのカバーアートの縦横比を使用する\",\n        \"language\": \"言語\",\n        \"imageAspectRatio_description\": \"有効にすると、カバーアートはネイティブの縦横比で表示されます。縦横比が 1:1 でない場合、残りのスペースは空白になります\",\n        \"lastfm_description\": \"アーティストとアルバムページに Last.fm へのリンクを表示します\",\n        \"lastfm\": \"Last.fm のリンクを表示\",\n        \"lastfmApiKey\": \"{{lastfm}} API キー\",\n        \"homeConfiguration_description\": \"ホーム画面に表示する項目と表示順序を設定します\",\n        \"homeConfiguration\": \"ホーム画面の設定\",\n        \"externalLinks\": \"外部リンクを表示する\",\n        \"externalLinks_description\": \"アーティスト/アルバムページで外部リンク (Last.fm、MusicBrainz) を表示できるようにします\",\n        \"enableAutoTranslation\": \"自動翻訳を有効にする\",\n        \"enableAutoTranslation_description\": \"歌詞が読み込まれたときに自動的に翻訳を有効にします\",\n        \"albumBackground_description\": \"アルバムアートを含むアルバムページに背景画像を追加します\",\n        \"albumBackground\": \"アルバムの背景画像\",\n        \"albumBackgroundBlur\": \"アルバムの背景画像のぼかしサイズ\",\n        \"albumBackgroundBlur_description\": \"アルバムの背景画像に適用されるぼかしの量を調整します\",\n        \"artistBackground\": \"アーティストの背景画像\",\n        \"artistBackground_description\": \"アーティストアートを含むアーティストページに背景画像を追加します\",\n        \"artistBackgroundBlur\": \"アーティストの背景画像のぼかしサイズ\",\n        \"artistBackgroundBlur_description\": \"アーティストの背景画像に適用されるぼかしの量を調整します\",\n        \"artistConfiguration\": \"アルバムアーティストページの設定\",\n        \"artistConfiguration_description\": \"アルバムアーティストページに表示するアイテムと順序を設定します\",\n        \"buttonSize_description\": \"プレーヤーバーのボタンのサイズ\",\n        \"buttonSize\": \"プレーヤーバーのボタンサイズ\",\n        \"clearCache_description\": \"Feishin の「ハードクリア」。Feishin のキャッシュをクリアするだけでなく、ブラウザーのキャッシュ (保存された画像やその他のアセット) も空にします。サーバーの資格情報や設定は保持されます\",\n        \"clearCache\": \"ブラウザーのキャッシュをクリア\",\n        \"clearCacheSuccess\": \"キャッシュが正常にクリアされました\",\n        \"clearQueryCache_description\": \"Feishin の「ソフトクリア」。これにより、プレイリストとトラックのメタデータが更新され、保存された歌詞がリセットされます。設定、サーバーの資格情報、キャッシュされた画像は保持されます\",\n        \"clearQueryCache\": \"Feishin のキャッシュをクリアする\",\n        \"contextMenu_description\": \"アイテムを右クリックした際にメニューに表示される項目を非表示にすることができます。チェックされていない項目は非表示になります\",\n        \"contextMenu\": \"コンテキストメニュー (右クリック) の設定\",\n        \"crossfadeStyle\": \"クロスフェードのスタイル\",\n        \"customCss_description\": \"カスタム CSS コンテンツ。注: コンテンツとリモート URL は許可されていないプロパティです。コンテンツのプレビューは以下に表示されます。設定していない追加フィールドは、サニタイズ処理により表示されています\",\n        \"customCss\": \"カスタム CSS\",\n        \"customCssEnable_description\": \"カスタム CSS の記述を許可します\",\n        \"customCssEnable\": \"カスタム CSS を有効にする\",\n        \"customCssNotice\": \"警告: ある程度のサニタイズ (url() と content: の禁止) はありますが、カスタム CSS を使用するとインターフェースの変更によりリスクが生じる可能性があります\",\n        \"releaseChannel_optionBeta\": \"ベータ\",\n        \"releaseChannel_optionLatest\": \"最新\",\n        \"releaseChannel\": \"リリースチャンネル\",\n        \"releaseChannel_description\": \"自動更新のために、安定版、ベータ版、またはアルファ版 (nightly build) リリースから選択してください\",\n        \"discordDisplayType_artistname\": \"アーティスト名\",\n        \"discordDisplayType_songname\": \"曲名\",\n        \"discordLinkType_description\": \"{{discord}} Rich Presence において、曲とアーティストのフィールドに {{lastfm}} または {{musicbrainz}} への外部リンクを追加します。{{musicbrainz}} は最も正確ですが、タグが必要でアーティストリンクを提供しません。一方、{{lastfm}} は常にリンクを提供します。追加のネットワークリクエストは発生しません\",\n        \"discordPausedStatus\": \"一時停止時に Rich Presence を表示\",\n        \"discordRichPresence\": \"{{discord}} Rich Presence\",\n        \"discordServeImage_description\": \"{{discord}} Rich Presence 用のカバーアートをサーバーから共有します。Jellyfin と Navidrome でのみ利用できます。{{discord}} は bot を使用して画像を取得するため、サーバーはパブリックインターネットからアクセスできる必要があります\",\n        \"exportImportSettings_control_exportText\": \"設定をエクスポート\",\n        \"exportImportSettings_control_importText\": \"設定をインポート\",\n        \"exportImportSettings_control_title\": \"設定をインポート/エクスポート\",\n        \"exportImportSettings_control_description\": \"JSON 経由で設定をエクスポートおよびインポートする\",\n        \"exportImportSettings_destructiveWarning\": \"設定のインポートは破壊的です。下の「インポート」をクリックする前に、上記の内容を必ずご確認ください!\",\n        \"hotkey_navigateHome\": \"ホーム画面へ移動\",\n        \"playButtonBehavior_optionPlayShuffled\": \"$t(player.shuffle)\",\n        \"playerbarOpenDrawer\": \"プレーヤーバーの全画面表示切り替え\",\n        \"transcode\": \"トランスコーディングを有効にする\",\n        \"transcode_description\": \"さまざまなフォーマットへのトランスコーディングを可能にします\",\n        \"discordServeImage\": \"サーバーから {{discord}} に画像を提供する\",\n        \"transcodeFormat\": \"トランスコードする形式\",\n        \"transcodeFormat_description\": \"トランスコードする形式を選択します。サーバーに決定させる場合は空白のままにしておきます\",\n        \"transcodeBitrate\": \"トランスコードするビットレート\",\n        \"transcodeBitrate_description\": \"トランスコードするビットレートを選択します。0 はサーバーが選択することを意味します\",\n        \"notify_description\": \"現在の曲を変更するときに通知を表示します\",\n        \"notify\": \"曲の通知を有効にする\",\n        \"exportImportSettings_offendingKeyError\": \"「{{offendingKey}}」は正しくありません - {{reason}}\",\n        \"discordDisplayType\": \"{{discord}} Presence 表示タイプ\",\n        \"discordLinkType_none\": \"$t(common.none)\",\n        \"discordListening_description\": \"Playing ではなく Listening としてステータスを表示します\",\n        \"discordListening\": \"ステータスを Listening として表示\",\n        \"discordPausedStatus_description\": \"有効にすると、プレーヤーが一時停止されているときにもステータスを表示します\",\n        \"discordDisplayType_description\": \"ステータスで聴いている内容を変更します\",\n        \"discordLinkType\": \"{{discord}} Presence リンク\",\n        \"discordLinkType_mbz_lastfm\": \"{{musicbrainz}} と {{lastfm}} のフォールバック\",\n        \"homeFeature\": \"ホーム画面のカルーセル\",\n        \"homeFeature_description\": \"ホーム画面に大きなカルーセルを表示するかどうかを制御します\",\n        \"exportImportSettings_notValidJSON\": \"渡されたファイルは有効な JSON ではありません\",\n        \"exportImportSettings_importSuccess\": \"設定が正常にインポートされました!\",\n        \"exportImportSettings_importModalTitle\": \"Feishin 設定をインポート\",\n        \"exportImportSettings_importBtn\": \"設定をインポート\",\n        \"autoDJ_description\": \"類似の曲を自動でキューに追加します\",\n        \"autoDJ\": \"自動 DJ\",\n        \"autoDJ_itemCount_description\": \"自動 DJ が有効なときにキューに追加しようとした曲数\",\n        \"autoDJ_itemCount\": \"曲数\",\n        \"autoDJ_timing\": \"タイミング\",\n        \"autoDJ_timing_description\": \"自動 DJ が作動するまでのキューに残っている曲数\",\n        \"analyticsDisable\": \"使用状況に基づく分析のオプトアウト\",\n        \"analyticsDisable_description\": \"匿名化された利用データは、アプリケーションの改善のために開発者に送信されます\",\n        \"useThemeAccentColor\": \"テーマのアクセントカラーを使用\",\n        \"useThemeAccentColor_description\": \"カスタムアクセントカラーの代わりに、選択したテーマで定義されたプライマリカラーを使用します\",\n        \"artistReleaseTypeConfiguration\": \"アーティストリリースタイプの設定\",\n        \"artistReleaseTypeConfiguration_description\": \"アルバムアーティストページでどのリリースタイプをどのような順序で表示するかを設定します\",\n        \"followCurrentSong\": \"現在の曲をフォロー\",\n        \"followCurrentSong_description\": \"再生キューを現在再生中の曲まで自動的にスクロールします\",\n        \"logLevel\": \"ログレベル\",\n        \"logLevel_description\": \"表示するログの最小レベルを設定します。Debug はすべてのログを表示し、Error はエラーのみを表示します\",\n        \"logLevel_optionDebug\": \"Debug\",\n        \"logLevel_optionError\": \"Error\",\n        \"logLevel_optionInfo\": \"Info\",\n        \"logLevel_optionWarn\": \"Warn\",\n        \"playerFilters\": \"キューから曲をフィルタリング\",\n        \"playerFilters_description\": \"以下の基準に基づいて曲をキューに追加しないようにします\",\n        \"artistRadioCount\": \"アーティスト / トラックのラジオカウント\",\n        \"artistRadioCount_description\": \"アーティストラジオとトラックラジオで取得する曲数を設定します\",\n        \"imageResolution\": \"画像の解像度\",\n        \"imageResolution_description\": \"アプリ内で使用される画像の解像度。値を 0 に設定すると、デフォルトでネイティブ画像解像度が適用されます\",\n        \"showLyricsInSidebar_description\": \"添付の再生キューに歌詞を表示するパネルが追加されます\",\n        \"showLyricsInSidebar\": \"サイドバーのプレーヤーに歌詞を表示\",\n        \"showRatings\": \"星評価を表示する\",\n        \"imageResolution_optionSidebar\": \"サイドバー\",\n        \"imageResolution_optionHeader\": \"ヘッダー\",\n        \"imageResolution_optionFullScreenPlayer\": \"全画面プレーヤー\",\n        \"playerbarSlider\": \"プレーヤーバースライダー\",\n        \"playerbarSlider_description\": \"低速または従量制のインターネット接続の場合は、波形は推奨されません\",\n        \"playerbarSliderType_optionSlider\": \"スライダー\",\n        \"playerbarSliderType_optionWaveform\": \"波形\",\n        \"playerbarWaveformAlign\": \"波形アライメント\",\n        \"showRatings_description\": \"インターフェースに星評価機能を表示するかどうかを制御します\",\n        \"showVisualizerInSidebar\": \"サイドバーのプレーヤーにビジュアライザーを表示\",\n        \"combinedLyricsAndVisualizer\": \"サイドバーのプレーヤーに歌詞とビジュアライザーを統合\",\n        \"audioFadeOnStatusChange_description\": \"再生 / 一時停止の状態が変わったときにフェードアウトとフェードインを有効にします\",\n        \"audioFadeOnStatusChange\": \"ステータス変更時の音声フェード\",\n        \"combinedLyricsAndVisualizer_description\": \"歌詞とビジュアライザーを同じパネルに統合します\",\n        \"showVisualizerInSidebar_description\": \"サイドバーのプレーヤーにビジュアライザーを表示するパネルが追加されます\",\n        \"queryBuilderCustomFields\": \"カスタムフィールド\",\n        \"queryBuilderCustomFields_inputLabel\": \"ラベル\",\n        \"queryBuilderCustomFields_inputTag\": \"タグ\",\n        \"queryBuilderCustomFields_description\": \"クエリビルダーで使用するカスタムフィールドを追加します\",\n        \"queryBuilder\": \"クエリビルダー\",\n        \"homeFeatureStyle_description\": \"ホーム画面のカルーセルのスタイルを制御します\",\n        \"homeFeatureStyle\": \"ホーム画面のカルーセルのスタイル\",\n        \"homeFeatureStyle_optionMultiple\": \"複数\",\n        \"homeFeatureStyle_optionSingle\": \"単数\",\n        \"mpvExtraParameters\": \"MPV の追加パラメータ\",\n        \"mpvExtraParameters_description\": \"MPV に渡す追加の引数を設定します\",\n        \"pathReplace\": \"ファイルパスの置換\",\n        \"pathReplace_description\": \"サーバーのデフォルトのファイルパスを置き換えます\",\n        \"pathReplace_optionRemovePrefix\": \"接頭辞を削除\",\n        \"pathReplace_optionAddPrefix\": \"接頭辞を追加\",\n        \"analyticsEnable\": \"使用状況に基づく分析を送信する\",\n        \"analyticsEnable_description\": \"匿名化された利用データは、アプリケーションの改善のために開発者に送信されます\",\n        \"automaticUpdates\": \"自動更新\",\n        \"automaticUpdates_description\": \"更新を自動的に確認してインストールします\",\n        \"releaseChannel_optionAlpha\": \"アルファ (nightly)\",\n        \"discordStateIcon\": \"再生中アイコンを表示\",\n        \"discordStateIcon_description\": \"Rich Presence ステータスに小さな再生アイコンを表示します。「一時停止時に Rich Presence を表示」が有効になっている場合は、常に一時停止アイコンが表示されます\",\n        \"sidebarPlaylistListFilterRegex_description\": \"この正規表現に一致するプレイリストをサイドバーから非表示にします\",\n        \"sidebarPlaylistListFilterRegex_placeholder\": \"例: ^Daily Mix.*\",\n        \"sidebarPlaylistListFilterRegex\": \"プレイリストフィルターの正規表現\",\n        \"sidebarPlaylistSorting\": \"サイドバーでプレイリストを並べ替え\",\n        \"sidebarPlaylistSorting_description\": \"デフォルトのサーバー順ではなく、ドラッグアンドドロップを使用してサイドバーでプレイリストを手動で並べ替えることができます\",\n        \"playerItemConfiguration_description\": \"全画面プレーヤーに表示する項目と順序を設定します\",\n        \"playerItemConfiguration\": \"プレーヤーの項目設定\",\n        \"autosave\": \"再生キューを自動的に保存\",\n        \"autosave_description\": \"再生キューをサーバーに自動的に保存できるようにします。これは Navidrome/Subsonic を使用している場合にのみ可能であり、再生キューを混在させることはできません。\",\n        \"autosaveCount\": \"自動再生キューの保存頻度\",\n        \"autosaveCount_description\": \"キューが保存されるまでにトラックが変更される回数を設定します。1 (最小値) は曲が変わるたびに保存されることを意味します\",\n        \"useThemePrimaryShade_description\": \"選択したテーマで定義されたプライマリシェードをプライマリカラーのバリアントに使用します\",\n        \"useThemePrimaryShade\": \"テーマのプライマリシェードを使用\",\n        \"primaryShade\": \"プライマリシェード\",\n        \"primaryShade_description\": \"ボタン、リンク、およびその他の主要色要素に使用されるプライマリシェード (0–9) を上書きします\",\n        \"playerbarWaveformAlign_optionTop\": \"上部\",\n        \"playerbarWaveformAlign_optionCenter\": \"中央\",\n        \"playerbarWaveformAlign_optionBottom\": \"下部\",\n        \"imageResolution_optionTable\": \"表\",\n        \"imageResolution_optionItemCard\": \"アイテムカード\",\n        \"blurExplicitImages\": \"露骨な画像をぼかす\",\n        \"blurExplicitImages_description\": \"露骨な表現を含むタグが付けられたアルバムおよび楽曲のアートワークをぼかします\",\n        \"enableGridMultiSelect\": \"グリッドの複数選択を有効にする\",\n        \"enableGridMultiSelect_description\": \"有効にすると、グリッドビューで複数のアイテムを選択できます。無効にすると、グリッドアイテムの画像をクリックするとアイテムページに移動します\",\n        \"playerbarWaveformBarWidth\": \"波形バーの幅\",\n        \"playerbarWaveformGap\": \"波形ギャップ\",\n        \"playerbarWaveformRadius\": \"波形半径\",\n        \"hotkey_listNavigateToPage\": \"項目の詳細ページへ移動\",\n        \"hotkey_listPlayDefault\": \"リストを再生 (デフォルト)\",\n        \"hotkey_listPlayLast\": \"最後に再生\",\n        \"hotkey_listPlayNext\": \"次に再生\",\n        \"hotkey_listPlayNow\": \"今すぐ再生\",\n        \"spotify_description\": \"アーティストとアルバムページに Spotify へのリンクを表示します\",\n        \"spotify\": \"Spotify のリンクを表示\",\n        \"nativeSpotify_description\": \"ブラウザーの代わりに Spotify アプリで開きます\",\n        \"nativeSpotify\": \"Spotify アプリを使用\",\n        \"listenbrainz_description\": \"アーティストとアルバムページに ListenBrainz へのリンクを表示します\",\n        \"listenbrainz\": \"ListenBrainz のリンクを表示\",\n        \"qobuz_description\": \"アーティストとアルバムページに Qobuz へのリンクを表示します\",\n        \"qobuz\": \"Qobuz のリンクを表示\",\n        \"sidePlayQueueLayout\": \"サイド再生キューのレイアウト\",\n        \"sidePlayQueueLayout_description\": \"結合されたサイド再生キューのレイアウトを設定します\",\n        \"sidePlayQueueLayout_optionHorizontal\": \"水平\",\n        \"sidePlayQueueLayout_optionVertical\": \"垂直\"\n    },\n    \"action\": {\n        \"editPlaylist\": \"$t(entity.playlist, {\\\"count\\\": 1}) を編集\",\n        \"goToPage\": \"ページへ移動\",\n        \"moveToTop\": \"先頭に移動\",\n        \"clearQueue\": \"キューをクリア\",\n        \"addToFavorites\": \"$t(entity.favorite, {\\\"count\\\": 2}) に追加\",\n        \"addToPlaylist\": \"$t(entity.playlist, {\\\"count\\\": 1}) に追加\",\n        \"createPlaylist\": \"$t(entity.playlist, {\\\"count\\\": 1}) を作成\",\n        \"removeFromPlaylist\": \"$t(entity.playlist, {\\\"count\\\": 1}) から削除\",\n        \"viewPlaylists\": \"$t(entity.playlist, {\\\"count\\\": 2}) を表示\",\n        \"refresh\": \"$t(common.refresh)\",\n        \"deletePlaylist\": \"$t(entity.playlist, {\\\"count\\\": 1}) を削除\",\n        \"removeFromQueue\": \"キューから削除\",\n        \"deselectAll\": \"すべて選択解除\",\n        \"moveToBottom\": \"末尾に移動\",\n        \"setRating\": \"評価を設定する\",\n        \"toggleSmartPlaylistEditor\": \"$t(entity.smartPlaylist) エディタの切り替え\",\n        \"removeFromFavorites\": \"$t(entity.favorite, {\\\"count\\\": 2}) から削除\",\n        \"openIn\": {\n            \"lastfm\": \"Last.fm で開く\",\n            \"musicbrainz\": \"MusicBrainz で開く\",\n            \"spotify\": \"Spotify で開く\",\n            \"listenbrainz\": \"ListenBrainz で開く\",\n            \"qobuz\": \"Qobuz で開く\"\n        },\n        \"moveToNext\": \"次\",\n        \"downloadStarted\": \"{{count}} 曲のダウンロードを開始しました\",\n        \"moveItems\": \"曲を移動\",\n        \"shuffle\": \"シャッフル\",\n        \"shuffleAll\": \"すべてシャッフル\",\n        \"shuffleSelected\": \"選択した曲をシャッフル\",\n        \"viewMore\": \"さらに表示\",\n        \"createRadioStation\": \"$t(entity.radioStation, {\\\"count\\\": 1}) を作成\",\n        \"deleteRadioStation\": \"$t(entity.radioStation, {\\\"count\\\": 1}) を削除\",\n        \"selectAll\": \"すべて選択\",\n        \"moveUp\": \"上に移動\",\n        \"moveDown\": \"下に移動\",\n        \"holdToMoveToTop\": \"押し続けると一番上に移動します\",\n        \"holdToMoveToBottom\": \"押し続けると一番下に移動します\",\n        \"openApplicationDirectory\": \"アプリケーションディレクトリを開く\",\n        \"selectRangeOfItems\": \"項目の範囲を選択\",\n        \"addOrRemoveFromSelection\": \"選択に追加または削除\",\n        \"goToCurrent\": \"現在の項目へ移動\"\n    },\n    \"common\": {\n        \"backward\": \"戻る\",\n        \"increase\": \"増加\",\n        \"rating\": \"評価\",\n        \"bpm\": \"BPM\",\n        \"refresh\": \"再読み込み\",\n        \"unknown\": \"不明\",\n        \"areYouSure\": \"実行しますか?\",\n        \"edit\": \"編集\",\n        \"favorite\": \"お気に入り\",\n        \"left\": \"左側\",\n        \"save\": \"保存\",\n        \"right\": \"右側\",\n        \"currentSong\": \"現在の $t(entity.track, {\\\"count\\\": 1})\",\n        \"collapse\": \"折りたたみ\",\n        \"trackNumber\": \"トラック\",\n        \"descending\": \"降順\",\n        \"add\": \"追加\",\n        \"gap\": \"ギャップ\",\n        \"ascending\": \"昇順\",\n        \"dismiss\": \"無視\",\n        \"year\": \"年\",\n        \"manage\": \"管理\",\n        \"limit\": \"制限\",\n        \"minimize\": \"最小化\",\n        \"modified\": \"変更済み\",\n        \"duration\": \"長さ\",\n        \"name\": \"名前\",\n        \"maximize\": \"最大化\",\n        \"decrease\": \"減少\",\n        \"ok\": \"OK\",\n        \"description\": \"説明\",\n        \"configure\": \"設定\",\n        \"path\": \"パス\",\n        \"center\": \"中央\",\n        \"no\": \"いいえ\",\n        \"owner\": \"所有者\",\n        \"enable\": \"有効\",\n        \"clear\": \"クリア\",\n        \"forward\": \"進む\",\n        \"delete\": \"削除\",\n        \"cancel\": \"キャンセル\",\n        \"forceRestartRequired\": \"変更を適用するために再起動が必要です… 通知を閉じると再起動します\",\n        \"setting_other\": \"設定\",\n        \"version\": \"バージョン\",\n        \"title\": \"タイトル\",\n        \"filter_other\": \"フィルター\",\n        \"filters\": \"フィルター\",\n        \"create\": \"作成\",\n        \"bitrate\": \"ビットレート\",\n        \"saveAndReplace\": \"保存して変更\",\n        \"action_other\": \"アクション\",\n        \"playerMustBePaused\": \"プレーヤーを一時停止する必要があります\",\n        \"confirm\": \"確認\",\n        \"resetToDefault\": \"デフォルトにリセット\",\n        \"home\": \"ホーム\",\n        \"comingSoon\": \"近日利用可能になる予定です…\",\n        \"reset\": \"リセット\",\n        \"channel_other\": \"チャンネル\",\n        \"disable\": \"無効\",\n        \"sortOrder\": \"順序\",\n        \"none\": \"なし\",\n        \"menu\": \"メニュー\",\n        \"restartRequired\": \"再起動が必要です\",\n        \"previousSong\": \"前の $t(entity.track, {\\\"count\\\": 1})\",\n        \"noResultsFromQuery\": \"条件にマッチするものがありません\",\n        \"quit\": \"終了\",\n        \"expand\": \"展開\",\n        \"search\": \"検索\",\n        \"saveAs\": \"名前を付けて保存\",\n        \"disc\": \"ディスク\",\n        \"yes\": \"はい\",\n        \"random\": \"ランダム\",\n        \"size\": \"サイズ\",\n        \"biography\": \"バイオグラフィー\",\n        \"note\": \"ノート\",\n        \"explicitStatus\": \"明示的なステータス\",\n        \"additionalParticipants\": \"追加参加者\",\n        \"newVersion\": \"新しいバージョン ({{version}}) がインストールされました\",\n        \"viewReleaseNotes\": \"リリースノートを表示する\",\n        \"bitDepth\": \"ビット深度\",\n        \"close\": \"閉じる\",\n        \"codec\": \"コーデック\",\n        \"mbid\": \"MusicBrainz ID\",\n        \"sampleRate\": \"サンプルレート\",\n        \"preview\": \"プレビュー\",\n        \"private\": \"プライベート\",\n        \"public\": \"パブリック\",\n        \"share\": \"共有\",\n        \"tags\": \"タグ\",\n        \"trackGain\": \"トラックゲイン\",\n        \"trackPeak\": \"トラックピーク\",\n        \"translation\": \"翻訳\",\n        \"reload\": \"リロード\",\n        \"explicit\": \"明示的\",\n        \"albumGain\": \"アルバムゲイン\",\n        \"albumPeak\": \"アルバムピーク\",\n        \"releaseType\": \"リリースタイプ\",\n        \"doNotShowAgain\": \"再度表示しない\",\n        \"externalLinks\": \"外部リンク\",\n        \"sort\": \"分類\",\n        \"gridRows\": \"グリッド行\",\n        \"countSelected\": \"{{count}} 個選択されました\",\n        \"view\": \"表示\",\n        \"noFilters\": \"フィルターが設定されていません\",\n        \"retry\": \"再試行\",\n        \"itemsMore\": \"{{count}} 個以上\",\n        \"faster\": \"より速く\",\n        \"slower\": \"より遅く\",\n        \"example\": \"例\",\n        \"mood\": \"気分\",\n        \"recordLabel\": \"レコードレーベル\",\n        \"tableColumns\": \"テーブル列\",\n        \"clean\": \"クリーン\",\n        \"filter_single\": \"シングル\",\n        \"filter_multiple\": \"複数枚組\",\n        \"rename\": \"名前を変更\",\n        \"newVersionAvailable\": \"新しいバージョンが利用可能です\",\n        \"numberOfResults\": \"{{numberOfResults}} 件の結果\"\n    },\n    \"table\": {\n        \"config\": {\n            \"view\": {\n                \"table\": \"表\",\n                \"grid\": \"グリッド\",\n                \"list\": \"リスト\",\n                \"detail\": \"詳細\"\n            },\n            \"general\": {\n                \"displayType\": \"表示タイプ\",\n                \"gap\": \"$t(common.gap)\",\n                \"tableColumns\": \"テーブル列\",\n                \"autoFitColumns\": \"カラム長を自動調整\",\n                \"size\": \"$t(common.size)\",\n                \"itemSize\": \"項目のサイズ (px)\",\n                \"itemGap\": \"項目の間隔 (px)\",\n                \"followCurrentSong\": \"現在の曲をフォロー\",\n                \"advancedSettings\": \"詳細設定\",\n                \"autosize\": \"自動サイズ調整\",\n                \"moveUp\": \"上に移動\",\n                \"moveDown\": \"下に移動\",\n                \"alignLeft\": \"左揃え\",\n                \"alignCenter\": \"中央揃え\",\n                \"alignRight\": \"右揃え\",\n                \"itemsPerRow\": \"行あたりの項目数\",\n                \"size_default\": \"デフォルト\",\n                \"pinToLeft\": \"左にピン留め\",\n                \"pinToRight\": \"右にピン留め\",\n                \"size_compact\": \"コンパクト\",\n                \"size_large\": \"大きい\",\n                \"pagination_itemsPerPage\": \"ページあたりの項目数\",\n                \"pagination_infinite\": \"無限\",\n                \"pagination\": \"ページネーション\",\n                \"pagination_paginate\": \"ページ分割\",\n                \"showHeader\": \"ヘッダーを表示\",\n                \"verticalBorders\": \"列の境界線\",\n                \"rowHoverHighlight\": \"行ホバーハイライト\",\n                \"alternateRowColors\": \"交互の行の色\",\n                \"horizontalBorders\": \"行の境界線\"\n            },\n            \"label\": {\n                \"releaseDate\": \"発売日\",\n                \"title\": \"$t(common.title)\",\n                \"duration\": \"$t(common.duration)\",\n                \"titleCombined\": \"$t(common.title) (結合)\",\n                \"dateAdded\": \"追加日\",\n                \"size\": \"$t(common.size)\",\n                \"bpm\": \"$t(common.bpm)\",\n                \"lastPlayed\": \"最後に再生\",\n                \"trackNumber\": \"トラック番号\",\n                \"rowIndex\": \"行インデックス\",\n                \"rating\": \"$t(common.rating)\",\n                \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n                \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n                \"note\": \"$t(common.note)\",\n                \"biography\": \"$t(common.biography)\",\n                \"owner\": \"$t(common.owner)\",\n                \"path\": \"$t(common.path)\",\n                \"channels\": \"$t(common.channel, {\\\"count\\\": 2})\",\n                \"playCount\": \"再生回数\",\n                \"bitrate\": \"$t(common.bitrate)\",\n                \"actions\": \"$t(common.action, {\\\"count\\\": 2})\",\n                \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n                \"discNumber\": \"ディスク番号\",\n                \"favorite\": \"$t(common.favorite)\",\n                \"year\": \"$t(common.year)\",\n                \"albumArtist\": \"$t(entity.albumArtist, {\\\"count\\\": 1})\",\n                \"codec\": \"$t(common.codec)\",\n                \"songCount\": \"$t(entity.track, {\\\"count\\\": 2})\",\n                \"albumCount\": \"$t(entity.album, {\\\"count\\\": 2})\",\n                \"bitDepth\": \"$t(common.bitDepth)\",\n                \"genreBadge\": \"$t(entity.genre, {\\\"count\\\": 1}) (バッジ)\",\n                \"image\": \"画像\",\n                \"sampleRate\": \"$t(common.sampleRate)\",\n                \"composer\": \"作曲家\",\n                \"titleArtist\": \"$t(common.title) (アーティスト)\",\n                \"albumGroup\": \"アルバムグループ\"\n            }\n        },\n        \"column\": {\n            \"comment\": \"コメント\",\n            \"album\": \"アルバム\",\n            \"rating\": \"評価\",\n            \"favorite\": \"お気に入り\",\n            \"playCount\": \"再生回数\",\n            \"albumCount\": \"$t(entity.album, {\\\"count\\\": 2})\",\n            \"releaseYear\": \"年\",\n            \"lastPlayed\": \"最後に再生\",\n            \"biography\": \"バイオグラフィー\",\n            \"releaseDate\": \"発売日\",\n            \"bitrate\": \"ビットレート\",\n            \"title\": \"タイトル\",\n            \"bpm\": \"BPM\",\n            \"dateAdded\": \"追加日\",\n            \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n            \"songCount\": \"$t(entity.track, {\\\"count\\\": 2})\",\n            \"trackNumber\": \"トラック\",\n            \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n            \"albumArtist\": \"アルバムアーティスト\",\n            \"path\": \"パス\",\n            \"discNumber\": \"ディスク\",\n            \"channels\": \"$t(common.channel, {\\\"count\\\": 2})\",\n            \"size\": \"$t(common.size)\",\n            \"codec\": \"$t(common.codec)\",\n            \"bitDepth\": \"$t(common.bitDepth)\",\n            \"sampleRate\": \"$t(common.sampleRate)\",\n            \"owner\": \"所有者\"\n        }\n    },\n    \"error\": {\n        \"remotePortWarning\": \"新たなポート設定を適用するためサーバーを再起動してください\",\n        \"systemFontError\": \"システムフォントを取得する際にエラーが発生しました\",\n        \"playbackError\": \"メディアの再生開始時にエラーが発生しました\",\n        \"remotePortError\": \"リモートサーバーのポート設定時にエラーが発生しました\",\n        \"serverRequired\": \"サーバーが必要です\",\n        \"authenticationFailed\": \"認証に失敗しました\",\n        \"apiRouteError\": \"リクエストをルーティングできません\",\n        \"genericError\": \"エラーが発生しました\",\n        \"credentialsRequired\": \"ログイン情報が必要です\",\n        \"sessionExpiredError\": \"セッションの有効期限が切れました\",\n        \"remoteEnableError\": \"リモートサーバーを$t(common.enable)にする際にエラーが発生しました\",\n        \"localFontAccessDenied\": \"ローカルフォントへのアクセスが拒否されました\",\n        \"serverNotSelectedError\": \"サーバーが選択されていません\",\n        \"remoteDisableError\": \"リモートサーバーを$t(common.disable)にする際にエラーが発生しました\",\n        \"mpvRequired\": \"MPV が必要です\",\n        \"audioDeviceFetchError\": \"オーディオデバイスの取得時にエラーが発生しました\",\n        \"invalidServer\": \"無効なサーバー\",\n        \"loginRateError\": \"ログイン試行回数が多すぎます。数秒後に再試行してください\",\n        \"endpointNotImplementedError\": \"{{serverType}} にはエンドポイント {{endpoint}} が実装されていません\",\n        \"badAlbum\": \"このページが表示されたのは、この曲がアルバムに含まれていないためです。この問題は、音楽フォルダーの最上位に曲がある場合に発生する可能性が高くなります。Jellyfin は、トラックがフォルダー内にある場合にのみトラックをグループ化します\",\n        \"networkError\": \"ネットワークエラーが発生しました\",\n        \"notificationDenied\": \"通知の許可が拒否されました。この設定は効果がありません\",\n        \"openError\": \"ファイルを開けませんでした\",\n        \"badValue\": \"無効なオプション「{{value}}」。この値は存在しません\",\n        \"multipleServerSaveQueueError\": \"再生キューに現在のサーバーに存在しない曲が 1 曲以上あります。これはサポートされていません\",\n        \"noNetwork\": \"サーバーが利用できません\",\n        \"noNetworkDescription\": \"このサーバーに接続できませんでした\",\n        \"saveQueueFailed\": \"キューを保存できませんでした\",\n        \"settingsSyncError\": \"レンダラーとメインプロセスの設定に矛盾が見つかりました。変更を適用するにはアプリケーションを再起動してください\",\n        \"invalidJson\": \"無効な JSON\",\n        \"serverLockSingleServer\": \"サーバーがロックされている場合、1 つのサーバーのみが許可されます\",\n        \"playbackPausedDueToError\": \"エラーのため再生が一時停止されました\"\n    },\n    \"filter\": {\n        \"mostPlayed\": \"最も多く再生\",\n        \"playCount\": \"再生回数\",\n        \"isCompilation\": \"コンピレーションアルバム\",\n        \"recentlyPlayed\": \"最近の再生\",\n        \"isRated\": \"評価済み\",\n        \"title\": \"タイトル\",\n        \"rating\": \"評価\",\n        \"search\": \"検索\",\n        \"bitrate\": \"ビットレート\",\n        \"recentlyAdded\": \"最近の追加\",\n        \"note\": \"ノート\",\n        \"name\": \"名前\",\n        \"dateAdded\": \"追加日\",\n        \"releaseDate\": \"発売日\",\n        \"communityRating\": \"コミュニティの評価\",\n        \"path\": \"パス\",\n        \"favorited\": \"お気に入り\",\n        \"albumArtist\": \"$t(entity.albumArtist, {\\\"count\\\": 1})\",\n        \"isRecentlyPlayed\": \"最近再生済み\",\n        \"isFavorited\": \"お気に入り済み\",\n        \"bpm\": \"BPM\",\n        \"releaseYear\": \"発売年\",\n        \"disc\": \"ディスク\",\n        \"biography\": \"バイオグラフィー\",\n        \"songCount\": \"曲数\",\n        \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n        \"duration\": \"長さ\",\n        \"random\": \"ランダム\",\n        \"lastPlayed\": \"最後に再生\",\n        \"toYear\": \"年まで\",\n        \"fromYear\": \"年から\",\n        \"criticRating\": \"批評家の評価\",\n        \"trackNumber\": \"トラック\",\n        \"comment\": \"コメント\",\n        \"recentlyUpdated\": \"新規更新\",\n        \"isPublic\": \"共有済み\",\n        \"channels\": \"$t(common.channel, {\\\"count\\\": 2})\",\n        \"owner\": \"$t(common.owner)\",\n        \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n        \"albumCount\": \"$t(entity.album, {\\\"count\\\": 2}) 枚\",\n        \"id\": \"ID\",\n        \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n        \"explicitStatus\": \"$t(common.explicitStatus)\",\n        \"sortName\": \"ソート名\",\n        \"matchAnd\": \"すべて\",\n        \"matchOr\": \"いずれか\"\n    },\n    \"page\": {\n        \"sidebar\": {\n            \"nowPlaying\": \"再生中\",\n            \"playlists\": \"$t(entity.playlist, {\\\"count\\\": 2})\",\n            \"search\": \"$t(common.search)\",\n            \"tracks\": \"$t(entity.track, {\\\"count\\\": 2})\",\n            \"albums\": \"$t(entity.album, {\\\"count\\\": 2})\",\n            \"genres\": \"$t(entity.genre, {\\\"count\\\": 2})\",\n            \"folders\": \"$t(entity.folder, {\\\"count\\\": 2})\",\n            \"settings\": \"$t(common.setting, {\\\"count\\\": 2})\",\n            \"home\": \"$t(common.home)\",\n            \"artists\": \"$t(entity.artist, {\\\"count\\\": 2})\",\n            \"albumArtists\": \"$t(entity.albumArtist, {\\\"count\\\": 2})\",\n            \"myLibrary\": \"マイライブラリ\",\n            \"shared\": \"$t(entity.playlist, {\\\"count\\\": 2}) を共有\",\n            \"radio\": \"$t(entity.radioStation, {\\\"count\\\": 2})\",\n            \"favorites\": \"$t(entity.favorite, {\\\"count\\\": 2})\",\n            \"collections\": \"コレクション\"\n        },\n        \"fullscreenPlayer\": {\n            \"config\": {\n                \"showLyricMatch\": \"歌詞のマッチを表示\",\n                \"dynamicBackground\": \"ダイナミック背景\",\n                \"synchronized\": \"同期\",\n                \"followCurrentLyric\": \"歌詞を再生位置に追従\",\n                \"opacity\": \"非透過率\",\n                \"lyricSize\": \"歌詞のサイズ\",\n                \"showLyricProvider\": \"歌詞の提供元を表示\",\n                \"unsynchronized\": \"非同期\",\n                \"lyricAlignment\": \"歌詞の位置\",\n                \"useImageAspectRatio\": \"画像のアスペクト比を使用する\",\n                \"lyricGap\": \"歌詞の間隔\",\n                \"dynamicImageBlur\": \"画像のぼかしサイズ\",\n                \"dynamicIsImage\": \"背景画像を有効にする\",\n                \"lyricOffset\": \"歌詞のオフセット (ms)\"\n            },\n            \"upNext\": \"次へ\",\n            \"lyrics\": \"歌詞\",\n            \"related\": \"関連\",\n            \"visualizer\": \"ビジュアライザー\",\n            \"noLyrics\": \"歌詞が見つかりません\"\n        },\n        \"appMenu\": {\n            \"selectServer\": \"サーバーを選択\",\n            \"version\": \"バージョン {{version}}\",\n            \"settings\": \"$t(common.setting, {\\\"count\\\": 2})\",\n            \"manageServers\": \"サーバーの管理\",\n            \"expandSidebar\": \"サイドバーを展開\",\n            \"collapseSidebar\": \"サイドバーを折りたたむ\",\n            \"openBrowserDevtools\": \"ブラウザーの開発者ツールを開く\",\n            \"quit\": \"$t(common.quit)\",\n            \"goBack\": \"戻る\",\n            \"goForward\": \"進む\",\n            \"privateModeOff\": \"プライベートモードをオフにする\",\n            \"privateModeOn\": \"プライベートモードをオンにする\",\n            \"selectMusicFolder\": \"音楽フォルダーを選択\",\n            \"noMusicFolder\": \"音楽フォルダーが選択されていません\",\n            \"commandPalette\": \"コマンドパレットを開く\",\n            \"multipleMusicFolders\": \"{{count}} 個の音楽フォルダーが選択されました\"\n        },\n        \"contextMenu\": {\n            \"addToPlaylist\": \"$t(action.addToPlaylist)\",\n            \"addToFavorites\": \"$t(action.addToFavorites)\",\n            \"setRating\": \"$t(action.setRating)\",\n            \"moveToTop\": \"$t(action.moveToTop)\",\n            \"deletePlaylist\": \"$t(action.deletePlaylist)\",\n            \"moveToBottom\": \"$t(action.moveToBottom)\",\n            \"createPlaylist\": \"$t(action.createPlaylist)\",\n            \"removeFromPlaylist\": \"$t(action.removeFromPlaylist)\",\n            \"removeFromFavorites\": \"$t(action.removeFromFavorites)\",\n            \"addNext\": \"$t(player.addNext)\",\n            \"deselectAll\": \"$t(action.deselectAll)\",\n            \"addLast\": \"$t(player.addLast)\",\n            \"addFavorite\": \"$t(action.addToFavorites)\",\n            \"play\": \"$t(player.play)\",\n            \"numberSelected\": \"{{count}} 個 選択\",\n            \"removeFromQueue\": \"$t(action.removeFromQueue)\",\n            \"download\": \"ダウンロード\",\n            \"moveToNext\": \"$t(action.moveToNext)\",\n            \"playSimilarSongs\": \"$t(player.playSimilarSongs)\",\n            \"shareItem\": \"アイテムを共有する\",\n            \"goToAlbum\": \"$t(entity.album, {\\\"count\\\": 1}) に移動\",\n            \"goToAlbumArtist\": \"$t(entity.albumArtist, {\\\"count\\\": 1}) に移動\",\n            \"showDetails\": \"情報を取得する\",\n            \"playShuffled\": \"$t(player.shuffle)\",\n            \"moveItems\": \"$t(action.moveItems)\",\n            \"goTo\": \"移動\"\n        },\n        \"home\": {\n            \"mostPlayed\": \"最も多く再生\",\n            \"newlyAdded\": \"新規追加リリース\",\n            \"title\": \"$t(common.home)\",\n            \"explore\": \"ライブラリから検索\",\n            \"recentlyPlayed\": \"最近の再生\",\n            \"recentlyReleased\": \"最近のリリース\",\n            \"genres\": \"$t(entity.genre, {\\\"count\\\": 2})\"\n        },\n        \"albumDetail\": {\n            \"moreFromArtist\": \"$t(entity.artist, {\\\"count\\\": 1}) の他の項目\",\n            \"moreFromGeneric\": \"{{item}} の他の作品\",\n            \"released\": \"リリース\"\n        },\n        \"setting\": {\n            \"playbackTab\": \"再生\",\n            \"generalTab\": \"一般\",\n            \"hotkeysTab\": \"ホットキー\",\n            \"windowTab\": \"ウィンドウ\",\n            \"advanced\": \"高度\",\n            \"analytics\": \"分析\",\n            \"updates\": \"更新\",\n            \"cache\": \"キャッシュ\",\n            \"application\": \"アプリケーション\",\n            \"queryBuilder\": \"クエリビルダー\",\n            \"theme\": \"テーマ\",\n            \"controls\": \"コントロール\",\n            \"sidebar\": \"サイドバー\",\n            \"remote\": \"リモート\",\n            \"exportImport\": \"インポート / エクスポート\",\n            \"scrobble\": \"Scrobble\",\n            \"audio\": \"オーディオ\",\n            \"lyrics\": \"歌詞\",\n            \"lyricsDisplay\": \"歌詞表示\",\n            \"transcoding\": \"トランスコーディング\",\n            \"discord\": \"Discord\",\n            \"logger\": \"ロガー\",\n            \"playerFilters\": \"プレーヤーフィルター\"\n        },\n        \"albumArtistList\": {\n            \"title\": \"$t(entity.albumArtist, {\\\"count\\\": 2})\"\n        },\n        \"genreList\": {\n            \"title\": \"$t(entity.genre, {\\\"count\\\": 2})\",\n            \"showAlbums\": \"$t(entity.genre, {\\\"count\\\": 1}) $t(entity.album, {\\\"count\\\": 2}) を表示\",\n            \"showTracks\": \"$t(entity.genre, {\\\"count\\\": 1}) $t(entity.track, {\\\"count\\\": 2}) を表示\"\n        },\n        \"trackList\": {\n            \"title\": \"$t(entity.track, {\\\"count\\\": 2})\",\n            \"artistTracks\": \"{{artist}} のトラック\",\n            \"genreTracks\": \"\\\"{{genre}}\\\" $t(entity.track, {\\\"count\\\": 2})\"\n        },\n        \"globalSearch\": {\n            \"commands\": {\n                \"serverCommands\": \"サーバーコマンド\",\n                \"goToPage\": \"ページへ移動\",\n                \"searchFor\": \"{{query}} を検索\"\n            },\n            \"title\": \"コマンド\"\n        },\n        \"playlistList\": {\n            \"title\": \"$t(entity.playlist, {\\\"count\\\": 2})\"\n        },\n        \"albumList\": {\n            \"title\": \"$t(entity.album, {\\\"count\\\": 2})\",\n            \"artistAlbums\": \"{{artist}} のアルバム\",\n            \"genreAlbums\": \"\\\"{{genre}}\\\" $t(entity.album, {\\\"count\\\": 2})\"\n        },\n        \"albumArtistDetail\": {\n            \"about\": \"{{artist}} について\",\n            \"appearsOn\": \"出演\",\n            \"recentReleases\": \"最近のリリース\",\n            \"viewDiscography\": \"ディスコグラフィーを見る\",\n            \"topSongs\": \"人気曲\",\n            \"topSongsFrom\": \"{{title}} の人気曲\",\n            \"viewAll\": \"すべて表示\",\n            \"viewAllTracks\": \"$t(entity.track, {\\\"count\\\": 2}) をすべて表示\",\n            \"relatedArtists\": \"関連の $t(entity.artist, {\\\"count\\\": 2})\",\n            \"groupingTypeAll\": \"すべてのリリースタイプ\",\n            \"groupingTypePrimary\": \"主なリリースタイプ\",\n            \"favoriteSongs\": \"お気に入りの曲\",\n            \"topSongsCommunity\": \"コミュニティ\",\n            \"favoriteSongsFrom\": \"{{title}} のお気に入りの曲\",\n            \"topSongsPersonal\": \"個人的\"\n        },\n        \"manageServers\": {\n            \"title\": \"サーバーの管理\",\n            \"serverDetails\": \"サーバーの詳細\",\n            \"url\": \"URL\",\n            \"username\": \"ユーザーネーム\",\n            \"editServerDetailsTooltip\": \"サーバーの詳細を編集する\",\n            \"removeServer\": \"リモートサーバー\"\n        },\n        \"itemDetail\": {\n            \"openFile\": \"ファイルマネージャーでトラックを表示する\",\n            \"copyPath\": \"パスをクリップボードにコピーする\",\n            \"copiedPath\": \"パスが正常にコピーされました\"\n        },\n        \"playlist\": {\n            \"reorder\": \"ID によるソート時のみ並べ替えが可能です\"\n        },\n        \"radioList\": {\n            \"title\": \"ラジオ局\"\n        },\n        \"favorites\": {\n            \"title\": \"$t(entity.favorite, {\\\"count\\\": 2})\"\n        },\n        \"folderList\": {\n            \"title\": \"$t(entity.folder, {\\\"count\\\": 2})\"\n        },\n        \"windowBar\": {\n            \"paused\": \"(一時停止) \",\n            \"privateMode\": \"(プライベートモード)\"\n        },\n        \"collections\": {\n            \"overrideExisting\": \"既存のものを上書き\",\n            \"saveAsCollection\": \"コレクションとして保存\"\n        },\n        \"releasenotes\": {\n            \"commitsSinceStable\": \"{{stable}} 以降のコミット\",\n            \"noNewCommits\": \"この範囲に新しいコミットはありません\",\n            \"noStableReleaseToCompare\": \"比較可能な安定版リリースはありません\"\n        }\n    },\n    \"form\": {\n        \"deletePlaylist\": {\n            \"title\": \"$t(entity.playlist, {\\\"count\\\": 1}) を削除\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) が削除されました\",\n            \"input_confirm\": \"確認のため $t(entity.playlist, {\\\"count\\\": 1}) の名前を入力してください\"\n        },\n        \"createPlaylist\": {\n            \"input_description\": \"$t(common.description)\",\n            \"title\": \"$t(entity.playlist, {\\\"count\\\": 1}) を作成\",\n            \"input_public\": \"公開\",\n            \"input_name\": \"$t(common.name)\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) を作成しました\",\n            \"input_owner\": \"$t(common.owner)\"\n        },\n        \"addServer\": {\n            \"title\": \"サーバーを追加\",\n            \"input_username\": \"ユーザー名\",\n            \"input_url\": \"URL\",\n            \"input_password\": \"パスワード\",\n            \"input_legacyAuthentication\": \"レガシー認証を有効化\",\n            \"input_name\": \"サーバー名\",\n            \"success\": \"サーバーが追加されました\",\n            \"input_savePassword\": \"パスワードを保存\",\n            \"ignoreSsl\": \"SSL を無視します ($t(common.restartRequired))\",\n            \"ignoreCors\": \"CORS を無視します ($t(common.restartRequired))\",\n            \"error_savePassword\": \"パスワードを保存する際にエラーが発生しました\",\n            \"input_preferInstantMixDescription\": \"類似曲を取得するにはインスタントミックスのみを使用してください。この動作を変更するプラグインがある場合に役立ちます\",\n            \"input_preferInstantMix\": \"インスタントミックスを優先する\",\n            \"input_preferRemoteUrl\": \"公開 URL を優先する\",\n            \"input_remoteUrl\": \"公開 URL\",\n            \"input_remoteUrlPlaceholder\": \"オプション: 外部機能用の公開 URL\"\n        },\n        \"addToPlaylist\": {\n            \"success\": \"$t(entity.trackWithCount, {\\\"count\\\": {{message}} }) を $t(entity.playlistWithCount, {\\\"count\\\": {{numOfPlaylists}} }) に追加しました\",\n            \"title\": \"$t(entity.playlist, {\\\"count\\\": 1}) に追加\",\n            \"input_skipDuplicates\": \"重複をスキップ\",\n            \"input_playlists\": \"$t(entity.playlist, {\\\"count\\\": 2})\",\n            \"create\": \"$t(entity.playlist, {\\\"count\\\": 1}) {{playlist}} を作成する\",\n            \"searchOrCreate\": \"$t(entity.playlist, {\\\"count\\\": 2}) を検索するか、入力して新しいプレイリストを作成してください\"\n        },\n        \"updateServer\": {\n            \"title\": \"サーバーをアップデート\",\n            \"success\": \"サーバーがアップデートされました\"\n        },\n        \"queryEditor\": {\n            \"input_optionMatchAll\": \"すべて一致\",\n            \"input_optionMatchAny\": \"一部一致\",\n            \"title\": \"クエリエディタ\",\n            \"addRuleGroup\": \"ルールグループを追加\",\n            \"removeRuleGroup\": \"ルールグループを削除\",\n            \"resetToDefault\": \"デフォルトにリセット\",\n            \"clearFilters\": \"フィルターを削除\"\n        },\n        \"lyricSearch\": {\n            \"input_name\": \"$t(common.name)\",\n            \"input_artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n            \"title\": \"歌詞検索\"\n        },\n        \"editPlaylist\": {\n            \"title\": \"$t(entity.playlist, {\\\"count\\\": 1}) を編集\",\n            \"publicJellyfinNote\": \"Jellyfin では、何らかの理由でプレイリストが公開されているかどうかが表示されません。公開されたままにしたい場合は、以下の項目を選択してください\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) が正常に更新されました\",\n            \"editNote\": \"大規模なプレイリストの場合、手動編集は推奨されません。既存のプレイリストを上書きすることでデータ損失が発生するリスクを許容しますか?\"\n        },\n        \"shareItem\": {\n            \"allowDownloading\": \"ダウンロードを許可\",\n            \"description\": \"説明\",\n            \"setExpiration\": \"有効期限を設定\",\n            \"success\": \"共有リンクがクリップボードにコピーされました (またはここをクリックして開きます)\",\n            \"expireInvalid\": \"有効期限は将来の日時である必要があります\",\n            \"createFailed\": \"共有リンクを作成できませんでした (共有は有効になっていますか?)\",\n            \"copyToClipboard\": \"クリップボードにコピー: Ctrl+C、Enter\",\n            \"successMustClick\": \"共有リンクが正常に作成されました。開くにはここをクリックしてください\"\n        },\n        \"privateMode\": {\n            \"enabled\": \"プライベートモードが有効になりました。再生ステータスは外部連携から非表示になっています\",\n            \"disabled\": \"プライベートモードが無効になりました。再生ステータスは有効になっている外部連携に表示されています\",\n            \"title\": \"プライベートモード\"\n        },\n        \"largeFetchConfirmation\": {\n            \"title\": \"キューにアイテムを追加する\",\n            \"description\": \"このアクションは、現在のフィルターされたビュー内のすべてのアイテムを追加します\"\n        },\n        \"createRadioStation\": {\n            \"success\": \"ラジオ局が正常に作成されました\",\n            \"title\": \"ラジオ局を作成\",\n            \"input_homepageUrl\": \"ホームページ URL\",\n            \"input_name\": \"名前\",\n            \"input_streamUrl\": \"ストリーム URL\"\n        },\n        \"lyricsExport\": {\n            \"export\": \"歌詞をエクスポート\",\n            \"input_synced\": \"同期歌詞をエクスポート\",\n            \"input_offset\": \"$t(setting.lyricOffset)\"\n        },\n        \"shuffleAll\": {\n            \"title\": \"ランダムに再生\",\n            \"input_genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n            \"input_limit\": \"何曲?\",\n            \"input_minYear\": \"年から\",\n            \"input_maxYear\": \"年まで\",\n            \"input_played_optionAll\": \"すべてのトラック\",\n            \"input_played_optionUnplayed\": \"未再生のトラックのみ\",\n            \"input_played_optionPlayed\": \"再生されたトラックのみ\",\n            \"input_played\": \"再生フィルター\"\n        },\n        \"saveQueue\": {\n            \"success\": \"プレイキューをサーバーに保存しました\"\n        }\n    },\n    \"entity\": {\n        \"genre_other\": \"ジャンル\",\n        \"playlistWithCount_other\": \"{{count}} 個のプレイリスト\",\n        \"playlist_other\": \"プレイリスト\",\n        \"artist_other\": \"アーティスト\",\n        \"folderWithCount_other\": \"{{count}} 個のフォルダー\",\n        \"albumArtist_other\": \"アルバムアーティスト\",\n        \"track_other\": \"トラック\",\n        \"albumArtistCount_other\": \"{{count}} アルバムアーティスト\",\n        \"albumWithCount_other\": \"{{count}} 枚のアルバム\",\n        \"favorite_other\": \"お気に入り\",\n        \"artistWithCount_other\": \"{{count}} 人のアーティスト\",\n        \"folder_other\": \"フォルダー\",\n        \"smartPlaylist\": \"スマート $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"album_other\": \"アルバム\",\n        \"genreWithCount_other\": \"{{count}} 個のジャンル\",\n        \"trackWithCount_other\": \"{{count}} 個のトラック\",\n        \"play_other\": \"{{count}} 回再生\",\n        \"song_other\": \"曲\",\n        \"radioStation_other\": \"ラジオ局\",\n        \"radioStationWithCount_other\": \"{{count}} 局のラジオ局\"\n    },\n    \"dragDropZone\": {\n        \"error_oneFileOnly\": \"1 つのファイルのみ選択してください\",\n        \"error_readingFile\": \"ファイルの読み取り中に問題が発生しました: {{errorMessage}}\",\n        \"mainText\": \"ここにファイルをドロップしてください\"\n    },\n    \"releaseType\": {\n        \"primary\": {\n            \"broadcast\": \"放送\",\n            \"ep\": \"EP\",\n            \"other\": \"その他\",\n            \"single\": \"シングル\",\n            \"album\": \"$t(entity.album, {\\\"count\\\": 1})\"\n        },\n        \"secondary\": {\n            \"audiobook\": \"オーディオブック\",\n            \"audioDrama\": \"オーディオドラマ\",\n            \"compilation\": \"コンピレーション\",\n            \"djMix\": \"DJ ミックス\",\n            \"demo\": \"デモ\",\n            \"soundtrack\": \"サウンドトラック\",\n            \"fieldRecording\": \"フィールドレコーディング\",\n            \"interview\": \"インタビュー\",\n            \"live\": \"ライブ\",\n            \"mixtape\": \"ミックステープ\",\n            \"remix\": \"リミックス\",\n            \"spokenWord\": \"スポークン・ワード\"\n        }\n    },\n    \"datetime\": {\n        \"minuteShort\": \"分\",\n        \"secondShort\": \"秒\",\n        \"hourShort\": \"時間\",\n        \"dayShort\": \"日\"\n    },\n    \"queryBuilder\": {\n        \"standardTags\": \"標準タグ\",\n        \"customTags\": \"カスタムタグ\"\n    },\n    \"filterOperator\": {\n        \"matchesRegex\": \"正規表現に一致\",\n        \"notContains\": \"含まれていない\",\n        \"after\": \"以降\",\n        \"afterDate\": \"以降 (日付)\",\n        \"before\": \"以前\",\n        \"beforeDate\": \"以前 (日付)\",\n        \"contains\": \"を含む\",\n        \"endsWith\": \"で終わる\",\n        \"inPlaylist\": \"いずれか\",\n        \"inTheRange\": \"範囲内\",\n        \"inTheRangeDate\": \"範囲内 (日付)\",\n        \"is\": \"完全一致\",\n        \"isNot\": \"不一致\",\n        \"startsWith\": \"で始まる\",\n        \"inTheLast\": \"以内\",\n        \"isGreaterThan\": \"より大きい\",\n        \"isLessThan\": \"より小さい\",\n        \"notInPlaylist\": \"いずれでもない\",\n        \"notInTheLast\": \"より前\"\n    },\n    \"visualizer\": {\n        \"visualizerType\": \"ビジュアライザーの種類\",\n        \"colors\": \"色\",\n        \"cyclePresets\": \"サイクルプリセット\",\n        \"cycleTime\": \"サイクルタイム（秒）\",\n        \"includeAllPresets\": \"すべてのプリセットを含める\",\n        \"ignoredPresets\": \"無視されたプリセット\",\n        \"selectedPresets\": \"選択されたプリセット\",\n        \"randomizeNextPreset\": \"次のプリセットをランダム化\",\n        \"blendTime\": \"ブレンド時間\",\n        \"presets\": \"プリセット\",\n        \"selectPreset\": \"プリセットを選択\",\n        \"applyPreset\": \"プリセットを適用\",\n        \"saveAsPreset\": \"プリセットとして保存\",\n        \"updatePreset\": \"プリセットを更新\",\n        \"copyConfiguration\": \"設定をコピーする\",\n        \"pasteConfiguration\": \"設定を貼り付け\",\n        \"pasteConfigurationPlaceholder\": \"ここに JSON 設定を貼り付けてください...\",\n        \"pasteFromClipboard\": \"クリップボードから貼り付け\",\n        \"applyConfiguration\": \"設定を適用\",\n        \"configCopied\": \"設定をクリップボードにコピーしました\",\n        \"configCopyFailed\": \"設定のコピーに失敗しました\",\n        \"configPasted\": \"設定が正常に適用されました\",\n        \"configPasteFailed\": \"設定の適用に失敗しました。形式を確認してください。\",\n        \"configPasteReadFailed\": \"クリップボードからの読み取りに失敗しました\",\n        \"presetName\": \"プリセット名\",\n        \"presetNamePlaceholder\": \"プリセット名を入力\",\n        \"general\": \"全般\",\n        \"mode\": \"モード\",\n        \"mode1To8\": \"モード 1 - 8\",\n        \"mode10\": \"モード 10\",\n        \"barSpace\": \"バースペース\",\n        \"lineWidth\": \"線幅\",\n        \"fillAlpha\": \"アルファ塗りつぶしを設定\",\n        \"channelLayout\": \"チャンネルレイアウト\",\n        \"maxFPS\": \"最大フレームレート\",\n        \"opacity\": \"不透明度\",\n        \"customGradients\": \"カスタムグラデーション\",\n        \"addCustomGradient\": \"カスタムグラデーションを追加\",\n        \"gradientName\": \"グラデーション名\",\n        \"gradientNamePlaceholder\": \"グラデーション名\",\n        \"vertical\": \"垂直\",\n        \"horizontal\": \"水平\",\n        \"colorStops\": \"カラー停止点の数\",\n        \"addColor\": \"色を加える\",\n        \"position\": \"位置\",\n        \"level\": \"レベル\",\n        \"remove\": \"取り除く\",\n        \"pasteGradient\": \"グラデーションを貼り付け\",\n        \"pasteGradientPlaceholder\": \"グラデーションの JSON をここに貼り付けてください...\",\n        \"custom\": \"カスタム\",\n        \"builtIn\": \"組み込み\",\n        \"colorMode\": \"カラーモード\",\n        \"gradient\": \"勾配\",\n        \"gradientLeft\": \"左へのグラデーション\",\n        \"gradientRight\": \"右へのグラデーション\",\n        \"fft\": \"高速フーリエ変換\",\n        \"fftSize\": \"FFT サイズ\",\n        \"smoothing\": \"平滑化\",\n        \"frequencyRangeAndScaling\": \"周波数範囲とスケーリング\",\n        \"minimumFrequency\": \"最小周波数\",\n        \"maximumFrequency\": \"最大周波数\",\n        \"frequencyScale\": \"周波数スケール\",\n        \"sensitivity\": \"感度\",\n        \"weightingFilter\": \"重み付けフィルタ\",\n        \"minimumDecibels\": \"最小デシベル\",\n        \"maximumDecibels\": \"最大デシベル\",\n        \"linearAmplitude\": \"線形振幅\",\n        \"linearBoost\": \"リニアブースト\",\n        \"peakBehavior\": \"ピーク時の振る舞い\",\n        \"showPeaks\": \"ピークスを表示\",\n        \"fadePeaks\": \"フェードピークス\",\n        \"peakLine\": \"ピークライン\",\n        \"gravity\": \"重力\",\n        \"peakFadeTime\": \"ピークフェード時間（ミリ秒）\",\n        \"peakHoldTime\": \"ピークホールド時間（ミリ秒）\",\n        \"radialSpectrum\": \"放射状スペクトル\",\n        \"radial\": \"ラジアル\",\n        \"radialInvert\": \"放射状インバート\",\n        \"spinSpeed\": \"回転速度\",\n        \"radius\": \"半径\",\n        \"reflexMirror\": \"反射鏡\",\n        \"reflexFit\": \"リフレックス・フィット\",\n        \"reflexRatio\": \"反射比\",\n        \"reflexAlpha\": \"リフレックス・アルファ\",\n        \"reflexBrightness\": \"反射輝度\",\n        \"mirror\": \"鏡\",\n        \"miscellaneousSettings\": \"その他の設定\",\n        \"alphaBars\": \"アルファバー\",\n        \"ansiBands\": \"ANSI バンド\",\n        \"ledBars\": \"LED バー\",\n        \"trueLeds\": \"真の LED\",\n        \"lumiBars\": \"ルミ・バー\",\n        \"outlineBars\": \"アウトラインバー\",\n        \"roundBars\": \"丸棒\",\n        \"lowResolution\": \"低解像度\",\n        \"splitGradient\": \"分割グラデーション\",\n        \"showFPS\": \"FPS を表示\",\n        \"showScaleX\": \"X 軸スケールを表示\",\n        \"noteLabels\": \"注釈ラベル\",\n        \"showScaleY\": \"Y 軸スケールを表示\",\n        \"options\": {\n            \"mode\": {\n                \"0\": \"[0] 離散周波数\",\n                \"1\": \"[1] 1/24 オクターブ / 240 バンド\",\n                \"2\": \"[2] 1/12 オクターブ / 120 バンド\",\n                \"3\": \"[3] 1/8 オクターブ / 80 バンド\",\n                \"4\": \"[4] 1/6 オクターブ / 60 バンド\",\n                \"5\": \"[5] 1/4 オクターブ / 40 バンド\",\n                \"6\": \"[6] 1/3 オクターブ / 30 バンド\",\n                \"7\": \"[7] 半オクターブ / 20 バンド\",\n                \"8\": \"[8] フルオクターブ / 10 バンド\",\n                \"10\": \"[10] 折れ線グラフ / 面グラフ\"\n            },\n            \"colorMode\": {\n                \"gradient\": \"勾配\",\n                \"barIndex\": \"バー・インデックス\",\n                \"barLevel\": \"バーレベル\"\n            },\n            \"gradient\": {\n                \"classic\": \"クラシック\",\n                \"prism\": \"プリズム\",\n                \"rainbow\": \"虹\",\n                \"steelblue\": \"スチールブルー\",\n                \"orangered\": \"オレンジレッド\"\n            },\n            \"channelLayout\": {\n                \"single\": \"シングル\",\n                \"dualCombined\": \"デュアルコンバインド\",\n                \"dualHorizontal\": \"デュアル水平\",\n                \"dualVertical\": \"デュアルバーティカル\"\n            },\n            \"frequencyScale\": {\n                \"none\": \"なし\",\n                \"bark\": \"樹皮スケール\",\n                \"linear\": \"線形スケール\",\n                \"log\": \"対数スケール\",\n                \"mel\": \"メル尺度\"\n            },\n            \"weightingFilter\": {\n                \"none\": \"なし\",\n                \"a\": \"A\",\n                \"b\": \"B\",\n                \"c\": \"C\",\n                \"d\": \"D\",\n                \"z\": \"Z\"\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "src/i18n/locales/ko.json",
    "content": "{\n    \"action\": {\n        \"createPlaylist\": \"$t(entity.playlist, {\\\"count\\\": 1}) 생성\",\n        \"addToFavorites\": \"$t(entity.favorite, {\\\"count\\\": 2})에 추가\",\n        \"addToPlaylist\": \"$t(entity.playlist, {\\\"count\\\": 1})에 추가\",\n        \"clearQueue\": \"대기열 지우기\",\n        \"deletePlaylist\": \"$t(entity.playlist, {\\\"count\\\": 1}) 삭제\",\n        \"deselectAll\": \"모두 선택 해제\",\n        \"editPlaylist\": \"$t(entity.playlist, {\\\"count\\\": 1}) 편집\",\n        \"goToPage\": \"페이지 이동\",\n        \"moveToBottom\": \"맨 아래로 이동\",\n        \"moveToTop\": \"맨 위로 이동\",\n        \"moveToNext\": \"다음으로 이동\",\n        \"removeFromQueue\": \"대기열에서 제거\",\n        \"refresh\": \"$t(common.refresh)\",\n        \"removeFromFavorites\": \"$t(entity.favorite, {\\\"count\\\": 2})에서 제거\",\n        \"removeFromPlaylist\": \"$t(entity.playlist, {\\\"count\\\": 1})에서 제거\",\n        \"openIn\": {\n            \"musicbrainz\": \"MusicBrainz에서 보기\",\n            \"lastfm\": \"Last.fm에서 보기\"\n        },\n        \"viewPlaylists\": \"$t(entity.playlist, {\\\"count\\\": 2}) 보기\",\n        \"setRating\": \"평점 지정\",\n        \"toggleSmartPlaylistEditor\": \"$t(entity.smartPlaylist) 편집기 펼치기\",\n        \"addOrRemoveFromSelection\": \"선택항목에서 추가 또는 제거\",\n        \"selectRangeOfItems\": \"항목의 범위 선택\",\n        \"createRadioStation\": \"$t(entity.radioStation, {\\\"count\\\": 1}) 생성\",\n        \"deleteRadioStation\": \"$t(entity.radioStation, {\\\"count\\\": 1}) 삭제\",\n        \"selectAll\": \"전부 선택\",\n        \"downloadStarted\": \"{{count}}개 항목 다운로드 시작했습니다\",\n        \"moveUp\": \"위로 옮기기\",\n        \"moveDown\": \"아래로 옮기기\",\n        \"holdToMoveToTop\": \"맨 위로 옮기기 위해 끌기\",\n        \"holdToMoveToBottom\": \"맨 아래로 옮기기 위해 끌기\",\n        \"moveItems\": \"항목 옮기기\",\n        \"shuffle\": \"섞기\",\n        \"shuffleAll\": \"모두 섞기\",\n        \"shuffleSelected\": \"선택항목 섞기\",\n        \"viewMore\": \"더 보기\",\n        \"openApplicationDirectory\": \"앱 디렉토리 열기\"\n    },\n    \"common\": {\n        \"translation\": \"번역\",\n        \"resetToDefault\": \"기본 설정으로 되돌리기\",\n        \"right\": \"오른쪽\",\n        \"save\": \"저장\",\n        \"increase\": \"증가\",\n        \"version\": \"버전\",\n        \"year\": \"년\",\n        \"reset\": \"초기화\",\n        \"random\": \"랜덤\",\n        \"close\": \"닫기\",\n        \"codec\": \"코덱\",\n        \"create\": \"만들기\",\n        \"disc\": \"디스크\",\n        \"gap\": \"갭\",\n        \"left\": \"왼쪽\",\n        \"add\": \"추가\",\n        \"backward\": \"뒤로\",\n        \"saveAs\": \"(으)로 저장하기\",\n        \"search\": \"검색\",\n        \"setting_other\": \"설정\",\n        \"share\": \"공유\",\n        \"size\": \"크기\",\n        \"sortOrder\": \"순서\",\n        \"title\": \"곡명\",\n        \"trackNumber\": \"트랙번호\",\n        \"trackGain\": \"트랙 게인\",\n        \"trackPeak\": \"트랙 피크\",\n        \"unknown\": \"알 수 없음\",\n        \"cancel\": \"취소\",\n        \"clear\": \"지우기\",\n        \"collapse\": \"접기\",\n        \"comingSoon\": \"조만간…\",\n        \"configure\": \"설정\",\n        \"confirm\": \"확인\",\n        \"currentSong\": \"현재 $t(entity.track, {\\\"count\\\": 1})\",\n        \"decrease\": \"감소\",\n        \"delete\": \"삭제\",\n        \"descending\": \"내림차순\",\n        \"description\": \"설명\",\n        \"disable\": \"비활성\",\n        \"edit\": \"편집\",\n        \"enable\": \"활성\",\n        \"expand\": \"확장\",\n        \"favorite\": \"즐겨찾기\",\n        \"forceRestartRequired\": \"변경 사항을 적용하려면 재실행 하세요... 알림을 닫으면 재실행합니다\",\n        \"forward\": \"앞으로\",\n        \"limit\": \"제한\",\n        \"manage\": \"관리하다\",\n        \"maximize\": \"최대화\",\n        \"menu\": \"메뉴\",\n        \"minimize\": \"최소화\",\n        \"modified\": \"수정된\",\n        \"name\": \"이름\",\n        \"path\": \"경로\",\n        \"playerMustBePaused\": \"플레이어가 일시정지 되어야 합니다\",\n        \"preview\": \"미리보기\",\n        \"previousSong\": \"이전곡 $t(entity.track, {\\\"count\\\": 1})\",\n        \"quit\": \"종료\",\n        \"refresh\": \"새로고침\",\n        \"reload\": \"리로드\",\n        \"restartRequired\": \"반드시 재실행되어야 합니다\",\n        \"saveAndReplace\": \"저장하고 변경하기\",\n        \"yes\": \"네\",\n        \"ascending\": \"오름차순\",\n        \"areYouSure\": \"확실한가요?\",\n        \"bitrate\": \"비트 전송률\",\n        \"bpm\": \"bpm\",\n        \"biography\": \"바이오그래피\",\n        \"center\": \"중앙\",\n        \"channel_other\": \"채널\",\n        \"filter_other\": \"필터\",\n        \"mbid\": \"MusicBrainz ID\",\n        \"dismiss\": \"닫기\",\n        \"duration\": \"길이\",\n        \"home\": \"홈\",\n        \"no\": \"아니오\",\n        \"none\": \"없음\",\n        \"rating\": \"평점\",\n        \"action_other\": \"동작\",\n        \"newVersion\": \"새로운 버전이 설치되었습니다. {{version}}\",\n        \"viewReleaseNotes\": \"릴리스노트 보기\",\n        \"albumGain\": \"앨범 게인\",\n        \"albumPeak\": \"앨범 피크\",\n        \"bitDepth\": \"비트 심도\",\n        \"filters\": \"필터\",\n        \"noResultsFromQuery\": \"쿼리 결과가 없습니다\",\n        \"note\": \"노트\",\n        \"ok\": \"OK\",\n        \"owner\": \"소유자\",\n        \"sampleRate\": \"샘플레이트\",\n        \"tags\": \"태그\",\n        \"additionalParticipants\": \"추가 참여자\",\n        \"explicitStatus\": \"성인컨텐츠\",\n        \"private\": \"비공개\",\n        \"public\": \"공개\",\n        \"recordLabel\": \"레이블\",\n        \"releaseType\": \"발매형태\",\n        \"explicit\": \"성인컨텐츠\",\n        \"clean\": \"클린\",\n        \"countSelected\": \"{{count}}개 선택됨\",\n        \"doNotShowAgain\": \"다시 보지 않기\",\n        \"view\": \"보기\",\n        \"externalLinks\": \"외부 링크\",\n        \"faster\": \"빠르게\",\n        \"noFilters\": \"필터 미설정\",\n        \"slower\": \"천천히\",\n        \"sort\": \"정렬\",\n        \"gridRows\": \"행 그리드\",\n        \"tableColumns\": \"테이블 열\",\n        \"itemsMore\": \"{{count}}개 더\"\n    },\n    \"entity\": {\n        \"albumWithCount_other\": \"{{count}} 앨범\",\n        \"artist_other\": \"아티스트\",\n        \"artistWithCount_other\": \"{{count}} 아티스트\",\n        \"favorite_other\": \"즐겨찾기\",\n        \"folder_other\": \"폴더\",\n        \"genre_other\": \"장르\",\n        \"genreWithCount_other\": \"{{count}} 장르\",\n        \"playlist_other\": \"플레이리스트\",\n        \"album_other\": \"앨범\",\n        \"albumArtist_other\": \"앨범 아티스트\",\n        \"albumArtistCount_other\": \"{{count}} 앨범 아티스트\",\n        \"folderWithCount_other\": \"{{count}} 폴더\",\n        \"trackWithCount_other\": \"{{count}} 트랙\",\n        \"song_other\": \"곡\",\n        \"play_other\": \"{{count}} 재생\",\n        \"playlistWithCount_other\": \"{{count}} 재생목록\",\n        \"smartPlaylist\": \"스마트 $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"track_other\": \"트랙\",\n        \"radioStation_other\": \"라디오 방송국\",\n        \"radioStationWithCount_other\": \"{{count}}개 라디오 방송국\"\n    },\n    \"error\": {\n        \"systemFontError\": \"시스템 폰트를 가져오는데 실패하였습니다\",\n        \"loginRateError\": \"너무 많은 로그인 시도하였습니다 잠시 후 다시 시도해 주세요\",\n        \"mpvRequired\": \"MPV 필요\",\n        \"openError\": \"파일을 열 수 없습니다\",\n        \"remoteDisableError\": \"원격 서버를 $t(common.disable) 하는데 실패하였습니다\",\n        \"playbackError\": \"미디어를 재생하는 도중에 에러가 발생하였습니다\",\n        \"remoteEnableError\": \"원격 서버를 $t(common.enable) 하는데 실패하였습니다\",\n        \"serverNotSelectedError\": \"선택된 서버가 없습니다\",\n        \"serverRequired\": \"서버가 필요합니다\",\n        \"sessionExpiredError\": \"세션이 만료되었습니다\",\n        \"networkError\": \"네트워크 에러가 발생하였습니다\",\n        \"remotePortError\": \"원격 서버의 포트 설정하는데 실패하였습니다\",\n        \"remotePortWarning\": \"새로 설정한 포트를 적용하기 위해 서버를 재실행 해 주세요\",\n        \"audioDeviceFetchError\": \"오디오 장치를 불러올 수 없습니다\",\n        \"authenticationFailed\": \"인증 실패\",\n        \"badAlbum\": \"이 곡은 앨범의 일부가 아니기 때문에 표시되는 것입니다. 음악 폴더의 최상위에 곡이 있는 경우 이런 문제가 발생할 가능성이 높습니다. Jellyfin은 폴더 내 그룹만 추적합니다\",\n        \"credentialsRequired\": \"인증서가 필요함\",\n        \"endpointNotImplementedError\": \"엔드포인트 {{endpoint}} 는 {{serverType}} 에 대해 구현되지 않았습니다\",\n        \"genericError\": \"에러가 발생했습니다\",\n        \"invalidServer\": \"잘못된 서버\",\n        \"localFontAccessDenied\": \"로컬 글꼴에 접근 거부되었습니다\",\n        \"apiRouteError\": \"요청 보내기 실패\",\n        \"badValue\": \"옵션이 없습니다 {{value}}. 이 값은 더이상 존재하지 않습니다\",\n        \"notificationDenied\": \"알림에 대한 권한이 거부되었습니다. 이 설정은 변경되지 않습니다\"\n    },\n    \"filter\": {\n        \"title\": \"곡명\",\n        \"isRecentlyPlayed\": \"최근에 재생한\",\n        \"name\": \"이름\",\n        \"path\": \"경로\",\n        \"playCount\": \"재생 횟수\",\n        \"random\": \"무작위\",\n        \"recentlyAdded\": \"최근에 추가된\",\n        \"releaseDate\": \"발매일\",\n        \"recentlyPlayed\": \"최근에 재생된\",\n        \"recentlyUpdated\": \"최근에 업데이트된\",\n        \"search\": \"검색\",\n        \"dateAdded\": \"추가된 날짜\",\n        \"lastPlayed\": \"마지막으로 재생한\",\n        \"mostPlayed\": \"가장 많이 재생한\",\n        \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n        \"albumArtist\": \"$t(entity.albumArtist, {\\\"count\\\": 1})\",\n        \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n        \"communityRating\": \"커뮤니티 평점\",\n        \"criticRating\": \"비평가 평점\",\n        \"disc\": \"디스크\",\n        \"bitrate\": \"비트 전송률\",\n        \"biography\": \"바이오그래피\",\n        \"channels\": \"$t(common.channel_other)\",\n        \"duration\": \"길이\",\n        \"bpm\": \"bpm\",\n        \"albumCount\": \"$t(entity.album, {\\\"count\\\": 2}) 앨범수\",\n        \"comment\": \"코멘트\",\n        \"favorited\": \"즐겨찾기\",\n        \"fromYear\": \"시작 년도\",\n        \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n        \"id\": \"아이디\",\n        \"isCompilation\": \"편집앨범 여부\",\n        \"isFavorited\": \"즐겨찾기 여부\",\n        \"isPublic\": \"공유 여부\",\n        \"isRated\": \"평가 여부\",\n        \"note\": \"노트\",\n        \"owner\": \"$t(common.owner)\",\n        \"rating\": \"평가\",\n        \"releaseYear\": \"발매연도\",\n        \"songCount\": \"곡 갯수\",\n        \"toYear\": \"년도까지\",\n        \"trackNumber\": \"트랙\",\n        \"explicitStatus\": \"$t(common.explicitStatus)\"\n    },\n    \"form\": {\n        \"addServer\": {\n            \"title\": \"서버 추가하기\",\n            \"success\": \"서버 추가하였습니다\",\n            \"input_name\": \"서버 이름\",\n            \"input_password\": \"비밀번호\",\n            \"input_savePassword\": \"비밀번호 저장하기\",\n            \"input_url\": \"url\",\n            \"error_savePassword\": \"비밀번호를 저장하는 도중 오류가 발생했습니다\",\n            \"ignoreCors\": \"CORS 무시 ($t(common.restartRequired))\",\n            \"ignoreSsl\": \"SSL 무시 ($t(common.restartRequired))\",\n            \"input_legacyAuthentication\": \"레거시 인증 사용\",\n            \"input_username\": \"유저 이름\",\n            \"input_preferInstantMix\": \"즉석 믹스 선호\",\n            \"input_preferInstantMixDescription\": \"비슷한 곳을 찾기 위해 즉석 믹스를 사용합니다. 이 명령을 수정하기 위한 플러그인을 설치한 경우 유용합니다\"\n        },\n        \"addToPlaylist\": {\n            \"input_skipDuplicates\": \"중복 건너뛰기\",\n            \"title\": \"$t(entity.playlist, {\\\"count\\\": 1}) 에 추가\",\n            \"input_playlists\": \"$t(entity.playlist, {\\\"count\\\": 2})\",\n            \"success\": \"$t(entity.playlistWithCount, {\\\"count\\\": {{numOfPlaylists}} })에 $t(entity.trackWithCount, {\\\"count\\\": {{message}} })가 추가되었습니다\",\n            \"create\": \"$t(entity.playlist, {\\\"count\\\": 1}) {{playlist}} 생성\",\n            \"searchOrCreate\": \"$t(entity.playlist, {\\\"count\\\": 2}) 검색 또는 입력하여 새로 만들기\"\n        },\n        \"lyricSearch\": {\n            \"title\": \"가사 검색\",\n            \"input_name\": \"$t(common.name)\",\n            \"input_artist\": \"$t(entity.artist, {\\\"count\\\": 1})\"\n        },\n        \"queryEditor\": {\n            \"input_optionMatchAll\": \"모두 일치\",\n            \"input_optionMatchAny\": \"무엇이든 일치\",\n            \"title\": \"쿼리 편집기\"\n        },\n        \"editPlaylist\": {\n            \"title\": \"$t(entity.playlist, {\\\"count\\\": 1}) 편집\",\n            \"publicJellyfinNote\": \"Jellyfin은 재생목록 공개 여부를 노출하지 않습니다. 만약 공개되길 원한다면 다음을 선택하세요\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) 업데이트 되었습니다\"\n        },\n        \"shareItem\": {\n            \"allowDownloading\": \"다운로드 허용\",\n            \"description\": \"설명\",\n            \"success\": \"클립보드에 공유 링크를 복사했습니다 (또는 열어보려면 클릭하세요)\",\n            \"expireInvalid\": \"만료 날짜는 미래 날짜여야만 합니다\",\n            \"createFailed\": \"공유 링크를 생성하는데 실패하였습니다 (혹시 공유하기 설정되어 있나요?)\",\n            \"setExpiration\": \"만료 기간 설정하기\"\n        },\n        \"updateServer\": {\n            \"title\": \"서버 업데이트\",\n            \"success\": \"서버 업데이트 되었습니다\"\n        },\n        \"createPlaylist\": {\n            \"input_description\": \"$t(common.description)\",\n            \"input_name\": \"$t(common.name)\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1})를 생성했습니다\",\n            \"input_owner\": \"$t(common.owner)\",\n            \"input_public\": \"공개\",\n            \"title\": \"$t(entity.playlist, {\\\"count\\\": 1}) 생성\"\n        },\n        \"deletePlaylist\": {\n            \"input_confirm\": \"확인을 위해 $t(entity.playlist, {\\\"count\\\": 1})의 이름을 적어주세요\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1})가 삭제되었습니다\",\n            \"title\": \"$t(entity.playlist, {\\\"count\\\": 1}) 삭제\"\n        },\n        \"privateMode\": {\n            \"enabled\": \"프라이빗 모드가 활성화되었습니다. 재생상태가 외부 서비스에 지금부터 노출되지 않습니다\",\n            \"disabled\": \"프라이빗 모드가 비활성화되었습니다. 재생상태가 외부서비스에서 지금부터 표시됩니다\",\n            \"title\": \"프라이빗 모드\"\n        }\n    },\n    \"page\": {\n        \"appMenu\": {\n            \"goBack\": \"뒤로\",\n            \"selectServer\": \"서버를 선택하세요\",\n            \"goForward\": \"앞으로\",\n            \"manageServers\": \"서버 설정하기\",\n            \"openBrowserDevtools\": \"브라우저 개발자 도구 열기\",\n            \"version\": \"버전 {{version}}\",\n            \"collapseSidebar\": \"사이드바 줄이기\",\n            \"expandSidebar\": \"사이드바 확장\",\n            \"privateModeOff\": \"프라이빗 모드 끄기\",\n            \"privateModeOn\": \"프라이빗 모드 켜기\"\n        },\n        \"manageServers\": {\n            \"title\": \"서버 설정하기\",\n            \"serverDetails\": \"서버 세부설정\",\n            \"editServerDetailsTooltip\": \"서버 세부설정 편집하기\",\n            \"url\": \"URL\",\n            \"username\": \"유저 이름\",\n            \"removeServer\": \"서버 제거하기\"\n        },\n        \"fullscreenPlayer\": {\n            \"config\": {\n                \"opacity\": \"투명도\",\n                \"lyricAlignment\": \"가사 정렬\",\n                \"useImageAspectRatio\": \"이미지 종횡비 사용\",\n                \"synchronized\": \"동기화\",\n                \"unsynchronized\": \"비동기화\",\n                \"dynamicBackground\": \"동적 배경\",\n                \"dynamicImageBlur\": \"흐린 이미지 수준\",\n                \"dynamicIsImage\": \"배경이미지 켜기\",\n                \"followCurrentLyric\": \"가사 따라가기\",\n                \"lyricOffset\": \"가사 옵셋(1/1000초)\",\n                \"lyricGap\": \"가사 간격\",\n                \"lyricSize\": \"가사 크기\",\n                \"showLyricMatch\": \"가사 일치 표시\",\n                \"showLyricProvider\": \"가사 제공자 표시\"\n            },\n            \"lyrics\": \"가사\",\n            \"related\": \"관련\",\n            \"upNext\": \"이전 다음\",\n            \"visualizer\": \"비주얼라이저\",\n            \"noLyrics\": \"가사 없음\"\n        },\n        \"contextMenu\": {\n            \"download\": \"다운로드\",\n            \"numberSelected\": \"{{count}}개 선택됨\",\n            \"shareItem\": \"공유\",\n            \"goToAlbum\": \"$t(entity.album, {\\\"count\\\": 1})으로 이동\",\n            \"goToAlbumArtist\": \"$t(entity.albumArtist, {\\\"count\\\": 1})으로 이동\",\n            \"showDetails\": \"추가정보\"\n        },\n        \"albumArtistDetail\": {\n            \"about\": \"{{artist}}에 대해\",\n            \"viewDiscography\": \"디스코그래피 보기\",\n            \"appearsOn\": \"참여 앨범\",\n            \"recentReleases\": \"최근 앨범\",\n            \"relatedArtists\": \"연관 $t(entity.artist, {\\\"count\\\": 2})\",\n            \"topSongs\": \"최고의 곡들\",\n            \"topSongsFrom\": \"{{title}}이 포함된 최고의 곡들\",\n            \"viewAll\": \"전부 보이기\",\n            \"viewAllTracks\": \"$t(entity.track, {\\\"count\\\": 2}) 전부 보이기\"\n        },\n        \"albumArtistList\": {\n            \"title\": \"$t(entity.albumArtist, {\\\"count\\\": 2})\"\n        },\n        \"albumDetail\": {\n            \"moreFromArtist\": \"$t(entity.artist, {\\\"count\\\": 1}) 더 보기\",\n            \"moreFromGeneric\": \"{{item}} 더 보기\",\n            \"released\": \"발매\"\n        },\n        \"albumList\": {\n            \"artistAlbums\": \"{{artist}}의 앨범\"\n        },\n        \"genreList\": {\n            \"showAlbums\": \"$t(entity.genre, {\\\"count\\\": 1}) $t(entity.album, {\\\"count\\\": 2}) 표시\",\n            \"showTracks\": \"$t(entity.genre, {\\\"count\\\": 1}) $t(entity.track, {\\\"count\\\": 2}) 표시\"\n        },\n        \"globalSearch\": {\n            \"commands\": {\n                \"goToPage\": \"페이지로 이동\",\n                \"searchFor\": \"{{query}} 찾기\",\n                \"serverCommands\": \"서버 명령\"\n            },\n            \"title\": \"명령\"\n        },\n        \"home\": {\n            \"explore\": \"라이브러리 탐색\",\n            \"mostPlayed\": \"자주 플레이된 곡\",\n            \"newlyAdded\": \"최근에 추가된 곡\",\n            \"recentlyPlayed\": \"최근에 플레이된 곡\",\n            \"recentlyReleased\": \"최근에 발매된 곡\"\n        },\n        \"itemDetail\": {\n            \"copyPath\": \"클립보드에 경로를 복사\",\n            \"copiedPath\": \"경로 복사 성공\",\n            \"openFile\": \"이 노래를 파일관리자에서 표시\"\n        },\n        \"playlist\": {\n            \"reorder\": \"ID로 정렬한 경우에만 순서변경이 가능합니다\"\n        },\n        \"setting\": {\n            \"advanced\": \"고급\",\n            \"generalTab\": \"일반\",\n            \"hotkeysTab\": \"단축키\",\n            \"playbackTab\": \"재생\",\n            \"windowTab\": \"윈도우\"\n        },\n        \"sidebar\": {\n            \"myLibrary\": \"내 라이브러리\",\n            \"nowPlaying\": \"재생중\",\n            \"shared\": \"공유 $t(entity.playlist, {\\\"count\\\": 2})\"\n        },\n        \"trackList\": {\n            \"artistTracks\": \"{{artist}}의 음악\"\n        }\n    },\n    \"table\": {\n        \"config\": {\n            \"label\": {\n                \"playCount\": \"재생 횟수\",\n                \"dateAdded\": \"추가된 날짜\"\n            },\n            \"view\": {\n                \"table\": \"표\"\n            }\n        }\n    },\n    \"player\": {\n        \"addLast\": \"마지막에 추가\",\n        \"addNext\": \"바로 다음에 추가\",\n        \"favorite\": \"즐겨찾기\",\n        \"mute\": \"음소거\",\n        \"muted\": \"음소거됨\",\n        \"next\": \"다음\",\n        \"play\": \"재생\",\n        \"playbackFetchCancel\": \"좀 시간이 걸립니다... 취소하시려면 이 알림을 닫아주세요\",\n        \"playbackFetchInProgress\": \"곡 불러오는 중…\",\n        \"playbackFetchNoResults\": \"곡이 없습니다\",\n        \"playbackSpeed\": \"재생 속도\",\n        \"playRandom\": \"랜덤 재생\",\n        \"playSimilarSongs\": \"비슷한 곡 재생\",\n        \"previous\": \"이전\",\n        \"queue_clear\": \"재생 대기열 지우기\",\n        \"queue_moveToBottom\": \"선택한 곡을 가장 위로 이동\",\n        \"queue_moveToTop\": \"선택한 곡을 가장 아래로 이동\",\n        \"queue_remove\": \"선택한 항목 삭제\",\n        \"repeat\": \"반복\",\n        \"repeat_all\": \"모두 반복하기\",\n        \"repeat_off\": \"반복 비활성화됨\",\n        \"shuffle\": \"무작위 재생\",\n        \"shuffle_off\": \"무작위 재생 비활성화됨\",\n        \"skip\": \"건너뛰기\",\n        \"skip_back\": \"이전으로 건너뛰기\",\n        \"skip_forward\": \"다음으로 건너뛰기\",\n        \"stop\": \"중지\",\n        \"toggleFullscreenPlayer\": \"전체화면으로 전환\",\n        \"unfavorite\": \"즐겨찾기 취소\",\n        \"pause\": \"멈춤\",\n        \"viewQueue\": \"대기열 보기\"\n    },\n    \"setting\": {\n        \"accentColor_description\": \"앱의 강조색상 설정\",\n        \"accentColor\": \"강조색상\",\n        \"albumBackground_description\": \"앨범아트가 있는 경우 앨범화면에 배경이미지 추가\",\n        \"albumBackground\": \"앨범 배경이미지\",\n        \"albumBackgroundBlur_description\": \"앨범 배경이미지의 흐려짐 정도 조정\",\n        \"albumBackgroundBlur\": \"앨범배경이미지 흐려짐 크기\",\n        \"applicationHotkeys_description\": \"앱의 단축키 설정. 앱 전체에 적용되는 단축키를 설정하기 위해서는 체크박스에 체크하세요(PC만 가능)\",\n        \"applicationHotkeys\": \"앱 단축키\",\n        \"artistBackground\": \"아티스트 배경이미지\",\n        \"artistBackground_description\": \"아티스트 페이지에 아티스트가 포함된 배경이미지를 추가\",\n        \"artistBackgroundBlur\": \"아티스트 배경이미지 흐려짐 크기\",\n        \"artistBackgroundBlur_description\": \"아티스트 배경이미지에 적용된 흐려짐 크기 조정\",\n        \"artistConfiguration\": \"앨범아티스트 페이지 설정\",\n        \"artistConfiguration_description\": \"앨범아티스트 페이지에 표시할 정보 및 순서 설정\",\n        \"audioDevice_description\": \"음악재생에 사용할 장치 선택(웹플레이어만 가능)\",\n        \"audioDevice\": \"오디오 장치\",\n        \"audioExclusiveMode_description\": \"단독재생모드 켜기. 이 모드에서는 일반적으로 시스템의 재생장치가 고정되며 MPV로만 오디오가 재생됩니다\",\n        \"audioExclusiveMode\": \"오디오 단독재생모드\",\n        \"audioPlayer_description\": \"재생을 위한 오디오 플레이어 선택\",\n        \"audioPlayer\": \"오디오 플레이어\",\n        \"buttonSize_description\": \"플레이어 바 버튼 크기\",\n        \"buttonSize\": \"플레이어 바 버튼 크기\",\n        \"clearCache_description\": \"페이신 하드클리어. 페이신의 캐시 및 브라우저의 캐시 삭제(저장된 이미지 및 기타 정보). 서버 접속정보 및 설정은 유지됩니다\"\n    },\n    \"releaseType\": {\n        \"primary\": {\n            \"broadcast\": \"방송\",\n            \"ep\": \"ep앨범\",\n            \"other\": \"기타\",\n            \"single\": \"싱글\"\n        },\n        \"secondary\": {\n            \"audiobook\": \"오디오북\",\n            \"audioDrama\": \"오디오 드라마\",\n            \"compilation\": \"컴필레이션\",\n            \"djMix\": \"DJ 믹스\",\n            \"demo\": \"데모\",\n            \"fieldRecording\": \"현지녹음\",\n            \"interview\": \"인터뷰\",\n            \"live\": \"라이브\",\n            \"mixtape\": \"믹스테이프\",\n            \"remix\": \"리믹스\",\n            \"soundtrack\": \"사운드트랙\",\n            \"spokenWord\": \"보컬사운드\"\n        }\n    }\n}\n"
  },
  {
    "path": "src/i18n/locales/nb-NO.json",
    "content": "{\n    \"action\": {\n        \"openIn\": {\n            \"lastfm\": \"Åpne i Last.fm\",\n            \"musicbrainz\": \"Åpne i MusicBrainz\",\n            \"spotify\": \"Åpne i Spotify\"\n        },\n        \"moveToBottom\": \"flytt til bunnen\",\n        \"deletePlaylist\": \"slett $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"deselectAll\": \"avmarker alle\",\n        \"editPlaylist\": \"rediger $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"addToFavorites\": \"legg til $t(entity.favorite, {\\\"count\\\": 2})\",\n        \"addToPlaylist\": \"legg til $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"clearQueue\": \"tøm kø\",\n        \"createPlaylist\": \"opprett $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"goToPage\": \"gå til side\",\n        \"moveToTop\": \"flytt til toppen\",\n        \"refresh\": \"$t(common.refresh)\",\n        \"removeFromFavorites\": \"fjern fra $t(entity.favorite, {\\\"count\\\": 2})\",\n        \"moveToNext\": \"flytt til neste\",\n        \"setRating\": \"angi vurdering\",\n        \"removeFromQueue\": \"fjern fra kø\",\n        \"removeFromPlaylist\": \"fjern fra $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"viewPlaylists\": \"vise $t(entity.playlist, {\\\"count\\\": 2})\",\n        \"toggleSmartPlaylistEditor\": \"bytt $t(entity.smartPlaylist) editor\",\n        \"selectAll\": \"marker alle\",\n        \"downloadStarted\": \"startet nedlasting av {{count}} elementer\",\n        \"selectRangeOfItems\": \"velg en rekke elementer\",\n        \"addOrRemoveFromSelection\": \"legge til eller fjerne fra utvalg\",\n        \"moveUp\": \"flytt opp\",\n        \"moveDown\": \"flytt ned\",\n        \"createRadioStation\": \"opprett $t(entity.radioStation, {\\\"count\\\": 1})\",\n        \"deleteRadioStation\": \"slett $t(entity.radioStation, {\\\"count\\\": 1})\",\n        \"holdToMoveToTop\": \"hold nede for å gå til toppen\",\n        \"holdToMoveToBottom\": \"hold nede for å gå til bunnen\",\n        \"moveItems\": \"flytt elementer\",\n        \"shuffle\": \"tilfeldig avspilling\",\n        \"shuffleAll\": \"tilfelding avspilling av alt\",\n        \"shuffleSelected\": \"tilfelding avspilling av utvalgte\",\n        \"viewMore\": \"se mer\",\n        \"openApplicationDirectory\": \"åpne applikasjonskatalogen\"\n    },\n    \"common\": {\n        \"bpm\": \"bpm\",\n        \"cancel\": \"avbryt\",\n        \"center\": \"midtstill\",\n        \"clear\": \"tøm\",\n        \"collapse\": \"slå sammen\",\n        \"configure\": \"konfigurer\",\n        \"confirm\": \"bekreft\",\n        \"currentSong\": \"gjeldende $t(entity.track, {\\\"count\\\": 1})\",\n        \"version\": \"versjon\",\n        \"areYouSure\": \"er du sikker?\",\n        \"ascending\": \"stigende\",\n        \"backward\": \"bakover\",\n        \"biography\": \"biografi\",\n        \"bitrate\": \"bithastighet\",\n        \"close\": \"lukk\",\n        \"codec\": \"kodek\",\n        \"comingSoon\": \"kommer snart…\",\n        \"create\": \"opprett\",\n        \"decrease\": \"minsk\",\n        \"disable\": \"deaktiver\",\n        \"disc\": \"skive\",\n        \"duration\": \"lengde\",\n        \"enable\": \"aktiver\",\n        \"expand\": \"utvid\",\n        \"favorite\": \"favoritt\",\n        \"filters\": \"filter\",\n        \"forceRestartRequired\": \"ta omstart for å aktivere endringene... lukk meldingen for å ta omstart\",\n        \"forward\": \"framover\",\n        \"gap\": \"avstand\",\n        \"home\": \"hjem\",\n        \"increase\": \"øke\",\n        \"left\": \"venstre\",\n        \"limit\": \"grense\",\n        \"menu\": \"meny\",\n        \"minimize\": \"minimer\",\n        \"modified\": \"modifisert\",\n        \"mbid\": \"MusicBrainz ID\",\n        \"name\": \"navn\",\n        \"no\": \"nei\",\n        \"none\": \"ingen\",\n        \"noResultsFromQuery\": \"spørringen ga ikke noe resultat\",\n        \"note\": \"merke\",\n        \"owner\": \"eier\",\n        \"playerMustBePaused\": \"spilleren må settes på pause\",\n        \"path\": \"sti\",\n        \"previousSong\": \"forrige $t(entity.track, {\\\"count\\\": 1})\",\n        \"refresh\": \"frisk opp\",\n        \"rating\": \"vurdering\",\n        \"random\": \"vilkårlig\",\n        \"reset\": \"tilbakestill\",\n        \"restartRequired\": \"omstart nødvendig\",\n        \"save\": \"lagre\",\n        \"saveAs\": \"lagre som\",\n        \"saveAndReplace\": \"lagre og overskriv\",\n        \"search\": \"søk\",\n        \"trackGain\": \"forsterkningsgrad spor\",\n        \"trackPeak\": \"maksnivå spor\",\n        \"translation\": \"oversettelse\",\n        \"unknown\": \"ukjent\",\n        \"preview\": \"forhåndsvisning\",\n        \"share\": \"del\",\n        \"quit\": \"avslutt\",\n        \"size\": \"størrelse\",\n        \"setting_one\": \"innstilling\",\n        \"setting_other\": \"innstillinger\",\n        \"trackNumber\": \"spor\",\n        \"title\": \"tittel\",\n        \"channel_one\": \"kanal\",\n        \"channel_other\": \"kanaler\",\n        \"filter_one\": \"filter\",\n        \"filter_other\": \"filter\",\n        \"add\": \"legg til\",\n        \"edit\": \"rediger\",\n        \"resetToDefault\": \"nullstill\",\n        \"ok\": \"ok\",\n        \"reload\": \"last inn på nytt\",\n        \"action_one\": \"handling\",\n        \"action_other\": \"handlinger\",\n        \"year\": \"år\",\n        \"yes\": \"ja\",\n        \"descending\": \"synkende\",\n        \"dismiss\": \"lukk\",\n        \"delete\": \"slett\",\n        \"description\": \"beskrivelse\",\n        \"manage\": \"håndtere\",\n        \"maximize\": \"maksimer\",\n        \"right\": \"høyre\",\n        \"sortOrder\": \"rekkefølge\",\n        \"tags\": \"tagger\",\n        \"newVersion\": \"en ny versjon har blitt installert ({{version}})\",\n        \"viewReleaseNotes\": \"se utgivelsesnotater\",\n        \"additionalParticipants\": \"ytterligere deltakere\",\n        \"albumGain\": \"gjennomsnittlig lydnivå for album\",\n        \"albumPeak\": \"høyeste lydnivå for album\",\n        \"bitDepth\": \"bitdybde\",\n        \"sampleRate\": \"samplingsfrekvens\",\n        \"countSelected\": \"{{count}} valgt\",\n        \"doNotShowAgain\": \"ikke vis dette igjen\",\n        \"view\": \"vis\",\n        \"example\": \"eksempel\",\n        \"externalLinks\": \"eksterne lenker\",\n        \"faster\": \"raskere\",\n        \"filter_single\": \"enkelt\",\n        \"filter_multiple\": \"flerfoldige\",\n        \"mood\": \"humør\",\n        \"noFilters\": \"ingen filtre konfigurert\",\n        \"private\": \"privat\",\n        \"public\": \"offentlig\",\n        \"retry\": \"prøv igjen\",\n        \"recordLabel\": \"plateselskap\",\n        \"releaseType\": \"utgivelsestype\",\n        \"rename\": \"gi nytt navn\",\n        \"slower\": \"saktere\",\n        \"sort\": \"sorter\",\n        \"explicit\": \"grov\",\n        \"clean\": \"ren\",\n        \"gridRows\": \"rutenettrader\",\n        \"tableColumns\": \"tabellkolonner\",\n        \"itemsMore\": \"{{count}} fler\",\n        \"explicitStatus\": \"grovhetsstatus\",\n        \"newVersionAvailable\": \"en ny version er tilgjengelig\"\n    },\n    \"entity\": {\n        \"smartPlaylist\": \"smart $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"album_one\": \"album\",\n        \"album_other\": \"album\",\n        \"albumArtist_one\": \"albumartist\",\n        \"albumArtist_other\": \"albumartister\",\n        \"albumArtistCount_one\": \"{{count}} albumartist\",\n        \"albumArtistCount_other\": \"{{count}} albumartister\",\n        \"albumWithCount_one\": \"{{count}} album\",\n        \"albumWithCount_other\": \"{{count}} album\",\n        \"favorite_one\": \"favoritt\",\n        \"favorite_other\": \"favoritter\",\n        \"folder_one\": \"mappe\",\n        \"folder_other\": \"mapper\",\n        \"play_one\": \"{{count}} avspilling\",\n        \"play_other\": \"{{count}} avspillinger\",\n        \"playlistWithCount_one\": \"{{count}} spilleliste\",\n        \"playlistWithCount_other\": \"{{count}} spillelister\",\n        \"artistWithCount_one\": \"{{count}} artist\",\n        \"artistWithCount_other\": \"{{count}} artister\",\n        \"genre_one\": \"sjanger\",\n        \"genre_other\": \"sjangere\",\n        \"track_one\": \"spor\",\n        \"track_other\": \"spor\",\n        \"genreWithCount_one\": \"{{count}} sjanger\",\n        \"genreWithCount_other\": \"{{count}} sjangere\",\n        \"playlist_one\": \"spilleliste\",\n        \"playlist_other\": \"spillelister\",\n        \"folderWithCount_one\": \"{{count}} mappe\",\n        \"folderWithCount_other\": \"{{count}} mapper\",\n        \"trackWithCount_one\": \"{{count}} spor\",\n        \"trackWithCount_other\": \"{{count}} spor\",\n        \"artist_one\": \"artist\",\n        \"artist_other\": \"artister\",\n        \"song_one\": \"sang\",\n        \"song_other\": \"sanger\",\n        \"radioStation_one\": \"radiostasjon\",\n        \"radioStation_other\": \"radiostasjoner\",\n        \"radioStationWithCount_one\": \"{{count}} radiostasjon\",\n        \"radioStationWithCount_other\": \"{{count}} radiostasjoner\"\n    },\n    \"error\": {\n        \"apiRouteError\": \"kan ikke behandle forespørselen\",\n        \"mpvRequired\": \"MPV er påkrevd\",\n        \"authenticationFailed\": \"autentisering feilet\",\n        \"badAlbum\": \"du ser denne siden fordi sangen ikke er med i et album. Mest sannsynlig opplever du dette problemet fordi du har en sang helt øverst i musikkmappen. Jellyfin grupperer kun spor som ligger i en mappe\",\n        \"endpointNotImplementedError\": \"endepunkt {{endpoint}} er ikke implementert for {{serverType}}\",\n        \"credentialsRequired\": \"innloggingsdetaljer er påkrevd\",\n        \"genericError\": \"en feil har oppstått\",\n        \"invalidServer\": \"ugyldig tjener\",\n        \"playbackError\": \"et problem oppstod ved avspilling av media\",\n        \"localFontAccessDenied\": \"ingen tilgang til lokale skrifttyper\",\n        \"loginRateError\": \"for mange innloggingsforsøk, vennligst prøv igjen om noen få sekunder\",\n        \"audioDeviceFetchError\": \"en feil oppstod ved innhenting av lydenheter\",\n        \"networkError\": \"det har oppstått et nettverksproblem\",\n        \"openError\": \"kunne ikke åpne fil\",\n        \"serverNotSelectedError\": \"ingen tjener er valgt\",\n        \"remotePortError\": \"et problem oppstod med å sette serverport\",\n        \"systemFontError\": \"et problem oppstod med innlasting av systemskrifttyper\",\n        \"serverRequired\": \"tjener er påkrevd\",\n        \"sessionExpiredError\": \"sesjonen din har utløpt\",\n        \"remotePortWarning\": \"ta omstart av serveren for å aktivere ny port\",\n        \"remoteDisableError\": \"en problem oppstod ved å $t(common.disable) serveren\",\n        \"remoteEnableError\": \"et problem oppstod ved å $t(common.enable) serveren\",\n        \"notificationDenied\": \"tillatelser for varsler ble avvist. Denne innstillingen har ingen effekt\",\n        \"badValue\": \"ugyldig alternativ \\\"{{value}}\\\". Denne verdien eksisterer ikke lenger\",\n        \"noNetwork\": \"tjener utilgjengelig\",\n        \"noNetworkDescription\": \"kunne ikke koble til tjeneren\",\n        \"invalidJson\": \"ugyldig JSON\",\n        \"saveQueueFailed\": \"kunne ikke lagre kø\",\n        \"multipleServerSaveQueueError\": \"Spillekøen har en eller flere sanger som ikke finnes på gjeldene tjener. Dette er ikke støttet\",\n        \"serverLockSingleServer\": \"kun én tjener er tillatt når tjener er låst\",\n        \"settingsSyncError\": \"avvik ble funnet mellom innstillinger i avspilleren og hovedprosessen. ta en omstart av applikasjonen for å aktivere endringene\",\n        \"playbackPausedDueToError\": \"avspilling ble paused på grunn av en feil\"\n    },\n    \"filter\": {\n        \"bpm\": \"bpm\",\n        \"criticRating\": \"kritikervurdering\",\n        \"id\": \"id\",\n        \"name\": \"navn\",\n        \"bitrate\": \"bithastighet\",\n        \"albumArtist\": \"$t(entity.albumArtist, {\\\"count\\\": 1})\",\n        \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n        \"biography\": \"biografi\",\n        \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n        \"duration\": \"lengde\",\n        \"favorited\": \"merket som favoritt\",\n        \"comment\": \"kommentar\",\n        \"communityRating\": \"fellesskapsvurdering\",\n        \"dateAdded\": \"lagt til dato\",\n        \"disc\": \"skive\",\n        \"isPublic\": \"er offentlig\",\n        \"isRecentlyPlayed\": \"er avspilt nylig\",\n        \"mostPlayed\": \"mest avspilt\",\n        \"owner\": \"$t(common.owner)\",\n        \"path\": \"sti\",\n        \"lastPlayed\": \"sist avspilt\",\n        \"rating\": \"vurdering\",\n        \"recentlyPlayed\": \"nylig avspilt\",\n        \"playCount\": \"antall avspillinger\",\n        \"recentlyUpdated\": \"nylig oppdatert\",\n        \"random\": \"vilkårlig\",\n        \"search\": \"søk\",\n        \"songCount\": \"antall sanger\",\n        \"title\": \"tittel\",\n        \"toYear\": \"til år\",\n        \"releaseDate\": \"utgivelsesdato\",\n        \"releaseYear\": \"utgivelsesår\",\n        \"note\": \"notat\",\n        \"isRated\": \"er vurdert\",\n        \"fromYear\": \"fra år\",\n        \"isCompilation\": \"er samling\",\n        \"isFavorited\": \"er merket som favoritt\",\n        \"recentlyAdded\": \"nylig lagt til\",\n        \"channels\": \"$t(common.channel, {\\\"count\\\": 2})\",\n        \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n        \"trackNumber\": \"spor\",\n        \"albumCount\": \"$t(entity.album, {\\\"count\\\": 2}) opptelling\",\n        \"matchAnd\": \"og\",\n        \"matchOr\": \"eller\",\n        \"sortName\": \"sorter navn\",\n        \"explicitStatus\": \"$t(common.explicitStatus)\"\n    },\n    \"form\": {\n        \"createPlaylist\": {\n            \"input_description\": \"$t(common.description)\",\n            \"input_owner\": \"$t(common.owner)\",\n            \"input_public\": \"offentlig\",\n            \"title\": \"opprett $t(entity.playlist, {\\\"count\\\": 1})\",\n            \"input_name\": \"$t(common.name)\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) opprettet\"\n        },\n        \"lyricSearch\": {\n            \"input_artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n            \"input_name\": \"$t(common.name)\",\n            \"title\": \"sangtekstsøk\"\n        },\n        \"addServer\": {\n            \"ignoreCors\": \"ignorer cors ($t(common.restartRequired))\",\n            \"ignoreSsl\": \"ignorer ssl ($t(common.restartRequired))\",\n            \"error_savePassword\": \"et problem oppstod ved lagring av passord\",\n            \"input_savePassword\": \"lagre passord\",\n            \"input_url\": \"lenke\",\n            \"input_username\": \"brukernavn\",\n            \"success\": \"serveren er lagt til\",\n            \"input_legacyAuthentication\": \"aktiver tradisjonell autentisering\",\n            \"input_name\": \"servernavn\",\n            \"title\": \"legg til server\",\n            \"input_password\": \"passord\",\n            \"input_preferInstantMix\": \"foretrekk øyeblikkelig miks\",\n            \"input_preferInstantMixDescription\": \"bruk bare øyeblikkelig miks for innhenting av lignende sanger. nyttig hvis du har tilleggsmoduler som endrer funksjonaliteten\",\n            \"input_preferRemoteUrl\": \"foretrekk offentlig url\",\n            \"input_remoteUrl\": \"offentlig url\",\n            \"input_remoteUrlPlaceholder\": \"valgfritt: offentlig nettadresse for eksterne funksjoner\"\n        },\n        \"addToPlaylist\": {\n            \"success\": \"la $t(entity.trackWithCount, {\\\"count\\\": {{message}} }) til $t(entity.playlistWithCount, {\\\"count\\\": {{numOfPlaylists}} })\",\n            \"title\": \"legg til i $t(entity.playlist, {\\\"count\\\": 1})\",\n            \"input_skipDuplicates\": \"hopp over duplikater\",\n            \"input_playlists\": \"$t(entity.playlist, {\\\"count\\\": 2})\",\n            \"searchOrCreate\": \"søk $t(entity.playlist, {\\\"count\\\": 2}) eller skriv for å opprette en\"\n        },\n        \"deletePlaylist\": {\n            \"title\": \"slett $t(entity.playlist, {\\\"count\\\": 1})\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) er slettet\",\n            \"input_confirm\": \"skrive inn navnet på $t(entity.playlist, {\\\"count\\\": 1}) for å bekrefte\"\n        },\n        \"editPlaylist\": {\n            \"title\": \"rediger $t(entity.playlist, {\\\"count\\\": 1})\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) er oppdatert\",\n            \"publicJellyfinNote\": \"Jellyfin av en grunn kan ikke oppgi om en spilleliste er offentlig eller ikke. Hvis du ønsker at denne skal beholdes offentlig, vennligst ha følgende inndata valgt\"\n        },\n        \"shareItem\": {\n            \"allowDownloading\": \"tillat nedlasting\",\n            \"description\": \"beskrivelse\",\n            \"createFailed\": \"opprettelse av delt ressurs feilet (er deling aktivert?)\",\n            \"setExpiration\": \"angi utløpstid\",\n            \"success\": \"del lenke som er kopiert til utklippstavlen (eller klikk her for å åpne)\",\n            \"expireInvalid\": \"utløpstid må være et fremtidig tidspunkt\",\n            \"copyToClipboard\": \"Kopier til kopitavle: Ctrl+C, Enter\",\n            \"successMustClick\": \"opprettet deling. trykk her for å åpne\"\n        },\n        \"updateServer\": {\n            \"success\": \"vellykket oppdatering av serveren\",\n            \"title\": \"oppdater server\"\n        },\n        \"queryEditor\": {\n            \"input_optionMatchAll\": \"match alle\",\n            \"input_optionMatchAny\": \"matche hvilken som helst\",\n            \"title\": \"redigeringsverktøy for spørringer\",\n            \"addRuleGroup\": \"legg til regelgruppe\",\n            \"removeRuleGroup\": \"fjern regelgruppe\",\n            \"resetToDefault\": \"tilbakestill til standard\",\n            \"clearFilters\": \"tøm filter\"\n        },\n        \"largeFetchConfirmation\": {\n            \"title\": \"legg til elementer i køen\",\n            \"description\": \"Denne handlingen vil legge alle elementene til den gjeldende filtervisningen\"\n        },\n        \"createRadioStation\": {\n            \"success\": \"vellykket opprettelse av radiostasjon\",\n            \"title\": \"opprett radiostasjon\",\n            \"input_homepageUrl\": \"hjemmesidelenke\",\n            \"input_name\": \"navn\",\n            \"input_streamUrl\": \"strømmelenke\"\n        },\n        \"saveQueue\": {\n            \"success\": \"lagre spillekø på tjener\"\n        },\n        \"lyricsExport\": {\n            \"export\": \"eksporter sangtekster\",\n            \"input_synced\": \"eksporter sunkroniserte sangtekster\",\n            \"input_offset\": \"$t(setting.lyricOffset)\"\n        },\n        \"shuffleAll\": {\n            \"title\": \"spill av tilfeldig\",\n            \"input_genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n            \"input_limit\": \"hvor mange sanger?\",\n            \"input_minYear\": \"fra år\",\n            \"input_maxYear\": \"til år\",\n            \"input_played\": \"avspillingsfilter\",\n            \"input_played_optionAll\": \"alle sanger\",\n            \"input_played_optionUnplayed\": \"bare uavspilte sanger\",\n            \"input_played_optionPlayed\": \"bare avspilte sanger\"\n        }\n    },\n    \"page\": {\n        \"appMenu\": {\n            \"collapseSidebar\": \"slå sammen sidefelt\",\n            \"quit\": \"$t(common.quit)\",\n            \"selectServer\": \"velg server\",\n            \"version\": \"versjon {{version}}\",\n            \"manageServers\": \"administrere servere\",\n            \"goBack\": \"gå tilbake\",\n            \"openBrowserDevtools\": \"åpne utviklingsverktøy i nettleser\",\n            \"settings\": \"$t(common.setting, {\\\"count\\\": 2})\",\n            \"expandSidebar\": \"utvid sidefelt\",\n            \"goForward\": \"gå fremover\"\n        },\n        \"contextMenu\": {\n            \"addToPlaylist\": \"$t(action.addToPlaylist)\",\n            \"showDetails\": \"hent info\",\n            \"moveToTop\": \"$t(action.moveToTop)\",\n            \"moveToBottom\": \"$t(action.moveToBottom)\",\n            \"numberSelected\": \"{{count}} valgt\",\n            \"addLast\": \"$t(player.addLast)\",\n            \"addNext\": \"$t(player.addNext)\",\n            \"createPlaylist\": \"$t(action.createPlaylist)\",\n            \"play\": \"$t(player.play)\",\n            \"removeFromFavorites\": \"$t(action.removeFromFavorites)\",\n            \"download\": \"last ned\",\n            \"playSimilarSongs\": \"$t(player.playSimilarSongs)\",\n            \"removeFromPlaylist\": \"$t(action.removeFromPlaylist)\",\n            \"removeFromQueue\": \"$t(action.removeFromQueue)\",\n            \"setRating\": \"$t(action.setRating)\",\n            \"addToFavorites\": \"$t(action.addToFavorites)\",\n            \"moveToNext\": \"$t(action.moveToNext)\",\n            \"playShuffled\": \"$t(player.shuffle)\",\n            \"shareItem\": \"del element\",\n            \"addFavorite\": \"$t(action.addToFavorites)\",\n            \"deletePlaylist\": \"$t(action.deletePlaylist)\",\n            \"deselectAll\": \"$t(action.deselectAll)\"\n        },\n        \"albumArtistDetail\": {\n            \"topSongs\": \"beste sanger\",\n            \"viewDiscography\": \"se diskografi\",\n            \"recentReleases\": \"nylige utgivelser\",\n            \"topSongsFrom\": \"beste sanger fra {{title}}\",\n            \"viewAllTracks\": \"se alle $t(entity.track, {\\\"count\\\": 2})\",\n            \"viewAll\": \"se alle\",\n            \"about\": \"Om {{artist}}\",\n            \"appearsOn\": \"opptrer på\",\n            \"relatedArtists\": \"relatert $t(entity.artist, {\\\"count\\\": 2})\"\n        },\n        \"albumList\": {\n            \"artistAlbums\": \"album av {{artist}}\",\n            \"genreAlbums\": \"\\\"{{genre}}\\\" $t(entity.album, {\\\"count\\\": 2})\",\n            \"title\": \"$t(entity.album, {\\\"count\\\": 2})\"\n        },\n        \"albumArtistList\": {\n            \"title\": \"$t(entity.albumArtist, {\\\"count\\\": 2})\"\n        },\n        \"albumDetail\": {\n            \"moreFromArtist\": \"mer fra denne $t(entity.artist, {\\\"count\\\": 1})\",\n            \"moreFromGeneric\": \"mer fra {{item}}\",\n            \"released\": \"utgitt\"\n        },\n        \"fullscreenPlayer\": {\n            \"config\": {\n                \"dynamicIsImage\": \"aktiver bakgrunnsbilde\",\n                \"lyricGap\": \"sangtekstavstand\",\n                \"dynamicImageBlur\": \"bilduskarphetstørrelse\",\n                \"lyricAlignment\": \"sangtekstjustering\",\n                \"lyricOffset\": \"sangtekstforskyvning (ms)\",\n                \"lyricSize\": \"sangtekststørrelse\",\n                \"opacity\": \"absorpsjon\",\n                \"showLyricMatch\": \"vis sangteksttreff\",\n                \"showLyricProvider\": \"vis sangteksttilbyder\",\n                \"synchronized\": \"synkronisert\",\n                \"unsynchronized\": \"usynkronisert\",\n                \"dynamicBackground\": \"dynamisk bakgrunn\",\n                \"useImageAspectRatio\": \"bruk sideforhold til bildet\",\n                \"followCurrentLyric\": \"følg sangtekst\"\n            },\n            \"noLyrics\": \"fant ikke sangtekst\",\n            \"lyrics\": \"sangtekst\",\n            \"upNext\": \"kommende\",\n            \"visualizer\": \"fremviser\",\n            \"related\": \"relatert\"\n        },\n        \"genreList\": {\n            \"title\": \"$t(entity.genre, {\\\"count\\\": 2})\",\n            \"showAlbums\": \"vis $t(entity.genre, {\\\"count\\\": 1}) $t(entity.album, {\\\"count\\\": 2})\",\n            \"showTracks\": \"vis $t(entity.genre, {\\\"count\\\": 1}) $t(entity.track, {\\\"count\\\": 2})\"\n        },\n        \"globalSearch\": {\n            \"title\": \"kommandoer\",\n            \"commands\": {\n                \"goToPage\": \"gå til side\",\n                \"searchFor\": \"søk etter {{query}}\",\n                \"serverCommands\": \"serverkommandoer\"\n            }\n        },\n        \"home\": {\n            \"recentlyPlayed\": \"nylig avspilt\",\n            \"explore\": \"utforsk biblioteket ditt\",\n            \"mostPlayed\": \"mest spilt\",\n            \"newlyAdded\": \"utgivelser nylig lagt til\",\n            \"title\": \"$t(common.home)\"\n        },\n        \"manageServers\": {\n            \"title\": \"administrere servere\",\n            \"url\": \"lenke\",\n            \"username\": \"brukernavn\",\n            \"editServerDetailsTooltip\": \"rediger serverdetaljer\",\n            \"removeServer\": \"fjern server\",\n            \"serverDetails\": \"serverdetaljer\"\n        },\n        \"itemDetail\": {\n            \"openFile\": \"vis spor i filbhehandleren\",\n            \"copiedPath\": \"vellykket kopiering av stien\",\n            \"copyPath\": \"kopier stien til utklippstavlen\"\n        },\n        \"trackList\": {\n            \"genreTracks\": \"\\\"{{genre}}\\\" $t(entity.track, {\\\"count\\\": 2})\",\n            \"title\": \"$t(entity.track, {\\\"count\\\": 2})\",\n            \"artistTracks\": \"spor fra {{artist}}\"\n        },\n        \"sidebar\": {\n            \"albumArtists\": \"$t(entity.albumArtist, {\\\"count\\\": 2})\",\n            \"tracks\": \"$t(entity.track, {\\\"count\\\": 2})\",\n            \"nowPlaying\": \"spilles nå\",\n            \"folders\": \"$t(entity.folder, {\\\"count\\\": 2})\",\n            \"genres\": \"$t(entity.genre, {\\\"count\\\": 2})\",\n            \"home\": \"$t(common.home)\",\n            \"albums\": \"$t(entity.album, {\\\"count\\\": 2})\",\n            \"playlists\": \"$t(entity.playlist, {\\\"count\\\": 2})\",\n            \"search\": \"$t(common.search)\",\n            \"settings\": \"$t(common.setting, {\\\"count\\\": 2})\",\n            \"shared\": \"delte $t(entity.playlist, {\\\"count\\\": 2})\",\n            \"artists\": \"$t(entity.artist, {\\\"count\\\": 2})\",\n            \"myLibrary\": \"mitt bibliotek\"\n        },\n        \"setting\": {\n            \"generalTab\": \"generelt\",\n            \"advanced\": \"avansert\",\n            \"hotkeysTab\": \"hurtigtaster\",\n            \"playbackTab\": \"avspilling\",\n            \"windowTab\": \"vindu\",\n            \"theme\": \"tema\"\n        },\n        \"playlistList\": {\n            \"title\": \"$t(entity.playlist, {\\\"count\\\": 2})\"\n        },\n        \"playlist\": {\n            \"reorder\": \"omorganisering kun mulig ved sortering på id\"\n        }\n    },\n    \"player\": {\n        \"addLast\": \"legg til sist\",\n        \"queue_remove\": \"fjern valgte\",\n        \"queue_moveToBottom\": \"flytt valgte til toppen\",\n        \"addNext\": \"legg til som neste\",\n        \"favorite\": \"favoritt\",\n        \"mute\": \"skru av lyden\",\n        \"muted\": \"lyden er skrudd av\",\n        \"next\": \"neste\",\n        \"repeat_all\": \"gjenta alle\",\n        \"playbackFetchCancel\": \"dette kommer til å ta en stund... lukk denne meldingen for å avbryte\",\n        \"playRandom\": \"spill vilkårlig\",\n        \"queue_clear\": \"tøm kø\",\n        \"repeat_off\": \"gjentakelse er deaktivert\",\n        \"playbackFetchInProgress\": \"laster sanger…\",\n        \"repeat\": \"gjenta\",\n        \"play\": \"spill\",\n        \"previous\": \"forrige\",\n        \"queue_moveToTop\": \"flytt valgte til bunnen\",\n        \"playbackFetchNoResults\": \"ingen sanger funnet\",\n        \"playbackSpeed\": \"avspillingshastighet\",\n        \"playSimilarSongs\": \"spill lignende sanger\",\n        \"skip\": \"hopp over\",\n        \"shuffle\": \"spill i tilfeldig rekkefølge\",\n        \"shuffle_off\": \"tilfeldig rekkefølge skrudd av\",\n        \"skip_back\": \"hopp bakover\",\n        \"skip_forward\": \"hopp fremover\",\n        \"stop\": \"stopp\",\n        \"toggleFullscreenPlayer\": \"bytt til fullskjermspiller\",\n        \"pause\": \"sett på pause\",\n        \"viewQueue\": \"se kø\",\n        \"unfavorite\": \"fjern fra favoritter\"\n    },\n    \"setting\": {\n        \"accentColor\": \"aksentfarge\",\n        \"accentColor_description\": \"setter aksentfarge i applikasjonen\",\n        \"albumBackground\": \"album bakgrunnsbilde\",\n        \"albumBackgroundBlur\": \"album bakgrunnsbilde uskarphetsstørrelse\",\n        \"albumBackgroundBlur_description\": \"justerer grad av uskarphet lagt til på album bakgrunnsbilde\",\n        \"audioDevice\": \"lydenhet\",\n        \"zoom\": \"zoomprosent\",\n        \"zoom_description\": \"angir zoomprosent for applikasjonen\"\n    },\n    \"table\": {\n        \"config\": {\n            \"label\": {\n                \"playCount\": \"antall avspillinger\",\n                \"releaseDate\": \"utgivelsesdato\",\n                \"trackNumber\": \"spornummer\",\n                \"rowIndex\": \"radindeks\",\n                \"dateAdded\": \"dato lagt til\",\n                \"discNumber\": \"skivenummer\",\n                \"lastPlayed\": \"sist avspilt\"\n            },\n            \"view\": {\n                \"table\": \"tabell\",\n                \"grid\": \"rutenett\",\n                \"list\": \"liste\"\n            },\n            \"general\": {\n                \"autoFitColumns\": \"automatisk kolonnetilpasning\",\n                \"displayType\": \"visningstype\",\n                \"followCurrentSong\": \"følg gjeldende sang\",\n                \"advancedSettings\": \"avanserte innstillinger\",\n                \"moveUp\": \"flytt opp\",\n                \"moveDown\": \"flytt ned\",\n                \"pinToLeft\": \"fest til venstre\",\n                \"pinToRight\": \"fest til høyre\",\n                \"alignLeft\": \"venstrejuster\",\n                \"alignCenter\": \"midtjuster\",\n                \"alignRight\": \"høyrejuster\"\n            }\n        },\n        \"column\": {\n            \"releaseYear\": \"år\",\n            \"comment\": \"kommentar\",\n            \"biography\": \"biografi\",\n            \"album\": \"album\",\n            \"albumArtist\": \"albumartist\",\n            \"dateAdded\": \"dato lagt til\",\n            \"discNumber\": \"skive\",\n            \"favorite\": \"favoritt\",\n            \"lastPlayed\": \"sist avspilt\",\n            \"path\": \"sti\",\n            \"playCount\": \"avspillinger\",\n            \"rating\": \"vurdering\",\n            \"releaseDate\": \"utgivelsesdato\",\n            \"title\": \"tittel\",\n            \"trackNumber\": \"spor\",\n            \"owner\": \"eier\"\n        }\n    },\n    \"filterOperator\": {\n        \"after\": \"er etter\",\n        \"afterDate\": \"er etter (date)\",\n        \"before\": \"er før\",\n        \"beforeDate\": \"er før (date)\",\n        \"contains\": \"inneholder\",\n        \"endsWith\": \"ender med\",\n        \"inPlaylist\": \"er inne i\",\n        \"inTheLast\": \"er inne i de siste\",\n        \"inTheRange\": \"er innenfor området\",\n        \"isGreaterThan\": \"er større enn\",\n        \"isLessThan\": \"er mindre enn\",\n        \"matchesRegex\": \"samsvarer med regex\",\n        \"notContains\": \"inneholder ikke\",\n        \"notInPlaylist\": \"er ikke med i\",\n        \"notInTheLast\": \"er ikke med i de siste\",\n        \"startsWith\": \"starter med\",\n        \"inTheRangeDate\": \"er innenfor området (date)\",\n        \"is\": \"er\",\n        \"isNot\": \"er ikke\"\n    },\n    \"datetime\": {\n        \"minuteShort\": \"m\",\n        \"secondShort\": \"s\",\n        \"hourShort\": \"h\",\n        \"dayShort\": \"d\"\n    },\n    \"visualizer\": {\n        \"options\": {\n            \"weightingFilter\": {\n                \"a\": \"A\",\n                \"b\": \"B\",\n                \"c\": \"C\",\n                \"d\": \"D\",\n                \"z\": \"Z\"\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "src/i18n/locales/nl.json",
    "content": "{\n    \"action\": {\n        \"editPlaylist\": \"pas $t(entity.playlist, {\\\"count\\\": 1}) aan\",\n        \"goToPage\": \"ga naar pagina\",\n        \"moveToTop\": \"verplaats naar begin\",\n        \"addToFavorites\": \"toevoegen aan $t(entity.favorite, {\\\"count\\\": 2})\",\n        \"addToPlaylist\": \"toevoegen aan $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"createPlaylist\": \"maak $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"removeFromPlaylist\": \"verwijder uit $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"viewPlaylists\": \"bekijk $t(entity.playlist, {\\\"count\\\": 2})\",\n        \"refresh\": \"$t(common.refresh)\",\n        \"deletePlaylist\": \"verwijder $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"removeFromQueue\": \"verwijder uit wachtrij\",\n        \"deselectAll\": \"deselecteer alles\",\n        \"moveToBottom\": \"verplaats naar einde\",\n        \"setRating\": \"kies beoordeling\",\n        \"toggleSmartPlaylistEditor\": \"editor $t(entity.smartPlaylist) schakelen\",\n        \"removeFromFavorites\": \"verwijder uit $t(entity.favorite, {\\\"count\\\": 2})\",\n        \"clearQueue\": \"verwijder lijst\",\n        \"openIn\": {\n            \"lastfm\": \"Open in Last.fm\",\n            \"musicbrainz\": \"Open in MusicBrainz\"\n        },\n        \"moveToNext\": \"ga naar volgende\",\n        \"downloadStarted\": \"begonnen met downloaden van {{count}} items\",\n        \"moveItems\": \"verplaats items\",\n        \"shuffle\": \"shuffle\",\n        \"shuffleAll\": \"shuffle alles\",\n        \"shuffleSelected\": \"shuffle geselecteerde\",\n        \"viewMore\": \"bekijk meer\",\n        \"addOrRemoveFromSelection\": \"toevoegen aan of verwijderen uit selectie\",\n        \"selectRangeOfItems\": \"selecteer een reeks van nummers\",\n        \"createRadioStation\": \"maak $t(entity.radioStation, {\\\"count\\\": 1})\",\n        \"deleteRadioStation\": \"verwijder $t(entity.radioStation, {\\\"count\\\": 1})\",\n        \"selectAll\": \"selecteer alles\",\n        \"moveUp\": \"verplaats omhoog\",\n        \"moveDown\": \"verplaats omlaag\",\n        \"holdToMoveToTop\": \"ingedrukt houden om naar begin te verplaatsen\",\n        \"holdToMoveToBottom\": \"ingedrukt houden om naar einde te verplaatsen\",\n        \"openApplicationDirectory\": \"applicatiemap openen\"\n    },\n    \"common\": {\n        \"backward\": \"achteruit\",\n        \"increase\": \"verhogen\",\n        \"rating\": \"rating\",\n        \"bpm\": \"bpm\",\n        \"areYouSure\": \"weet je het zeker?\",\n        \"edit\": \"aanpassen\",\n        \"favorite\": \"favoriet\",\n        \"left\": \"links\",\n        \"currentSong\": \"huidig $t(entity.track, {\\\"count\\\": 1})\",\n        \"collapse\": \"samenvouwen\",\n        \"descending\": \"aflopend\",\n        \"add\": \"toevoegen\",\n        \"gap\": \"gat\",\n        \"ascending\": \"oplopend\",\n        \"dismiss\": \"negeren\",\n        \"manage\": \"beheren\",\n        \"limit\": \"limiet\",\n        \"minimize\": \"minimaliseren\",\n        \"modified\": \"aangepast\",\n        \"duration\": \"duur\",\n        \"name\": \"naam\",\n        \"maximize\": \"maximaliseren\",\n        \"decrease\": \"verminder\",\n        \"ok\": \"ok\",\n        \"description\": \"beschrijving\",\n        \"configure\": \"configureren\",\n        \"path\": \"pad\",\n        \"center\": \"centreren\",\n        \"no\": \"nee\",\n        \"owner\": \"eigenaar\",\n        \"enable\": \"activeren\",\n        \"clear\": \"opschonen\",\n        \"forward\": \"vooruit\",\n        \"delete\": \"verwijder\",\n        \"cancel\": \"annuleer\",\n        \"forceRestartRequired\": \"herstart om aanpassingen toe te passen... wanneer de notificatie gesloten wordt zal de applicatie herstarten\",\n        \"filter_one\": \"filter\",\n        \"filter_other\": \"filters\",\n        \"filters\": \"filters\",\n        \"create\": \"aanmaken\",\n        \"bitrate\": \"bitrate\",\n        \"action_one\": \"actie\",\n        \"action_other\": \"acties\",\n        \"playerMustBePaused\": \"player moet gepauzeerd zijn\",\n        \"confirm\": \"bevestig\",\n        \"home\": \"home\",\n        \"comingSoon\": \"komt binnenkort…\",\n        \"channel_one\": \"kanaal\",\n        \"channel_other\": \"kanalen\",\n        \"disable\": \"deactiveren\",\n        \"none\": \"geen\",\n        \"menu\": \"menu\",\n        \"previousSong\": \"vorige $t(entity.track, {\\\"count\\\": 1})\",\n        \"noResultsFromQuery\": \"de zoekopdracht leverde geen resultaten op\",\n        \"quit\": \"sluiten\",\n        \"expand\": \"vergroten\",\n        \"disc\": \"disk\",\n        \"random\": \"willekeurig\",\n        \"biography\": \"biografie\",\n        \"note\": \"Opmerking\",\n        \"refresh\": \"verversen\",\n        \"unknown\": \"onbekend\",\n        \"save\": \"opslaan\",\n        \"right\": \"rechts\",\n        \"trackNumber\": \"track\",\n        \"year\": \"jaar\",\n        \"version\": \"versie\",\n        \"title\": \"titel\",\n        \"saveAndReplace\": \"opslaan en vervangen\",\n        \"resetToDefault\": \"herstellen naar standaard\",\n        \"reset\": \"terugzetten\",\n        \"sortOrder\": \"volgorde\",\n        \"restartRequired\": \"herstart is nodig\",\n        \"search\": \"zoeken\",\n        \"saveAs\": \"opslaan als\",\n        \"yes\": \"ja\",\n        \"size\": \"grootte\",\n        \"reload\": \"herlaad\",\n        \"setting_one\": \"instelling\",\n        \"setting_other\": \"instellingen\",\n        \"close\": \"sluiten\",\n        \"additionalParticipants\": \"andere deelnemers\",\n        \"newVersion\": \"een nieuwe versie is geïnstalleerd ({{version}})\",\n        \"viewReleaseNotes\": \"lees uitgavenotities\",\n        \"albumGain\": \"album gain\",\n        \"translation\": \"vertaling\",\n        \"explicitStatus\": \"expliciete status\",\n        \"bitDepth\": \"bitdiepte\",\n        \"codec\": \"codec\",\n        \"mbid\": \"MusicBrainz ID\",\n        \"share\": \"deel\",\n        \"explicit\": \"expliciet\",\n        \"sampleRate\": \"sample rate\",\n        \"tags\": \"tags\",\n        \"albumPeak\": \"albumpiek\",\n        \"doNotShowAgain\": \"niet opnieuw tonen\",\n        \"externalLinks\": \"externe links\",\n        \"faster\": \"sneller\",\n        \"preview\": \"voorvertoning\",\n        \"private\": \"privé\",\n        \"public\": \"publiekelijk\",\n        \"recordLabel\": \"platenlabel\",\n        \"releaseType\": \"uitgavetype\",\n        \"slower\": \"slomer\",\n        \"sort\": \"sorteer\",\n        \"trackGain\": \"trackvolume\",\n        \"trackPeak\": \"piekniveau\",\n        \"clean\": \"schoon\",\n        \"gridRows\": \"rasterrijen\",\n        \"tableColumns\": \"tabelkolommen\",\n        \"itemsMore\": \"{{count}} meer\",\n        \"countSelected\": \"{{count}} geselecteerd\",\n        \"view\": \"bekijken\",\n        \"noFilters\": \"geen filters ingesteld\",\n        \"example\": \"voorbeeld\",\n        \"mood\": \"stemming\",\n        \"retry\": \"opnieuw proberen\",\n        \"filter_single\": \"single\",\n        \"rename\": \"hernoemen\",\n        \"filter_multiple\": \"meerdere\"\n    },\n    \"filter\": {\n        \"rating\": \"rating\",\n        \"communityRating\": \"community rating\",\n        \"criticRating\": \"criticus rating\",\n        \"mostPlayed\": \"meest gespeeld\",\n        \"comment\": \"commentaar\",\n        \"playCount\": \"aantal keer afgespeeld\",\n        \"recentlyUpdated\": \"recentelijk geüpdate\",\n        \"channels\": \"$t(common.channel, {\\\"count\\\": 2})\",\n        \"isCompilation\": \"is compilatie\",\n        \"recentlyPlayed\": \"recentelijk afgespeeld\",\n        \"isRated\": \"is rated\",\n        \"owner\": \"$t(common.owner)\",\n        \"bitrate\": \"bitrate\",\n        \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n        \"recentlyAdded\": \"recentelijk toegevoegd\",\n        \"note\": \"notitie\",\n        \"name\": \"naam\",\n        \"dateAdded\": \"datum toegevoegd\",\n        \"albumCount\": \"$t(entity.album, {\\\"count\\\": 2}) totaal\",\n        \"path\": \"pad\",\n        \"favorited\": \"favoriet\",\n        \"albumArtist\": \"$t(entity.albumArtist, {\\\"count\\\": 1})\",\n        \"isRecentlyPlayed\": \"is recentelijk afgespeeld\",\n        \"isFavorited\": \"is favoriet\",\n        \"bpm\": \"bpm\",\n        \"id\": \"id\",\n        \"disc\": \"disk\",\n        \"biography\": \"biografie\",\n        \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n        \"duration\": \"duratie\",\n        \"isPublic\": \"is publiek\",\n        \"random\": \"willekeurig\",\n        \"lastPlayed\": \"laatst gespeeld\",\n        \"fromYear\": \"van jaar\",\n        \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n        \"title\": \"titel\",\n        \"search\": \"zoeken\",\n        \"releaseDate\": \"releasedatum\",\n        \"releaseYear\": \"release jaar\",\n        \"songCount\": \"aantal nummers\",\n        \"toYear\": \"tot jaar\",\n        \"trackNumber\": \"track\",\n        \"explicitStatus\": \"$t(common.explicitStatus)\",\n        \"sortName\": \"sorteernaam\",\n        \"matchAnd\": \"en\",\n        \"matchOr\": \"of\"\n    },\n    \"page\": {\n        \"contextMenu\": {\n            \"setRating\": \"$t(action.setRating)\",\n            \"addToPlaylist\": \"$t(action.addToPlaylist)\",\n            \"addToFavorites\": \"$t(action.addToFavorites)\",\n            \"moveToTop\": \"$t(action.moveToTop)\",\n            \"deletePlaylist\": \"$t(action.deletePlaylist)\",\n            \"moveToBottom\": \"$t(action.moveToBottom)\",\n            \"createPlaylist\": \"$t(action.createPlaylist)\",\n            \"removeFromPlaylist\": \"$t(action.removeFromPlaylist)\",\n            \"removeFromFavorites\": \"$t(action.removeFromFavorites)\",\n            \"addNext\": \"$t(player.addNext)\",\n            \"deselectAll\": \"$t(action.deselectAll)\",\n            \"addLast\": \"$t(player.addLast)\",\n            \"addFavorite\": \"$t(action.addToFavorites)\",\n            \"play\": \"$t(player.play)\",\n            \"numberSelected\": \"{{count}} geselecteerd\",\n            \"removeFromQueue\": \"$t(action.removeFromQueue)\",\n            \"download\": \"download\",\n            \"shareItem\": \"deel item\",\n            \"goToAlbum\": \"ga naar $t(entity.album, {\\\"count\\\": 1})\",\n            \"goToAlbumArtist\": \"ga naar $t(entity.albumArtist, {\\\"count\\\": 1})\",\n            \"showDetails\": \"haal info op\",\n            \"moveItems\": \"$t(action.moveItems)\",\n            \"moveToNext\": \"$t(action.moveToNext)\",\n            \"playSimilarSongs\": \"$t(player.playSimilarSongs)\",\n            \"playShuffled\": \"$t(player.shuffle)\",\n            \"goTo\": \"ga naar\"\n        },\n        \"appMenu\": {\n            \"selectServer\": \"selecteer server\",\n            \"version\": \"versie {{version}}\",\n            \"settings\": \"$t(common.setting, {\\\"count\\\": 2})\",\n            \"manageServers\": \"beheer servers\",\n            \"expandSidebar\": \"zijbalk uitklappen\",\n            \"collapseSidebar\": \"zijbalk inklappen\",\n            \"openBrowserDevtools\": \"open browser devtools\",\n            \"quit\": \"$t(common.quit)\",\n            \"goBack\": \"terug\",\n            \"goForward\": \"vooruit\",\n            \"privateModeOff\": \"schakel private modus uit\",\n            \"privateModeOn\": \"schakel private modus in\",\n            \"selectMusicFolder\": \"selecteer muziekfolder\",\n            \"noMusicFolder\": \"geen muziekfolder geselecteerd\",\n            \"multipleMusicFolders\": \"{{count}} muziekfolders geselecteerd\",\n            \"commandPalette\": \"open opdrachtvenster\"\n        },\n        \"albumDetail\": {\n            \"moreFromArtist\": \"meer van deze $t(entity.artist, {\\\"count\\\": 1})\",\n            \"moreFromGeneric\": \"meer van {{item}}\",\n            \"released\": \"uitgebracht\"\n        },\n        \"fullscreenPlayer\": {\n            \"config\": {\n                \"dynamicBackground\": \"dynamische achtergrond\",\n                \"followCurrentLyric\": \"volg de actuele songtekst\",\n                \"opacity\": \"opaciteit\",\n                \"lyricSize\": \"tekstgrootte\",\n                \"lyricAlignment\": \"songtekst uitlijning\",\n                \"lyricGap\": \"tekstkloof\",\n                \"dynamicImageBlur\": \"blur grootte van afbeelding\",\n                \"dynamicIsImage\": \"schakel achtergrondafbeelding in\",\n                \"showLyricMatch\": \"toon liedtekst match\",\n                \"synchronized\": \"gesynchronizeerd\",\n                \"unsynchronized\": \"niet gesynchronizeerd\",\n                \"useImageAspectRatio\": \"gebruik aspect ratio van de afbeelding\",\n                \"lyricOffset\": \"songtekst-vertraging (ms)\",\n                \"showLyricProvider\": \"toon songtekstaanbieder\"\n            },\n            \"lyrics\": \"liedtekst\",\n            \"related\": \"gerelateerd\",\n            \"upNext\": \"volgende\",\n            \"noLyrics\": \"geen liedtekst gevonden\",\n            \"visualizer\": \"visualizer\"\n        },\n        \"albumArtistList\": {\n            \"title\": \"$t(entity.albumArtist, {\\\"count\\\": 2})\"\n        },\n        \"albumList\": {\n            \"title\": \"$t(entity.album, {\\\"count\\\": 2})\",\n            \"artistAlbums\": \"albums van {{artist}}\",\n            \"genreAlbums\": \"\\\"{{genre}}\\\" $t(entity.album, {\\\"count\\\": 2})\"\n        },\n        \"albumArtistDetail\": {\n            \"about\": \"Over {{artist}}\",\n            \"appearsOn\": \"verschijnt op\",\n            \"viewDiscography\": \"bekijk discografie\",\n            \"relatedArtists\": \"gerelateerd $t(entity.artist, {\\\"count\\\": 2})\",\n            \"topSongs\": \"top nummers\",\n            \"topSongsFrom\": \"top nummers van {{title}}\",\n            \"viewAll\": \"bekijk alle\",\n            \"viewAllTracks\": \"bekijk alle $t(entity.track, {\\\"count\\\": 2})\",\n            \"recentReleases\": \"recente uitgaven\",\n            \"groupingTypeAll\": \"alle soorten publicaties\",\n            \"groupingTypePrimary\": \"primaire publicatiesoorten\",\n            \"favoriteSongs\": \"favoriete nummers\",\n            \"topSongsCommunity\": \"community\",\n            \"topSongsPersonal\": \"persoonlijk\",\n            \"favoriteSongsFrom\": \"favoriete nummers van {{title}}\"\n        },\n        \"manageServers\": {\n            \"title\": \"beheer servers\",\n            \"serverDetails\": \"server details\",\n            \"url\": \"URL\",\n            \"username\": \"gebruikersnaam\",\n            \"editServerDetailsTooltip\": \"bewerk server details\",\n            \"removeServer\": \"verwijder server\"\n        },\n        \"genreList\": {\n            \"showAlbums\": \"toon $t(entity.genre, {\\\"count\\\": 1}) $t(entity.album, {\\\"count\\\": 2})\",\n            \"showTracks\": \"toon $t(entity.genre, {\\\"count\\\": 1}) $t(entity.track, {\\\"count\\\": 2})\",\n            \"title\": \"$t(entity.genre, {\\\"count\\\": 2})\"\n        },\n        \"globalSearch\": {\n            \"commands\": {\n                \"goToPage\": \"ga naar pagina\",\n                \"searchFor\": \"zoek naar {{query}}\",\n                \"serverCommands\": \"server commandos\"\n            },\n            \"title\": \"commandos\"\n        },\n        \"home\": {\n            \"explore\": \"ontdek van uw biblitheek\",\n            \"mostPlayed\": \"meest gespeeld\",\n            \"newlyAdded\": \"nieuw toegevoegde uitgaven\",\n            \"recentlyPlayed\": \"recent afgespeeld\",\n            \"recentlyReleased\": \"recent uitgekomen\",\n            \"title\": \"$t(common.home)\",\n            \"genres\": \"$t(entity.genre, {\\\"count\\\": 2})\"\n        },\n        \"favorites\": {\n            \"title\": \"$t(entity.favorite, {\\\"count\\\": 2})\"\n        },\n        \"itemDetail\": {\n            \"copyPath\": \"kopieer pad naar klembord\",\n            \"copiedPath\": \"pad succesvol gekopieerd\",\n            \"openFile\": \"toon nummer in bestandsbeheerder\"\n        },\n        \"playlist\": {\n            \"reorder\": \"herschikken is alleen ingeschakeld wanneer er op ID wordt gestorteerd\"\n        },\n        \"playlistList\": {\n            \"title\": \"$t(entity.playlist, {\\\"count\\\": 2})\"\n        },\n        \"setting\": {\n            \"advanced\": \"geavanceerd\",\n            \"analytics\": \"analyses\",\n            \"generalTab\": \"algemeen\",\n            \"hotkeysTab\": \"sneltoetsen\",\n            \"playbackTab\": \"weergave\",\n            \"windowTab\": \"venster\",\n            \"updates\": \"update\",\n            \"cache\": \"cache\",\n            \"application\": \"applicatie\",\n            \"queryBuilder\": \"querybouwer\",\n            \"theme\": \"thema\",\n            \"controls\": \"besturing\",\n            \"sidebar\": \"zijbalk\",\n            \"remote\": \"afstand\",\n            \"exportImport\": \"importeren/exporteren\",\n            \"scrobble\": \"scrobble\",\n            \"audio\": \"geluid\",\n            \"lyrics\": \"songtekst\",\n            \"transcoding\": \"transcoderen\",\n            \"discord\": \"discord\",\n            \"lyricsDisplay\": \"songtekstweergave\",\n            \"logger\": \"logger\",\n            \"playerFilters\": \"spelerfilters\"\n        },\n        \"sidebar\": {\n            \"albumArtists\": \"$t(entity.albumArtist, {\\\"count\\\": 2})\",\n            \"albums\": \"$t(entity.album, {\\\"count\\\": 2})\",\n            \"artists\": \"$t(entity.artist, {\\\"count\\\": 2})\",\n            \"favorites\": \"$t(entity.favorite, {\\\"count\\\": 2})\",\n            \"folders\": \"$t(entity.folder, {\\\"count\\\": 2})\",\n            \"genres\": \"$t(entity.genre, {\\\"count\\\": 2})\",\n            \"home\": \"$t(common.home)\",\n            \"myLibrary\": \"mijn bibliotheek\",\n            \"nowPlaying\": \"nu aan het spelen\",\n            \"playlists\": \"$t(entity.playlist, {\\\"count\\\": 2})\",\n            \"search\": \"$t(common.search)\",\n            \"settings\": \"$t(common.setting, {\\\"count\\\": 2})\",\n            \"shared\": \"$t(entity.playlist, {\\\"count\\\": 2}) gedeeld\",\n            \"tracks\": \"$t(entity.track, {\\\"count\\\": 2})\",\n            \"radio\": \"$t(entity.radioStation, {\\\"count\\\": 2})\",\n            \"collections\": \"verzamelingen\"\n        },\n        \"trackList\": {\n            \"artistTracks\": \"nummers van {{artist}}\",\n            \"genreTracks\": \"\\\"{{genre}}\\\" $t(entity.track, {\\\"count\\\": 2})\",\n            \"title\": \"$t(entity.track, {\\\"count\\\": 2})\"\n        },\n        \"radioList\": {\n            \"title\": \"radiostations\"\n        },\n        \"folderList\": {\n            \"title\": \"$t(entity.folder, {\\\"count\\\": 2})\"\n        },\n        \"windowBar\": {\n            \"paused\": \"(Gepauzeerd) \",\n            \"privateMode\": \"(Privémodus)\"\n        },\n        \"collections\": {\n            \"overrideExisting\": \"bestaande overschrijven\",\n            \"saveAsCollection\": \"sla op als verzameling\"\n        },\n        \"releasenotes\": {\n            \"commitsSinceStable\": \"commits sinds {{stable}}\",\n            \"noNewCommits\": \"geen nieuwe commits in dit bereik\",\n            \"noStableReleaseToCompare\": \"geen stabiele uitgave beschikbaar om mee te vergelijken\"\n        }\n    },\n    \"error\": {\n        \"remotePortWarning\": \"herstart de server om de nieuwe poort in te stellen\",\n        \"systemFontError\": \"er is iets fout gegaan tijdens het verkrijgen van systeem fonts\",\n        \"playbackError\": \"er is iets fout gegaan bij het afspelen van de media\",\n        \"endpointNotImplementedError\": \"endpoint {{endpoint}} is niet geïmplementeerd voor {{serverType}}\",\n        \"remotePortError\": \"er is iets fout gegaan tijdens het selecteren van de remote server\",\n        \"serverRequired\": \"server vereist\",\n        \"authenticationFailed\": \"authenticatie mislukt\",\n        \"apiRouteError\": \"verzoek kan niet doorgestuurd worden\",\n        \"genericError\": \"er is iets fout gegaan\",\n        \"credentialsRequired\": \"inloggegevens vereist\",\n        \"sessionExpiredError\": \"jouw sessie is verlopen\",\n        \"remoteEnableError\": \"er is iets fout gegaan tijdens het $t(common.enable) van de remote server\",\n        \"localFontAccessDenied\": \"toegang geweigerd tot lokale fonts\",\n        \"serverNotSelectedError\": \"geen server geselecteerd\",\n        \"remoteDisableError\": \"er is iets fout gegaan tijdens het $t(common.disable) van de remote server\",\n        \"mpvRequired\": \"MPV vereist\",\n        \"audioDeviceFetchError\": \"er is iets mis gegaan met het ophalen van de audioapparaten\",\n        \"invalidServer\": \"ongeldige server\",\n        \"loginRateError\": \"te veel login pogingen, probeer het opnieuw in een paar seconde\",\n        \"badValue\": \"ongeldige optie \\\"{{value}}\\\". Deze waarde bestaat niet langer\",\n        \"networkError\": \"een netwerkfout heeft zich voorgedaan\",\n        \"notificationDenied\": \"toestemming voor meldingen werd afgewezen. Deze instelling heeft geen effect\",\n        \"openError\": \"kon het bestand niet openen\",\n        \"badAlbum\": \"je ziet deze pagina omdat dit nummer niet onderdeel is van een album. je komt waarchijnlijk dit probleem tegen als je een nummer op het bovenste niveau van je muziekmap hebt staan. Jellyfin kan alleen nummers groeperen als ze in een folder zitten\",\n        \"multipleServerSaveQueueError\": \"De afspeellijst bevat een of meer nummers die niet afkomstig zijn van de huidige server. Dit wordt niet ondersteund\",\n        \"noNetwork\": \"server niet beschikbaar\",\n        \"noNetworkDescription\": \"kan geen verbinding maken met deze server\",\n        \"saveQueueFailed\": \"kan wachtrij niet opslaan\",\n        \"settingsSyncError\": \"Er zijn verschillen gevonden tussen de instellingen in de renderer en het hoofdproces. Start de applicatie opnieuw op om de wijzigingen toe te passen\",\n        \"invalidJson\": \"ongeldige JSON\",\n        \"serverLockSingleServer\": \"slechts één server is toegestaan als server op slot is gezet\"\n    },\n    \"entity\": {\n        \"genre_one\": \"genre\",\n        \"genre_other\": \"genres\",\n        \"playlistWithCount_one\": \"{{count}} afspeellijst\",\n        \"playlistWithCount_other\": \"{{count}} afspeellijsten\",\n        \"playlist_one\": \"afspeellijst\",\n        \"playlist_other\": \"afspeellijsten\",\n        \"artist_one\": \"artiest\",\n        \"artist_other\": \"artiesten\",\n        \"folderWithCount_one\": \"{{count}} folder\",\n        \"folderWithCount_other\": \"{{count}} folders\",\n        \"albumArtist_one\": \"albumartiest\",\n        \"albumArtist_other\": \"albumartiesten\",\n        \"track_one\": \"track\",\n        \"track_other\": \"tracks\",\n        \"albumArtistCount_one\": \"{{count}} albumartiest\",\n        \"albumArtistCount_other\": \"{{count}} albumartiesten\",\n        \"albumWithCount_one\": \"{{count}} album\",\n        \"albumWithCount_other\": \"{{count}} albums\",\n        \"favorite_one\": \"favoriet\",\n        \"favorite_other\": \"favorieten\",\n        \"artistWithCount_one\": \"{{count}} artiest\",\n        \"artistWithCount_other\": \"{{count}} artiesten\",\n        \"folder_one\": \"folder\",\n        \"folder_other\": \"folders\",\n        \"smartPlaylist\": \"smart $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"album_one\": \"album\",\n        \"album_other\": \"albums\",\n        \"genreWithCount_one\": \"{{count}} genre\",\n        \"genreWithCount_other\": \"{{count}} genres\",\n        \"trackWithCount_one\": \"{{count}} track\",\n        \"trackWithCount_other\": \"{{count}} tracks\",\n        \"song_one\": \"lied\",\n        \"song_other\": \"liedjes\",\n        \"play_one\": \"{{count}} keer afgespeeld\",\n        \"play_other\": \"{{count}} keren afgespeeld\",\n        \"radioStation_one\": \"radiostation\",\n        \"radioStation_other\": \"radiostations\",\n        \"radioStationWithCount_one\": \"{{count}} radiostation\",\n        \"radioStationWithCount_other\": \"{{count}} radiostations\"\n    },\n    \"table\": {\n        \"column\": {\n            \"rating\": \"rating\",\n            \"size\": \"$t(common.size)\",\n            \"albumArtist\": \"albumartiest\",\n            \"biography\": \"biografie\",\n            \"bitrate\": \"bitsnelheid\",\n            \"comment\": \"opmerking\",\n            \"dateAdded\": \"datum toegevoegd\",\n            \"favorite\": \"favoriet\",\n            \"discNumber\": \"disc\",\n            \"bpm\": \"bpm\",\n            \"album\": \"album\",\n            \"lastPlayed\": \"laatst gespeeld\",\n            \"path\": \"pad\",\n            \"playCount\": \"keren gespeeld\",\n            \"releaseDate\": \"uitgavedatum\",\n            \"releaseYear\": \"jaar\",\n            \"title\": \"titel\",\n            \"trackNumber\": \"nummer\",\n            \"owner\": \"eigenaar\",\n            \"albumCount\": \"$t(entity.album, {\\\"count\\\": 2})\",\n            \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n            \"bitDepth\": \"$t(common.bitDepth)\",\n            \"channels\": \"$t(common.channel, {\\\"count\\\": 2})\",\n            \"codec\": \"$t(common.codec)\",\n            \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n            \"sampleRate\": \"$t(common.sampleRate)\",\n            \"songCount\": \"$t(entity.track, {\\\"count\\\": 2})\"\n        },\n        \"config\": {\n            \"label\": {\n                \"rating\": \"$t(common.rating)\",\n                \"composer\": \"componist\",\n                \"dateAdded\": \"datum toegevoegd\",\n                \"discNumber\": \"discnummer\",\n                \"image\": \"afbeelding\",\n                \"lastPlayed\": \"laatst gespeeld\",\n                \"playCount\": \"keren afgespeeld\",\n                \"releaseDate\": \"uitgavedatum\",\n                \"rowIndex\": \"rij-index\",\n                \"trackNumber\": \"nummer\",\n                \"actions\": \"$t(common.action, {\\\"count\\\": 2})\",\n                \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n                \"albumCount\": \"$t(entity.album, {\\\"count\\\": 2})\",\n                \"albumArtist\": \"$t(entity.albumArtist, {\\\"count\\\": 1})\",\n                \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n                \"biography\": \"$t(common.biography)\",\n                \"bitDepth\": \"$t(common.bitDepth)\",\n                \"bitrate\": \"$t(common.bitrate)\",\n                \"bpm\": \"$t(common.bpm)\",\n                \"channels\": \"$t(common.channel, {\\\"count\\\": 2})\",\n                \"codec\": \"$t(common.codec)\",\n                \"duration\": \"$t(common.duration)\",\n                \"favorite\": \"$t(common.favorite)\",\n                \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n                \"genreBadge\": \"$t(entity.genre, {\\\"count\\\": 1}) (badges)\",\n                \"note\": \"$t(common.note)\",\n                \"owner\": \"$t(common.owner)\",\n                \"path\": \"$t(common.path)\",\n                \"sampleRate\": \"$t(common.sampleRate)\",\n                \"size\": \"$t(common.size)\",\n                \"songCount\": \"$t(entity.track, {\\\"count\\\": 2})\",\n                \"title\": \"$t(common.title)\",\n                \"titleArtist\": \"$t(common.title) (artiest)\",\n                \"titleCombined\": \"$t(common.title) (gecombineerd)\",\n                \"year\": \"$t(common.year)\"\n            },\n            \"general\": {\n                \"advancedSettings\": \"geavanceerde instellingen\",\n                \"autoFitColumns\": \"kolommen automatisch passend maken\",\n                \"autosize\": \"automatische afmetingen\",\n                \"moveUp\": \"omhoog verplaatsen\",\n                \"moveDown\": \"omlaag verplaatsen\",\n                \"pinToLeft\": \"links vastpinnen\",\n                \"pinToRight\": \"rechts vastpinnen\",\n                \"alignLeft\": \"links uitlijnen\",\n                \"alignCenter\": \"centreren\",\n                \"alignRight\": \"rechts uitlijnen\",\n                \"followCurrentSong\": \"huidige nummer volgen\",\n                \"displayType\": \"weergavesoort\",\n                \"itemGap\": \"ruimte tussen items (px)\",\n                \"itemSize\": \"grootte item (px)\",\n                \"itemsPerRow\": \"items per rij\",\n                \"size_default\": \"standaard\",\n                \"size_compact\": \"compact\",\n                \"size_large\": \"groot\",\n                \"tableColumns\": \"kolommen\",\n                \"pagination\": \"paginering\",\n                \"pagination_itemsPerPage\": \"items per pagina\",\n                \"pagination_infinite\": \"oneindig\",\n                \"pagination_paginate\": \"gepagineerd\",\n                \"alternateRowColors\": \"afwisselende rijkleuren\",\n                \"horizontalBorders\": \"randen om rijen\",\n                \"rowHoverHighlight\": \"oplichtende rijen bij zweven met de muis\",\n                \"showHeader\": \"toon kop\",\n                \"verticalBorders\": \"randen om kolommen\",\n                \"gap\": \"$t(common.gap)\",\n                \"size\": \"$t(common.size)\"\n            },\n            \"view\": {\n                \"grid\": \"grid\",\n                \"list\": \"lijst\",\n                \"table\": \"tabel\",\n                \"detail\": \"details\"\n            }\n        }\n    },\n    \"setting\": {\n        \"hotkey_rate5\": \"rating 5 sterren\",\n        \"hotkey_rate4\": \"rating 4 sterren\",\n        \"discordLinkType_description\": \"voegt externe links van {{lastfm}} of {{musicbrainz}} toe aan het nummer- en artiestveld in {{discord}} rich presence. {{musicbrainz}} is de meest accurate, maar vereist tags en geeft geen artiestenlinks, terwijl {{lastfm}} altijd een link moet aanbieden. maakt geen extra netwerkverzoeken\",\n        \"discordLinkType_mbz_lastfm\": \"{{musicbrainz}} met {{lastfm}} terugval\",\n        \"discordLinkType_none\": \"$t(common.none)\",\n        \"discordLinkType\": \"{{discord}} presencekoppelingen\",\n        \"discordListening_description\": \"status weergeven als ‘luisterend’ in plaats van ‘afspelend’\",\n        \"discordListening\": \"status weergeven als 'luisterend'\",\n        \"discordPausedStatus_description\": \"wanneer ingeschakeld, wordt de status ook weergegeven als de speler gepauzeerd is\",\n        \"discordPausedStatus\": \"rich presence tonen wanneer gepauzeerd\",\n        \"discordRichPresence\": \"{{discord}} rich presence\",\n        \"exitToTray_description\": \"sluit de applicatie naar het systeemvak\",\n        \"exitToTray\": \"sluit naar systeemvak\",\n        \"exportImportSettings_control_description\": \"exporteer en importeer instellingen via JSON\",\n        \"exportImportSettings_control_exportText\": \"exporteer instellingen\",\n        \"exportImportSettings_control_importText\": \"importeer instellingen\",\n        \"exportImportSettings_control_title\": \"importeer / exporteer instellingen\",\n        \"exportImportSettings_destructiveWarning\": \"instellingen importeren is destructief, beoordeel bovenstaande voordat je beneden op \\\"importeer\\\" klikt!\",\n        \"exportImportSettings_importBtn\": \"importeer instellingen\",\n        \"exportImportSettings_importModalTitle\": \"importeer feishing-instellingen\",\n        \"exportImportSettings_importSuccess\": \"instellingen zijn succesvol geïmporteerd!\",\n        \"exportImportSettings_notValidJSON\": \"het ingevoerde bestand is geen geldige JSON\",\n        \"exportImportSettings_offendingKeyError\": \"\\\"{{offendingKey}}\\\" is onjuist - {{reason}}\",\n        \"externalLinks_description\": \"maakt het mogelijk om externe links (Last.fm, MusicBrainz) te tonen op artiesten-/albumpagina's\",\n        \"externalLinks\": \"toon externe links\",\n        \"followLyric_description\": \"scroll de songtekst naar de huidige positie\",\n        \"followLyric\": \"volg huidige songtekst\",\n        \"font_description\": \"zet het lettertype om te gebruiken in de applicatie\",\n        \"font\": \"lettertype\",\n        \"fontType_description\": \"ingebouwde lettertypes selecteert een van de lettertypes aangeboden door feishin. met systeemlettertype kunt u elk lettertype selecteren dat door uw besturingssysteem wordt aangeboden. met aangepast kunt u uw eigen lettertype opgeven\",\n        \"fontType_optionBuiltIn\": \"ingebouwde lettertype\",\n        \"fontType_optionCustom\": \"aangepaste lettertype\",\n        \"fontType_optionSystem\": \"systeemlettertype\",\n        \"fontType\": \"lettertype-type\",\n        \"gaplessAudio_description\": \"stelt de gapless audio-instelling voor mpv in\",\n        \"gaplessAudio_optionWeak\": \"zwak (aanbevolen)\",\n        \"gaplessAudio\": \"gapless audio\",\n        \"globalMediaHotkeys_description\": \"het gebruik van systeem mediahotkeys voor controle van afspelen aan-/uitzetten\",\n        \"globalMediaHotkeys\": \"globale mediasneltoetsen\",\n        \"autoDJ\": \"auto-DJ\",\n        \"autoDJ_description\": \"soortgelijke nummers automatisch aan wachtrij toevoegen\",\n        \"autoDJ_itemCount\": \"aantal items\",\n        \"autoDJ_itemCount_description\": \"het aantal items dat aan de wachtrij wordt geprobeerd toe te voegen als auto-DJ is ingeschakeld\",\n        \"autoDJ_timing\": \"timing\",\n        \"autoDJ_timing_description\": \"het aantal overgebleven nummers in de wachtrij voordat auto-DJ wordt aangeroepen\",\n        \"accentColor_description\": \"stel de accentkleur voor de applicatie in\",\n        \"accentColor\": \"accentkleur\",\n        \"useThemeAccentColor\": \"gebruik accentkleur van thema\",\n        \"useThemeAccentColor_description\": \"gebruik de primaire kleur zoals gedefinieerd in het gekozen thema in plaats van de aangepaste accentkleur\",\n        \"albumBackground_description\": \"toon de albumhoes als achtergrond op albumpagina's\",\n        \"albumBackground\": \"achtergrondafbeelding album\",\n        \"albumBackgroundBlur_description\": \"de hoeveelheid vervaging die wordt toegepast op de achtergrondafbeelding van een album\",\n        \"albumBackgroundBlur\": \"hoeveelheid vervaging achtergrondafbeelding\",\n        \"analyticsDisable\": \"Opt-out van gebruiksgebaseerde gegevensverzameling\",\n        \"analyticsDisable_description\": \"Geanonimiseerde gebruiksgegevens worden naar de ontwikkelaars gestuurd om te ondersteunen bij het verbeteren van de applicatie\",\n        \"applicationHotkeys_description\": \"configureer sneltoetsen. vink aan om als globale sneltoets in te stellen (enkel voor desktop)\",\n        \"applicationHotkeys\": \"applicatiesneltoetsen\",\n        \"artistBackground\": \"achtergrondafbeelding artiest\",\n        \"artistBackground_description\": \"gebruik de artiestafbeelding als achtergrond op artiestpagina's\",\n        \"artistBackgroundBlur\": \"hoeveelheid vervaging van achtergrondafbeelding\",\n        \"artistBackgroundBlur_description\": \"de hoeveelheid vervaging die wordt toegepast op de achtergrondafbeelding van een artiest\",\n        \"artistConfiguration\": \"configuratie albumartiestpagina\",\n        \"artistConfiguration_description\": \"configureer welke items worden getoond op de albumartiestpagina en in welke volgorde\",\n        \"artistReleaseTypeConfiguration\": \"configuratie artiestuitgavesoorten\",\n        \"artistReleaseTypeConfiguration_description\": \"configureer welke uitgavesoorten worden getoond op de albumartiestpagina en in welke volgorde\",\n        \"audioDevice_description\": \"kies het audioapparaat dat wordt gebruikt om af te spelen\",\n        \"audioDevice\": \"audioapparaat\",\n        \"audioExclusiveMode_description\": \"schakel exclusieve uitvoermodus in. In deze modus wordt het systeem normaliter uitgesloten en zal enkel mpv audio kunnen uitvoeren\",\n        \"audioExclusiveMode\": \"audio-exclusieve modus\",\n        \"audioPlayer_description\": \"kies de audiospeler om te gebruiken bij het afspelen\",\n        \"audioPlayer\": \"audiospeler\",\n        \"buttonSize_description\": \"de grootte van de knoppen in de afspeelbalk\",\n        \"buttonSize\": \"knopgrootte afspeelbalk\",\n        \"clearCache_description\": \"een 'harde schoning' van feishin. naast het legen van feishin's cache wordt de browser-cache (opgeslagen afbeeldingen en andere gegevens) geleegd. inloggegevens en instellingen blijven bewaard\",\n        \"clearCache\": \"browser-cache legen\",\n        \"clearCacheSuccess\": \"cache succesvol geleegd\",\n        \"clearQueryCache_description\": \"een 'zachte schoning' van feishin. dit zal afspeellijsten verversen, metadata volgen en opgeslagen songteksten herstellen. inloggegevens en gecachete afbeeldingen blijven bewaard\",\n        \"clearQueryCache\": \"feishin's cache legen\",\n        \"contextMenu_description\": \"maakt het mogelijk om items te verbergen in het menu dat verschijnt bij het rechts klikken op een item. uitgevinkte items worden verborgen\",\n        \"contextMenu\": \"configuratie contextmenu (rechtermuisklik)\",\n        \"crossfadeDuration_description\": \"bepaal de duur van het crossfade-effect\",\n        \"crossfadeDuration\": \"duur crossfade\",\n        \"crossfadeStyle\": \"crossfade-stijl\",\n        \"crossfadeStyle_description\": \"kies de crossfade-stijl om te gebruiken met de audiospeler\",\n        \"customCss\": \"aangepaste css\",\n        \"customCss_description\": \"inhoud van de aangepastge css. Opmerking: content en niet-lokale urls zijn niet toegestaan. Een voorvertoning van de inhoud wordt hieronder getoond. Aanvullende velden die niet zijn ingesteld zijn aanwezig vanwege sanering\",\n        \"customCssEnable_description\": \"sta toe aangepaste css te schrijven\",\n        \"customCssEnable\": \"aangepaste css inschakelen\",\n        \"customCssNotice\": \"Waarschuwing: ondanks sanering (het niet toestaan van url() en content:) brengt aangepaste css nog steeds risico's met zich mee omdat de interface wordt gewijzigd\",\n        \"customFontPath_description\": \"bepaal het pad naar het aangepaste lettertype voor gebruik in de applicatie\",\n        \"customFontPath\": \"aangepaste lettertypelocatie\",\n        \"releaseChannel_optionBeta\": \"beta\",\n        \"releaseChannel_optionLatest\": \"meest recente\",\n        \"releaseChannel\": \"releasekanaal\",\n        \"releaseChannel_description\": \"kies tussen stabiele, of beta- of alpha-releases (nachtelijk) voor automatische updates\",\n        \"disableLibraryUpdateOnStartup\": \"niet controleren op nieuwe versies bij het opstarten\",\n        \"discordApplicationId_description\": \"de applicatie-id voor {{discord}} rich presence (standaard is {{defaultId}})\",\n        \"hotkey_listPlayNow\": \"nu in lijst spelen\",\n        \"hotkey_navigateHome\": \"navigeer naar startpagina\",\n        \"hotkey_playbackNext\": \"volgend nummer\",\n        \"hotkey_playbackPause\": \"pauzeren\",\n        \"hotkey_playbackPlay\": \"afspelen\",\n        \"hotkey_playbackPlayPause\": \"afspelen / pauzeren\",\n        \"hotkey_playbackPrevious\": \"vorig nummer\",\n        \"hotkey_playbackStop\": \"stoppen\",\n        \"hotkey_rate0\": \"wis beoordeling\",\n        \"hotkey_rate1\": \"beoordeel 1 ster\",\n        \"hotkey_rate2\": \"beoordeel 2 sterren\",\n        \"hotkey_rate3\": \"beoordeel 3 sterren\",\n        \"hotkey_skipBackward\": \"spring terug\",\n        \"hotkey_skipForward\": \"spring vooruit\",\n        \"hotkey_toggleCurrentSongFavorite\": \"schakel favorietstatus $t(common.currentSong)\",\n        \"hotkey_toggleFullScreenPlayer\": \"schakel afspelen in volledig scherm\",\n        \"hotkey_togglePreviousSongFavorite\": \"schakel favorietstatus $t(common.previousSong)\",\n        \"hotkey_toggleQueue\": \"schakel wachtrij\",\n        \"hotkey_toggleRepeat\": \"schakel herhalen\",\n        \"hotkey_toggleShuffle\": \"schakel willekeurig afspelen\",\n        \"hotkey_unfavoriteCurrentSong\": \"verwijder $t(common.currentSong) uit favorieten\",\n        \"hotkey_unfavoritePreviousSong\": \"verwijder $t(common.previousSong) uit favorieten\",\n        \"hotkey_volumeDown\": \"volume omlaag\",\n        \"hotkey_volumeMute\": \"volume dempen\",\n        \"hotkey_volumeUp\": \"volume omhoog\",\n        \"hotkey_zoomIn\": \"inzoomen\",\n        \"hotkey_zoomOut\": \"uitzoomen\",\n        \"imageAspectRatio\": \"gebruik originele verhoudingen van albumhoes\",\n        \"imageAspectRatio_description\": \"toon albumhoes in de originele verhoudingen, indien ingeschakeld. bij albumhoezen die geen 1:1-verhouding hebben zal de overige ruimte leeg blijven\",\n        \"language\": \"taal\",\n        \"language_description\": \"stel de taal voor applicatie in ($t(common.restartRequired))\",\n        \"lastfm_description\": \"toon links naar Last.fm op artiest- en albumpagina's\",\n        \"lastfm\": \"toon Last.fm-links\",\n        \"lastfmApiKey_description\": \"de API-sleutel voor {{lastfm}}. vereist voor albumhoezen\",\n        \"lastfmApiKey\": \"{{lastfm}}-API-sleutel\",\n        \"lyricFetch_description\": \"bevraag verschillende bronnen op het internet voor songteksten\",\n        \"lyricFetch\": \"haal songteksten op van het internet\",\n        \"lyricFetchProvider_description\": \"kies de diensten die geraadpleegd worden voor songteksten\",\n        \"lyricFetchProvider\": \"diensten voor songteksten\",\n        \"lyricOffset_description\": \"compenseer de songtekst met het gegeven aantal milliseconden\",\n        \"lyricOffset\": \"compensatie songtekst (ms)\",\n        \"logLevel\": \"logniveau\",\n        \"logLevel_description\": \"het laagste logniveau dat wordt getoond. debug toont alle logs, error toont enkel foutmeldingen\",\n        \"logLevel_optionDebug\": \"debug\",\n        \"logLevel_optionError\": \"fouten\",\n        \"logLevel_optionInfo\": \"informatief\",\n        \"logLevel_optionWarn\": \"waarschuwingen\",\n        \"minimizeToTray_description\": \"minimaliseer de applicatie naar het systeemvak\",\n        \"minimizeToTray\": \"minimaliseer naar systeemvak\",\n        \"minimumScrobblePercentage_description\": \"het minimumpercentage dat van een nummer gespeeld om worden om deze te scrobblen\",\n        \"minimumScrobblePercentage\": \"minimale duur voor scrobblen (percentage)\",\n        \"minimumScrobbleSeconds_description\": \"de minimale duur in seconden dat van een nummer gespeeld moet zijn om deze te scrobblen\",\n        \"minimumScrobbleSeconds\": \"minimale duur voor scrobblen (seconden)\",\n        \"mpvExecutablePath_description\": \"bepaal het pad naar het uitvoerbare bestand van mpv. indien leeg wordt het standaard pad gebruikt\",\n        \"showRatings\": \"toon beoordelingssterren\",\n        \"showVisualizerInSidebar_description\": \"een paneel met de visualiseerder wordt aan de zijbalk toegevoegd\",\n        \"showVisualizerInSidebar\": \"toon visualiseerder in zijbalk\",\n        \"combinedLyricsAndVisualizer_description\": \"combineer songtekst en visualiseerder in hetzelfde paneel\",\n        \"combinedLyricsAndVisualizer\": \"combineer songtekst en visualseerder in zijbalk\",\n        \"preservePitch_description\": \"behoud toonhoogte bij het aanpassen van de afspeelsnelheid\",\n        \"preservePitch\": \"behoud toonhoogte\",\n        \"audioFadeOnStatusChange\": \"audio faseert uit bij statuswijziging\",\n        \"audioFadeOnStatusChange_description\": \"past in- en uitfasering toe als de afspeelstatus verandert\",\n        \"preventSleepOnPlayback_description\": \"voorkom slaapstand van het scherm als muziek afspeelt\",\n        \"preventSleepOnPlayback\": \"voorkom slaapstand bij afspelen\",\n        \"remotePassword_description\": \"bepaal het wachtwoord voor de externe-bedieningserver. Deze gegevens worden standaard onveilig verstuurd, dus gebruik bij voorkeur een uniek wachtwoord waar je niet om geeft\",\n        \"remotePassword\": \"wachtwoord van externe-bedieningserver\",\n        \"remotePort_description\": \"bepaal de poort voor de externe-bedieningserver\",\n        \"remotePort\": \"poort van externe-bedieningserver\",\n        \"remoteUsername\": \"gebruikersnaam van externe-bedieningserver\",\n        \"remoteUsername_description\": \"bepaal de gebruikersnaam voor de externe-bedieningserver. Als zowel gebruikersnaam als wachtwoord leeg is wordt geen authenticatie toegepast\",\n        \"replayGainClipping_description\": \"Voorkom clipping veroorzaakt door {{ReplayGain}} door automatisch het niveau te verlagen\",\n        \"replayGainClipping\": \"{{ReplayGain}}-clipping\",\n        \"replayGainFallback_description\": \"niveau in dB dat wordt toegepast als het bestand geen {{ReplayGain}}-tags bevat\",\n        \"replayGainFallback\": \"{{ReplayGain}}-terugval\",\n        \"replayGainMode_description\": \"pas het volumeniveau aan volgens {{ReplayGain}}-waarden opgeslagen in de metadata van het bestand\",\n        \"replayGainMode\": \"{{ReplayGain}}-modus\",\n        \"replayGainPreamp_description\": \"pas het voorverstekerniveau aan dat wordt toegepast op {{ReplayGain}}-waarden\",\n        \"replayGainPreamp\": \"{{ReplayGain}}-voorversterker (dB)\",\n        \"discordApplicationId\": \"{{discord}}-applicatie-id\",\n        \"discordDisplayType_artistname\": \"artiestnamen\",\n        \"discordDisplayType_description\": \"verandert waar je naar luistert in je status\",\n        \"discordDisplayType_songname\": \"liednaam\",\n        \"discordDisplayType\": \"weergavesoort {{discord}}-aanwezigheid\",\n        \"discordIdleStatus_description\": \"Werk de status bij als de speler inactief is\",\n        \"discordIdleStatus\": \"toon inactiviteit in rich presence\",\n        \"discordRichPresence_description\": \"toon afspeelstatus in {{discord}} rich presence. Afbeeldingssleutelwoorden zijn {{icon}}, {{playing}} en {{paused}}\",\n        \"discordServeImage\": \"deel afbeeldingen van de server met {{discord}}\",\n        \"discordServeImage_description\": \"deel albumhoezen voor {{discord}} rich presence vanaf de server zelf. enkel beschikbaar voor Jellyfin en Navidrome. {{discord}} gebruikt een bot om afbeeldingen op te vragen, dus moet je server publiek toegankelijk zijn\",\n        \"discordUpdateInterval\": \"verversinterval voor {{discord}} rich presence\",\n        \"discordUpdateInterval_description\": \"de interval in seconden tussen elke update (minimaal 15 seconden)\",\n        \"enableAutoTranslation_description\": \"schakel automatische vertaling in na het laden van songteksten\",\n        \"enableAutoTranslation\": \"automatisch vertalen inschakelen\",\n        \"enableRemote_description\": \"sta toe dat andere apparaten de applicatie kunnen bedienen via de externe-bedieningserver\",\n        \"enableRemote\": \"externe-bedieningserver inschakelen\",\n        \"followCurrentSong_description\": \"scroll de wachtrij automatisch naar het nummer dat momenteel wordt afgespeeld\",\n        \"followCurrentSong\": \"volg actieve nummer\",\n        \"homeConfiguration_description\": \"configureer welke items in welke volgorde getoond worden op de thuispagina\",\n        \"homeConfiguration\": \"configuratie thuispagina\",\n        \"homeFeature_description\": \"of de uitgelicht-carrousel op de thuispagina wordt getoond\",\n        \"homeFeature\": \"uitgelicht-carrousel thuispagina\",\n        \"hotkey_browserBack\": \"browser terug\",\n        \"hotkey_browserForward\": \"browser vooruit\",\n        \"hotkey_favoriteCurrentSong\": \"maak $t(common.currentSong) favoriet\",\n        \"hotkey_favoritePreviousSong\": \"maak $t(common.previousSong) favoriet\",\n        \"hotkey_globalSearch\": \"globaal zoeken\",\n        \"hotkey_localSearch\": \"zoeken op pagina\",\n        \"hotkey_listNavigateToPage\": \"navigeer naar lijst-item\",\n        \"hotkey_listPlayDefault\": \"speel in lijst\",\n        \"hotkey_listPlayLast\": \"speel laatste in lijst\",\n        \"hotkey_listPlayNext\": \"speel volgende in lijst\",\n        \"mpvExecutablePath\": \"pad uitvoerbaar bestand mpv\",\n        \"mpvExtraParameters\": \"aanvullende parameters mpv\",\n        \"mpvExtraParameters_description\": \"aanvullende parameters die aan mpv worden meegegeven\",\n        \"mpvExtraParameters_help\": \"één per regel\",\n        \"musicbrainz_description\": \"toon links naar MusicBrainz op artiest- en albumpagina's, als een MusicBrainz-ID aanwezig is\",\n        \"musicbrainz\": \"toon MusicBrainz-links\",\n        \"neteaseTranslation_description\": \"Haalt songteksten van NetEase op en toont deze, indien beschikbaar\",\n        \"neteaseTranslation\": \"Gebruikt vertalingen van NetEase\",\n        \"notify\": \"Nummerwisselnotificaties\",\n        \"notify_description\": \"Toont een notificatie als het actieve nummer wisselt\",\n        \"pathReplace\": \"bestandspadvervanging\",\n        \"pathReplace_description\": \"vervang het standaard bestandspad van je server\",\n        \"pathReplace_optionRemovePrefix\": \"verwijder voorvoegsel\",\n        \"pathReplace_optionAddPrefix\": \"voeg voorvoegsel toe\",\n        \"passwordStore_description\": \"welke wachtwoord- of secret-store gebruikt moet worden. wijzig dit als je problemen ervaart bij het opslaan van wachtwoorden\",\n        \"passwordStore\": \"wachtwoord- / secret-store\",\n        \"playerFilters\": \"Filter nummers uit de wachtrij\",\n        \"playerFilters_description\": \"Voorkom dat nummers aan de wachtrij worden toegevoegd op basis van de volgende criteria\",\n        \"playbackStyle_description\": \"kies de afspeelstijl om te gebruiken in de audiospeler\",\n        \"playbackStyle_optionCrossFade\": \"crossfade\",\n        \"playbackStyle_optionNormal\": \"normaal\",\n        \"playbackStyle\": \"afspeelstijl\",\n        \"playButtonBehavior_description\": \"het standaardgedrag van de afspelen-knop bij het toevoegen van nummers aan de wachtrij\",\n        \"playButtonBehavior\": \"gedrag afspelen-knop\",\n        \"artistRadioCount_description\": \"het aantal nummers dat moet worden opgehaald voor artiest- en nummer-radio\",\n        \"artistRadioCount\": \"aantal nummers artiest- / nummer-radio\",\n        \"imageResolution\": \"afbeeldingsgrootte\",\n        \"imageResolution_description\": \"de afmetingen van de afbeeldingen die gebruikt worden in de app. door 0 op te geven worden de originele afmetingen gebruikt\",\n        \"imageResolution_optionTable\": \"tabel\",\n        \"imageResolution_optionItemCard\": \"item-kaart\",\n        \"imageResolution_optionSidebar\": \"zijbalk\",\n        \"imageResolution_optionHeader\": \"kop\",\n        \"imageResolution_optionFullScreenPlayer\": \"schermvullende speler\",\n        \"playerbarOpenDrawer_description\": \"open de schermvullende speler door te klikken op de afspeelbalk\",\n        \"playerbarOpenDrawer\": \"volledig scherm via afspeelbalk\",\n        \"playerbarSlider\": \"voortgangsindicator in afspeelbalk\",\n        \"playerbarSlider_description\": \"golfvorm wordt afgeraden op een trage verbinding of bij een datalimiet\",\n        \"playerbarSliderType_optionSlider\": \"voortgangsindicator\",\n        \"playerbarSliderType_optionWaveform\": \"golfvorm\",\n        \"playerbarWaveformAlign\": \"uitlijning golfvorm\",\n        \"playerbarWaveformAlign_optionTop\": \"boven\",\n        \"playerbarWaveformAlign_optionCenter\": \"midden\",\n        \"playerbarWaveformAlign_optionBottom\": \"onder\",\n        \"playerbarWaveformBarWidth\": \"breedte golfvormbalk\",\n        \"playerbarWaveformGap\": \"tussenruimte golfvorm\",\n        \"playerbarWaveformRadius\": \"straal golfvorm\",\n        \"preferLocalLyrics_description\": \"geef de voorkeur aan lokale songteksten indien beschikbaar\",\n        \"preferLocalLyrics\": \"prefereer lokale songteksten\",\n        \"showLyricsInSidebar_description\": \"er zal een paneel worden toegevoegd aan de wachtrij waarin songteksten worden getoond\",\n        \"showLyricsInSidebar\": \"toon songteksten in zijbalk\",\n        \"showRatings_description\": \"toont beoordelingssterren in de interface\",\n        \"sampleRate\": \"bemonsteringsfrequentie\",\n        \"sampleRate_description\": \"de bemonsteringsfrequentie die wordt gebruikt als de gekozen bemonsteringsfrequentie afwijkt van die van de actieve media. bij een waarde lager dan 8000 wordt de standaard frequentie gebruikt\",\n        \"savePlayQueue_description\": \"sla de wachtij op bij het afsluiten van de applicatie en herstel deze als de applicatie wordt geopend\",\n        \"savePlayQueue\": \"sla wachtrij op\",\n        \"scrobble_description\": \"scrobblet afgespeelde nummers naar de mediaserver\",\n        \"scrobble\": \"scrobblen\",\n        \"showSkipButton_description\": \"toont of verstopt de spoelknoppen op de afspeelbalk\",\n        \"showSkipButton\": \"toon spoelknoppen\",\n        \"showSkipButtons_description\": \"toont of verstopt de spoelknoppen op de afspeelbalk\",\n        \"showSkipButtons\": \"toon spoelknoppen\",\n        \"sidebarCollapsedNavigation_description\": \"toon of verstop de navigatie in de ingeklapte zijbalk\",\n        \"sidebarCollapsedNavigation\": \"zijbalknavigatie (ingeklapt)\",\n        \"sidebarConfiguration_description\": \"kies de items en hun volgorde voor in de zijbalk\",\n        \"sidebarConfiguration\": \"configuratie zijbalk\",\n        \"sidebarPlaylistList_description\": \"toon of verstop afspeellijsten in de zijbalk\",\n        \"sidebarPlaylistList\": \"afspeellijsten zijbalk\",\n        \"sidePlayQueueStyle_description\": \"de stijl van de wachtrij aan de zijkant\",\n        \"sidePlayQueueStyle_optionAttached\": \"aangekoppeld\",\n        \"sidePlayQueueStyle_optionDetached\": \"afgekoppeld\",\n        \"homeFeatureStyle_description\": \"bepaalt de stijl van de uitgelicht-carrousel op de homepagina\",\n        \"homeFeatureStyle\": \"stijl uitgelicht-carrousel\",\n        \"homeFeatureStyle_optionMultiple\": \"meervoudig\",\n        \"homeFeatureStyle_optionSingle\": \"enkelvoudig\",\n        \"blurExplicitImages\": \"vervaag expliciete afbeeldingen\",\n        \"blurExplicitImages_description\": \"hoezen van albums en nummers die getagd zijn als expliciet zullen worden vervaagd\",\n        \"mediaSession\": \"mediasessie inschakelen\",\n        \"sidePlayQueueStyle\": \"stijl van zijwachtrij\",\n        \"skipDuration_description\": \"de tijdsduur die wordt doorgespoeld bij gebruik van de spoelknoppen in de afspeelbalk\",\n        \"skipDuration\": \"doorspoelduur\",\n        \"startMinimized_description\": \"start de applicatie in het systeemvak\",\n        \"startMinimized\": \"start geminimaliseerd\",\n        \"theme\": \"thema\",\n        \"theme_description\": \"het visuele thema dat de applicatie gebruikt\",\n        \"themeDark_description\": \"het donkere thema dat de applicatie gebruikt\",\n        \"themeDark\": \"thema (donker)\",\n        \"themeLight_description\": \"het lichte thema dat de applicatie gebruikt\",\n        \"themeLight\": \"thema (licht)\",\n        \"transcode\": \"transcoderen inschakelen\",\n        \"transcode_description\": \"schakel transcoderen naar andere formaten in\",\n        \"transcodeBitrate_description\": \"de bitsnelheid waarnaar wordt getranscodeerd. bij 0 bepaalt de server de waarde\",\n        \"transcodeBitrate\": \"transcodeerbitsnelheid\",\n        \"transcodeFormat_description\": \"het formaat waarnaar wordt getranscodeerd. laat leeg om de server te laten bepalen\",\n        \"transcodeFormat\": \"transcodeerformaat\",\n        \"translationApiKey_description\": \"api-sleutel voor vertaling (enkel globaal service-eindpunt)\",\n        \"translationApiKey\": \"vertalings-api-sleutel\",\n        \"translationApiProvider_description\": \"api-provider voor vertalingen\",\n        \"translationApiProvider\": \"vertalings-api-provider\",\n        \"translationTargetLanguage_description\": \"doeltaal voor vertalingen\",\n        \"translationTargetLanguage\": \"doeltaal vertaling\",\n        \"trayEnabled_description\": \"toon/verstop het systeemvakicoon/-menu. indien uitgeschakeld wordt het minimaliseren/sluiten naar het systeemvak ook uitgeschakeld\",\n        \"trayEnabled\": \"toon systeemvak\",\n        \"useSystemTheme_description\": \"volg de systeemvoorkeur voor licht of donker thema\",\n        \"useSystemTheme\": \"gebruik systeemthema\",\n        \"volumeWheelStep_description\": \"de hoeveelheid volume die gewijzigd wordt bij het scrollen met het muiswiel op de volumebalk\",\n        \"volumeWheelStep\": \"volumestap muiswiel\",\n        \"volumeWidth_description\": \"de breedte van de volumebalk\",\n        \"volumeWidth\": \"volumebalkbreedte\",\n        \"webAudio_description\": \"gebruik web-audio. dit schakeld geavanceerde mogelijkheden als replaygain in. schakel uit als dit niet werkt\",\n        \"webAudio\": \"gebruik web-audio\",\n        \"windowBarStyle\": \"vensterbalkstijl\",\n        \"windowBarStyle_description\": \"kies de stijl van de vensterbalk\",\n        \"zoom\": \"zoompercentage\",\n        \"zoom_description\": \"het zoompercentage van de applicatie\",\n        \"queryBuilder\": \"opdrachtbouwer\",\n        \"queryBuilderCustomFields\": \"aangepaste velden\",\n        \"queryBuilderCustomFields_description\": \"voeg aangepaste velden voor gebruik in opdrachtbouwers toe\",\n        \"enableGridMultiSelect\": \"meervoudig selecteren in grid\",\n        \"enableGridMultiSelect_description\": \"staat toe meerdere items in gridweergaven te selecteren. indien uitgeschakeld zal het klikken op een item in een gridweergave naar diens pagina navigeren\",\n        \"sidebarPlaylistSorting\": \"afspeellijstsortering in zijbalk\",\n        \"sidebarPlaylistSorting_description\": \"activeert het handmatig sorteren van de afspeellijst in de zijbalk door middel van slepen in plaats van het gebruiken van de servervolgorde\",\n        \"sidebarPlaylistListFilterRegex_description\": \"verberg afspeellijsten in de zijbalk die overeenkomen met deze reguliere expressie\",\n        \"sidebarPlaylistListFilterRegex_placeholder\": \"bijv. ^Daily Mix.*\",\n        \"sidebarPlaylistListFilterRegex\": \"regex afspeellijstfilter\",\n        \"mediaSession_description\": \"schakelt mediasessie-integratie in, waarbij mediabesturing en metadata in het volumeweergave en het lock-scherm worden weergegeven\",\n        \"skipPlaylistPage\": \"sla afspeellijstpagina over\",\n        \"skipPlaylistPage_description\": \"ga naar de nummerlijst in plaats van de standaard pagina bij het navigeren naar een afspeellijst\",\n        \"queryBuilderCustomFields_inputLabel\": \"label\",\n        \"queryBuilderCustomFields_inputTag\": \"tag\",\n        \"playButtonBehavior_optionAddLast\": \"$t(player.addLast)\",\n        \"playButtonBehavior_optionAddNext\": \"$t(player.addNext)\",\n        \"playButtonBehavior_optionPlay\": \"$t(player.play)\",\n        \"playButtonBehavior_optionPlayShuffled\": \"$t(player.shuffle)\",\n        \"replayGainMode_optionAlbum\": \"$t(entity.album, {\\\"count\\\": 1})\",\n        \"replayGainMode_optionNone\": \"$t(common.none)\",\n        \"replayGainMode_optionTrack\": \"$t(entity.track, {\\\"count\\\": 1})\",\n        \"analyticsEnable\": \"Verstuur gebruiksstatistieken\",\n        \"analyticsEnable_description\": \"Geanonimiseerde gebruiksgegevens worden naar de ontwikkelaar gestuurd om te helpen de applicatie te verbeteren\",\n        \"automaticUpdates\": \"Automatisch bijwerken\",\n        \"automaticUpdates_description\": \"Zoek en installeer updates automatisch\",\n        \"releaseChannel_optionAlpha\": \"alfa (nachtelijk)\",\n        \"discordStateIcon\": \"toon afspeelicoon\",\n        \"discordStateIcon_description\": \"toon een klein afspeelicoon in de rich-presence-status. het gepauzeerde icoon wordt altijd getoond als \\\"rich presence tonen wanneer gepauzeerd\\\" is ingeschakeld\"\n    },\n    \"form\": {\n        \"addServer\": {\n            \"title\": \"server toevoegen\",\n            \"input_username\": \"gebruikersnaam\",\n            \"input_url\": \"url\",\n            \"input_password\": \"wachtwoord\",\n            \"input_legacyAuthentication\": \"activeer legacy authenticatie\",\n            \"input_name\": \"server naam\",\n            \"success\": \"server met succes toegevoegd\",\n            \"input_savePassword\": \"wachtwoord opslaan\",\n            \"ignoreSsl\": \"negeer ssl $t(common.restartRequired)\",\n            \"ignoreCors\": \"negeer cors $t(common.restartRequired)\",\n            \"error_savePassword\": \"er is iets mis gegaan met het opslaan van het wachtwoord\",\n            \"input_preferInstantMix\": \"verkies directe mix\",\n            \"input_preferInstantMixDescription\": \"gebruik alleen instant mix om vergelijkbare nummer te krijgen. handig wanneer je plugins hebt die dit gedrag aanpassen\",\n            \"input_preferRemoteUrl\": \"geef voorkeur aan openbare url\",\n            \"input_remoteUrl\": \"publieke url\",\n            \"input_remoteUrlPlaceholder\": \"optioneel: publieke url voor externe mogelijkheden\"\n        },\n        \"deletePlaylist\": {\n            \"title\": \"verwijder $t(entity.playlist, {\\\"count\\\": 1})\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) succesvol verwijdert\",\n            \"input_confirm\": \"Typ de naam van $t(entity.playlist, {\\\"count\\\": 1}) om te bevestigen\"\n        },\n        \"createPlaylist\": {\n            \"input_description\": \"$t(common.description)\",\n            \"title\": \"$t(entity.playlist, {\\\"count\\\": 1}) aanmaken\",\n            \"input_public\": \"publiek\",\n            \"input_name\": \"$t(common.name)\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) aangemaakt\",\n            \"input_owner\": \"$t(common.owner)\"\n        },\n        \"addToPlaylist\": {\n            \"success\": \"$t(entity.trackWithCount, {\\\"count\\\": {{message}} }) aan $t(entity.trackWithCount, {\\\"count\\\": {{message}} }) toegevoegd\",\n            \"title\": \"aan $t(entity.playlist, {\\\"count\\\": 1}) toevoegen\",\n            \"input_skipDuplicates\": \"duplicaten overslaan\",\n            \"input_playlists\": \"$t(entity.playlist, {\\\"count\\\": 2})\",\n            \"create\": \"maak $t(entity.playlist, {\\\"count\\\": 1}) {{playlist}}\",\n            \"searchOrCreate\": \"zoek $t(entity.playlist, {\\\"count\\\": 2}) of typ om een nieuwe te maken\"\n        },\n        \"queryEditor\": {\n            \"input_optionMatchAll\": \"alles matchen\",\n            \"input_optionMatchAny\": \"elke match\",\n            \"title\": \"zoekopdrachtbewerker\",\n            \"addRuleGroup\": \"rolgroep toevoegen\",\n            \"removeRuleGroup\": \"rolgroep verwijderen\",\n            \"resetToDefault\": \"terugzetten naar standaard\",\n            \"clearFilters\": \"filters wissen\"\n        },\n        \"lyricSearch\": {\n            \"input_name\": \"$t(common.name)\",\n            \"input_artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n            \"title\": \"tekst zoeken\"\n        },\n        \"editPlaylist\": {\n            \"title\": \"$t(entity.playlist, {\\\"count\\\": 1}) aanpassen\",\n            \"publicJellyfinNote\": \"Jellyfin laat niet weten of een playlist publiek of privaat is. Als u wilt dat dit publiek blijft, selecteer de volgende invoer\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) succesvol geüpdatet\",\n            \"editNote\": \"Handmatige bewerking wordt afgeraden voor grote afspeellijsten. Weet je zeker dat je het risico op dataverlies wilt accepteren door de bestaande afspeellijst te overschrijven?\"\n        },\n        \"updateServer\": {\n            \"title\": \"update server\",\n            \"success\": \"server succesvol geüpdatet\"\n        },\n        \"shareItem\": {\n            \"allowDownloading\": \"sta downloaden toe\",\n            \"description\": \"beschrijving\",\n            \"setExpiration\": \"stel vervaldatum in\",\n            \"success\": \"deel link gekopiëerd naar klembord (of klik hier om te openen)\",\n            \"expireInvalid\": \"vervaldatum moet in de toekomst zijn\",\n            \"createFailed\": \"kon share niet aanmaken (is delen aangezet?)\",\n            \"copyToClipboard\": \"Kopieer naar klembord: Ctrl+C, Enter\",\n            \"successMustClick\": \"gedeelde bron succesvol aangemaakt. klik hier om te openen\"\n        },\n        \"privateMode\": {\n            \"enabled\": \"private modus ingeschakeld, afspeelstatus is nu verborgen voor externe integraties\",\n            \"disabled\": \"private modus uitgeschakeld, afspeelstatus is nu zichtbaar voor externe integraties\",\n            \"title\": \"private modus\"\n        },\n        \"largeFetchConfirmation\": {\n            \"title\": \"items toevoegen aan de wachtrij\",\n            \"description\": \"Deze actie voegt alle items in de huidige gefilterde weergave toe\"\n        },\n        \"shuffleAll\": {\n            \"title\": \"willekeurig afspelen\",\n            \"input_genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n            \"input_limit\": \"hoeveel nummers?\",\n            \"input_minYear\": \"van jaar\",\n            \"input_maxYear\": \"naar jaar\",\n            \"input_played\": \"speel filter\",\n            \"input_played_optionAll\": \"alle nummers\",\n            \"input_played_optionUnplayed\": \"alleen ongespeelde nummers\",\n            \"input_played_optionPlayed\": \"alleen gespeelde nummers\"\n        },\n        \"createRadioStation\": {\n            \"success\": \"radiostation succesvol aangemaakt\",\n            \"title\": \"radiostation aanmaken\",\n            \"input_homepageUrl\": \"thuispagina-url\",\n            \"input_name\": \"naam\",\n            \"input_streamUrl\": \"stream-url\"\n        },\n        \"lyricsExport\": {\n            \"export\": \"exporteer songtekst\",\n            \"input_synced\": \"exporteer gesynchroniseerde songtekst\",\n            \"input_offset\": \"$t(setting.lyricOffset)\"\n        },\n        \"saveQueue\": {\n            \"success\": \"wachtrij opgeslagen op server\"\n        }\n    },\n    \"player\": {\n        \"addLast\": \"achteraan\",\n        \"addNext\": \"volgende\",\n        \"addLastShuffled\": \"als laatste toevoegen (willekeurig)\",\n        \"addNextShuffled\": \"als volgende toevoegen (willekeurig)\",\n        \"favorite\": \"favoriet\",\n        \"mute\": \"dempen\",\n        \"muted\": \"gedempt\",\n        \"next\": \"volgende\",\n        \"play\": \"afspelen\",\n        \"playbackFetchCancel\": \"dit duurt even... sluit de notificatie om te annuleren\",\n        \"playbackFetchInProgress\": \"nummers laden…\",\n        \"playbackFetchNoResults\": \"geen nummers gevonden\",\n        \"playbackSpeed\": \"weergavesnelheid\",\n        \"playRandom\": \"willekeurig afspelen\",\n        \"playSimilarSongs\": \"vergelijkbare nummers afspelen\",\n        \"previous\": \"vorige\",\n        \"queue_clear\": \"wachtrij wissen\",\n        \"queue_moveToBottom\": \"verplaats geselecteerde naar boven\",\n        \"queue_moveToTop\": \"verplaats geselecteerde naar beneden\",\n        \"artistRadio\": \"artiestenradio\",\n        \"holdToShuffle\": \"vasthouden om willekeurig af te spelen\",\n        \"lyrics\": \"songtekst\",\n        \"queue_remove\": \"verwijder geselecteerde\",\n        \"repeat\": \"herhalen\",\n        \"repeat_all\": \"alles herhalen\",\n        \"repeat_off\": \"herhalen uitgeschakeld\",\n        \"restoreQueueFromServer\": \"herstel wachtrij van server\",\n        \"saveQueueToServer\": \"sla wachtrij op server op\",\n        \"shuffle\": \"afspelen (willekeurig)\",\n        \"shuffle_off\": \"willekeurig afspelen uitgeschakeld\",\n        \"skip\": \"overslaan\",\n        \"skip_back\": \"spring terug\",\n        \"skip_forward\": \"spring vooruit\",\n        \"stop\": \"stoppen\",\n        \"toggleFullscreenPlayer\": \"schakel speler in volledig scherm\",\n        \"trackRadio\": \"nummerradio\",\n        \"unfavorite\": \"verwijder favoriet\",\n        \"pause\": \"pauzeren\",\n        \"viewQueue\": \"toon wachtrij\",\n        \"sleepTimer\": \"slaaptimer\",\n        \"sleepTimer_endOfSong\": \"einde van huidige nummer\",\n        \"sleepTimer_minutes\": \"{{count}} min\",\n        \"sleepTimer_hours\": \"{{count}} uur\",\n        \"sleepTimer_custom\": \"afwijkend\",\n        \"sleepTimer_off\": \"uit\",\n        \"sleepTimer_timeRemaining\": \"{{time}} resterend\",\n        \"sleepTimer_setCustom\": \"timer instellen\",\n        \"sleepTimer_cancel\": \"timer annuleren\"\n    },\n    \"datetime\": {\n        \"minuteShort\": \"m\",\n        \"secondShort\": \"s\",\n        \"hourShort\": \"h\",\n        \"dayShort\": \"d\"\n    },\n    \"filterOperator\": {\n        \"afterDate\": \"is na (datum)\",\n        \"before\": \"is voor\",\n        \"beforeDate\": \"is vóór (datum)\",\n        \"contains\": \"bevat\",\n        \"endsWith\": \"eindigt met\",\n        \"inPlaylist\": \"is in\",\n        \"inTheLast\": \"is in de laatste\",\n        \"inTheRange\": \"ligt binnen het bereik\",\n        \"inTheRangeDate\": \"ligt binnen het bereik (datum)\",\n        \"is\": \"is\",\n        \"isNot\": \"is niet\",\n        \"isGreaterThan\": \"is groter dan\",\n        \"isLessThan\": \"is minder dan\",\n        \"matchesRegex\": \"komt overeen met regex\",\n        \"notContains\": \"bevat geen\",\n        \"notInPlaylist\": \"is niet in\",\n        \"notInTheLast\": \"is niet in de laatste\",\n        \"startsWith\": \"begint met\",\n        \"after\": \"is na\"\n    },\n    \"queryBuilder\": {\n        \"standardTags\": \"standaard tags\",\n        \"customTags\": \"aangepaste tags\"\n    },\n    \"releaseType\": {\n        \"primary\": {\n            \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n            \"broadcast\": \"uitzending\",\n            \"ep\": \"ep\",\n            \"other\": \"overig\",\n            \"single\": \"single\"\n        },\n        \"secondary\": {\n            \"audiobook\": \"luisterboek\",\n            \"audioDrama\": \"luisterdrama\",\n            \"compilation\": \"compilatie\",\n            \"djMix\": \"dj-mix\",\n            \"demo\": \"demo\",\n            \"fieldRecording\": \"veldopname\",\n            \"interview\": \"interview\",\n            \"live\": \"live\",\n            \"mixtape\": \"mixtape\",\n            \"remix\": \"remix\",\n            \"soundtrack\": \"soundtrack\",\n            \"spokenWord\": \"gesproken woord\"\n        }\n    },\n    \"dragDropZone\": {\n        \"error_oneFileOnly\": \"Kies één bestand\",\n        \"error_readingFile\": \"probleem opgetreden bij het lezen van het bestand: {{errorMessage}}\",\n        \"mainText\": \"sleep hier een bestand naartoe\"\n    },\n    \"visualizer\": {\n        \"visualizerType\": \"Type Visualiseerder\",\n        \"cyclePresets\": \"Doorloop Voorinstellingen\",\n        \"cycleTime\": \"Cyclustijd (seconden)\",\n        \"ignoredPresets\": \"Genegeerde Voorinstellingen\",\n        \"selectedPresets\": \"Gekozen Voorinstellingen\",\n        \"includeAllPresets\": \"Alle Voorinstellingen Opnemen\",\n        \"randomizeNextPreset\": \"Willekeurige Volgende Voorinstelling\",\n        \"blendTime\": \"Mengtijd\",\n        \"presets\": \"Voorinstellingen\",\n        \"selectPreset\": \"Kies Voorinstelling\",\n        \"applyPreset\": \"Voorinstelling Toepassen\",\n        \"saveAsPreset\": \"Opslaan als Voorinstelling\",\n        \"updatePreset\": \"Voorinstelling Bijwerken\",\n        \"copyConfiguration\": \"Kopieer Configuratie\",\n        \"pasteConfiguration\": \"Plak Configuratie\",\n        \"pasteConfigurationPlaceholder\": \"Plak JSON-configuratie hier...\",\n        \"pasteFromClipboard\": \"Plakken vanaf Klembord\",\n        \"applyConfiguration\": \"Configuratie Toepassen\",\n        \"configCopied\": \"Configuratie gekopieerd naar het klembord\",\n        \"configCopyFailed\": \"Kopiëren van configuratie is mislukt\",\n        \"configPasted\": \"Configuratie succesvol toegepast\",\n        \"configPasteFailed\": \"Toepassen configuratie mislukt. Controleer het formaat.\",\n        \"configPasteReadFailed\": \"Lezen van het klembord mislukt\",\n        \"presetName\": \"Naam Voorinstelling\",\n        \"presetNamePlaceholder\": \"Voer de naam van de voorinstelling in\",\n        \"general\": \"Algemeen\",\n        \"mode\": \"Modus\",\n        \"mode1To8\": \"Modus 1-8\",\n        \"mode10\": \"Modus 10\",\n        \"barSpace\": \"Balkruimte\",\n        \"lineWidth\": \"Lijnbreedte\",\n        \"fillAlpha\": \"Alfavulling\",\n        \"channelLayout\": \"Kanaalindeling\",\n        \"maxFPS\": \"Max FPS\",\n        \"opacity\": \"Opaciteit\",\n        \"customGradients\": \"Aangepaste Kleurverlopen\",\n        \"addCustomGradient\": \"Voeg Aangepast Kleurverloop Toe\",\n        \"gradientName\": \"Naam Kleurverloop\",\n        \"gradientNamePlaceholder\": \"Naam Kleurverloop\",\n        \"vertical\": \"Verticaal\",\n        \"horizontal\": \"Horizontaal\",\n        \"colorStops\": \"Kleurstop\",\n        \"addColor\": \"Voeg Kleur Toe\",\n        \"position\": \"Positie\",\n        \"level\": \"Niveau\",\n        \"remove\": \"Verwijder\",\n        \"pasteGradient\": \"Plak Kleurverloop\",\n        \"pasteGradientPlaceholder\": \"Plak JSON van kleurverloop hier...\",\n        \"custom\": \"Aangepast\",\n        \"builtIn\": \"Ingebouwd\",\n        \"colors\": \"Kleuren\",\n        \"colorMode\": \"Kleurmodus\",\n        \"gradient\": \"Kleurverloop\",\n        \"gradientLeft\": \"Kleurverloop Links\",\n        \"gradientRight\": \"Kleurverloop Rechts\",\n        \"fft\": \"FFT\",\n        \"fftSize\": \"FFT-grootte\",\n        \"smoothing\": \"Gladstrijken\",\n        \"frequencyRangeAndScaling\": \"Frequentiebereik en -schaling\",\n        \"minimumFrequency\": \"Minimumfrequentie\",\n        \"maximumFrequency\": \"Maximumfrequentie\",\n        \"frequencyScale\": \"Frequentieschaal\",\n        \"sensitivity\": \"Gevoeligheid\",\n        \"weightingFilter\": \"Gewichtsfilter\",\n        \"minimumDecibels\": \"Minimum aantal decibel\",\n        \"maximumDecibels\": \"Maximum aantal decibel\",\n        \"linearAmplitude\": \"Lineaire Amplitude\",\n        \"linearBoost\": \"Lineaire Versterking\",\n        \"peakBehavior\": \"Piekgedrag\",\n        \"showPeaks\": \"Toon Pieken\",\n        \"fadePeaks\": \"Vervaag Pieken\",\n        \"peakLine\": \"Pieklijn\",\n        \"gravity\": \"Zwaartekracht\",\n        \"peakFadeTime\": \"Piekvervagingstijd (ms)\",\n        \"peakHoldTime\": \"Piekvasthoudtijd (ms)\",\n        \"radialSpectrum\": \"Radiaal Spectrum\",\n        \"radial\": \"Radiaal\",\n        \"radialInvert\": \"Geïnverteerde Radiaal\",\n        \"spinSpeed\": \"Draaisnelheid\",\n        \"radius\": \"Radius\",\n        \"reflexMirror\": \"Reflexspiegel\",\n        \"reflexRatio\": \"Reflexverhouding\",\n        \"reflexAlpha\": \"Reflex-alfa\",\n        \"reflexFit\": \"Reflex-inpassing\",\n        \"reflexBrightness\": \"Reflex-helderheid\",\n        \"mirror\": \"Spiegel\",\n        \"miscellaneousSettings\": \"Diverse Instellingen\",\n        \"alphaBars\": \"Alfabalken\",\n        \"ansiBands\": \"ANSI-banden\",\n        \"ledBars\": \"LED-balken\",\n        \"trueLeds\": \"Ware LEDs\",\n        \"lumiBars\": \"Lumi-balken\",\n        \"outlineBars\": \"Uitgelijnde balken\",\n        \"roundBars\": \"Ronde Balken\",\n        \"lowResolution\": \"Lage Resolutie\",\n        \"splitGradient\": \"Gescheiden Kleurverloop\",\n        \"showFPS\": \"Toon FPS\",\n        \"showScaleX\": \"Toon X-schaal\",\n        \"showScaleY\": \"Toon Y-schaal\",\n        \"noteLabels\": \"Nootlabels\",\n        \"options\": {\n            \"mode\": {\n                \"0\": \"[0] Discrete Frequenties\",\n                \"1\": \"[1] 1/24e octaaf / 240 bands\",\n                \"2\": \"[2] 1/12e octaaf / 120 bands\",\n                \"3\": \"[3] 1/8e octaaf / 80 bands\",\n                \"4\": \"[4] 1/6e octaaf / 60 bands\",\n                \"5\": \"[5] 1/4e octaaf / 40 bands\",\n                \"6\": \"[6] 1/3e octaaf / 30 bands\",\n                \"7\": \"[7] Half octaaf / 20 bands\",\n                \"8\": \"[8] Volledig octaaf / 10 bands\",\n                \"10\": \"[10] Lijn- / Gebiedsgrafiek\"\n            },\n            \"colorMode\": {\n                \"gradient\": \"Kleurverloop\",\n                \"barIndex\": \"Balk-index\",\n                \"barLevel\": \"Balkniveau\"\n            },\n            \"gradient\": {\n                \"classic\": \"Klassiek\",\n                \"prism\": \"Prisma\",\n                \"rainbow\": \"Regenboog\",\n                \"steelblue\": \"Staalblauw\",\n                \"orangered\": \"Oranjerood\"\n            },\n            \"channelLayout\": {\n                \"single\": \"Enkelvoudig\",\n                \"dualCombined\": \"Duaalgecombineerd\",\n                \"dualHorizontal\": \"Duaalhorizontaal\",\n                \"dualVertical\": \"Duaalvertikaal\"\n            },\n            \"frequencyScale\": {\n                \"none\": \"Geen\",\n                \"bark\": \"Bark-schaal\",\n                \"linear\": \"Lineaire Schaal\",\n                \"log\": \"Log-schaal\",\n                \"mel\": \"Mel-schaal\"\n            },\n            \"weightingFilter\": {\n                \"none\": \"Geen\",\n                \"a\": \"A\",\n                \"b\": \"B\",\n                \"c\": \"C\",\n                \"d\": \"D\",\n                \"z\": \"Z\"\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "src/i18n/locales/pl.json",
    "content": "{\n    \"action\": {\n        \"editPlaylist\": \"edytuj $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"goToPage\": \"idź do strony\",\n        \"clearQueue\": \"wyczyść kolejkę\",\n        \"addToFavorites\": \"dodaj do $t(entity.favorite, {\\\"count\\\": 2})\",\n        \"removeFromPlaylist\": \"usuń z $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"viewPlaylists\": \"zobacz $t(entity.playlist, {\\\"count\\\": 2})\",\n        \"refresh\": \"$t(common.refresh)\",\n        \"removeFromQueue\": \"usuń z kolejki\",\n        \"deselectAll\": \"odznacz wszystko\",\n        \"toggleSmartPlaylistEditor\": \"przełącz edytor $t(entity.smartPlaylist)\",\n        \"removeFromFavorites\": \"usuń z $t(entity.favorite, {\\\"count\\\": 2})\",\n        \"moveToTop\": \"przesuń na górę\",\n        \"addToPlaylist\": \"dodaj do $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"createPlaylist\": \"utwórz $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"deletePlaylist\": \"usuń $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"moveToBottom\": \"przesuń na dół\",\n        \"setRating\": \"oceń\",\n        \"openIn\": {\n            \"lastfm\": \"Otwórz w Last.fm\",\n            \"musicbrainz\": \"Otwórz w MusicBrainz\"\n        },\n        \"moveToNext\": \"przesuń na następne\",\n        \"downloadStarted\": \"rozpoczęto pobieranie {{count}} elementów\",\n        \"moveItems\": \"przenieś elementy\",\n        \"shuffle\": \"odtwarzaj losowo\",\n        \"shuffleAll\": \"odtwarzaj wszystkie losowo\",\n        \"shuffleSelected\": \"odtwarzaj losowo wybrane\",\n        \"viewMore\": \"wyświetl więcej\",\n        \"moveUp\": \"przenieś wyżej\",\n        \"moveDown\": \"przenieś niżej\",\n        \"holdToMoveToTop\": \"przytrzymaj aby, przesunąć na górę\",\n        \"holdToMoveToBottom\": \"przytrzymaj aby, przesunąć na dół\",\n        \"createRadioStation\": \"utwórz $t(entity.radioStation, {\\\"count\\\": 1})\",\n        \"deleteRadioStation\": \"usuń $t(entity.radioStation, {\\\"count\\\": 1})\",\n        \"addOrRemoveFromSelection\": \"dodaj lub usuń z wyboru\",\n        \"selectRangeOfItems\": \"wybierz zakres elementów\",\n        \"selectAll\": \"wybierz wszystkie\",\n        \"openApplicationDirectory\": \"otwórz katalog aplikacji\",\n        \"goToCurrent\": \"przejdź do aktualnego elementu\"\n    },\n    \"common\": {\n        \"increase\": \"zwiększ\",\n        \"rating\": \"ocena\",\n        \"bpm\": \"bpm\",\n        \"refresh\": \"odśwież\",\n        \"unknown\": \"nieznany\",\n        \"areYouSure\": \"czy jesteś pewien?\",\n        \"edit\": \"edytuj\",\n        \"favorite\": \"ulubiony\",\n        \"save\": \"zapisz\",\n        \"right\": \"prawo\",\n        \"trackNumber\": \"utwór\",\n        \"descending\": \"malejąco\",\n        \"add\": \"dodaj\",\n        \"ascending\": \"rosnąco\",\n        \"dismiss\": \"odrzuć\",\n        \"year\": \"rok\",\n        \"limit\": \"limit\",\n        \"minimize\": \"zminimalizuj\",\n        \"modified\": \"zmodyfikowany\",\n        \"duration\": \"długość\",\n        \"name\": \"nazwa\",\n        \"maximize\": \"zmaksymalizuj\",\n        \"ok\": \"ok\",\n        \"description\": \"opis\",\n        \"configure\": \"konfiguruj\",\n        \"no\": \"nie\",\n        \"owner\": \"właściciel\",\n        \"enable\": \"włącz\",\n        \"clear\": \"wyczyść\",\n        \"forward\": \"do przodu\",\n        \"delete\": \"usuń\",\n        \"cancel\": \"anuluj\",\n        \"forceRestartRequired\": \"zrestartuj aby zastosować zmiany... zamknij powiadomienie aby zrestartować\",\n        \"setting_one\": \"ustawienie\",\n        \"setting_few\": \"ustawienia\",\n        \"setting_many\": \"ustawień\",\n        \"version\": \"wersja\",\n        \"title\": \"tytuł\",\n        \"filter_one\": \"filtr\",\n        \"filter_few\": \"filtry\",\n        \"filter_many\": \"filtrów\",\n        \"filters\": \"filtry\",\n        \"create\": \"stwórz\",\n        \"bitrate\": \"bitrate\",\n        \"saveAndReplace\": \"zapisz i zamień\",\n        \"action_one\": \"akcja\",\n        \"action_few\": \"akcje\",\n        \"action_many\": \"akcji\",\n        \"playerMustBePaused\": \"odtwarzacz musi być zapauzowany\",\n        \"confirm\": \"potwierdź\",\n        \"resetToDefault\": \"przywróć do domyślnych\",\n        \"home\": \"główna\",\n        \"comingSoon\": \"już wkrótce…\",\n        \"reset\": \"zresetuj\",\n        \"channel_one\": \"kanał\",\n        \"channel_few\": \"kanałów\",\n        \"channel_many\": \"kanałów\",\n        \"disable\": \"wyłącz\",\n        \"sortOrder\": \"kolejność\",\n        \"none\": \"żaden\",\n        \"menu\": \"menu\",\n        \"restartRequired\": \"wymagany restart\",\n        \"previousSong\": \"poprzedni $t(entity.track, {\\\"count\\\": 1})\",\n        \"noResultsFromQuery\": \"kolejka zwróciła brak wyników\",\n        \"quit\": \"wyjdź\",\n        \"expand\": \"rozszerz\",\n        \"search\": \"szukaj\",\n        \"saveAs\": \"zapisz jako\",\n        \"disc\": \"płyta\",\n        \"yes\": \"tak\",\n        \"random\": \"losowy\",\n        \"size\": \"wielkość\",\n        \"biography\": \"biografia\",\n        \"backward\": \"wstecz\",\n        \"left\": \"lewo\",\n        \"currentSong\": \"obecnie $t(entity.track, {\\\"count\\\": 1})\",\n        \"collapse\": \"zwiń\",\n        \"gap\": \"luka\",\n        \"manage\": \"zarządzaj\",\n        \"decrease\": \"obniż\",\n        \"path\": \"ścieżka\",\n        \"center\": \"środkowy\",\n        \"note\": \"notatka\",\n        \"albumPeak\": \"spadek albumu\",\n        \"albumGain\": \"wzrost albumu\",\n        \"mbid\": \"ID MusicBrainz\",\n        \"reload\": \"przeładuj\",\n        \"share\": \"udostępnij\",\n        \"trackGain\": \"gain utworu\",\n        \"trackPeak\": \"peak utworu\",\n        \"codec\": \"kodek\",\n        \"preview\": \"podgląd\",\n        \"close\": \"zamknij\",\n        \"translation\": \"tłumaczenie\",\n        \"additionalParticipants\": \"dodatkowi uczestnicy\",\n        \"newVersion\": \"nowa wersja została zaintalowana ({{version}})\",\n        \"viewReleaseNotes\": \"zobacz notatki dotyczące wydania\",\n        \"bitDepth\": \"głębia bitowa\",\n        \"sampleRate\": \"częstotliwość próbkowania\",\n        \"tags\": \"tagi\",\n        \"explicitStatus\": \"status explicit\",\n        \"doNotShowAgain\": \"nie pokazuj tego ponownie\",\n        \"externalLinks\": \"linki zewnętrzne\",\n        \"faster\": \"szybciej\",\n        \"private\": \"prywatne\",\n        \"public\": \"publiczne\",\n        \"recordLabel\": \"wytwórnia\",\n        \"releaseType\": \"typ wydania\",\n        \"slower\": \"wolniej\",\n        \"sort\": \"sortuj\",\n        \"explicit\": \"explicit\",\n        \"clean\": \"czyste\",\n        \"gridRows\": \"siatka wierszy\",\n        \"tableColumns\": \"tabela kolumn\",\n        \"itemsMore\": \"{{count}} więcej\",\n        \"noFilters\": \"nie skonfigurowano filtrów\",\n        \"view\": \"wyświetl\",\n        \"countSelected\": \"wybrano {{count}}\",\n        \"retry\": \"spróbuj ponownie\",\n        \"mood\": \"nastrój\",\n        \"example\": \"przykład\",\n        \"filter_multiple\": \"multi\",\n        \"filter_single\": \"single\",\n        \"rename\": \"zmień nazwę\",\n        \"newVersionAvailable\": \"nowa wersja jest dostępna\"\n    },\n    \"entity\": {\n        \"genre_one\": \"gatunek\",\n        \"genre_few\": \"gatunki\",\n        \"genre_many\": \"gatunków\",\n        \"playlistWithCount_one\": \"{{count}} playlista\",\n        \"playlistWithCount_few\": \"{{count}} playlisty\",\n        \"playlistWithCount_many\": \"{{count}} playlist\",\n        \"playlist_one\": \"playlista\",\n        \"playlist_few\": \"playlisty\",\n        \"playlist_many\": \"playlist\",\n        \"artist_one\": \"wykonawca\",\n        \"artist_few\": \"wykonawcy\",\n        \"artist_many\": \"wykonawców\",\n        \"folderWithCount_one\": \"{{count}} katalog\",\n        \"folderWithCount_few\": \"{{count}} katalogi\",\n        \"folderWithCount_many\": \"{{count}} katalogów\",\n        \"albumArtist_one\": \"wykonawca albumu\",\n        \"albumArtist_few\": \"wykonawcy albumu\",\n        \"albumArtist_many\": \"wykonawcy albumów\",\n        \"track_one\": \"utwór\",\n        \"track_few\": \"utwory\",\n        \"track_many\": \"utworów\",\n        \"albumArtistCount_one\": \"{{count}} wykonawca albumu\",\n        \"albumArtistCount_few\": \"{{count}} wykonawców albumu\",\n        \"albumArtistCount_many\": \"{{count}} wykonawców albumu\",\n        \"albumWithCount_one\": \"{{count}} album\",\n        \"albumWithCount_few\": \"{{count}} albumy\",\n        \"albumWithCount_many\": \"{{count}} albumów\",\n        \"favorite_one\": \"ulubiony\",\n        \"favorite_few\": \"ulubione\",\n        \"favorite_many\": \"ulubionych\",\n        \"artistWithCount_one\": \"{{count}} wykonawca\",\n        \"artistWithCount_few\": \"{{count}} wykonawców\",\n        \"artistWithCount_many\": \"{{count}} wykonawców\",\n        \"folder_one\": \"katalog\",\n        \"folder_few\": \"katalogi\",\n        \"folder_many\": \"katalogów\",\n        \"smartPlaylist\": \"inteligentna $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"album_one\": \"album\",\n        \"album_few\": \"albumy\",\n        \"album_many\": \"albumów\",\n        \"genreWithCount_one\": \"{{count}} gatunek\",\n        \"genreWithCount_few\": \"{{count}} gatunki\",\n        \"genreWithCount_many\": \"{{count}} gatunków\",\n        \"trackWithCount_one\": \"{{count}} utwór\",\n        \"trackWithCount_few\": \"{{count}} utwory\",\n        \"trackWithCount_many\": \"{{count}} utworów\",\n        \"play_one\": \"{{count}} odtworzenie\",\n        \"play_few\": \"{{count}} odtworzenia\",\n        \"play_many\": \"{{count}} odtworzeń\",\n        \"song_one\": \"piosenka\",\n        \"song_few\": \"piosenki\",\n        \"song_many\": \"­piosenek\",\n        \"radioStation_one\": \"stacja radiowa\",\n        \"radioStation_few\": \"stacje radiowe\",\n        \"radioStation_many\": \"stacji radiowych\",\n        \"radioStationWithCount_one\": \"{{count}} stacja radiowa\",\n        \"radioStationWithCount_few\": \"{{count}} stacje radiowych\",\n        \"radioStationWithCount_many\": \"{{count}} stacji radiowych\"\n    },\n    \"error\": {\n        \"remotePortWarning\": \"uruchom ponownie serwer aby używać nowego portu\",\n        \"systemFontError\": \"wystąpił błąd podczas próby pobrania czcionek systemowych\",\n        \"playbackError\": \"wystąpił błąd podczas próby odtwarzania mediów\",\n        \"endpointNotImplementedError\": \"punkt końcowy {{endpoint}} nie został zaimplementowany dla {{serverType}}\",\n        \"remotePortError\": \"wystąpił problem podczas ustawiania portu dla zdalnego serwera\",\n        \"serverRequired\": \"wymagany serwer\",\n        \"authenticationFailed\": \"uwierzytelnianie nie powiodło się\",\n        \"apiRouteError\": \"nie można wykonać żądania\",\n        \"genericError\": \"wystąpił błąd\",\n        \"credentialsRequired\": \"wymagane poświadczenia\",\n        \"sessionExpiredError\": \"twoja sesja wygasła\",\n        \"remoteEnableError\": \"wystąpił błąd podczas próby $t(common.enable) zdalnego serwera\",\n        \"localFontAccessDenied\": \"dostęp do lokalnych czcionek odrzucony\",\n        \"serverNotSelectedError\": \"nie zaznaczono serwera\",\n        \"remoteDisableError\": \"wystąpił błąd podczas próby $t(common.disable) zdalnego serwera\",\n        \"mpvRequired\": \"wymagane MPV\",\n        \"audioDeviceFetchError\": \"wystąpił błąd podczas próby znalezienia urządzeń dźwiękowych\",\n        \"invalidServer\": \"nieprawidłowy serwer\",\n        \"loginRateError\": \"zbyt dużo prób logowania, poczekaj chwilę i spróbuj ponownie\",\n        \"badAlbum\": \"ta strona jest wyświetlana, ponieważ ten utwór nie jest częścią albumu. najprawdopodobniej ten problem występuje, jeśli utwór znajduje się w nadrzędnym folderze plików z muzyką. Jellyfin grupuje utwory tylko wtedy, gdy znajdują się one w folderze\",\n        \"networkError\": \"wystąpił błąd sieciowy\",\n        \"openError\": \"nie można otworzyć pliku\",\n        \"badValue\": \"niewłaściwa opcja \\\"{{value}}\\\". ta wartość już nie istnieje\",\n        \"notificationDenied\": \"odmówiono uprawnień dla powiadomień. to ustawienie nie będzie miało efektu\",\n        \"multipleServerSaveQueueError\": \"kolejka odtwarzania ma jedną lub więcej piosenek które nie pochodzą z aktualnego serwera. to nie jest wspierane\",\n        \"saveQueueFailed\": \"nie udało się zapisać kolejki\",\n        \"settingsSyncError\": \"zostały znalezione różnice pomiędzy ustawieniami w rendererze a głównym procesem. uruchom aplikację ponownie aby, zastosować zmiany\",\n        \"noNetwork\": \"serwer niedostępny\",\n        \"noNetworkDescription\": \"nie udało się połączyć z tym serwerem\",\n        \"invalidJson\": \"nieprawidłowy JSON\",\n        \"serverLockSingleServer\": \"dozwolony jest tylko jeden serwer gdy serwer jest zablokowany\",\n        \"playbackPausedDueToError\": \"odtwarzanie zostało wstrzymane z powodu błędu\"\n    },\n    \"filter\": {\n        \"mostPlayed\": \"najczęściej odtwarzane\",\n        \"playCount\": \"liczba odtworzeń\",\n        \"isCompilation\": \"jest kompilacją\",\n        \"recentlyPlayed\": \"ostatnio odtwarzane\",\n        \"isRated\": \"jest ocenione\",\n        \"title\": \"tytuł\",\n        \"rating\": \"ocena\",\n        \"search\": \"wyszukaj\",\n        \"bitrate\": \"bitrate\",\n        \"recentlyAdded\": \"ostatnio dodane\",\n        \"note\": \"notatka\",\n        \"name\": \"nazwa\",\n        \"dateAdded\": \"dodano datę\",\n        \"releaseDate\": \"data premiery\",\n        \"communityRating\": \"ocena społeczności\",\n        \"path\": \"ścieżka\",\n        \"favorited\": \"ulubione\",\n        \"albumArtist\": \"$t(entity.albumArtist, {\\\"count\\\": 1})\",\n        \"isRecentlyPlayed\": \"było niedawno odtwarzane\",\n        \"isFavorited\": \"jest ulubione\",\n        \"bpm\": \"bpm\",\n        \"releaseYear\": \"rok wydania\",\n        \"disc\": \"płyta\",\n        \"biography\": \"biografia\",\n        \"songCount\": \"liczba utworów\",\n        \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n        \"duration\": \"długość\",\n        \"random\": \"losowy\",\n        \"lastPlayed\": \"ostatnio odtwarzane\",\n        \"toYear\": \"do roku\",\n        \"fromYear\": \"od roku\",\n        \"criticRating\": \"ocena krytyków\",\n        \"trackNumber\": \"utwór\",\n        \"comment\": \"komentarz\",\n        \"recentlyUpdated\": \"ostatnio aktualizowane\",\n        \"channels\": \"$t(common.channel, {\\\"count\\\": 2})\",\n        \"owner\": \"$t(common.owner)\",\n        \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n        \"albumCount\": \"liczba $t(entity.album, {\\\"count\\\": 2})\",\n        \"id\": \"id\",\n        \"isPublic\": \"jest publiczny\",\n        \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n        \"explicitStatus\": \"$t(common.explicitStatus)\",\n        \"sortName\": \"sortowanie po nazwie\",\n        \"matchAnd\": \"i\",\n        \"matchOr\": \"lub\"\n    },\n    \"form\": {\n        \"deletePlaylist\": {\n            \"title\": \"usuń $t(entity.playlist, {\\\"count\\\": 1})\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) usunięta pomyślnie\",\n            \"input_confirm\": \"wpisz nazwę $t(entity.playlist, {\\\"count\\\": 1}) aby potwierdzić\"\n        },\n        \"createPlaylist\": {\n            \"input_description\": \"$t(common.description)\",\n            \"title\": \"utwórz $t(entity.playlist, {\\\"count\\\": 1})\",\n            \"input_public\": \"publiczny\",\n            \"input_name\": \"$t(common.name)\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) utworzona pomyślnie\",\n            \"input_owner\": \"$t(common.owner)\"\n        },\n        \"addServer\": {\n            \"title\": \"dodaj serwer\",\n            \"input_username\": \"nazwa użytkownika\",\n            \"input_url\": \"adres\",\n            \"input_password\": \"hasło\",\n            \"input_legacyAuthentication\": \"umożliw starsze uwierzytelnianie\",\n            \"input_name\": \"nazwa serwera\",\n            \"success\": \"serwer dodany pomyślnie\",\n            \"input_savePassword\": \"zapisz hasło\",\n            \"ignoreSsl\": \"zignoruj ssl ($t(common.restartRequired))\",\n            \"ignoreCors\": \"zignoruj cors ($t(common.restartRequired))\",\n            \"error_savePassword\": \"wystąpił błąd podczas próby zapisania hasła\",\n            \"input_preferInstantMix\": \"preferuj natychmiastowy mix\",\n            \"input_preferInstantMixDescription\": \"używaj tylko natychmiastowego mixu, by otrzymać podobne piosenki. przydatne gdy masz wtyczki które zmieniają to zachowanie\",\n            \"input_preferRemoteUrl\": \"preferuj publiczny url\",\n            \"input_remoteUrl\": \"publiczny url\",\n            \"input_remoteUrlPlaceholder\": \"opcjonalne: publiczny url dla funkcji zewnętrznych\"\n        },\n        \"addToPlaylist\": {\n            \"success\": \"dodano $t(entity.trackWithCount, {\\\"count\\\": {{message}} }) do $t(entity.playlistWithCount, {\\\"count\\\": {{numOfPlaylists}} })\",\n            \"title\": \"dodano do $t(entity.playlist, {\\\"count\\\": 1})\",\n            \"input_skipDuplicates\": \"pomiń duplikaty\",\n            \"input_playlists\": \"$t(entity.playlist, {\\\"count\\\": 2})\",\n            \"create\": \"utwórz $t(entity.playlist, {\\\"count\\\": 1}) {{playlist}}\",\n            \"searchOrCreate\": \"wyszukaj $t(entity.playlist, {\\\"count\\\": 2}) lub wpisz, aby utworzyć nową\"\n        },\n        \"updateServer\": {\n            \"title\": \"uaktualnij serwer\",\n            \"success\": \"serwer zaaktualizowany pomyślnie\"\n        },\n        \"queryEditor\": {\n            \"input_optionMatchAll\": \"dopasuj wszystkie\",\n            \"input_optionMatchAny\": \"dopasuj dowolne\",\n            \"title\": \"edytor zapytań\",\n            \"addRuleGroup\": \"dodaj grupę zasad\",\n            \"removeRuleGroup\": \"usuń grupę zasad\",\n            \"resetToDefault\": \"przywróć domyślne\",\n            \"clearFilters\": \"wyczyść filtry\"\n        },\n        \"lyricSearch\": {\n            \"input_name\": \"$t(common.name)\",\n            \"input_artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n            \"title\": \"wyszukiwanie tekstów\"\n        },\n        \"editPlaylist\": {\n            \"title\": \"edytuj $t(entity.playlist, {\\\"count\\\": 1})\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) zaktualizowana pomyślnie\",\n            \"publicJellyfinNote\": \"Z jakiegoś powodu Jellyfin nie udostępnia informacji na temat publiczności playlisty. Jeżeli chcesz, aby ta pozostała publiczna, mniej wybraną poniższą opcję\",\n            \"editNote\": \"manualne edytowanie nie jest zalecane dla dużych playlist. czy na pewno zgadzasz się na ryzyko utraty danych wywołane przez nadpisanie istniejącej playlisty?\"\n        },\n        \"shareItem\": {\n            \"allowDownloading\": \"zezwól na pobieranie\",\n            \"description\": \"opis\",\n            \"setExpiration\": \"ustaw czas wygaśnięcia\",\n            \"success\": \"link do udostępniania skopiowany do schowka (lub kliknij tutaj, aby otworzyć)\",\n            \"createFailed\": \"nie udało się utworzyć linku do udostępniania (czy udostępnianie jest włączone?)\",\n            \"expireInvalid\": \"ustawiony czas wygaśnięcia musi być w przyszłości\",\n            \"copyToClipboard\": \"Skopiuj do schowka: Ctrl+C, Enter\",\n            \"successMustClick\": \"udostępnianie utworzone pomyślnie, kliknij tutaj żeby otworzyć\"\n        },\n        \"privateMode\": {\n            \"enabled\": \"tryb prywatny włączony, status odtwarzania jest ukryty przed usługami zewnętrznymi\",\n            \"disabled\": \"tryb prywatny wyłączony, status odtwarzania jest widoczny dla usług zewnętrznych\",\n            \"title\": \"tryb prywatny\"\n        },\n        \"largeFetchConfirmation\": {\n            \"title\": \"dodaj elementy do kolejki\",\n            \"description\": \"Ta akcja doda wszystkie elementy w aktualnie przefiltrowanym widoku\"\n        },\n        \"shuffleAll\": {\n            \"title\": \"odtwarzaj losowo\",\n            \"input_genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n            \"input_limit\": \"ile piosenek?\",\n            \"input_minYear\": \"z roku\",\n            \"input_maxYear\": \"do roku\",\n            \"input_played\": \"filtr odtwarzania\",\n            \"input_played_optionAll\": \"wszystkie utwory\",\n            \"input_played_optionUnplayed\": \"tylko nieodtworzone utwory\",\n            \"input_played_optionPlayed\": \"tylko odtworzone utwory\"\n        },\n        \"saveQueue\": {\n            \"success\": \"zapisano kolejkę odtwarzania na serwerze\"\n        },\n        \"createRadioStation\": {\n            \"success\": \"stacja radiowa utworzona pomyślnie\",\n            \"title\": \"utwórz stację radiową\",\n            \"input_homepageUrl\": \"url strony głównej\",\n            \"input_name\": \"nazwa\",\n            \"input_streamUrl\": \"url strumienia\"\n        },\n        \"lyricsExport\": {\n            \"export\": \"eksportuj tekst\",\n            \"input_synced\": \"eksportuj zsynchronizowany tekst\",\n            \"input_offset\": \"$t(setting.lyricOffset)\"\n        }\n    },\n    \"page\": {\n        \"fullscreenPlayer\": {\n            \"config\": {\n                \"showLyricMatch\": \"pokaż dopasowanie tekstu\",\n                \"dynamicBackground\": \"dynamiczne tło\",\n                \"synchronized\": \"zsynchronizowane\",\n                \"followCurrentLyric\": \"podążaj za aktualnym tekstem\",\n                \"opacity\": \"przezroczystość\",\n                \"lyricSize\": \"rozmiar tekstu\",\n                \"showLyricProvider\": \"pokaż dostawce tekstu\",\n                \"unsynchronized\": \"niezsynchronizowane\",\n                \"lyricAlignment\": \"wyrównaj tekst\",\n                \"useImageAspectRatio\": \"użyj współczynnika proporcji obrazu\",\n                \"lyricGap\": \"odstępy tekstu\",\n                \"dynamicImageBlur\": \"rozmiar rozmycia obrazu\",\n                \"dynamicIsImage\": \"włącz obraz w tle\",\n                \"lyricOffset\": \"opóźnienie tekstów (ms)\"\n            },\n            \"upNext\": \"następne\",\n            \"lyrics\": \"tekst\",\n            \"related\": \"powiązane\",\n            \"visualizer\": \"wizualizer\",\n            \"noLyrics\": \"nie znaleziono tekstu\"\n        },\n        \"appMenu\": {\n            \"selectServer\": \"wybierz serwer\",\n            \"version\": \"wersja {{version}}\",\n            \"settings\": \"$t(common.setting, {\\\"count\\\": 2})\",\n            \"manageServers\": \"zarządzaj serwerami\",\n            \"expandSidebar\": \"rozwiń pasek boczny\",\n            \"collapseSidebar\": \"zwiń pasek boczny\",\n            \"openBrowserDevtools\": \"otwórz narzędzia deweloperskie przeglądarki\",\n            \"quit\": \"$t(common.quit)\",\n            \"goBack\": \"do tyłu\",\n            \"goForward\": \"do przodu\",\n            \"privateModeOff\": \"wyłącz tryb prywatny\",\n            \"privateModeOn\": \"włącz tryb prywatny\",\n            \"selectMusicFolder\": \"wybierz folder muzyki\",\n            \"noMusicFolder\": \"nie wybrano folderu muzyki\",\n            \"multipleMusicFolders\": \"wybrano {{count}} folderów muzyki\",\n            \"commandPalette\": \"otwórz paletę komend\"\n        },\n        \"contextMenu\": {\n            \"addToPlaylist\": \"$t(action.addToPlaylist)\",\n            \"addToFavorites\": \"$t(action.addToFavorites)\",\n            \"setRating\": \"$t(action.setRating)\",\n            \"moveToTop\": \"$t(action.moveToTop)\",\n            \"deletePlaylist\": \"$t(action.deletePlaylist)\",\n            \"moveToBottom\": \"$t(action.moveToBottom)\",\n            \"createPlaylist\": \"$t(action.createPlaylist)\",\n            \"removeFromPlaylist\": \"$t(action.removeFromPlaylist)\",\n            \"removeFromFavorites\": \"$t(action.removeFromFavorites)\",\n            \"addNext\": \"$t(player.addNext)\",\n            \"deselectAll\": \"$t(action.deselectAll)\",\n            \"addLast\": \"$t(player.addLast)\",\n            \"addFavorite\": \"$t(action.addToFavorites)\",\n            \"play\": \"$t(player.play)\",\n            \"numberSelected\": \"zaznaczono {{count}}\",\n            \"removeFromQueue\": \"$t(action.removeFromQueue)\",\n            \"shareItem\": \"udostępnij pozycję\",\n            \"showDetails\": \"zobacz informacje\",\n            \"download\": \"pobierz\",\n            \"playShuffled\": \"$t(player.shuffle)\",\n            \"playSimilarSongs\": \"$t(player.playSimilarSongs)\",\n            \"moveToNext\": \"$t(action.moveToNext)\",\n            \"goToAlbum\": \"przejdź do $t(entity.album, {\\\"count\\\": 1})\",\n            \"goToAlbumArtist\": \"przejdź do $t(entity.albumArtist, {\\\"count\\\": 1})\",\n            \"moveItems\": \"$t(action.moveItems)\",\n            \"goTo\": \"przejdź do\"\n        },\n        \"albumDetail\": {\n            \"moreFromArtist\": \"więcej od $t(entity.artist, {\\\"count\\\": 1})\",\n            \"moreFromGeneric\": \"więcej od {{item}}\",\n            \"released\": \"wydany\"\n        },\n        \"albumArtistList\": {\n            \"title\": \"$t(entity.albumArtist, {\\\"count\\\": 2})\"\n        },\n        \"genreList\": {\n            \"title\": \"$t(entity.genre, {\\\"count\\\": 2})\",\n            \"showAlbums\": \"pokaż $t(entity.album, {\\\"count\\\": 2}) $t(entity.genre, {\\\"count\\\": 1})\",\n            \"showTracks\": \"pokaż $t(entity.track, {\\\"count\\\": 2}) $t(entity.genre, {\\\"count\\\": 1})\"\n        },\n        \"albumList\": {\n            \"title\": \"$t(entity.album, {\\\"count\\\": 2})\",\n            \"artistAlbums\": \"albumy wykonawcy {{artist}}\",\n            \"genreAlbums\": \"$t(entity.album, {\\\"count\\\": 2}) \\\"{{genre}}\\\"\"\n        },\n        \"sidebar\": {\n            \"nowPlaying\": \"teraz odtwarzane\",\n            \"playlists\": \"$t(entity.playlist, {\\\"count\\\": 2})\",\n            \"search\": \"$t(common.search)\",\n            \"tracks\": \"$t(entity.track, {\\\"count\\\": 2})\",\n            \"albums\": \"$t(entity.album, {\\\"count\\\": 2})\",\n            \"genres\": \"$t(entity.genre, {\\\"count\\\": 2})\",\n            \"folders\": \"$t(entity.folder, {\\\"count\\\": 2})\",\n            \"settings\": \"$t(common.setting, {\\\"count\\\": 2})\",\n            \"home\": \"$t(common.home)\",\n            \"artists\": \"$t(entity.artist, {\\\"count\\\": 2})\",\n            \"albumArtists\": \"$t(entity.albumArtist, {\\\"count\\\": 2})\",\n            \"shared\": \"udostępniono $t(entity.playlist, {\\\"count\\\": 2})\",\n            \"myLibrary\": \"Moja biblioteka\",\n            \"favorites\": \"$t(entity.favorite, {\\\"count\\\": 2})\",\n            \"radio\": \"$t(entity.radioStation, {\\\"count\\\": 2})\",\n            \"collections\": \"kolekcje\"\n        },\n        \"home\": {\n            \"mostPlayed\": \"najczęściej odtwarzane\",\n            \"newlyAdded\": \"niedawno dodane\",\n            \"title\": \"$t(common.home)\",\n            \"explore\": \"przeglądaj z biblioteki\",\n            \"recentlyPlayed\": \"ostatnio odtwarzane\",\n            \"recentlyReleased\": \"ostatnio wydane\",\n            \"genres\": \"$t(entity.genre, {\\\"count\\\": 2})\"\n        },\n        \"setting\": {\n            \"playbackTab\": \"odtworzenia\",\n            \"generalTab\": \"ogólne\",\n            \"hotkeysTab\": \"skróty klawiszowe\",\n            \"windowTab\": \"okno\",\n            \"advanced\": \"zaawansowane\",\n            \"analytics\": \"analityka\",\n            \"updates\": \"aktualizacja\",\n            \"cache\": \"cache\",\n            \"application\": \"aplikacja\",\n            \"queryBuilder\": \"kreator zapytań\",\n            \"theme\": \"motyw\",\n            \"controls\": \"sterowanie\",\n            \"sidebar\": \"pasek boczny\",\n            \"remote\": \"zdalne\",\n            \"exportImport\": \"import/eksport\",\n            \"scrobble\": \"scrobble\",\n            \"audio\": \"audio\",\n            \"lyrics\": \"tekst\",\n            \"transcoding\": \"transkodowanie\",\n            \"discord\": \"discord\",\n            \"playerFilters\": \"filtry odtwarzacza\",\n            \"logger\": \"logger\",\n            \"lyricsDisplay\": \"wyświetlanie tekstu\"\n        },\n        \"trackList\": {\n            \"title\": \"$t(entity.track, {\\\"count\\\": 2})\",\n            \"artistTracks\": \"utwory przez {{artist}}\",\n            \"genreTracks\": \"$t(entity.track, {\\\"count\\\": 2}) \\\"{{genre}}\\\"\"\n        },\n        \"globalSearch\": {\n            \"commands\": {\n                \"serverCommands\": \"komendy serwera\",\n                \"goToPage\": \"przejdź do strony\",\n                \"searchFor\": \"wyszukaj {{query}}\"\n            },\n            \"title\": \"komendy\"\n        },\n        \"playlistList\": {\n            \"title\": \"$t(entity.playlist, {\\\"count\\\": 2})\"\n        },\n        \"albumArtistDetail\": {\n            \"topSongs\": \"popularne utwory\",\n            \"topSongsFrom\": \"popularne utwory z {{title}}\",\n            \"about\": \"O {{artist}}\",\n            \"recentReleases\": \"ostatnie wydania\",\n            \"viewAll\": \"zobacz wszystko\",\n            \"viewDiscography\": \"przeglądaj dyskografię\",\n            \"relatedArtists\": \"powiązane z $t(entity.artist, {\\\"count\\\": 2})\",\n            \"appearsOn\": \"pojawia się na\",\n            \"viewAllTracks\": \"zobacz wszystko $t(entity.track, {\\\"count\\\": 2})\",\n            \"groupingTypeAll\": \"wszystkie typy wydań\",\n            \"groupingTypePrimary\": \"główne typy wydań\",\n            \"favoriteSongs\": \"ulubione piosenki\",\n            \"topSongsCommunity\": \"społeczność\",\n            \"topSongsPersonal\": \"osobiste\",\n            \"favoriteSongsFrom\": \"ulubione piosenki z {{title}}\"\n        },\n        \"itemDetail\": {\n            \"copyPath\": \"kopiuj ścieżkę do schowka\",\n            \"copiedPath\": \"ścieżka została skopiowana pomyślnie\",\n            \"openFile\": \"pokaż utwór w menedżerze plików\"\n        },\n        \"manageServers\": {\n            \"title\": \"zarządzaj serwerami\",\n            \"url\": \"URL\",\n            \"username\": \"nazwa użytkownika\",\n            \"removeServer\": \"usuń serwer\",\n            \"serverDetails\": \"szczegóły serwera\",\n            \"editServerDetailsTooltip\": \"edytuj szczegóły serwera\"\n        },\n        \"playlist\": {\n            \"reorder\": \"zmiana kolejności jest możliwa tylko podczas sortowania według id\"\n        },\n        \"favorites\": {\n            \"title\": \"$t(entity.favorite, {\\\"count\\\": 2})\"\n        },\n        \"folderList\": {\n            \"title\": \"$t(entity.folder, {\\\"count\\\": 2})\"\n        },\n        \"radioList\": {\n            \"title\": \"stacje radiowe\"\n        },\n        \"windowBar\": {\n            \"paused\": \"(Wstrzymane) \",\n            \"privateMode\": \"(Tryb prywatny)\"\n        },\n        \"collections\": {\n            \"overrideExisting\": \"nadpisz istniejące\",\n            \"saveAsCollection\": \"zapisz jako kolekcję\"\n        },\n        \"releasenotes\": {\n            \"commitsSinceStable\": \"commity od {{stable}}\",\n            \"noNewCommits\": \"brak nowych commitów w tym zakresie\",\n            \"noStableReleaseToCompare\": \"brak dostępnego stabilnego wydania do porównania\"\n        }\n    },\n    \"player\": {\n        \"repeat_all\": \"powtarzaj wszystkie\",\n        \"stop\": \"stop\",\n        \"repeat\": \"powtarzaj jeden\",\n        \"queue_remove\": \"usuń zaznaczone\",\n        \"playRandom\": \"odtwarzaj losowo\",\n        \"skip\": \"pomiń\",\n        \"previous\": \"poprzedni\",\n        \"toggleFullscreenPlayer\": \"przełącz odtwarzacz pełnoekranowy\",\n        \"skip_back\": \"przeskocz do tyłu\",\n        \"favorite\": \"ulubione\",\n        \"next\": \"następny\",\n        \"shuffle\": \"odtwarzaj (losowo)\",\n        \"playbackFetchNoResults\": \"nie znaleziono utworów\",\n        \"playbackFetchInProgress\": \"wczytywanie utworów…\",\n        \"addNext\": \"następne\",\n        \"playbackSpeed\": \"prędkość odtwarzania\",\n        \"playbackFetchCancel\": \"to potrwa chwilę... zamknij powiadomienie aby anulować\",\n        \"play\": \"odtwarzaj\",\n        \"repeat_off\": \"powtarzanie wyłączone\",\n        \"pause\": \"wstrzymaj\",\n        \"queue_clear\": \"wyczyść kolejke\",\n        \"muted\": \"wyciszone\",\n        \"unfavorite\": \"usuń z ulubionych\",\n        \"queue_moveToTop\": \"przesuń zaznaczone na dół\",\n        \"queue_moveToBottom\": \"przesuń zaznaczone na górę\",\n        \"shuffle_off\": \"losowa kolejność wyłączona\",\n        \"addLast\": \"ostatnie\",\n        \"mute\": \"wycisz\",\n        \"skip_forward\": \"przeskocz do przodu\",\n        \"viewQueue\": \"zobacz kolejkę\",\n        \"playSimilarSongs\": \"odtwarzaj podobne\",\n        \"addLastShuffled\": \"ostatnie (wylosowane)\",\n        \"addNextShuffled\": \"następne (wylosowane)\",\n        \"holdToShuffle\": \"przytrzymaj aby odtwarzać losowo\",\n        \"lyrics\": \"tekst\",\n        \"restoreQueueFromServer\": \"przywróć kolejkę z serwera\",\n        \"saveQueueToServer\": \"zapisz kolejkę na serwerze\",\n        \"artistRadio\": \"radio wykonawcy\",\n        \"trackRadio\": \"radio utworu\",\n        \"sleepTimer\": \"wyłącznik czasowy\",\n        \"sleepTimer_endOfSong\": \"do końca aktualnej piosenki\",\n        \"sleepTimer_minutes\": \"{{count}} min\",\n        \"sleepTimer_hours\": \"{{count}} godz\",\n        \"sleepTimer_custom\": \"niestandardowy\",\n        \"sleepTimer_off\": \"wyłączony\",\n        \"sleepTimer_timeRemaining\": \"pozostało {{time}}\",\n        \"sleepTimer_setCustom\": \"ustaw wyłącznik\",\n        \"sleepTimer_cancel\": \"anuluj wyłączanie\",\n        \"albumRadio\": \"radio albumu\"\n    },\n    \"setting\": {\n        \"crossfadeStyle_description\": \"wybierz styl przenikania, który ma być używany do odtwarzania dźwięku\",\n        \"hotkey_skipBackward\": \"przeskocz do tyłu\",\n        \"audioDevice_description\": \"wybierz urządzenie dźwiękowe używane do odtwarzania\",\n        \"hotkey_playbackPause\": \"wstrzymaj\",\n        \"hotkey_volumeUp\": \"podgłoś\",\n        \"discordIdleStatus_description\": \"kiedy włączony, aktualizuje stan kiedy odtwarzacz jest bezczynny\",\n        \"lyricFetch\": \"pobierz teksty z internetu\",\n        \"enableRemote_description\": \"umożliwia serwerowi zdalnego sterowania zezwalanie innym urządzeniom na sterowanie aplikacją\",\n        \"fontType_optionSystem\": \"czcionka systemowa\",\n        \"hotkey_favoriteCurrentSong\": \"ulubiona $t(common.currentSong)\",\n        \"hotkey_zoomIn\": \"przybliż\",\n        \"hotkey_browserForward\": \"przeglądarka w przód\",\n        \"audioExclusiveMode_description\": \"włącz wyłączny tryb wyjścia. W tym trybie, system zwykle jest zablokowany i może odtwarzać tylko poprzez mpv\",\n        \"discordUpdateInterval\": \"{{discord}} interwał aktualizacji rich presence\",\n        \"fontType_optionBuiltIn\": \"wbudowana czcionka\",\n        \"hotkey_playbackPlayPause\": \"odtwarzaj / wstrzymaj\",\n        \"hotkey_rate1\": \"oceń na 1 gwiazdkę\",\n        \"hotkey_skipForward\": \"przeskocz do przodu\",\n        \"disableLibraryUpdateOnStartup\": \"wyłącz wyszukiwanie aktualizacji podczas uruchamiania aplikacji\",\n        \"discordApplicationId_description\": \"id dla aplikacji {{discord}} rich presence (domyślnie {{defaultId}})\",\n        \"gaplessAudio\": \"dźwięk bez przerw\",\n        \"hotkey_playbackPlay\": \"odtwarzaj\",\n        \"hotkey_togglePreviousSongFavorite\": \"dodaj $t(common.previousSong) do ulubionych\",\n        \"hotkey_volumeDown\": \"przycisz\",\n        \"hotkey_unfavoritePreviousSong\": \"usuń $t(common.previousSong) z ulubionych\",\n        \"audioPlayer_description\": \"wybierz odtwarzacz dźwięku który ma być używany do odtwarzania\",\n        \"globalMediaHotkeys\": \"globalne skróty klawiszowe multimediów\",\n        \"hotkey_globalSearch\": \"globalne wyszukiwanie\",\n        \"gaplessAudio_description\": \"ustaw dźwięk bez przerw dla mpv\",\n        \"exitToTray_description\": \"zamknij aplikację do zasobnika systemowego\",\n        \"followLyric_description\": \"przewiń tekst do obecnego momentu\",\n        \"hotkey_favoritePreviousSong\": \"ulubiona $t(common.previousSong)\",\n        \"lyricOffset\": \"opóźnienie tekstu (ms)\",\n        \"discordUpdateInterval_description\": \"czas w sekundach pomiędzy każdą aktualizacją (minimalnie 15 sekund)\",\n        \"fontType_optionCustom\": \"czcionka niestandardowa\",\n        \"audioExclusiveMode\": \"wyłączny tryb audio\",\n        \"lyricFetchProvider\": \"dostawcy tekstów internetowych\",\n        \"language_description\": \"ustaw język dla aplikacji ($t(common.restartRequired))\",\n        \"hotkey_rate3\": \"oceń na 3 gwiazdki\",\n        \"font\": \"czcionka\",\n        \"hotkey_toggleFullScreenPlayer\": \"przełącz tryb pełnoekranowy\",\n        \"hotkey_localSearch\": \"wyszukiwanie na stronie\",\n        \"hotkey_toggleQueue\": \"przełącz kolejkę\",\n        \"hotkey_rate5\": \"oceń na 5 gwiazdek\",\n        \"hotkey_playbackPrevious\": \"poprzedni utwór\",\n        \"crossfadeDuration_description\": \"ustaw czas trwania efektu przenikania\",\n        \"hotkey_toggleShuffle\": \"przełącz kolejność losową\",\n        \"discordRichPresence_description\": \"włącz status odtwarzania w {{discord}} (rich presence). Dzięki temu będą wyświetlane informacje takie jak: {{icon}}, {{playing}} i {{paused}}\",\n        \"audioDevice\": \"urządzenia dźwiękowe\",\n        \"hotkey_rate2\": \"oceń na 2 gwiazdki\",\n        \"exitToTray\": \"zamknij do zasobnika\",\n        \"hotkey_rate4\": \"oceń na 4 gwiazdki\",\n        \"enableRemote\": \"włącz zdalną kontrolę serwera\",\n        \"fontType_description\": \"wbudowana czcionka pozwala na wybranie czcionki dostarczonej z feishin. systemowa czcionka pozwala na wybranie czcionki dostarczonej przez system operacyjny. niestandardowa czcionka pozwala na wybranie własnej czcionki\",\n        \"accentColor\": \"kolor akcentujący\",\n        \"accentColor_description\": \"ustaw kolor akcentujący dla aplikacji\",\n        \"hotkey_toggleRepeat\": \"przełącz powtarzanie\",\n        \"lyricOffset_description\": \"opóźnienie tekstu przez podaną liczbę milisekund\",\n        \"fontType\": \"typ czcionki\",\n        \"applicationHotkeys\": \"skróty klawiszowe aplikacji\",\n        \"hotkey_playbackNext\": \"następny utwór\",\n        \"lyricFetch_description\": \"pobierz teksty z rozmaitych źródeł internetowych\",\n        \"lyricFetchProvider_description\": \"wybierz dostawców od których pobierane będą teksty\",\n        \"globalMediaHotkeys_description\": \"włącz lub wyłącz używanie systemowych skrótów klawiszowych do kontroli odtwarzania\",\n        \"customFontPath\": \"niestandardowa ścieżka czcionki\",\n        \"followLyric\": \"podążaj za tekstem\",\n        \"crossfadeDuration\": \"czas trwania przenikania\",\n        \"discordIdleStatus\": \"pokaż status w stanie bezczynności\",\n        \"audioPlayer\": \"odtwarzacz dźwięku\",\n        \"hotkey_zoomOut\": \"oddal\",\n        \"hotkey_unfavoriteCurrentSong\": \"usuń $t(common.currentSong) z ulubionych\",\n        \"hotkey_rate0\": \"wyczyść oceny\",\n        \"discordApplicationId\": \"id aplikacji {{discord}}\",\n        \"applicationHotkeys_description\": \"ustaw skróty klawiszowe aplikacji. przełącz pole wyboru aby ustawić skrót globalny (tylko komputery)\",\n        \"hotkey_volumeMute\": \"wycisz\",\n        \"hotkey_toggleCurrentSongFavorite\": \"dodaj $t(common.currentSong) do ulubionych\",\n        \"hotkey_browserBack\": \"przeglądarka wstecz\",\n        \"minimizeToTray\": \"zminimalizuj do zasobnika\",\n        \"customFontPath_description\": \"ustaw ścieżkę dla niestandardowych czcionek dla aplikacji\",\n        \"gaplessAudio_optionWeak\": \"słabe (rekomendowane)\",\n        \"hotkey_playbackStop\": \"zatrzymaj\",\n        \"font_description\": \"ustaw czcionkę dla aplikacji\",\n        \"playButtonBehavior_optionPlay\": \"$t(player.play)\",\n        \"minimumScrobblePercentage\": \"minimalny czas trwania scrobble (procentowy)\",\n        \"mpvExecutablePath_description\": \"ustaw ścieżkę dla plików wykonywalnych mpv. gdy puste, zostanie użyta domyślna ścieżka\",\n        \"playButtonBehavior_optionAddLast\": \"$t(player.addLast)\",\n        \"minimizeToTray_description\": \"zminimalizuj aplikację do zasobnika systemowego\",\n        \"remotePassword\": \"hasło dla serwera zdalnej kontroli\",\n        \"playbackStyle_optionCrossFade\": \"przenikanie\",\n        \"playbackStyle\": \"styl odtwarzania\",\n        \"playbackStyle_description\": \"wybierz styl odtwarzania dla odtwarzacza dźwięku\",\n        \"mpvExecutablePath\": \"ścieżka pliku wykonywalnego mpv\",\n        \"playButtonBehavior_description\": \"ustaw domyślne zachowanie dla przycisku odtwarzania kiedy piosenka zostanie dodana do kolejki\",\n        \"minimumScrobblePercentage_description\": \"minimalny czas odtwarzania piosenki który musi upłynąć aby uznać ją za scrobble\",\n        \"minimumScrobbleSeconds_description\": \"minimalny czas odtwarzania piosenki w sekundach jaki musi upłynąć aby uznać ją za scrobbling\",\n        \"playButtonBehavior\": \"zachowanie przycisku odtwarzania\",\n        \"playbackStyle_optionNormal\": \"normalny\",\n        \"playButtonBehavior_optionAddNext\": \"$t(player.addNext)\",\n        \"minimumScrobbleSeconds\": \"minimalne scrobble (w sekundach)\",\n        \"remotePort_description\": \"ustaw port dla serwera zdalnej kontroli\",\n        \"replayGainMode_description\": \"dostosuj wzmocnienie dźwięku zgodnie z wartościami {{ReplayGain}} przechowywanymi w metadanych do pliku\",\n        \"replayGainFallback\": \"rezerwowy {{ReplayGain}}\",\n        \"sidebarCollapsedNavigation_description\": \"pokaż lub ukryj nawigację na zwiniętym pasku bocznym\",\n        \"skipDuration\": \"czas trwania pominięcia\",\n        \"showSkipButtons\": \"pokaż przyciski pomijania\",\n        \"scrobble\": \"scrobbling\",\n        \"skipDuration_description\": \"ustaw czas pominięcia kiedy zostanie użyty przycisk pominięcia na pasku odtwarzania\",\n        \"replayGainClipping_description\": \"Zapobiegaj wzmocnieniu spowodowanemu przez {{ReplayGain}} na automatyczne obniżanie wzmocnienia\",\n        \"replayGainPreamp\": \"przedwzmacniacz {{ReplayGain}} (db)\",\n        \"sampleRate\": \"częstotliwość próbkowania\",\n        \"sidePlayQueueStyle_optionAttached\": \"przyłączony\",\n        \"sidebarConfiguration\": \"konfiguracja paska bocznego\",\n        \"sampleRate_description\": \"wybierz wyjściową częstotliwość próbkowania, która ma być używana, jeśli wybrana częstotliwość próbkowania różni się od częstotliwości bieżącego utworu. wartość mniejsza niż 8000 spowoduje użycie częstotliwości domyślnej\",\n        \"replayGainMode_optionNone\": \"$t(common.none)\",\n        \"replayGainClipping\": \"wzmocnienie {{ReplayGain}}\",\n        \"scrobble_description\": \"przekazywanie informacji o odtwarzaniu (scrobbling) do twojego serwera multimediów\",\n        \"sidePlayQueueStyle\": \"boczny styl kolejki odtwarzania\",\n        \"remoteUsername_description\": \"ustaw nazwę użytkownika dla serwera zdalnej kontroli. Jeśli nazwa użytkownika i hasło są puste, autoryzacja będzie wyłączona\",\n        \"replayGainMode_optionAlbum\": \"$t(entity.album, {\\\"count\\\": 1})\",\n        \"replayGainMode_optionTrack\": \"$t(entity.track, {\\\"count\\\": 1})\",\n        \"remotePassword_description\": \"ustawia hasło dla serwera zdalnego sterowania. Te poświadczenia są domyślnie przesyłane w sposób niezabezpieczony, dlatego należy użyć unikalnego hasła na którym ci nie zależy\",\n        \"showSkipButtons_description\": \"pokaż lub ukryj przyciski pomijania na pasku odtwarzacza\",\n        \"showSkipButton_description\": \"pokaż lub ukryj przyciski pomijania na pasku odtwarzacza\",\n        \"savePlayQueue\": \"zapisz kolejkę odtwarzania\",\n        \"sidebarPlaylistList_description\": \"pokaż lub ukryj listę odtwarzania na pasku bocznym\",\n        \"sidePlayQueueStyle_description\": \"ustaw boczny styl kolejki odtwarzania\",\n        \"replayGainMode\": \"tryb {{ReplayGain}}\",\n        \"replayGainFallback_description\": \"wzmocnienie w db do użycia w przypadku kiedy plik nie ma tagu {{ReplayGain}}\",\n        \"replayGainPreamp_description\": \"dostosuj wzmocnienie przedwzmacniacza zastosowane do wartości {{ReplayGain}}\",\n        \"sidebarConfiguration_description\": \"wybierz pozycje i ustaw je w kolejności w jakiej mają się pokazywać na pasku bocznym\",\n        \"remotePort\": \"port dla serwera zdalnej kontroli\",\n        \"sidePlayQueueStyle_optionDetached\": \"odłączony\",\n        \"remoteUsername\": \"nazwa użytkownika serwera zdalnej kontroli\",\n        \"showSkipButton\": \"pokaż przyciski pomijania\",\n        \"sidebarPlaylistList\": \"lista odtwarzania na pasku bocznym\",\n        \"sidebarCollapsedNavigation\": \"nawigacja na pasku bocznym (zwinięta)\",\n        \"savePlayQueue_description\": \"zapisz kolejkę odtwarzania kiedy aplikacja jest zamykana i wznów ją kiedy aplikacja jest otwierana\",\n        \"volumeWheelStep_description\": \"wartość zmiany glośności w czasie używania pokrętła myszy na pasku głośności\",\n        \"theme_description\": \"ustaw motyw dla aplikacji\",\n        \"themeLight\": \"motyw (jasny)\",\n        \"zoom\": \"procentowe przybliżenie\",\n        \"themeDark_description\": \"ustaw ciemny motyw do używania w aplikacji\",\n        \"themeLight_description\": \"ustaw jasny motyw do używania w aplikacji\",\n        \"zoom_description\": \"ustaw procentowe przybliżenie dla aplikacji\",\n        \"theme\": \"motyw\",\n        \"skipPlaylistPage_description\": \"przechodząc do listy odtwarzania, przejdź do strony listy odtwarzania zamiast do strony domyślnej\",\n        \"volumeWheelStep\": \"krok pokrętła głośności\",\n        \"windowBarStyle\": \"styl paska okna\",\n        \"useSystemTheme_description\": \"podążaj za systemem z ustawieniami jasnego lub ciemnego motywu\",\n        \"skipPlaylistPage\": \"pomiń stronę list odtwarzania\",\n        \"themeDark\": \"motyw (ciemny)\",\n        \"windowBarStyle_description\": \"wybierz styl paska okna\",\n        \"useSystemTheme\": \"użyj motywu systemowego\",\n        \"buttonSize\": \"Rozmiar przycisku paska odtwarzacza\",\n        \"clearQueryCache\": \"wyczyść pamięć podręczną feishin\",\n        \"clearCache_description\": \"\\\"twarde wyczyszczenie\\\" feishin. oprócz wyczyszczenia pamięci podręcznej feishin, opróżnij pamięć podręczną przeglądarki (zapisane obrazy i inne zasoby). dane i ustawienia serwera zostaną zachowane\",\n        \"clearQueryCache_description\": \"\\\"miękkie wyczyszczenie\\\" feishin. spowoduje to odświeżenie playlist, metadanych utworów i zresetowanie zapisanych tekstów. ustawienia, dane uwierzytelniające serwera i obrazy w pamięci podręcznej zostaną zachowane\",\n        \"buttonSize_description\": \"rozmiar przycisków paska odtwarzacza\",\n        \"clearCache\": \"wyczyść pamięć podręczną przeglądarki\",\n        \"externalLinks\": \"pokaż zewnętrzne linki\",\n        \"mpvExtraParameters_help\": \"po jednym na linię\",\n        \"passwordStore\": \"hasła\",\n        \"passwordStore_description\": \"jakie hasło ma być używane. zmień to, jeśli masz problemy z przechowywaniem haseł\",\n        \"startMinimized\": \"uruchom zminimalizowany\",\n        \"startMinimized_description\": \"uruchom aplikację w zasobniku systemowym\",\n        \"clearCacheSuccess\": \"pamięć podręczna została wyczyszczona pomyślnie\",\n        \"externalLinks_description\": \"umożliwia wyświetlanie linków zewnętrznych (Last.fm, MusicBrainz) na stronach wykonawców/albumów\",\n        \"homeConfiguration\": \"konfiguracja strony głównej\",\n        \"homeConfiguration_description\": \"konfiguracja elementów wyświetlanych na stronie głównej i ich kolejności\",\n        \"albumBackground_description\": \"dodaje obraz tła dla stron albumu zawierających grafikę albumu\",\n        \"albumBackgroundBlur\": \"rozmiar rozmycia obrazu tła albumu\",\n        \"albumBackgroundBlur_description\": \"dostosowywuje ilość rozmycia nakladanego na obraz tła albumu\",\n        \"albumBackground\": \"obraz tła albumu\",\n        \"artistConfiguration_description\": \"skonfiguruj jakie elementy są pokazywane, i w jakiej kolejności, na stronie albumu wykonawcy\",\n        \"discordListening_description\": \"pokazuje status jako słucha zamiast w grze\",\n        \"transcode_description\": \"włącza transkodowanie na inne formaty\",\n        \"transcodeBitrate\": \"bitrate do transkodowania\",\n        \"translationApiProvider\": \"usługodawca do api tłumaczeń\",\n        \"translationApiProvider_description\": \"wybór usługodawcy do api tłumaczeń\",\n        \"translationApiKey\": \"klucz api do tłumaczeń\",\n        \"transcodeFormat_description\": \"wybiera format do transkodowania. zostaw pusty aby serwer wybrał format\",\n        \"translationApiKey_description\": \"klucz api do tłumaczenia (Obsługuje tylko globalny endpoint)\",\n        \"homeFeature\": \"karuzela polecanych na stronie głównej\",\n        \"customCssEnable\": \"włącz niestandardowy css\",\n        \"customCssEnable_description\": \"pozwalaj na pisanie niestandardowego css\",\n        \"customCssNotice\": \"Ostrzeżenie: chociaż istnieje pewne filtrowanie (uniemożliwia używanie url() i content:), używanie niestandardowego css-a może stwarzać ryzyko przez zmiany w interfejsie\",\n        \"customCss_description\": \"zawartość niestandardowego css. Uwaga: content i zdalne url są niedozwolonymi właściwościami. Podgląd twojej zawartości jest pokazana poniżej. Dodatkowe pola których nie ustawiłeś, są obecne z powodu sanityzacji\",\n        \"customCss\": \"niestandardowy css\",\n        \"trayEnabled_description\": \"pokaż/ukryj ikonę/menu w zasobniku. jeżeli wyłączone, wyłącza też minimalizowanie.wyjście do zasobnika\",\n        \"webAudio_description\": \"używaj web audio. włącza to zaawansowane funkcje takie jak replaygain. wyłącz jeżeli nie działa poprawnie\",\n        \"artistConfiguration\": \"konfiguracja strony albumu wykonawcy\",\n        \"playButtonBehavior_optionPlayShuffled\": \"$t(player.shuffle)\",\n        \"playerbarOpenDrawer_description\": \"pozwala przełączyć na odtwarzacz pełnoekranowy po kliknięciu paska odtwarzania\",\n        \"playerbarOpenDrawer\": \"przełącznik pełnego ekranu na pasku odtwarzania\",\n        \"imageAspectRatio\": \"używaj natywnych proporcji okładki\",\n        \"volumeWidth\": \"szerokość paska głośności\",\n        \"discordListening\": \"pokazuj status jako słucha\",\n        \"imageAspectRatio_description\": \"jeżeli włączone, okładka będzie pokazywana z użyciem jej natywnych proporcji. dla okładek które nie mają proporcji 1:1, pozostałe miejsce będzie puste\",\n        \"volumeWidth_description\": \"szerokość paska głośności\",\n        \"contextMenu_description\": \"pozwala ci na ukrycie elementów które są pokazywane w menu po kliknięciu prawym przyciskiem myszy na element. elementy które zostały odznaczone będą ukryte\",\n        \"contextMenu\": \"konfiguracja menu kontekstowego (pod prawym przyciskiem myszy)\",\n        \"transcodeBitrate_description\": \"wybiera bitrate do transkodowania. 0 pozwala wybrać to serwerowi\",\n        \"transcodeFormat\": \"format do transkodowania\",\n        \"translationTargetLanguage_description\": \"język do którego będzie tłumaczona treść\",\n        \"trayEnabled\": \"pokazuj w zasobniku\",\n        \"webAudio\": \"używaj web audio\",\n        \"homeFeature_description\": \"ustawienie powoduje to czy wyświetlana jest karuzela z polecanymi utworami na stronie głównej\",\n        \"lastfmApiKey\": \"klucz API {{lastfm}}\",\n        \"lastfmApiKey_description\": \"klucz API dla {{lastfm}}. wymagany dla okładek\",\n        \"translationTargetLanguage\": \"docelowy język tłumaczenia\",\n        \"discordPausedStatus_description\": \"jeżeli włączone, status będzie pokazywany kiedy odtwarzanie jest wstrzymane\",\n        \"preferLocalLyrics\": \"preferuj lokalne teksty\",\n        \"preferLocalLyrics_description\": \"jeśli to możliwe, preferuj lokalne teksty zamiast tekstów zdalnych\",\n        \"lastfm\": \"pokazuj linki do last.fm\",\n        \"lastfm_description\": \"pokazuj linki do Last.fm na stronach wykonawców/albumów\",\n        \"musicbrainz\": \"pokazuj linki do MusicBrainz\",\n        \"musicbrainz_description\": \"pokazuj linki do MusicBrainz na stronach wykonawców/albumów, gdzie istnieje MusicBrainz ID\",\n        \"discordPausedStatus\": \"pokaż status podczas pauzy\",\n        \"discordServeImage\": \"wysyłaj obrazy dla {{discord}} z serwera\",\n        \"discordServeImage_description\": \"pokazuj okładki w statusie {{discord}} prosto z serwera, dostępne tylko dla Jellyfin i Navidrome. {{discord}} używa bota do pobierania obrazów, więc twój serwer musi być dostępny publicznie w internecie\",\n        \"analyticsDisable\": \"Zrezygnuj z analityki bazowanej na użytkowaniu\",\n        \"analyticsDisable_description\": \"Zanonymizowane dane użytkowania są wysyłane do dewelopera w celu poprawienia aplikacji\",\n        \"artistBackground\": \"obraz tła wykonawcy\",\n        \"artistBackground_description\": \"dodaje obraz tła do stron wykonawców\",\n        \"artistBackgroundBlur\": \"rozmiar rozmazania obrazu tła wykonawców\",\n        \"artistBackgroundBlur_description\": \"wybiera poziom rozmazania obrazów tła wykonawców\",\n        \"crossfadeStyle\": \"styl przenikania dźwięku\",\n        \"releaseChannel_optionBeta\": \"beta\",\n        \"releaseChannel_optionLatest\": \"najnowsza\",\n        \"releaseChannel\": \"kanał wydań\",\n        \"releaseChannel_description\": \"wybieraj pomiędzy wydaniami stabilnymi, beta lub alpha (nightly) dla automatycznych aktualizacji\",\n        \"discordDisplayType_artistname\": \"nazwa(y) wykonawców\",\n        \"discordDisplayType_description\": \"zmienia co jest pokazywane jako słuchane w twoim statusie\",\n        \"discordDisplayType_songname\": \"nazwa piosenki\",\n        \"discordDisplayType\": \"typ wyświetlania statusu {{discord}}\",\n        \"discordLinkType_description\": \"dodaje zewnętrzny link do {{lastfm}} lub {{musicbrainz}} do pól piosenki i wykonawcy w rich presence {{discord}}. {{musicbrainz}} jest najbardziej dokładnym, ale wymaga tagów i nie daje linków do wykonawców, gdy {{lastfm}} zawsze daje link. nie wywołuje dodatkowych żądań sieciowych\",\n        \"discordLinkType_mbz_lastfm\": \"{{musicbrainz}} z zastępczym {{lastfm}}\",\n        \"discordLinkType_none\": \"$t(common.none)\",\n        \"discordLinkType\": \"linki w statusie {{discord}}\",\n        \"discordRichPresence\": \"{{discord}} rich presence\",\n        \"enableAutoTranslation_description\": \"włącza automatyczne tłumaczenie tekstów, kiedy są ładowane\",\n        \"enableAutoTranslation\": \"włącz automatyczne tłumaczenie\",\n        \"exportImportSettings_control_description\": \"eksportuj i importuj ustawienia za pomocą pliku JSON\",\n        \"exportImportSettings_control_exportText\": \"eksportuj ustawienia\",\n        \"exportImportSettings_control_importText\": \"importuj ustawienia\",\n        \"exportImportSettings_control_title\": \"import / eksport ustawień\",\n        \"exportImportSettings_destructiveWarning\": \"importowanie ustawień jest destrukcyjne, sprawdź te powyższe przed kliknięciem \\\"importuj\\\" poniżej!\",\n        \"exportImportSettings_importBtn\": \"importuj ustawienia\",\n        \"exportImportSettings_importModalTitle\": \"importuj ustawienia feishin\",\n        \"exportImportSettings_importSuccess\": \"ustawienia zostały zaimportowane pomyślnie!\",\n        \"exportImportSettings_notValidJSON\": \"podany plik nie jest właściwym plikiem JSON\",\n        \"exportImportSettings_offendingKeyError\": \"\\\"{{offendingKey}}\\\" jest nieprawidłowy - {{reason}}\",\n        \"hotkey_navigateHome\": \"przejdź do strony głównej\",\n        \"language\": \"język\",\n        \"neteaseTranslation_description\": \"Gdy włączone, pobiera i wyświetla teksty przetłumaczone od NetEase, gdy dostępne\",\n        \"neteaseTranslation\": \"Włącz tłumaczenia NetEase\",\n        \"notify\": \"włącz powiadomienia piosenek\",\n        \"notify_description\": \"pokazuje powiadomienie, gdy aktualna piosenka jest zmieniana\",\n        \"playerbarSlider\": \"pasek suwaka odtwarzacza\",\n        \"playerbarSliderType_optionSlider\": \"suwak\",\n        \"playerbarSliderType_optionWaveform\": \"przebieg\",\n        \"playerbarWaveformAlign\": \"wyrównanie przebiegu\",\n        \"playerbarWaveformAlign_optionTop\": \"góra\",\n        \"playerbarWaveformAlign_optionCenter\": \"środek\",\n        \"playerbarWaveformAlign_optionBottom\": \"dół\",\n        \"playerbarWaveformBarWidth\": \"szerokość paska przebiegu\",\n        \"playerbarWaveformGap\": \"przerwa przebiegu\",\n        \"playerbarWaveformRadius\": \"promień przebiegu\",\n        \"showLyricsInSidebar_description\": \"będzie dodany panel do kolejki odtwarzania, który będzie wyświetlał tekst\",\n        \"showLyricsInSidebar\": \"pokazuj tekst w bocznym pasku odtwarzacza\",\n        \"showVisualizerInSidebar_description\": \"będzie dodany panel w bocznym pasku odtwarzacza, który będzie wyświetlał wizualizację\",\n        \"showVisualizerInSidebar\": \"pokazuj wizualizację w bocznym pasku odtwarzacza\",\n        \"preservePitch_description\": \"utrzymuje ton gdy zmieniana jest prędkość odtwarzania\",\n        \"preservePitch\": \"utrzymuj ton\",\n        \"preventSleepOnPlayback_description\": \"powstrzymuje ekran przed uśpieniem, gdy muzyka jest odtwarzana\",\n        \"preventSleepOnPlayback\": \"powstrzymuj uśpienie podczas odtwarzania\",\n        \"mediaSession_description\": \"włącza integrację z Media Session, wyświetlając sterowanie mediami i metadane w systemowym oknie zmiany głośności i na ekranie blokady\",\n        \"mediaSession\": \"włącz media session\",\n        \"transcode\": \"włącz transkodowanie\",\n        \"queryBuilder\": \"kreator zaptań\",\n        \"queryBuilderCustomFields_inputLabel\": \"label\",\n        \"queryBuilderCustomFields_inputTag\": \"tag\",\n        \"queryBuilderCustomFields\": \"niestandardowe pola\",\n        \"queryBuilderCustomFields_description\": \"dodaj niestandardowe pola do użycia w kreatorach zapytań\",\n        \"followCurrentSong_description\": \"automatycznie przewija kolejkę odtwarzania do aktualnie odtwarzanej piosenki\",\n        \"followCurrentSong\": \"śledź aktualną piosenkę\",\n        \"playerFilters\": \"Filtruj piosenki z kolejki\",\n        \"playerFilters_description\": \"nie dodawaj piosenek do kolejki na podstawie poniższych kryteriów\",\n        \"playerbarSlider_description\": \"krzywe nie są zalecane w przypadku wolnego lub ograniczonego połączenia internetowego\",\n        \"audioFadeOnStatusChange\": \"przenikanie dźwięku przy zmianie statusu\",\n        \"audioFadeOnStatusChange_description\": \"umożliwia zanikanie lub pojawianie się dźwięku gdy zmieni się status play/pauza\",\n        \"autoDJ\": \"automatyczny DJ\",\n        \"autoDJ_description\": \"automatycznie dodawaj podobne piosenki do kolejki\",\n        \"autoDJ_itemCount\": \"liczba elementów\",\n        \"autoDJ_itemCount_description\": \"liczba elementów, które będzie próbować dodać do kolejki kiedy automatyczny DJ jest włączony\",\n        \"autoDJ_timing\": \"czas dodawania\",\n        \"autoDJ_timing_description\": \"ilość piosenek pozostałych w kolejce przed tym gdy zostanie włączony automatyczny DJ\",\n        \"logLevel\": \"poziom logów\",\n        \"logLevel_description\": \"ustawia minimalny poziom logów do wyświetlenia. debugowanie wyświetla wszystkie logi błędy wyświetla tylko błędy\",\n        \"logLevel_optionDebug\": \"debugowanie\",\n        \"logLevel_optionError\": \"błędy\",\n        \"logLevel_optionInfo\": \"info\",\n        \"logLevel_optionWarn\": \"ostrzeżenia\",\n        \"useThemeAccentColor\": \"używaj koloru akcentu motywu\",\n        \"useThemeAccentColor_description\": \"używaj głównego koloru ustawionego w wybranym motywie zamiast niestandardowego koloru akcentu\",\n        \"artistRadioCount_description\": \"ustawia liczbę piosenek do załadowania dla radia wykonawcy i radia utworu\",\n        \"artistRadioCount\": \"liczba radio wykonawców/utworów\",\n        \"imageResolution\": \"rozdzielczość obrazu\",\n        \"imageResolution_description\": \"rozdzielczość dla obrazów używanych w programie. użycie wartości 0 ustawi rozdzielczość na natywną\",\n        \"imageResolution_optionTable\": \"tabela\",\n        \"imageResolution_optionItemCard\": \"karta elementu\",\n        \"imageResolution_optionSidebar\": \"­pasek boczny\",\n        \"imageResolution_optionHeader\": \"nagłówek\",\n        \"imageResolution_optionFullScreenPlayer\": \"odtwarzacz pełnoekranowy\",\n        \"combinedLyricsAndVisualizer_description\": \"połącz tekst i wizualizacje w tym samym panelu\",\n        \"combinedLyricsAndVisualizer\": \"połącz tekst i wizualizacje w pasku bocznym odtwarzacza\",\n        \"artistReleaseTypeConfiguration\": \"konfiguracja typu wydań wykonawcy\",\n        \"artistReleaseTypeConfiguration_description\": \"skonfiguruj jakie typy wydań są pokazywane i w jakiej kolejności na stronie albumów wykonawcy\",\n        \"showRatings_description\": \"kontroluje czy funkcja oceniania gwiazdkami jest pokazywana w interfejsie\",\n        \"showRatings\": \"pokaż ocenianie gwiazdkami\",\n        \"mpvExtraParameters\": \"dodatkowe parametry mpv\",\n        \"mpvExtraParameters_description\": \"dodatkowe argumenty do przekazania mpv\",\n        \"hotkey_listNavigateToPage\": \"lista nawigacja do strony elementu\",\n        \"hotkey_listPlayDefault\": \"lista odtwarzaj\",\n        \"hotkey_listPlayLast\": \"lista odtwarzaj ostatnie\",\n        \"hotkey_listPlayNext\": \"lista odtwarzaj następne\",\n        \"hotkey_listPlayNow\": \"lista odtwarzaj teraz\",\n        \"pathReplace\": \"zamiana ścieżki pliku\",\n        \"pathReplace_description\": \"zamień domyślną ścieżkę pliku twojego serwera\",\n        \"pathReplace_optionRemovePrefix\": \"usuń prefix\",\n        \"pathReplace_optionAddPrefix\": \"dodaj prefix\",\n        \"homeFeatureStyle_description\": \"kontroluje styl karuzeli polecanych na stronie głównej\",\n        \"homeFeatureStyle\": \"Styl karuzeli polecanych na stronie głównej\",\n        \"homeFeatureStyle_optionMultiple\": \"wiele\",\n        \"homeFeatureStyle_optionSingle\": \"jeden\",\n        \"enableGridMultiSelect_description\": \"gdy włączone, pozwala na wybieranie wielu elementów w widokach siatki, gdy wyłączone, klikanie obrazów elementów siatki będzie przenosić na stronę elementu\",\n        \"enableGridMultiSelect\": \"wybieranie wielu w siatce\",\n        \"sidebarPlaylistSorting_description\": \"pozwala na ręczne sortowanie playlist w bocznym pasku używając przeciągania i upuszczania zamiast używania domyślnej kolejności serwera\",\n        \"sidebarPlaylistSorting\": \"sortowanie playlist w bocznym pasku\",\n        \"sidebarPlaylistListFilterRegex_description\": \"ukryj playlisty w pasku bocznym pasujące do wyrażenia regularnego\",\n        \"sidebarPlaylistListFilterRegex_placeholder\": \"np. ^Miks codzienny.^\",\n        \"sidebarPlaylistListFilterRegex\": \"filtr playlist regex\",\n        \"blurExplicitImages\": \"rozmazuj nieodpowiednie obrazy\",\n        \"blurExplicitImages_description\": \"obrazy piosenek oraz albumów oznaczone jako nieodpowiednie będą rozmazywane\",\n        \"releaseChannel_optionAlpha\": \"alpha (nightly)\",\n        \"analyticsEnable\": \"Wysyłaj statystyki na podstawie użytkowania\",\n        \"analyticsEnable_description\": \"Zanonimizowane statystki użytkowania będą wysyłane do twórcy, aby pomóc w poprawie aplikacji\",\n        \"automaticUpdates\": \"Automatyczne aktualizacje\",\n        \"automaticUpdates_description\": \"Automatycznie sprawdzaj i instaluj aktualizacje\",\n        \"discordStateIcon\": \"pokaż ikonę odtwarzania\",\n        \"discordStateIcon_description\": \"pokazuje małą ikonę odtwarzania w statusie. ikona pauzy jest zawsze pokazywana gdy \\\"Pokaż status podczas pauzy\\\" jest włączone\",\n        \"useThemePrimaryShade\": \"użyj głównego odcienia motywu\",\n        \"useThemePrimaryShade_description\": \"używaj głównego odcienia zdefiniowanego w wybranym motywie dla głównych wariantów kolorów\",\n        \"primaryShade\": \"główny odcień\",\n        \"primaryShade_description\": \"nadpisz główny odcień (0-9) używany dla przycisków, linków i innych głównie pokolorowanych elementów\",\n        \"playerItemConfiguration_description\": \"skonfiguruj jakie elementy są pokazywane i w jakiej kolejności, w odtwarzaczu pełnoekranowym\",\n        \"playerItemConfiguration\": \"konfiguracja elementów odtwarzacza\",\n        \"autosave\": \"automatycznie zapisuj kolejkę odtwarzania\",\n        \"autosave_description\": \"włącz automatyczne zapisywanie kolejki odtwarzania na twój serwer. to jest możliwe tylko gdy używane jest Navidrome/Subsonic, i nie masz zmixowanej kolejki odtwarzania.\",\n        \"autosaveCount\": \"częstotliwość automatycznego zapisywania kolejki odtwarzania\",\n        \"autosaveCount_description\": \"ile razy piosenka zostanie zmieniona przed zapisaniem kolejki. 1 (najmniejsze) oznacza zapisywanie kolejki po każdej zmianie piosenki\"\n    },\n    \"table\": {\n        \"config\": {\n            \"view\": {\n                \"table\": \"tabela\",\n                \"grid\": \"siatka\",\n                \"list\": \"lista\",\n                \"detail\": \"szczegół\"\n            },\n            \"general\": {\n                \"displayType\": \"typ wyświetlania\",\n                \"gap\": \"$t(common.gap)\",\n                \"tableColumns\": \"kolumny tabeli\",\n                \"autoFitColumns\": \"automatyczne dopasowanie kolumn\",\n                \"size\": \"$t(common.size)\",\n                \"itemSize\": \"rozmiar elementu (px)\",\n                \"itemGap\": \"odstęp między elementami (px)\",\n                \"followCurrentSong\": \"śledź aktualną piosenkę\",\n                \"advancedSettings\": \"zaawansowane ustawienia\",\n                \"autosize\": \"rozmiar automatyczny\",\n                \"moveUp\": \"przesuń w górę\",\n                \"moveDown\": \"przesuń w dół\",\n                \"pinToLeft\": \"przypnij po lewej\",\n                \"pinToRight\": \"przypnij po prawej\",\n                \"alignLeft\": \"wyrównaj do lewej\",\n                \"alignCenter\": \"wyrównaj do środka\",\n                \"alignRight\": \"wyrównaj do prawej\",\n                \"itemsPerRow\": \"elementów na wiersz\",\n                \"size_default\": \"domyślny\",\n                \"size_compact\": \"kompaktowy\",\n                \"size_large\": \"duży\",\n                \"pagination\": \"numerowanie\",\n                \"pagination_itemsPerPage\": \"elementów na stronę\",\n                \"pagination_infinite\": \"nieskończone\",\n                \"pagination_paginate\": \"numerowane\",\n                \"alternateRowColors\": \"naprzemienne kolory wierszy\",\n                \"horizontalBorders\": \"obwódki wierszy\",\n                \"rowHoverHighlight\": \"podświetlanie wierszy po najechaniu\",\n                \"verticalBorders\": \"obwódki kolumn\",\n                \"showHeader\": \"pokaż nagłówek\"\n            },\n            \"label\": {\n                \"releaseDate\": \"data premiery\",\n                \"title\": \"$t(common.title)\",\n                \"duration\": \"$t(common.duration)\",\n                \"titleCombined\": \"$t(common.title) (połączony)\",\n                \"dateAdded\": \"data dodania\",\n                \"size\": \"$t(common.size)\",\n                \"bpm\": \"$t(common.bpm)\",\n                \"lastPlayed\": \"ostatnio odtwarzane\",\n                \"trackNumber\": \"numer utworu\",\n                \"rowIndex\": \"indeks wiersza\",\n                \"rating\": \"$t(common.rating)\",\n                \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n                \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n                \"note\": \"$t(common.note)\",\n                \"biography\": \"$t(common.biography)\",\n                \"owner\": \"$t(common.owner)\",\n                \"path\": \"$t(common.path)\",\n                \"channels\": \"$t(common.channel, {\\\"count\\\": 2})\",\n                \"playCount\": \"liczba odtworzeń\",\n                \"bitrate\": \"$t(common.bitrate)\",\n                \"actions\": \"$t(common.action, {\\\"count\\\": 2})\",\n                \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n                \"discNumber\": \"numer płyty\",\n                \"favorite\": \"$t(common.favorite)\",\n                \"year\": \"$t(common.year)\",\n                \"albumArtist\": \"$t(entity.albumArtist, {\\\"count\\\": 1})\",\n                \"codec\": \"$t(common.codec)\",\n                \"songCount\": \"$t(entity.track, {\\\"count\\\": 2})\",\n                \"albumCount\": \"$t(entity.album, {\\\"count\\\": 2})\",\n                \"genreBadge\": \"$t(entity.genre, {\\\"count\\\": 1}) (znaczki)\",\n                \"image\": \"obraz\",\n                \"bitDepth\": \"$t(common.bitDepth)\",\n                \"sampleRate\": \"$t(common.sampleRate)\",\n                \"composer\": \"kompozytor\",\n                \"titleArtist\": \"$t(common.title) (wykonawca)\",\n                \"albumGroup\": \"grupa albumu\"\n            }\n        },\n        \"column\": {\n            \"comment\": \"komentarz\",\n            \"album\": \"album\",\n            \"rating\": \"ocena\",\n            \"favorite\": \"ulubione\",\n            \"playCount\": \"odtwarzane\",\n            \"albumCount\": \"$t(entity.album, {\\\"count\\\": 2})\",\n            \"releaseYear\": \"rok\",\n            \"lastPlayed\": \"ostatnio odtwarzane\",\n            \"biography\": \"biografia\",\n            \"releaseDate\": \"data premiery\",\n            \"bitrate\": \"bitrate\",\n            \"title\": \"tytuł\",\n            \"bpm\": \"bpm\",\n            \"dateAdded\": \"data dodania\",\n            \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n            \"songCount\": \"$t(entity.track, {\\\"count\\\": 2})\",\n            \"trackNumber\": \"utwór\",\n            \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n            \"albumArtist\": \"wykonawca albumu\",\n            \"path\": \"ścieżka\",\n            \"discNumber\": \"płyta\",\n            \"channels\": \"$t(common.channel, {\\\"count\\\": 2})\",\n            \"size\": \"$t(common.size)\",\n            \"codec\": \"$t(common.codec)\",\n            \"owner\": \"właściciel\",\n            \"bitDepth\": \"$t(common.bitDepth)\",\n            \"sampleRate\": \"$t(common.sampleRate)\"\n        }\n    },\n    \"queryBuilder\": {\n        \"standardTags\": \"tagi standardowe\",\n        \"customTags\": \"tagi niestandardowe\"\n    },\n    \"releaseType\": {\n        \"primary\": {\n            \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n            \"broadcast\": \"broadcast\",\n            \"ep\": \"ep\",\n            \"other\": \"inne\",\n            \"single\": \"single\"\n        },\n        \"secondary\": {\n            \"audiobook\": \"audiobook\",\n            \"audioDrama\": \"audio drama\",\n            \"compilation\": \"kompilacja\",\n            \"djMix\": \"mix dj\",\n            \"demo\": \"demo\",\n            \"fieldRecording\": \"nagranie w terenie\",\n            \"interview\": \"wywiad\",\n            \"live\": \"na żywo\",\n            \"mixtape\": \"mixtape\",\n            \"remix\": \"remix\",\n            \"soundtrack\": \"ścieżka dźwiękowa\",\n            \"spokenWord\": \"słowo mówione\"\n        }\n    },\n    \"dragDropZone\": {\n        \"error_oneFileOnly\": \"Wybierz tylko 1 plik\",\n        \"error_readingFile\": \"wystąpił problem z odczytaniem pliku: {{errorMessage}}\",\n        \"mainText\": \"upuść plik tutaj\"\n    },\n    \"filterOperator\": {\n        \"after\": \"jest po\",\n        \"afterDate\": \"jest po (dacie)\",\n        \"before\": \"jest przed\",\n        \"beforeDate\": \"jest przed (datą)\",\n        \"contains\": \"zawiera\",\n        \"endsWith\": \"kończy się na\",\n        \"inPlaylist\": \"jest w\",\n        \"inTheLast\": \"jest w ostatnim\",\n        \"inTheRange\": \"jest w zakresie\",\n        \"inTheRangeDate\": \"jest w zakresie (dat)\",\n        \"is\": \"jest\",\n        \"isNot\": \"nie jest\",\n        \"isGreaterThan\": \"jest większe od\",\n        \"isLessThan\": \"jest mniejsze od\",\n        \"matchesRegex\": \"pasuje do wyrażenia regularnego (regex)\",\n        \"notContains\": \"nie zawiera\",\n        \"notInPlaylist\": \"nie jest w\",\n        \"notInTheLast\": \"nie jest w ostatnim\",\n        \"startsWith\": \"zaczyna się od\"\n    },\n    \"datetime\": {\n        \"minuteShort\": \"min\",\n        \"secondShort\": \"sek\",\n        \"hourShort\": \"godz\",\n        \"dayShort\": \"dzień\"\n    },\n    \"visualizer\": {\n        \"visualizerType\": \"Typ Wizualizacji\",\n        \"cycleTime\": \"Czas cyklu (w sekundach)\",\n        \"copyConfiguration\": \"Kopiuj Konfigurację\",\n        \"pasteConfiguration\": \"Wklej Konfigurację\",\n        \"pasteConfigurationPlaceholder\": \"Wklej konfigurację JSON tutaj...\",\n        \"pasteFromClipboard\": \"Wklej z schowka\",\n        \"applyConfiguration\": \"Zastosuj Konfigurację\",\n        \"configCopied\": \"Konfiguracja skopiowana do schowka\",\n        \"configCopyFailed\": \"Nie udało się skopiować konfiguracji\",\n        \"configPasted\": \"Konfiguracja zastosowana pomyślnie\",\n        \"configPasteFailed\": \"Nie udało się zastosować konfiguracji. Sprawdź jej format.\",\n        \"configPasteReadFailed\": \"Nie udało się odczytać z schowka\",\n        \"cyclePresets\": \"Cykl Ustawień\",\n        \"includeAllPresets\": \"Uwzględnij wszystkie Ustawienia\",\n        \"ignoredPresets\": \"Ignorowane Ustawienia\",\n        \"selectedPresets\": \"Wybrane Ustawienia\",\n        \"randomizeNextPreset\": \"Losuj Następne Ustawienie\",\n        \"blendTime\": \"Czas Mieszania\",\n        \"presets\": \"Ustawienia\",\n        \"selectPreset\": \"Wybierz Ustawienie\",\n        \"applyPreset\": \"Zastosuj Ustawienie\",\n        \"saveAsPreset\": \"Zapisz jako Ustawienie\",\n        \"updatePreset\": \"Uaktualnij Ustawienie\",\n        \"presetName\": \"Nazwa Ustawienia\",\n        \"presetNamePlaceholder\": \"Wpisz nazwę ustawienia\",\n        \"general\": \"Ogólne\",\n        \"mode\": \"Tryb\",\n        \"mode1To8\": \"Tryb 1 - 8\",\n        \"mode10\": \"Tryb 10\",\n        \"barSpace\": \"Odstęp Pasków\",\n        \"lineWidth\": \"Szerokość Linii\",\n        \"fillAlpha\": \"Wypełnij Alpha\",\n        \"channelLayout\": \"Układ Kanałów\",\n        \"maxFPS\": \"Maks FPS\",\n        \"opacity\": \"Nieprzezroczystość\",\n        \"customGradients\": \"­Niestandardowe Gradienty\",\n        \"addCustomGradient\": \"Dodaj Niestandardowy Gradient\",\n        \"gradientName\": \"Nazwa Gradientu\",\n        \"gradientNamePlaceholder\": \"Nazwa Gradientu\",\n        \"vertical\": \"Pionowy\",\n        \"horizontal\": \"Poziomy\",\n        \"colorStops\": \"Kroki Kolorów\",\n        \"addColor\": \"Dodaj Kolor\",\n        \"position\": \"Pozycja\",\n        \"level\": \"Poziom\",\n        \"remove\": \"Usuń\",\n        \"custom\": \"Niestandardowy\",\n        \"builtIn\": \"Wbudowany\",\n        \"colors\": \"Kolory\",\n        \"colorMode\": \"Tryb Koloru\",\n        \"gradient\": \"Gradient\",\n        \"gradientLeft\": \"Lewa Gradientu\",\n        \"gradientRight\": \"Prawa Gradientu\",\n        \"fft\": \"FFT\",\n        \"fftSize\": \"Rozmiar FFT\",\n        \"smoothing\": \"Wygładzanie\",\n        \"frequencyRangeAndScaling\": \"Zakres częstotliwości i skalowanie\",\n        \"minimumFrequency\": \"Minimalna Częstotliwość\",\n        \"maximumFrequency\": \"Maksymalna Częstotliwość\",\n        \"frequencyScale\": \"Skala Częstotliwości\",\n        \"sensitivity\": \"Czułość\",\n        \"weightingFilter\": \"Filtr Wagi\",\n        \"minimumDecibels\": \"Minimum Decybeli\",\n        \"maximumDecibels\": \"Maksimum Decybeli\",\n        \"linearAmplitude\": \"Amplituda Linearna\",\n        \"linearBoost\": \"Podbicie Linearne\",\n        \"peakBehavior\": \"Zachowanie Szczytów\",\n        \"showPeaks\": \"Pokaż Szczyty\",\n        \"fadePeaks\": \"Zanikaj Sczyty\",\n        \"peakLine\": \"Linia Szczytów\",\n        \"gravity\": \"Grawitacja\",\n        \"peakFadeTime\": \"Czas Zanikania Szczytów (ms)\",\n        \"peakHoldTime\": \"Czas Utrzymywania Szczytu (ms)\",\n        \"radialSpectrum\": \"Spektrum Promieniowe\",\n        \"radial\": \"Promieniowe\",\n        \"radialInvert\": \"Odwrócenie Promieniowe\",\n        \"spinSpeed\": \"Prędkość Obrotu\",\n        \"radius\": \"Promień\",\n        \"reflexMirror\": \"Lustro refleksyjne\",\n        \"reflexFit\": \"Dopasowanie Odbić\",\n        \"reflexRatio\": \"Współczynnik Odbić\",\n        \"reflexAlpha\": \"Alpha Odbić\",\n        \"reflexBrightness\": \"Jasność Odbić\",\n        \"mirror\": \"Odbij lustrzanie\",\n        \"miscellaneousSettings\": \"Różne Ustawienia\",\n        \"alphaBars\": \"Alpha Pasków\",\n        \"ledBars\": \"Paski LED\",\n        \"trueLeds\": \"Prawdziwe LEDy\",\n        \"lumiBars\": \"Paski Lumi\",\n        \"outlineBars\": \"Obwódki Pasków\",\n        \"roundBars\": \"Zaokrąglone Paski\",\n        \"lowResolution\": \"­Niska Rozdzielczość\",\n        \"splitGradient\": \"Rozdziel Gradient\",\n        \"showFPS\": \"Pokaż FPS\",\n        \"showScaleX\": \"Pokaż Skalę X\",\n        \"noteLabels\": \"Etykiety Nut\",\n        \"showScaleY\": \"Pokaż Skalę Y\",\n        \"options\": {\n            \"colorMode\": {\n                \"gradient\": \"Gradient\",\n                \"barIndex\": \"Indeks-Paska\",\n                \"barLevel\": \"Poziom-Paska\"\n            },\n            \"gradient\": {\n                \"classic\": \"Klasyczny\",\n                \"prism\": \"Pryzmat\",\n                \"rainbow\": \"Tęcza\",\n                \"steelblue\": \"Stalowoniebieski\",\n                \"orangered\": \"Pomarańczowo-czerwony\"\n            },\n            \"channelLayout\": {\n                \"single\": \"Pojedynczy\",\n                \"dualCombined\": \"Podwójne-Połączone\",\n                \"dualHorizontal\": \"Podwójne-Poziome\",\n                \"dualVertical\": \"Podwójne-Pionowe\"\n            },\n            \"frequencyScale\": {\n                \"linear\": \"Skala linearna\",\n                \"none\": \"Żadna\",\n                \"bark\": \"Skala bark\",\n                \"log\": \"Skala log\",\n                \"mel\": \"Skala Mel\"\n            },\n            \"weightingFilter\": {\n                \"none\": \"Żadne\",\n                \"a\": \"A\",\n                \"b\": \"B\",\n                \"c\": \"C\",\n                \"d\": \"D\",\n                \"z\": \"Z\"\n            },\n            \"mode\": {\n                \"0\": \"[0] Dyskretne częstotliwości\",\n                \"1\": \"[1] 1/24 oktawy / 240 pasm\",\n                \"2\": \"[2] 1/12 oktawy / 120 pasm\",\n                \"3\": \"[3] 1/8 oktawy / 80 pasm\",\n                \"4\": \"[4] 1/6 oktawy / 60 pasm\",\n                \"5\": \"[5] 1/4 oktawy / 40 pasm\",\n                \"6\": \"[6] 1/3 oktawy / 30 pasm\",\n                \"7\": \"[7] Pół oktawy / 20 pasm\",\n                \"8\": \"[8] Pełna oktawa / 10 pasm\",\n                \"10\": \"[10] Linia / Wykres miejscowy\"\n            }\n        },\n        \"pasteGradient\": \"Wklej Gradient\",\n        \"pasteGradientPlaceholder\": \"Wklej tutaj JSON gradientu...\",\n        \"ansiBands\": \"Paski ANSI\"\n    }\n}\n"
  },
  {
    "path": "src/i18n/locales/pt-BR.json",
    "content": "{\n    \"common\": {\n        \"backward\": \"para trás\",\n        \"areYouSure\": \"tem certeza?\",\n        \"add\": \"adicionar\",\n        \"ascending\": \"ascendente\",\n        \"center\": \"centro\",\n        \"cancel\": \"cancelar\",\n        \"bitrate\": \"taxa de bits\",\n        \"action_one\": \"ação\",\n        \"action_many\": \"ações\",\n        \"action_other\": \"ações\",\n        \"biography\": \"biografia\",\n        \"bpm\": \"BPM\",\n        \"edit\": \"editar\",\n        \"favorite\": \"favorito\",\n        \"currentSong\": \"$t(entity.track, {\\\"count\\\": 1}) atual\",\n        \"descending\": \"abaixar\",\n        \"dismiss\": \"liberar\",\n        \"duration\": \"duração\",\n        \"decrease\": \"diminuir\",\n        \"description\": \"descrição\",\n        \"configure\": \"configurar\",\n        \"enable\": \"habilitar\",\n        \"clear\": \"limpar\",\n        \"delete\": \"deletar\",\n        \"title\": \"titulo\",\n        \"create\": \"criar\",\n        \"confirm\": \"confirmar\",\n        \"home\": \"início\",\n        \"comingSoon\": \"em breve…\",\n        \"channel_one\": \"canal\",\n        \"channel_many\": \"canais\",\n        \"channel_other\": \"canais\",\n        \"disable\": \"desabilitar\",\n        \"expand\": \"expandir\",\n        \"disc\": \"disco\",\n        \"increase\": \"incrementar\",\n        \"rating\": \"classificação\",\n        \"refresh\": \"atualizar\",\n        \"unknown\": \"desconhecido\",\n        \"left\": \"esquerda\",\n        \"save\": \"salvar\",\n        \"right\": \"direita\",\n        \"collapse\": \"minimizar\",\n        \"trackNumber\": \"faixa\",\n        \"gap\": \"intervalo\",\n        \"year\": \"ano\",\n        \"manage\": \"gerenciar\",\n        \"limit\": \"limite\",\n        \"minimize\": \"minimizar\",\n        \"modified\": \"modificado\",\n        \"name\": \"nome\",\n        \"maximize\": \"maximizar\",\n        \"ok\": \"ok\",\n        \"path\": \"caminho\",\n        \"no\": \"não\",\n        \"owner\": \"dono\",\n        \"forward\": \"para frente\",\n        \"forceRestartRequired\": \"reinicie para aplicar as alterações… feche a notificação para reiniciar\",\n        \"setting_one\": \"configuração\",\n        \"setting_many\": \"\",\n        \"setting_other\": \"\",\n        \"version\": \"versão\",\n        \"filter_one\": \"filtro\",\n        \"filter_many\": \"filtros\",\n        \"filter_other\": \"filtros\",\n        \"filters\": \"filtros\",\n        \"saveAndReplace\": \"salvar e substituir\",\n        \"playerMustBePaused\": \"o player deve estar pausado\",\n        \"resetToDefault\": \"restaurar ao padrão\",\n        \"reset\": \"reiniciar\",\n        \"sortOrder\": \"ordem\",\n        \"none\": \"nenhum\",\n        \"menu\": \"menu\",\n        \"restartRequired\": \"é necessário reiniciar\",\n        \"previousSong\": \"anterior $t(entity.track, {\\\"count\\\": 1})\",\n        \"noResultsFromQuery\": \"a consulta não retornou resultados\",\n        \"quit\": \"sair\",\n        \"search\": \"procurar\",\n        \"saveAs\": \"salvar como\",\n        \"yes\": \"sim\",\n        \"random\": \"aleatório\",\n        \"size\": \"tamanho\",\n        \"note\": \"observação\",\n        \"mbid\": \"ID no MusicBrainz\",\n        \"reload\": \"recarregar\",\n        \"codec\": \"codec\",\n        \"preview\": \"pré-visualizar\",\n        \"share\": \"compartilhar\",\n        \"close\": \"fechar\",\n        \"translation\": \"tradução\",\n        \"albumGain\": \"ganho do album\",\n        \"trackPeak\": \"peak da faixa\",\n        \"albumPeak\": \"pico do álbum\",\n        \"trackGain\": \"ganho da faixa\",\n        \"additionalParticipants\": \"participantes adicionais\",\n        \"tags\": \"tags\",\n        \"newVersion\": \"uma nova versão foi instalada ({{version}})\",\n        \"viewReleaseNotes\": \"ver notas de lançamento\",\n        \"bitDepth\": \"profundidade de bits\",\n        \"sampleRate\": \"taxa de amostragem\"\n    },\n    \"action\": {\n        \"goToPage\": \"vá para página\",\n        \"addToFavorites\": \"adicionar em $t(entity.favorite, {\\\"count\\\": 2})\",\n        \"viewPlaylists\": \"ver $t(entity.playlist, {\\\"count\\\": 2})\",\n        \"setRating\": \"definir classificação\",\n        \"moveToTop\": \"mover para o topo\",\n        \"refresh\": \"$t(common.refresh)\",\n        \"removeFromQueue\": \"remover da fila\",\n        \"moveToBottom\": \"mover para baixo\",\n        \"editPlaylist\": \"editar $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"clearQueue\": \"Limpar fila\",\n        \"addToPlaylist\": \"adicionar à $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"createPlaylist\": \"Criar $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"removeFromPlaylist\": \"remover da $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"deletePlaylist\": \"deletar $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"deselectAll\": \"desmarcar todos\",\n        \"removeFromFavorites\": \"remover de $t(entity.favorite, {\\\"count\\\": 2})\",\n        \"openIn\": {\n            \"lastfm\": \"Abrir em Last.fm\",\n            \"musicbrainz\": \"Abrir em MusicBrainz\"\n        },\n        \"toggleSmartPlaylistEditor\": \"alternar editor $t(entity.smartPlaylist)\",\n        \"moveToNext\": \"mover para o próximo\"\n    },\n    \"form\": {\n        \"deletePlaylist\": {\n            \"title\": \"deletar $t(entity.playlist, {\\\"count\\\": 1})\",\n            \"input_confirm\": \"escreva o nome da $t(entity.playlist, {\\\"count\\\": 1}) para confirmar\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) deletada com sucesso\"\n        },\n        \"addServer\": {\n            \"title\": \"adicionar servidor\",\n            \"input_password\": \"senha\",\n            \"input_legacyAuthentication\": \"habilitar autenticação legada\",\n            \"error_savePassword\": \"um erro ocorreu ao tentar salvar a senha\",\n            \"ignoreSsl\": \"ignorar ssl ($t(common.restartRequired))\",\n            \"input_savePassword\": \"salvar senha\",\n            \"input_url\": \"url\",\n            \"success\": \"servidor adicionado com sucesso\",\n            \"input_name\": \"nome do servidor\",\n            \"input_username\": \"nome de usuário\",\n            \"ignoreCors\": \"ignorar CORS ($t(common.restartRequired))\",\n            \"input_preferInstantMix\": \"Preferir mixagem instantânea\",\n            \"input_preferInstantMixDescription\": \"Usar apenas a mixagem instantânea para obter músicas semelhantes. Útil se você tiver plugins que modificam esse comportamento\"\n        },\n        \"createPlaylist\": {\n            \"title\": \"criar $t(entity.playlist, {\\\"count\\\": 1})\",\n            \"input_public\": \"público\",\n            \"input_description\": \"$t(common.description)\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) criada com sucesso\",\n            \"input_owner\": \"$t(common.owner)\",\n            \"input_name\": \"$t(common.name)\"\n        },\n        \"updateServer\": {\n            \"title\": \"atualizar servidor\",\n            \"success\": \"servidor atualizado com sucesso\"\n        },\n        \"editPlaylist\": {\n            \"title\": \"editar $t(entity.playlist, {\\\"count\\\": 1})\",\n            \"publicJellyfinNote\": \"O Jellyfin por algum motivo não expõe se uma playlist é pública ou não. Se você deseja que ela permaneça pública, por favor selecione a seguinte entrada\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) atualizada com sucesso\"\n        },\n        \"addToPlaylist\": {\n            \"title\": \"adicionar à $t(entity.playlist, {\\\"count\\\": 1})\",\n            \"input_playlists\": \"$t(entity.playlist, {\\\"count\\\": 2})\",\n            \"input_skipDuplicates\": \"pular duplicadas\",\n            \"success\": \"adicionado $t(entity.trackWithCount, {\\\"count\\\": {{message}} }) para $t(entity.playlistWithCount, {\\\"count\\\": {{numOfPlaylists}} })\"\n        },\n        \"lyricSearch\": {\n            \"title\": \"pesquisa de letras\",\n            \"input_artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n            \"input_name\": \"$t(common.name)\"\n        },\n        \"shareItem\": {\n            \"createFailed\": \"falha ao criar compartilhamento (o compartilhamento está ativado?)\",\n            \"setExpiration\": \"definir expiração\",\n            \"success\": \"link de compartilhamento copiado para a área de transferência (ou clique aqui para abrir)\",\n            \"allowDownloading\": \"permitir downloads\",\n            \"description\": \"descrição\",\n            \"expireInvalid\": \"a expiração deve ser uma data futura\"\n        },\n        \"queryEditor\": {\n            \"input_optionMatchAny\": \"corresponder qualquer um\",\n            \"input_optionMatchAll\": \"corresponder todos\",\n            \"title\": \"Editor de consultas\"\n        },\n        \"privateMode\": {\n            \"enabled\": \"Modo privado ativado, o status de reprodução agora está oculto para integrações externas\",\n            \"disabled\": \"Modo privado desativado, o status de reprodução agora está visível para as integrações externas ativadas\",\n            \"title\": \"Modo privado\"\n        }\n    },\n    \"setting\": {\n        \"discordIdleStatus_description\": \"quando ativado, atualiza o status enquanto o player está ocioso\",\n        \"discordUpdateInterval_description\": \"o tempo em segundos entre cada atualização (mínimo 15 segundos)\",\n        \"playButtonBehavior_description\": \"define o comportamento padrão do botão play ao adicionar músicas à fila\",\n        \"discordApplicationId\": \"{{discord}} ID do aplicativo\",\n        \"audioPlayer\": \"player de áudio\",\n        \"applicationHotkeys\": \"teclas de atalho da aplicação\",\n        \"applicationHotkeys_description\": \"configure as teclas de atalho da aplicação. clique na caixa de seleção para definir como tecla de atalho global (somente desktop)\",\n        \"contextMenu\": \"configuração do menu de contexto (clique do botão direito do mouse)\",\n        \"clearQueryCache\": \"limpar cache do feishin\",\n        \"clearCache\": \"limpar cache do navegador\",\n        \"clearQueryCache_description\": \"uma 'limpeza leve' do feishin. isso irá renovar playlists, metadados de faixas, e resetar letras salvas. as configurações, as credenciais de servidor e o cache de imagens serão mantidos\",\n        \"audioPlayer_description\": \"selecione o player de áudio usado para reprodução\",\n        \"audioExclusiveMode\": \"modo de áudio exclusivo\",\n        \"buttonSize\": \"tamanho do botão da barra de reprodução\",\n        \"albumBackground_description\": \"adiciona uma imagem de fundo contendo a arte do álbum para a página de álbum\",\n        \"clearCache_description\": \"uma 'limpeza geral' do feishin. em adição a limpar o cache do feishin, limpa o cache do navegador (imagens salvas e outros recursos). as credenciais de servidor e as configurações serão mantidas\",\n        \"clearCacheSuccess\": \"cache limpo com sucesso\",\n        \"audioDevice\": \"dispositivo de áudio\",\n        \"audioDevice_description\": \"selecione o dispositivo de áudio usado para reprodução (somente player web)\",\n        \"audioExclusiveMode_description\": \"ativar modo de saída exclusiva. Neste modo, o sistema é geralmente bloqueado, e apenas mpv terá saída de áudio\",\n        \"accentColor\": \"cor de realce\",\n        \"accentColor_description\": \"define a cor de realce para a aplicação\",\n        \"artistConfiguration\": \"configuração da página de artista de álbum\",\n        \"artistConfiguration_description\": \"configure quais itens serão mostrados, e em qual ordem, na página de artista de álbum\",\n        \"buttonSize_description\": \"o tamanho dos botões da barra de reprodução\",\n        \"albumBackgroundBlur\": \"tamanho de desfoque da imagem de fundo do álbum\",\n        \"albumBackgroundBlur_description\": \"ajusta a quantidade de desfoque aplicada para a imagem de fundo do álbum\",\n        \"albumBackground\": \"imagem de fundo do álbum\",\n        \"contextMenu_description\": \"permite esconder itens exibidos no menu quando você clica em um item com o botão direito. itens não selecionados serão escondidos\",\n        \"customCssEnable\": \"habilitar css customizado\",\n        \"customCssEnable_description\": \"Permitir escrever CSS personalizado\",\n        \"crossfadeDuration\": \"duraçao de crossfade\",\n        \"customCss\": \"css customizado\",\n        \"crossfadeDuration_description\": \"define a duração do efeito crossfade\",\n        \"customCssNotice\": \"Atenção: embora haja alguma sanitização (proibindo url() e content:), usar CSS personalizado ainda pode representar riscos ao alterar a interface\",\n        \"crossfadeStyle_description\": \"seleciona qual estilo de crossfade usado no player de áudio\",\n        \"disableLibraryUpdateOnStartup\": \"desabilitar a verificação de novas versões na inicialização\",\n        \"artistBackground\": \"Imagem de fundo do artista\",\n        \"artistBackground_description\": \"Adiciona uma imagem de fundo às páginas do artista contendo a arte do artista\",\n        \"artistBackgroundBlur\": \"Tamanho do desfoque da imagem de fundo do artista\",\n        \"artistBackgroundBlur_description\": \"Ajusta a quantidade de desfoque aplicada à imagem de fundo do artista\",\n        \"customCss_description\": \"Conteúdo CSS personalizado. Observação: conteúdo e URLs remotas são propriedades não permitidas. Uma pré-visualização do seu conteúdo é exibida abaixo. Campos adicionais que você não definiu estão presentes devido à sanitização\",\n        \"customFontPath\": \"Caminho da fonte personalizada\",\n        \"customFontPath_description\": \"Define o caminho da fonte personalizada a ser usada na aplicação\",\n        \"releaseChannel_optionLatest\": \"Estável\",\n        \"releaseChannel_optionBeta\": \"beta\",\n        \"releaseChannel\": \"Canal de lançamento\",\n        \"releaseChannel_description\": \"Escolha entre versões estáveis ou versões beta para atualizações automáticas\",\n        \"discordApplicationId_description\": \"O ID da aplicação para o Rich Presence do {{discord}} (defaults: {{defaultId}})\",\n        \"discordPausedStatus\": \"Mostrar Rich Presence quando pausado\",\n        \"discordPausedStatus_description\": \"Quando ativado, o status será exibido mesmo quando o reprodutor estiver pausado\",\n        \"discordIdleStatus\": \"Mostrar status ocioso do Rich Presence\",\n        \"discordListening\": \"Mostrar status como ouvindo\",\n        \"discordListening_description\": \"Mostrar status como ouvindo em vez de reproduzindo\",\n        \"discordRichPresence_description\": \"Ativar status de reprodução no Rich Presence do {{discord}}. As chaves de imagem são: {{icon}}, {{playing}} e {{paused}}\",\n        \"discordServeImage\": \"Servir imagens do {{discord}} a partir do servidor\",\n        \"discordServeImage_description\": \"Compartilhar a capa para o Rich Presence do {{discord}} a partir do próprio servidor, disponível apenas para Jellyfin e Navidrome. O {{discord}} usa um bot para buscar imagens, portanto seu servidor deve estar acessível pela internet pública\",\n        \"discordUpdateInterval\": \"Intervalo de atualização do Rich Presence do {{discord}}\",\n        \"discordDisplayType\": \"Tipo de exibição da presença do {{discord}}\",\n        \"discordDisplayType_description\": \"Altera o que você está ouvindo no seu status\",\n        \"discordDisplayType_songname\": \"Nome da música\",\n        \"discordDisplayType_artistname\": \"Nome(s) do artista\",\n        \"discordLinkType\": \"Links de presença do {{discord}}\",\n        \"discordLinkType_description\": \"Adiciona links externos para {{lastfm}} ou {{musicbrainz}} aos campos de música e artista no Rich Presence do {{discord}}. {{musicbrainz}} é o mais preciso, mas requer tags e não fornece links de artistas, enquanto {{lastfm}} deve sempre fornecer um link. Não realiza requisições de rede adicionais\",\n        \"discordLinkType_none\": \"$t(common.none)\",\n        \"discordLinkType_mbz_lastfm\": \"{{musicbrainz}} com alternativa para {{lastfm}}\",\n        \"enableRemote\": \"Ativar servidor de controle remoto\",\n        \"enableRemote_description\": \"Ativa o servidor de controle remoto para permitir que outros dispositivos controlem o aplicativo\",\n        \"externalLinks\": \"Mostrar links externos\",\n        \"externalLinks_description\": \"Ativa a exibição de links externos (Last.fm, MusicBrainz) nas páginas de artista/álbum\",\n        \"exitToTray\": \"Minimizar para a bandeja\",\n        \"exitToTray_description\": \"Fechar o aplicativo para a bandeja do sistema\",\n        \"followLyric\": \"Seguir a letra atual\",\n        \"followLyric_description\": \"Mover a letra até o ponto atual da música\",\n        \"preferLocalLyrics\": \"Preferir letras locais\",\n        \"preferLocalLyrics_description\": \"Preferir letras locais em vez de letras remotas quando disponíveis\",\n        \"font\": \"Fonte\",\n        \"font_description\": \"Define a fonte a ser usada na aplicação\",\n        \"fontType\": \"Tipo de fonte\",\n        \"fontType_description\": \"Fonte interna escolhe uma das fontes fornecidas pelo Feishin. Fonte do sistema permite selecionar qualquer fonte disponível no seu sistema operacional. Personalizada permite que você forneça sua própria fonte\",\n        \"fontType_optionBuiltIn\": \"Fonte interna\",\n        \"fontType_optionCustom\": \"Fonte customizada\",\n        \"fontType_optionSystem\": \"Fonte do sistema\",\n        \"gaplessAudio\": \"Áudio sem intervalos\",\n        \"gaplessAudio_description\": \"Define a configuração de áudio sem intervalos para o MPV\",\n        \"gaplessAudio_optionWeak\": \"Fraco (recomendado)\",\n        \"globalMediaHotkeys\": \"Teclas de atalho globais de mídia\",\n        \"globalMediaHotkeys_description\": \"Ativar ou desativar o uso das teclas de atalho de mídia do sistema para controlar a reprodução\",\n        \"homeConfiguration\": \"Configuração da página inicial\",\n        \"homeConfiguration_description\": \"Configure quais itens são exibidos e em que ordem na página inicial\",\n        \"homeFeature\": \"Carrossel de destaque da página inicial\",\n        \"homeFeature_description\": \"Controla se o carrossel de destaque grande será exibido na página inicial\",\n        \"hotkey_browserBack\": \"Voltar no navegador\",\n        \"hotkey_browserForward\": \"Avançar no navegador\",\n        \"hotkey_favoriteCurrentSong\": \"Favoritar $t(common.currentSong)\",\n        \"hotkey_favoritePreviousSong\": \"Favoritar $t(common.previousSong)\",\n        \"hotkey_globalSearch\": \"Pesquisa global\",\n        \"hotkey_localSearch\": \"Busca na página\",\n        \"hotkey_navigateHome\": \"Navegar para a página inicial\",\n        \"hotkey_playbackNext\": \"Próxima faixa\",\n        \"hotkey_playbackPause\": \"Pausar\",\n        \"hotkey_playbackPlay\": \"Play\",\n        \"hotkey_playbackPlayPause\": \"Play / Pausar\",\n        \"hotkey_playbackPrevious\": \"Faixa anterior\",\n        \"hotkey_playbackStop\": \"Parar\",\n        \"hotkey_rate0\": \"Limpar avaliação\",\n        \"hotkey_rate1\": \"Avaliar: 1 estrela\",\n        \"hotkey_rate2\": \"Avaliar: 2 estrelas\",\n        \"hotkey_rate3\": \"Avaliar: 3 estrelas\",\n        \"hotkey_rate4\": \"Avaliar: 4 estrelas\",\n        \"hotkey_rate5\": \"Avaliar: 5 estrelas\",\n        \"hotkey_skipBackward\": \"Voltar\",\n        \"hotkey_skipForward\": \"Avançar\",\n        \"hotkey_toggleCurrentSongFavorite\": \"Alternar favorito de $t(common.currentSong)\",\n        \"hotkey_toggleFullScreenPlayer\": \"Alternar reprodutor em tela cheia\",\n        \"hotkey_togglePreviousSongFavorite\": \"Alternar favorito de $t(common.previousSong)\",\n        \"hotkey_toggleQueue\": \"Alternar fila\",\n        \"hotkey_toggleRepeat\": \"Alternar repetição\",\n        \"hotkey_toggleShuffle\": \"Alternar reprodução aleatória\",\n        \"hotkey_unfavoriteCurrentSong\": \"Remover $t(common.currentSong) dos favoritos\",\n        \"hotkey_unfavoritePreviousSong\": \"Remover $t(common.previousSong) dos favoritos\",\n        \"hotkey_volumeDown\": \"Diminuir volume\",\n        \"hotkey_volumeMute\": \"volume mudo\",\n        \"hotkey_volumeUp\": \"aumentar volume\",\n        \"hotkey_zoomIn\": \"aproximar\",\n        \"hotkey_zoomOut\": \"afastar\",\n        \"imageAspectRatio\": \"usar proporção nativa da capa\",\n        \"imageAspectRatio_description\": \"se ativado, a capa será exibida usando sua proporção nativa. Para capas que não forem 1:1, o espaço restante ficará vazio\",\n        \"language_description\": \"define o idioma da aplicação ($t(common.restartRequired))\",\n        \"lastfm\": \"mostrar links do Last.fm\",\n        \"lastfm_description\": \"exibir links para o Last.fm nas páginas de artista/álbum\",\n        \"lastfmApiKey\": \"{{lastfm}} chave API\",\n        \"lastfmApiKey_description\": \"a chave de API para {{lastfm}}. Necessária para capas de álbuns\",\n        \"lyricFetch\": \"buscar letras na internet\",\n        \"lyricFetch_description\": \"buscar letras em várias fontes da internet\",\n        \"lyricFetchProvider\": \"provedores para buscar letras\",\n        \"lyricFetchProvider_description\": \"Selecione os provedores para buscar letras. A ordem dos provedores é a ordem em que serão consultados\",\n        \"lyricOffset\": \"compensação da letra (ms)\",\n        \"lyricOffset_description\": \"compensar a letra pelo valor especificado em milissegundos\",\n        \"minimizeToTray\": \"minimizar para a bandeja\",\n        \"minimizeToTray_description\": \"minimizar a aplicação para a bandeja do sistema\",\n        \"minimumScrobblePercentage\": \"duração mínima para scrobble (percentual)\",\n        \"minimumScrobblePercentage_description\": \"o percentual mínimo da música que deve ser reproduzido antes de ser scrobblada\",\n        \"minimumScrobbleSeconds\": \"scrobble mínimo (segundos)\",\n        \"minimumScrobbleSeconds_description\": \"a duração mínima em segundos da música que deve ser reproduzida antes de ser scrobblada\",\n        \"mpvExecutablePath\": \"caminho do executável do MPV\",\n        \"mpvExecutablePath_description\": \"define o caminho para o executável do MPV. Se deixado vazio, o caminho padrão será usado\",\n        \"mpvExtraParameters_help\": \"um por linha\",\n        \"musicbrainz\": \"mostrar links do MusicBrainz\",\n        \"musicbrainz_description\": \"exibir links para o MusicBrainz nas páginas de artista/álbum, quando o ID do MusicBrainz existir\",\n        \"neteaseTranslation\": \"ativar traduções do NetEase\",\n        \"neteaseTranslation_description\": \"Quando ativado, busca e exibe letras traduzidas do NetEase, se disponíveis\",\n        \"passwordStore\": \"Armazenamento de senhas/segredos\",\n        \"passwordStore_description\": \"qual armazenamento de senhas/segredos usar. Altere isto se estiver tendo problemas para armazenar senhas\",\n        \"playbackStyle\": \"estilo de reprodução\",\n        \"playbackStyle_description\": \"selecione o estilo de reprodução a ser usado pelo reprodutor de áudio\",\n        \"playbackStyle_optionCrossFade\": \"transição suave\",\n        \"playbackStyle_optionNormal\": \"normal\",\n        \"playButtonBehavior\": \"comportamento do botão de reprodução\",\n        \"playButtonBehavior_optionAddLast\": \"$t(player.addLast)\",\n        \"playButtonBehavior_optionAddNext\": \"$t(player.addNext)\",\n        \"playButtonBehavior_optionPlay\": \"$t(player.play)\",\n        \"playButtonBehavior_optionPlayShuffled\": \"$t(player.shuffle)\",\n        \"playerbarOpenDrawer\": \"alternar tela cheia na barra do reprodutor\",\n        \"playerbarOpenDrawer_description\": \"permite clicar na barra do reprodutor para abrir o reprodutor em tela cheia\",\n        \"remotePassword\": \"Senha do servidor de controle remoto\",\n        \"remotePassword_description\": \"define a senha do servidor de controle remoto. Essas credenciais, por padrão, são transferidas de forma insegura — use uma senha única da qual você não dependa\",\n        \"remotePort\": \"porta do servidor de controle remoto\",\n        \"remotePort_description\": \"Define a porta do servidor de controle remoto\",\n        \"remoteUsername\": \"Nome de usuário do servidor de controle remoto\",\n        \"remoteUsername_description\": \"Define o nome de usuário do servidor de controle remoto. Se tanto o nome de usuário quanto a senha estiverem vazios, a autenticação será desativada\",\n        \"replayGainClipping\": \"Clipping do {{ReplayGain}}\",\n        \"replayGainClipping_description\": \"Evitar clipping causado pelo {{ReplayGain}} reduzindo automaticamente o ganho\",\n        \"replayGainFallback\": \"Fallback do {{ReplayGain}}\",\n        \"replayGainFallback_description\": \"Ganho em dB a ser aplicado se o arquivo não tiver tags de {{ReplayGain}}\",\n        \"replayGainMode\": \"Modo {{ReplayGain}}\",\n        \"replayGainMode_description\": \"Ajustar o ganho de volume de acordo com os valores de {{ReplayGain}} armazenados nos metadados do arquivo\",\n        \"replayGainMode_optionAlbum\": \"$t(entity.album, {\\\"count\\\": 1})\",\n        \"replayGainMode_optionNone\": \"$t(common.none)\",\n        \"replayGainMode_optionTrack\": \"$t(entity.track, {\\\"count\\\": 1})\",\n        \"replayGainPreamp\": \"Pré-amplificador {{ReplayGain}} (dB)\",\n        \"replayGainPreamp_description\": \"Ajustar o ganho do pré-amplificador aplicado aos valores de {{ReplayGain}}\",\n        \"sampleRate\": \"Taxa de amostragem (sample rate)\",\n        \"sampleRate_description\": \"Selecione a taxa de amostragem (sample rate) de saída a ser usada se a frequência selecionada for diferente da do arquivo atual. Um valor menor que 8000 usará a frequência padrão\",\n        \"savePlayQueue\": \"Salvar fila de reprodução\",\n        \"savePlayQueue_description\": \"Salvar a fila de reprodução ao fechar a aplicação e restaurá-la ao abrir a aplicação\",\n        \"scrobble\": \"Scrobblar\",\n        \"scrobble_description\": \"Scrobblar reproduções para o seu servidor de mídia\",\n        \"showRatings\": \"exibir avaliações por estrelas\",\n        \"showRatings_description\": \"exibir ou ocultar as avaliações por estrelas\",\n        \"showSkipButton\": \"Exibir botões de pular\",\n        \"showSkipButton_description\": \"Exibir ou ocultar os botões de pular na barra do reprodutor\",\n        \"showSkipButtons\": \"Exibir botões de pular\",\n        \"showSkipButtons_description\": \"Mostrar ou ocultar os botões de pular na barra do reprodutor\",\n        \"sidebarCollapsedNavigation\": \"Navegação da barra lateral (retraída)\",\n        \"sidebarCollapsedNavigation_description\": \"Exibir ou ocultar a navegação na barra lateral retraída\",\n        \"sidebarConfiguration\": \"Configuração da barra lateral\",\n        \"sidebarConfiguration_description\": \"Selecione os itens e a ordem em que aparecem na barra lateral\",\n        \"sidebarPlaylistList\": \"Lista de playlists da barra lateral\",\n        \"sidebarPlaylistList_description\": \"Exibir ou ocultar a lista de playlists na barra lateral\",\n        \"sidePlayQueueStyle\": \"Estilo da fila de reprodução lateral\",\n        \"sidePlayQueueStyle_description\": \"Define o estilo da fila de reprodução lateral\",\n        \"sidePlayQueueStyle_optionAttached\": \"Anexado\",\n        \"sidePlayQueueStyle_optionDetached\": \"Desanexado\",\n        \"skipDuration\": \"Duração do pulo\",\n        \"skipDuration_description\": \"Define a duração a ser pulada ao usar os botões de pular na barra do reprodutor\",\n        \"skipPlaylistPage\": \"Pular página da playlist\",\n        \"skipPlaylistPage_description\": \"Ao navegar para uma playlist, ir para a página da lista de músicas da playlist em vez da página padrão\",\n        \"startMinimized\": \"Iniciar minimizado\",\n        \"startMinimized_description\": \"Iniciar a aplicação na bandeja do sistema\",\n        \"preventSleepOnPlayback\": \"Evitar suspensão durante a reprodução\",\n        \"preventSleepOnPlayback_description\": \"Evitar que a tela entre em modo de suspensão enquanto a música está tocando\",\n        \"theme\": \"Tema\",\n        \"theme_description\": \"Define o tema a ser usado na aplicação\",\n        \"themeDark\": \"Tema (Escuro)\",\n        \"themeDark_description\": \"Define o tema escuro a ser usado na aplicação\",\n        \"themeLight\": \"Tema (Claro)\",\n        \"themeLight_description\": \"Define o tema claro a ser usado na aplicação\",\n        \"transcode_description\": \"Ativa a transcodificação para diferentes formatos\",\n        \"transcodeBitrate\": \"Taxa de bits para transcodificação\",\n        \"transcodeBitrate_description\": \"Seleciona a taxa de bits para transcodificação. 0 significa deixar o servidor escolher\",\n        \"transcodeFormat\": \"Formato para transcodificação\",\n        \"transcodeFormat_description\": \"Seleciona o formato para transcodificação. Deixe vazio para que o servidor decida\",\n        \"mediaSession\": \"Ativar sessão de mídia\",\n        \"mediaSession_description\": \"Ativa a integração com o Windows Media Session, exibindo controles de mídia e metadados na sobreposição de volume do sistema e na tela de bloqueio (somente Windows)\",\n        \"translationApiProvider\": \"Provedor da API de tradução\",\n        \"translationApiProvider_description\": \"Provedor da API para tradução\",\n        \"translationApiKey\": \"Chave da API de tradução\",\n        \"translationApiKey_description\": \"Chave da API para tradução (apenas endpoint de serviço global)\",\n        \"translationTargetLanguage\": \"Idioma de destino da tradução\",\n        \"translationTargetLanguage_description\": \"Idioma de destino para tradução\",\n        \"trayEnabled\": \"Exibir bandeja\",\n        \"trayEnabled_description\": \"Exibir/ocultar ícone/menu da bandeja. Se desativado, também desativa minimizar/sair para a bandeja\",\n        \"useSystemTheme\": \"Usar tema do sistema\",\n        \"useSystemTheme_description\": \"Seguir a preferência de tema claro ou escuro definida pelo sistema\",\n        \"volumeWheelStep\": \"Incremento da roda de volume\",\n        \"volumeWheelStep_description\": \"A quantidade de volume a ser alterada ao girar a roda do mouse sobre o controle de volume\",\n        \"volumeWidth\": \"Largura do controle deslizante de volume\",\n        \"volumeWidth_description\": \"A largura do controle deslizante de volume\",\n        \"webAudio\": \"Usar áudio da web\",\n        \"webAudio_description\": \"Usar áudio da web. Isso habilita recursos avançados como ReplayGain. Desative se houver problemas de funcionamento\",\n        \"preservePitch\": \"Preservar tom (pitch)\",\n        \"preservePitch_description\": \"Preserva o tom ao modificar a velocidade de reprodução\",\n        \"windowBarStyle\": \"Estilo de barra da janela\",\n        \"windowBarStyle_description\": \"Selecione o estilo da barra da janela\",\n        \"zoom\": \"Porcentagem de zoom\",\n        \"zoom_description\": \"Define a porcentagem de zoom para o aplicativo\"\n    },\n    \"table\": {\n        \"config\": {\n            \"label\": {\n                \"title\": \"$t(common.title)\",\n                \"titleCombined\": \"$t(common.title) (combinado)\",\n                \"discNumber\": \"numero do disco\",\n                \"actions\": \"$t(common.action_other)\",\n                \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n                \"albumArtist\": \"$t(entity.albumArtist, {\\\"count\\\": 1})\",\n                \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n                \"biography\": \"$t(common.biography)\",\n                \"bitrate\": \"$t(common.bitrate)\",\n                \"bpm\": \"$t(common.bpm)\",\n                \"channels\": \"$t(common.channel_other)\",\n                \"codec\": \"$t(common.codec)\",\n                \"dateAdded\": \"Data de adição\",\n                \"duration\": \"$t(common.duration)\",\n                \"favorite\": \"$t(common.favorite)\",\n                \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n                \"lastPlayed\": \"Última reprodução\",\n                \"note\": \"$t(common.note)\",\n                \"owner\": \"$t(common.owner)\",\n                \"path\": \"$t(common.path)\",\n                \"playCount\": \"Contagem de reproduções\",\n                \"rating\": \"$t(common.rating)\",\n                \"releaseDate\": \"Data de lançamento\",\n                \"rowIndex\": \"Índice da linha\",\n                \"size\": \"$t(common.size)\",\n                \"songCount\": \"$t(entity.track, {\\\"count\\\": 2})\",\n                \"trackNumber\": \"Número da faixa\",\n                \"year\": \"$t(common.year)\"\n            },\n            \"general\": {\n                \"autoFitColumns\": \"Ajuste automático das colunas\",\n                \"followCurrentSong\": \"Seguir música atual\",\n                \"displayType\": \"Tipo de exibição\",\n                \"gap\": \"$t(common.gap)\",\n                \"itemGap\": \"Espaçamento entre itens (px)\",\n                \"itemSize\": \"Tamanho de item (px)\",\n                \"size\": \"$t(common.size)\",\n                \"tableColumns\": \"Colunas da tabela\"\n            },\n            \"view\": {\n                \"grid\": \"Grade\",\n                \"list\": \"Lista\",\n                \"table\": \"Tabela\"\n            }\n        },\n        \"column\": {\n            \"title\": \"titulo\",\n            \"discNumber\": \"disco\",\n            \"size\": \"$t(common.size)\",\n            \"album\": \"Álbum\",\n            \"albumArtist\": \"Artista do álbum\",\n            \"albumCount\": \"$t(entity.album, {\\\"count\\\": 2})\",\n            \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n            \"biography\": \"Biografia\",\n            \"bitrate\": \"Taxa de bits\",\n            \"bpm\": \"BPM\",\n            \"channels\": \"$t(common.channel_other)\",\n            \"codec\": \"$t(common.codec)\",\n            \"comment\": \"Comentário\",\n            \"dateAdded\": \"Data adicionada\",\n            \"favorite\": \"Favorito\",\n            \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n            \"lastPlayed\": \"Último tocado\",\n            \"path\": \"Caminho\",\n            \"playCount\": \"Tocados\",\n            \"rating\": \"Avaliação\",\n            \"releaseDate\": \"Data de lançamento\",\n            \"releaseYear\": \"Ano\",\n            \"songCount\": \"$t(entity.track, {\\\"count\\\": 2})\",\n            \"trackNumber\": \"Faixa\"\n        }\n    },\n    \"page\": {\n        \"home\": {\n            \"mostPlayed\": \"mais tocado\",\n            \"newlyAdded\": \"lançamentos recém-adicionados\",\n            \"title\": \"$t(common.home)\",\n            \"explore\": \"explore a sua biblioteca\",\n            \"recentlyPlayed\": \"tocado recentemente\",\n            \"recentlyReleased\": \"Lançamentos recentes\"\n        },\n        \"albumArtistList\": {\n            \"title\": \"$t(entity.albumArtist, {\\\"count\\\": 2})\"\n        },\n        \"genreList\": {\n            \"title\": \"$t(entity.genre, {\\\"count\\\": 2})\",\n            \"showTracks\": \"mostrar $t(entity.genre, {\\\"count\\\": 1}) $t(entity.track, {\\\"count\\\": 2})\",\n            \"showAlbums\": \"mostrar $t(entity.genre, {\\\"count\\\": 1}) $t(entity.album, {\\\"count\\\": 2})\"\n        },\n        \"trackList\": {\n            \"title\": \"$t(entity.track, {\\\"count\\\": 2})\",\n            \"artistTracks\": \"faixas de {{artist}}\",\n            \"genreTracks\": \"\\\"{{genre}}\\\" $t(entity.track, {\\\"count\\\": 2})\"\n        },\n        \"globalSearch\": {\n            \"title\": \"comandos\",\n            \"commands\": {\n                \"serverCommands\": \"comandos do servidor\",\n                \"goToPage\": \"ir para a página\",\n                \"searchFor\": \"buscar por {{query}}\"\n            }\n        },\n        \"sidebar\": {\n            \"home\": \"$t(common.home)\",\n            \"tracks\": \"$t(entity.track, {\\\"count\\\": 2})\",\n            \"shared\": \"$t(entity.playlist, {\\\"count\\\": 2}) compartilhada\",\n            \"albums\": \"$t(entity.album, {\\\"count\\\": 2})\",\n            \"genres\": \"$t(entity.genre, {\\\"count\\\": 2})\",\n            \"folders\": \"$t(entity.folder, {\\\"count\\\": 2})\",\n            \"albumArtists\": \"$t(entity.albumArtist, {\\\"count\\\": 2})\",\n            \"artists\": \"$t(entity.artist, {\\\"count\\\": 2})\",\n            \"nowPlaying\": \"tocando agora\",\n            \"playlists\": \"$t(entity.playlist, {\\\"count\\\": 2})\",\n            \"search\": \"$t(common.search)\",\n            \"settings\": \"$t(common.setting, {\\\"count\\\": 2})\",\n            \"myLibrary\": \"minha biblioteca\"\n        },\n        \"playlistList\": {\n            \"title\": \"$t(entity.playlist, {\\\"count\\\": 2})\"\n        },\n        \"albumList\": {\n            \"title\": \"$t(entity.album, {\\\"count\\\": 2})\",\n            \"artistAlbums\": \"álbuns de {{artist}}\",\n            \"genreAlbums\": \"\\\"{{genre}}\\\" $t(entity.album, {\\\"count\\\": 2})\"\n        },\n        \"appMenu\": {\n            \"openBrowserDevtools\": \"abrir ferramentas do desenvolvedor\",\n            \"quit\": \"$t(common.quit)\",\n            \"selectServer\": \"selecionar servidor\",\n            \"collapseSidebar\": \"recolher barra lateral\",\n            \"expandSidebar\": \"expandir barra lateral\",\n            \"goBack\": \"voltar\",\n            \"goForward\": \"avançar\",\n            \"version\": \"versão {{version}}\",\n            \"manageServers\": \"gerenciar servidores\",\n            \"settings\": \"$t(common.setting, {\\\"count\\\": 2})\",\n            \"privateModeOff\": \"Desativar modo privado\",\n            \"privateModeOn\": \"Ativar modo privado\"\n        },\n        \"contextMenu\": {\n            \"moveToTop\": \"$t(action.moveToTop)\",\n            \"moveToBottom\": \"$t(action.moveToBottom)\",\n            \"removeFromFavorites\": \"$t(action.removeFromFavorites)\",\n            \"numberSelected\": \"{{count}} selecionado\",\n            \"addFavorite\": \"$t(action.addToFavorites)\",\n            \"addLast\": \"$t(player.addLast)\",\n            \"addNext\": \"$t(player.addNext)\",\n            \"addToFavorites\": \"$t(action.addToFavorites)\",\n            \"playSimilarSongs\": \"$t(player.playSimilarSongs)\",\n            \"removeFromQueue\": \"$t(action.removeFromQueue)\",\n            \"play\": \"$t(player.play)\",\n            \"playShuffled\": \"$t(player.shuffle)\",\n            \"createPlaylist\": \"$t(action.createPlaylist)\",\n            \"download\": \"baixar\",\n            \"shareItem\": \"compartilhar item\",\n            \"showDetails\": \"obter informações\",\n            \"addToPlaylist\": \"$t(action.addToPlaylist)\",\n            \"deletePlaylist\": \"$t(action.deletePlaylist)\",\n            \"deselectAll\": \"$t(action.deselectAll)\",\n            \"moveToNext\": \"$t(action.moveToNext)\",\n            \"removeFromPlaylist\": \"$t(action.removeFromPlaylist)\",\n            \"setRating\": \"$t(action.setRating)\",\n            \"goToAlbum\": \"Ir para $t(entity.album, {\\\"count\\\": 1})\",\n            \"goToAlbumArtist\": \"Ir para $t(entity.albumArtist, {\\\"count\\\": 1})\"\n        },\n        \"albumArtistDetail\": {\n            \"viewAllTracks\": \"ver todas as $t(entity.track, {\\\"count\\\": 2})\",\n            \"appearsOn\": \"aparece em\",\n            \"recentReleases\": \"lançamentos recentes\",\n            \"viewDiscography\": \"ver discografia\",\n            \"relatedArtists\": \"$t(entity.artist, {\\\"count\\\": 2}) relacionados\",\n            \"viewAll\": \"ver tudo\",\n            \"topSongsFrom\": \"músicas mais tocadas de {{title}}\",\n            \"topSongs\": \"músicas mais tocadas\",\n            \"about\": \"Sobre {{artist}}\"\n        },\n        \"fullscreenPlayer\": {\n            \"config\": {\n                \"unsynchronized\": \"não sincronizado\",\n                \"dynamicIsImage\": \"habilitar imagem de fundo\",\n                \"dynamicImageBlur\": \"tamanho do desfoque da imagem\",\n                \"lyricAlignment\": \"alinhamento da letra\",\n                \"showLyricMatch\": \"exibir correspondência da letra\",\n                \"showLyricProvider\": \"exibir origem da letra\",\n                \"synchronized\": \"sincronizado\",\n                \"lyricOffset\": \"deslocamento da letra (ms)\",\n                \"followCurrentLyric\": \"acompanhar letra\",\n                \"useImageAspectRatio\": \"usar proporção da imagem\",\n                \"lyricGap\": \"espaçamento da letra\",\n                \"lyricSize\": \"tamanho da letra\",\n                \"dynamicBackground\": \"fundo dinâmico\",\n                \"opacity\": \"opacidade\"\n            },\n            \"related\": \"relacionado\",\n            \"visualizer\": \"visualizador\",\n            \"upNext\": \"a seguir\",\n            \"lyrics\": \"letra\",\n            \"noLyrics\": \"nenhuma letra encontrada\"\n        },\n        \"albumDetail\": {\n            \"moreFromArtist\": \"mais deste $t(entity.artist, {\\\"count\\\": 1})\",\n            \"moreFromGeneric\": \"mais de {{item}}\",\n            \"released\": \"lançado\"\n        },\n        \"itemDetail\": {\n            \"copyPath\": \"copiar caminho para a área de transferência\",\n            \"copiedPath\": \"caminho copiado com sucesso\",\n            \"openFile\": \"mostrar faixa no gerenciador de arquivos\"\n        },\n        \"manageServers\": {\n            \"serverDetails\": \"detalhes do servidor\",\n            \"url\": \"URL\",\n            \"username\": \"nome de usuário\",\n            \"editServerDetailsTooltip\": \"editar detalhes do servidor\",\n            \"removeServer\": \"remover servidor\",\n            \"title\": \"gerenciar servidores\"\n        },\n        \"setting\": {\n            \"generalTab\": \"geral\",\n            \"hotkeysTab\": \"teclas de atalho\",\n            \"windowTab\": \"janela\",\n            \"advanced\": \"avançado\",\n            \"playbackTab\": \"reprodução\"\n        },\n        \"playlist\": {\n            \"reorder\": \"reordenar apenas disponível quando ordenado pelo id\"\n        }\n    },\n    \"filter\": {\n        \"title\": \"titulo\",\n        \"disc\": \"disco\",\n        \"mostPlayed\": \"mais tocado\",\n        \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n        \"name\": \"nome\",\n        \"biography\": \"bibliografia\",\n        \"duration\": \"duração\",\n        \"favorited\": \"favoritado\",\n        \"fromYear\": \"a partir do ano\",\n        \"songCount\": \"contador de músicas\",\n        \"toYear\": \"até o ano\",\n        \"random\": \"aleatório\",\n        \"search\": \"buscar\",\n        \"lastPlayed\": \"última tocada\",\n        \"isCompilation\": \"é compilação\",\n        \"trackNumber\": \"faixa\",\n        \"communityRating\": \"Nota da comunidade\",\n        \"isPublic\": \"é público\",\n        \"playCount\": \"contador de execuções\",\n        \"recentlyUpdated\": \"atualizado recentemente\",\n        \"dateAdded\": \"data de adição\",\n        \"isRecentlyPlayed\": \"foi tocado recentemente\",\n        \"albumArtist\": \"$t(entity.albumArtist, {\\\"count\\\": 1})\",\n        \"recentlyAdded\": \"adicionado recentemente\",\n        \"releaseDate\": \"data de lançamento\",\n        \"recentlyPlayed\": \"tocado recentemente\",\n        \"criticRating\": \"avaliação da crítica\",\n        \"isFavorited\": \"é favoritado\",\n        \"releaseYear\": \"ano de lançamento\",\n        \"rating\": \"avaliação\",\n        \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n        \"bpm\": \"bpm\",\n        \"channels\": \"$t(common.channel_other)\",\n        \"comment\": \"comentário\",\n        \"owner\": \"$t(common.owner)\",\n        \"path\": \"caminho\",\n        \"id\": \"id\",\n        \"bitrate\": \"bitrate\",\n        \"isRated\": \"possui avaliação\",\n        \"note\": \"nota\",\n        \"albumCount\": \"número de $t(entity.album, {\\\"count\\\": 2})\",\n        \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\"\n    },\n    \"player\": {\n        \"playbackFetchNoResults\": \"nenhuma música encontrada\",\n        \"playbackFetchInProgress\": \"carregando músicas…\",\n        \"skip_forward\": \"avançar\",\n        \"mute\": \"mudo\",\n        \"playSimilarSongs\": \"tocar músicas similares\",\n        \"skip\": \"pular\",\n        \"stop\": \"parar\",\n        \"addNext\": \"adicionar a seguir\",\n        \"muted\": \"mudo\",\n        \"queue_clear\": \"limpar fila\",\n        \"toggleFullscreenPlayer\": \"alternar player de tela cheia\",\n        \"addLast\": \"adicionar no final\",\n        \"next\": \"próximo\",\n        \"play\": \"tocar\",\n        \"playRandom\": \"tocar aleatório\",\n        \"shuffle_off\": \"aleatório desativado\",\n        \"queue_moveToBottom\": \"mover selecionados para o topo\",\n        \"queue_moveToTop\": \"mover selecionados para o fim\",\n        \"skip_back\": \"retroceder\",\n        \"unfavorite\": \"remover favorito\",\n        \"playbackSpeed\": \"velocidade de reprodução\",\n        \"previous\": \"anterior\",\n        \"favorite\": \"favorito\",\n        \"playbackFetchCancel\": \"isso está demorando um pouco... feche a notificação para cancelar\",\n        \"queue_remove\": \"remover selecionados\",\n        \"repeat\": \"repetir\",\n        \"repeat_all\": \"repetir tudo\",\n        \"repeat_off\": \"repetição desativada\",\n        \"shuffle\": \"tocar aleatório\",\n        \"pause\": \"pausar\",\n        \"viewQueue\": \"ver fila\"\n    },\n    \"entity\": {\n        \"albumArtist_one\": \"artista do álbum\",\n        \"albumArtist_many\": \"artistas do álbum\",\n        \"albumArtist_other\": \"artistas do álbum\",\n        \"albumArtistCount_one\": \"{{count}} artista do álbum\",\n        \"albumArtistCount_many\": \"{{count}} artistas do álbum\",\n        \"albumArtistCount_other\": \"{{count}} artistas do álbum\",\n        \"album_one\": \"álbum\",\n        \"album_many\": \"álbuns\",\n        \"album_other\": \"álbuns\",\n        \"artist_one\": \"artista\",\n        \"artist_many\": \"artistas\",\n        \"artist_other\": \"artistas\",\n        \"albumWithCount_one\": \"{{count}} álbum\",\n        \"albumWithCount_many\": \"{{count}} álbuns\",\n        \"albumWithCount_other\": \"{{count}} álbuns\",\n        \"favorite_one\": \"favorito\",\n        \"favorite_many\": \"favoritos\",\n        \"favorite_other\": \"favoritos\",\n        \"artistWithCount_one\": \"{{count}} artista\",\n        \"artistWithCount_many\": \"{{count}} artistas\",\n        \"artistWithCount_other\": \"{{count}} artistas\",\n        \"folder_one\": \"pasta\",\n        \"folder_many\": \"pastas\",\n        \"folder_other\": \"pastas\",\n        \"genre_one\": \"gênero\",\n        \"genre_many\": \"gêneros\",\n        \"genre_other\": \"gêneros\",\n        \"playlistWithCount_one\": \"{{count}} playlist\",\n        \"playlistWithCount_many\": \"{{count}} playlists\",\n        \"playlistWithCount_other\": \"{{count}} playlists\",\n        \"playlist_one\": \"Playlist\",\n        \"playlist_many\": \"Playlists\",\n        \"playlist_other\": \"Playlists\",\n        \"folderWithCount_one\": \"{{count}} pasta\",\n        \"folderWithCount_many\": \"{{count}} pastas\",\n        \"folderWithCount_other\": \"{{count}} pastas\",\n        \"genreWithCount_one\": \"{{count}} gênero\",\n        \"genreWithCount_many\": \"{{count}} gêneros\",\n        \"genreWithCount_other\": \"{{count}} gêneros\",\n        \"trackWithCount_one\": \"{{count}} faixa\",\n        \"trackWithCount_many\": \"{{count}} faixas\",\n        \"trackWithCount_other\": \"{{count}} faixas\",\n        \"track_one\": \"faixa\",\n        \"track_many\": \"faixas\",\n        \"track_other\": \"faixas\",\n        \"smartPlaylist\": \"$t(entity.playlist, {\\\"count\\\": 1}) inteligente\",\n        \"song_one\": \"música\",\n        \"song_many\": \"músicas\",\n        \"song_other\": \"músicas\",\n        \"play_one\": \"{{count}} reprodução\",\n        \"play_many\": \"{{count}} reproduções\",\n        \"play_other\": \"{{count}} reproduções\"\n    },\n    \"error\": {\n        \"remotePortWarning\": \"reinicie o servidor para aplicar a nova porta\",\n        \"systemFontError\": \"ocorreu um erro ao tentar obter fontes do sistema\",\n        \"playbackError\": \"ocorreu um erro ao tentar reproduzir a mídia\",\n        \"endpointNotImplementedError\": \"endpoint {{endpoint}} não está implementado para {{serverType}}\",\n        \"remotePortError\": \"ocorreu um erro ao tentar definir a porta do servidor remoto\",\n        \"serverRequired\": \"servidor necessário\",\n        \"authenticationFailed\": \"falha na autenticação\",\n        \"apiRouteError\": \"não é possível encaminhar a solicitação\",\n        \"genericError\": \"um erro ocorreu\",\n        \"credentialsRequired\": \"credenciais necessárias\",\n        \"sessionExpiredError\": \"sua sessão expirou\",\n        \"remoteEnableError\": \"ocorreu um erro ao tentar $t(common.enable) o servidor remoto\",\n        \"localFontAccessDenied\": \"acesso negado a fontes locais\",\n        \"serverNotSelectedError\": \"nenhum servidor selecionado\",\n        \"remoteDisableError\": \"ocorreu um erro ao tentar $t(common.disable) o servidor remoto\",\n        \"mpvRequired\": \"Requer MPV\",\n        \"audioDeviceFetchError\": \"ocorreu um erro ao tentar obter dispositivos de áudio\",\n        \"invalidServer\": \"servidor inválido\",\n        \"loginRateError\": \"muitas tentativas de login, tente novamente em alguns segundos\",\n        \"badAlbum\": \"você está vendo este erro por que está música não é parte de algum álbum. um motivo comum para você estar vendo este erro é se a sua música estiver na raiz da sua pasta de músicas. o Jellyfin apenas agrupa as músicas se elas estiveram na mesma pasta\",\n        \"networkError\": \"ocorreu um erro na internet\",\n        \"openError\": \"não foi possível abrir o arquivo\",\n        \"badValue\": \"opção inválida \\\"{{value}}\\\". este valor não existe no momento\",\n        \"notificationDenied\": \"As permissões para notificações foram negadas. Esta configuração não tem efeito\"\n    }\n}\n"
  },
  {
    "path": "src/i18n/locales/pt.json",
    "content": "{\n    \"action\": {\n        \"addToFavorites\": \"adicionar a $t(entity.favorite, {\\\"count\\\": 2})\",\n        \"addToPlaylist\": \"adicionar a $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"clearQueue\": \"limpar fila\",\n        \"createPlaylist\": \"criar $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"deletePlaylist\": \"apagar $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"deselectAll\": \"desmarcar todos\",\n        \"editPlaylist\": \"editar $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"goToPage\": \"vá para página\",\n        \"moveToNext\": \"mover para o próximo\",\n        \"moveToBottom\": \"mover para baixo\",\n        \"moveToTop\": \"mover para o topo\",\n        \"refresh\": \"$t(common.refresh)\",\n        \"removeFromFavorites\": \"remover de $t(entity.favorite, {\\\"count\\\": 2})\",\n        \"removeFromPlaylist\": \"remover da $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"removeFromQueue\": \"remover da fila\",\n        \"setRating\": \"definir classificação\",\n        \"toggleSmartPlaylistEditor\": \"alternar editor $t(entity.smartPlaylist)\",\n        \"viewPlaylists\": \"ver $t(entity.playlist, {\\\"count\\\": 2})\",\n        \"openIn\": {\n            \"lastfm\": \"Abrir em Last.fm\",\n            \"musicbrainz\": \"Abrir em MusicBrainz\"\n        }\n    },\n    \"common\": {\n        \"action_one\": \"ação\",\n        \"action_many\": \"ações\",\n        \"action_other\": \"ações\",\n        \"add\": \"adicionar\",\n        \"additionalParticipants\": \"participantes adicionais\",\n        \"newVersion\": \"uma nova versão foi instalada ({{version}})\",\n        \"viewReleaseNotes\": \"ver notas de lançamento\",\n        \"albumGain\": \"ganho do álbum\",\n        \"albumPeak\": \"pico do álbum\",\n        \"areYouSure\": \"tem certeza?\",\n        \"ascending\": \"ascendente\",\n        \"backward\": \"para trás\",\n        \"biography\": \"biografia\",\n        \"bitrate\": \"taxa de bits\",\n        \"bpm\": \"bpm\",\n        \"cancel\": \"cancelar\",\n        \"center\": \"centro\",\n        \"channel_one\": \"canal\",\n        \"channel_many\": \"canais\",\n        \"channel_other\": \"canais\",\n        \"clear\": \"limpar\",\n        \"close\": \"fechar\",\n        \"codec\": \"codec\",\n        \"collapse\": \"minimizar\",\n        \"comingSoon\": \"em breve…\",\n        \"configure\": \"configurar\",\n        \"confirm\": \"confirmar\",\n        \"create\": \"criar\",\n        \"currentSong\": \"$t(entity.track, {\\\"count\\\": 1}) atual\",\n        \"decrease\": \"diminuir\",\n        \"delete\": \"apagar\",\n        \"descending\": \"abaixar\",\n        \"description\": \"descrição\",\n        \"disable\": \"desativar\",\n        \"disc\": \"disco\",\n        \"dismiss\": \"liberar\",\n        \"duration\": \"duração\",\n        \"edit\": \"editar\",\n        \"enable\": \"ativar\",\n        \"expand\": \"expandir\",\n        \"favorite\": \"favorito\",\n        \"filter_one\": \"filtro\",\n        \"filter_many\": \"filtros\",\n        \"filter_other\": \"filtros\",\n        \"filters\": \"filtros\",\n        \"forceRestartRequired\": \"reinicie para aplicar as alterações… feche a notificação para reiniciar\",\n        \"forward\": \"para frente\",\n        \"gap\": \"intervalo\",\n        \"home\": \"início\",\n        \"increase\": \"incrementar\",\n        \"left\": \"esquerda\",\n        \"limit\": \"limite\",\n        \"manage\": \"gerir\",\n        \"maximize\": \"maximizar\",\n        \"menu\": \"menu\",\n        \"minimize\": \"minimizar\",\n        \"modified\": \"modificado\",\n        \"mbid\": \"ID no MusicBrainz\",\n        \"name\": \"nome\",\n        \"no\": \"não\",\n        \"none\": \"nenhum\",\n        \"noResultsFromQuery\": \"a consulta não retornou resultados\",\n        \"note\": \"observação\",\n        \"ok\": \"ok\",\n        \"owner\": \"dono\",\n        \"path\": \"caminho\",\n        \"playerMustBePaused\": \"o player deve estar pausado\",\n        \"preview\": \"pré-visualizar\",\n        \"previousSong\": \"anterior $t(entity.track, {\\\"count\\\": 1})\",\n        \"quit\": \"sair\",\n        \"random\": \"aleatório\",\n        \"rating\": \"classificação\",\n        \"refresh\": \"atualizar\",\n        \"reload\": \"recarregar\",\n        \"reset\": \"reiniciar\",\n        \"resetToDefault\": \"restaurar ao padrão\",\n        \"restartRequired\": \"é necessário reiniciar\",\n        \"right\": \"direita\",\n        \"save\": \"gravar\",\n        \"saveAndReplace\": \"gravar e substituir\",\n        \"saveAs\": \"gravar como\",\n        \"search\": \"procurar\",\n        \"setting_one\": \"configuração\",\n        \"setting_many\": \"\",\n        \"setting_other\": \"\",\n        \"share\": \"partilhar\",\n        \"size\": \"tamanho\",\n        \"sortOrder\": \"ordem\",\n        \"tags\": \"tags\",\n        \"title\": \"titulo\",\n        \"trackNumber\": \"faixa\",\n        \"trackGain\": \"ganho da faixa\",\n        \"trackPeak\": \"pico da faixa\",\n        \"translation\": \"tradução\",\n        \"unknown\": \"desconhecido\",\n        \"version\": \"versão\",\n        \"year\": \"ano\",\n        \"yes\": \"sim\"\n    },\n    \"entity\": {\n        \"album_one\": \"álbum\",\n        \"album_many\": \"álbuns\",\n        \"album_other\": \"álbuns\",\n        \"albumArtist_one\": \"artista do álbum\",\n        \"albumArtist_many\": \"artistas do álbum\",\n        \"albumArtist_other\": \"artistas do álbum\",\n        \"albumArtistCount_one\": \"{{count}} artista do álbum\",\n        \"albumArtistCount_many\": \"{{count}} artistas do álbum\",\n        \"albumArtistCount_other\": \"{{count}} artistas do álbum\",\n        \"albumWithCount_one\": \"{{count}} álbum\",\n        \"albumWithCount_many\": \"{{count}} álbuns\",\n        \"albumWithCount_other\": \"{{count}} álbuns\",\n        \"artist_one\": \"artista\",\n        \"artist_many\": \"artistas\",\n        \"artist_other\": \"artistas\",\n        \"artistWithCount_one\": \"{{count}} artista\",\n        \"artistWithCount_many\": \"{{count}} artistas\",\n        \"artistWithCount_other\": \"{{count}} artistas\",\n        \"favorite_one\": \"favorito\",\n        \"favorite_many\": \"favoritos\",\n        \"favorite_other\": \"favoritos\",\n        \"folder_one\": \"pasta\",\n        \"folder_many\": \"pastas\",\n        \"folder_other\": \"pastas\",\n        \"folderWithCount_one\": \"{{count}} pasta\",\n        \"folderWithCount_many\": \"{{count}} pastas\",\n        \"folderWithCount_other\": \"{{count}} pastas\",\n        \"genre_one\": \"gênero\",\n        \"genre_many\": \"gêneros\",\n        \"genre_other\": \"gêneros\",\n        \"genreWithCount_one\": \"{{count}} gênero\",\n        \"genreWithCount_many\": \"{{count}} gêneros\",\n        \"genreWithCount_other\": \"{{count}} gêneros\",\n        \"playlist_one\": \"playlist\",\n        \"playlist_many\": \"playlists\",\n        \"playlist_other\": \"playlists\",\n        \"play_one\": \"{{count}} reprodução\",\n        \"play_many\": \"{{count}} reproduções\",\n        \"play_other\": \"{{count}} reproduções\",\n        \"playlistWithCount_one\": \"{{count}} playlist\",\n        \"playlistWithCount_many\": \"{{count}} playlists\",\n        \"playlistWithCount_other\": \"{{count}} playlists\",\n        \"smartPlaylist\": \"$t(entity.playlist, {\\\"count\\\": 1}) inteligente\",\n        \"track_one\": \"faixa\",\n        \"track_many\": \"faixas\",\n        \"track_other\": \"faixas\",\n        \"song_one\": \"música\",\n        \"song_many\": \"músicas\",\n        \"song_other\": \"músicas\",\n        \"trackWithCount_one\": \"{{count}} faixa\",\n        \"trackWithCount_many\": \"{{count}} faixas\",\n        \"trackWithCount_other\": \"{{count}} faixas\"\n    },\n    \"error\": {\n        \"apiRouteError\": \"não é possível encaminhar a solicitação\",\n        \"audioDeviceFetchError\": \"ocorreu um erro ao tentar obter dispositivos de áudio\",\n        \"authenticationFailed\": \"falha na autenticação\",\n        \"badAlbum\": \"está a ver este erro por que está música não é parte de algum album. um motivo comum para si estar a ver este erro é se a sua música estiver na raiz da sua pasta de músicas. o Jellyfin apenas agrupa as músicas se elas estiveram na mesma pasta\",\n        \"badValue\": \"opção inválida \\\"{{value}}\\\". este valor não existe no momento\",\n        \"credentialsRequired\": \"credenciais necessárias\",\n        \"endpointNotImplementedError\": \"endpoint {{endpoint}} não está implementado para {{serverType}}\",\n        \"genericError\": \"um erro ocorreu\",\n        \"invalidServer\": \"servidor inválido\",\n        \"localFontAccessDenied\": \"acesso a fontes locais rejeitado\",\n        \"loginRateError\": \"muitas tentativas de login, tente novamente em alguns segundos\",\n        \"mpvRequired\": \"MPV necessário\",\n        \"networkError\": \"ocorreu um erro na internet\",\n        \"openError\": \"não foi possível abrir o ficheiro\",\n        \"playbackError\": \"ocorreu um erro ao tentar reproduzir a média\",\n        \"remoteDisableError\": \"ocorreu um erro ao tentar $t(common.disable) o servidor remoto\",\n        \"remoteEnableError\": \"ocorreu um erro ao tentar $t(common.enable) o servidor remoto\",\n        \"remotePortError\": \"ocorreu um erro ao tentar definir a porta do servidor remoto\",\n        \"remotePortWarning\": \"reinicie o servidor para aplicar a nova porta\",\n        \"serverNotSelectedError\": \"nenhum servidor selecionado\",\n        \"serverRequired\": \"servidor necessário\",\n        \"sessionExpiredError\": \"a sua sessão expirou\",\n        \"systemFontError\": \"ocorreu um erro ao tentar obter fontes do sistema\"\n    },\n    \"filter\": {\n        \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n        \"albumArtist\": \"$t(entity.albumArtist, {\\\"count\\\": 1})\",\n        \"albumCount\": \"número de $t(entity.album, {\\\"count\\\": 2})\",\n        \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n        \"biography\": \"bibliografia\",\n        \"bitrate\": \"bitrate\",\n        \"bpm\": \"bpm\",\n        \"channels\": \"$t(common.channel_other)\",\n        \"comment\": \"comentário\",\n        \"communityRating\": \"Nota da comunidade\",\n        \"criticRating\": \"avaliação da crítica\",\n        \"dateAdded\": \"data de adição\",\n        \"disc\": \"disco\",\n        \"duration\": \"duração\",\n        \"favorited\": \"favoritado\",\n        \"fromYear\": \"a partir do ano\",\n        \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n        \"id\": \"id\",\n        \"isCompilation\": \"é compilação\",\n        \"isFavorited\": \"é favoritado\",\n        \"isPublic\": \"é público\",\n        \"isRated\": \"possui avaliação\",\n        \"isRecentlyPlayed\": \"foi tocado recentemente\",\n        \"lastPlayed\": \"última tocada\",\n        \"mostPlayed\": \"mais tocado\",\n        \"name\": \"nome\",\n        \"note\": \"nota\",\n        \"owner\": \"$t(common.owner)\",\n        \"path\": \"caminho\",\n        \"playCount\": \"contador de reproduções\",\n        \"random\": \"aleatório\",\n        \"rating\": \"avaliação\",\n        \"recentlyAdded\": \"adicionado recentemente\",\n        \"recentlyPlayed\": \"tocado recentemente\",\n        \"recentlyUpdated\": \"atualizado recentemente\",\n        \"releaseDate\": \"data de lançamento\",\n        \"releaseYear\": \"ano de lançamento\",\n        \"search\": \"buscar\",\n        \"songCount\": \"contador de músicas\",\n        \"title\": \"titulo\",\n        \"toYear\": \"até o ano\",\n        \"trackNumber\": \"faixa\"\n    },\n    \"form\": {\n        \"addServer\": {\n            \"error_savePassword\": \"um erro ocorreu ao tentar gravar a palavra-passe\",\n            \"ignoreCors\": \"ignorar CORS ($t(common.restartRequired))\",\n            \"ignoreSsl\": \"ignorar ssl ($t(common.restartRequired))\",\n            \"input_legacyAuthentication\": \"ativar autenticação legada\",\n            \"input_name\": \"nome do servidor\",\n            \"input_password\": \"palavra-passe\",\n            \"input_savePassword\": \"gravar palavra-passe\",\n            \"input_url\": \"url\",\n            \"input_username\": \"nome de utilizador\",\n            \"success\": \"servidor adicionado com sucesso\",\n            \"title\": \"adicionar servidor\"\n        },\n        \"addToPlaylist\": {\n            \"input_playlists\": \"$t(entity.playlist, {\\\"count\\\": 2})\",\n            \"input_skipDuplicates\": \"pular duplicadas\",\n            \"success\": \"adicionado $t(entity.trackWithCount, {\\\"count\\\": {{message}} }) para $t(entity.playlistWithCount, {\\\"count\\\": {{numOfPlaylists}} })\",\n            \"title\": \"adicionar à $t(entity.playlist, {\\\"count\\\": 1})\"\n        },\n        \"createPlaylist\": {\n            \"input_description\": \"$t(common.description)\",\n            \"input_name\": \"$t(common.name)\",\n            \"input_owner\": \"$t(common.owner)\",\n            \"input_public\": \"público\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) criada com sucesso\",\n            \"title\": \"criar $t(entity.playlist, {\\\"count\\\": 1})\"\n        },\n        \"deletePlaylist\": {\n            \"input_confirm\": \"escreva o nome da $t(entity.playlist, {\\\"count\\\": 1}) para confirmar\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) apagada com sucesso\",\n            \"title\": \"apagar $t(entity.playlist, {\\\"count\\\": 1})\"\n        },\n        \"editPlaylist\": {\n            \"publicJellyfinNote\": \"O Jellyfin por algum motivo não expõe se uma playlist é pública ou não. Se deseja que ela permaneça pública, por favor selecione a seguinte entrada\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) atualizada com sucesso\",\n            \"title\": \"editar $t(entity.playlist, {\\\"count\\\": 1})\"\n        },\n        \"lyricSearch\": {\n            \"input_artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n            \"input_name\": \"$t(common.name)\",\n            \"title\": \"pesquisa de letras\"\n        },\n        \"queryEditor\": {\n            \"input_optionMatchAll\": \"corresponder todos\",\n            \"input_optionMatchAny\": \"corresponder qualquer um\"\n        },\n        \"shareItem\": {\n            \"allowDownloading\": \"permitir descargas\",\n            \"description\": \"descrição\",\n            \"setExpiration\": \"definir expiração\",\n            \"success\": \"ligação de compartilhamento copiado para a área de transferência (ou clique aqui para abrir)\",\n            \"expireInvalid\": \"a expiração deve ser uma data futura\",\n            \"createFailed\": \"falha ao criar compartilhamento (o compartilhamento está ativado?)\"\n        },\n        \"updateServer\": {\n            \"success\": \"servidor atualizado com sucesso\",\n            \"title\": \"atualizar servidor\"\n        }\n    },\n    \"page\": {\n        \"albumArtistDetail\": {\n            \"about\": \"Sobre {{artist}}\",\n            \"appearsOn\": \"aparece em\",\n            \"recentReleases\": \"lançamentos recentes\",\n            \"viewDiscography\": \"ver discografia\",\n            \"relatedArtists\": \"$t(entity.artist, {\\\"count\\\": 2}) relacionados\",\n            \"topSongs\": \"músicas mais tocadas\",\n            \"topSongsFrom\": \"músicas mais tocadas de {{title}}\",\n            \"viewAll\": \"ver tudo\",\n            \"viewAllTracks\": \"ver todas as $t(entity.track, {\\\"count\\\": 2})\"\n        },\n        \"albumArtistList\": {\n            \"title\": \"$t(entity.albumArtist, {\\\"count\\\": 2})\"\n        },\n        \"albumDetail\": {\n            \"moreFromArtist\": \"mais deste $t(entity.artist, {\\\"count\\\": 1})\",\n            \"moreFromGeneric\": \"mais que {{item}}\",\n            \"released\": \"lançado\"\n        },\n        \"albumList\": {\n            \"artistAlbums\": \"álbuns de {{artist}}\",\n            \"genreAlbums\": \"\\\"{{genre}}\\\" $t(entity.album, {\\\"count\\\": 2})\",\n            \"title\": \"$t(entity.album, {\\\"count\\\": 2})\"\n        },\n        \"appMenu\": {\n            \"collapseSidebar\": \"recolher barra lateral\",\n            \"expandSidebar\": \"expandir barra lateral\",\n            \"goBack\": \"voltar\",\n            \"goForward\": \"avançar\",\n            \"manageServers\": \"gerir servidores\",\n            \"openBrowserDevtools\": \"abrir ferramentas do programador\",\n            \"quit\": \"$t(common.quit)\",\n            \"selectServer\": \"selecionar servidor\",\n            \"settings\": \"$t(common.setting, {\\\"count\\\": 2})\",\n            \"version\": \"versão {{version}}\"\n        },\n        \"manageServers\": {\n            \"title\": \"gerir servidores\",\n            \"serverDetails\": \"pormenores do servidor\",\n            \"url\": \"URL\",\n            \"username\": \"nome de utilizador\",\n            \"editServerDetailsTooltip\": \"editar pormenores do servidor\",\n            \"removeServer\": \"remover servidor\"\n        },\n        \"contextMenu\": {\n            \"addFavorite\": \"$t(action.addToFavorites)\",\n            \"addLast\": \"$t(player.addLast)\",\n            \"addNext\": \"$t(player.addNext)\",\n            \"addToFavorites\": \"$t(action.addToFavorites)\",\n            \"addToPlaylist\": \"$t(action.addToPlaylist)\",\n            \"createPlaylist\": \"$t(action.createPlaylist)\",\n            \"deletePlaylist\": \"$t(action.deletePlaylist)\",\n            \"deselectAll\": \"$t(action.deselectAll)\",\n            \"download\": \"descarregar\",\n            \"moveToNext\": \"$t(action.moveToNext)\",\n            \"moveToBottom\": \"$t(action.moveToBottom)\",\n            \"moveToTop\": \"$t(action.moveToTop)\",\n            \"numberSelected\": \"{{count}} selecionado\",\n            \"play\": \"$t(player.play)\",\n            \"playSimilarSongs\": \"$t(player.playSimilarSongs)\",\n            \"removeFromFavorites\": \"$t(action.removeFromFavorites)\",\n            \"removeFromPlaylist\": \"$t(action.removeFromPlaylist)\",\n            \"removeFromQueue\": \"$t(action.removeFromQueue)\",\n            \"setRating\": \"$t(action.setRating)\",\n            \"playShuffled\": \"$t(player.shuffle)\",\n            \"shareItem\": \"partilhar elemento\",\n            \"showDetails\": \"obter informações\"\n        },\n        \"fullscreenPlayer\": {\n            \"config\": {\n                \"dynamicBackground\": \"fundo dinâmico\",\n                \"dynamicImageBlur\": \"tamanho do desfoque da imagem\",\n                \"dynamicIsImage\": \"ativar imagem de fundo\",\n                \"followCurrentLyric\": \"acompanhar letra\",\n                \"lyricAlignment\": \"alinhamento da letra\",\n                \"lyricOffset\": \"deslocamento da letra (ms)\",\n                \"lyricGap\": \"espaçamento da letra\",\n                \"lyricSize\": \"tamanho da letra\",\n                \"opacity\": \"opacidade\",\n                \"showLyricMatch\": \"exibir correspondência da letra\",\n                \"showLyricProvider\": \"exibir origem da letra\",\n                \"synchronized\": \"sincronizado\",\n                \"unsynchronized\": \"não sincronizado\",\n                \"useImageAspectRatio\": \"usar proporção da imagem\"\n            },\n            \"lyrics\": \"letra\",\n            \"related\": \"relacionado\",\n            \"upNext\": \"a seguir\",\n            \"visualizer\": \"visualizador\",\n            \"noLyrics\": \"nenhuma letra encontrada\"\n        },\n        \"genreList\": {\n            \"showAlbums\": \"mostrar $t(entity.genre, {\\\"count\\\": 1}) $t(entity.album, {\\\"count\\\": 2})\",\n            \"showTracks\": \"mostrar $t(entity.genre, {\\\"count\\\": 1}) $t(entity.track, {\\\"count\\\": 2})\",\n            \"title\": \"$t(entity.genre, {\\\"count\\\": 2})\"\n        },\n        \"globalSearch\": {\n            \"commands\": {\n                \"goToPage\": \"ir à página\",\n                \"searchFor\": \"procurar {{query}}\",\n                \"serverCommands\": \"comandos do servidor\"\n            },\n            \"title\": \"comandos\"\n        },\n        \"home\": {\n            \"explore\": \"explore a sua biblioteca\",\n            \"mostPlayed\": \"mais tocado\",\n            \"newlyAdded\": \"lançamentos recém-adicionados\",\n            \"recentlyPlayed\": \"tocado recentemente\",\n            \"title\": \"$t(common.home)\"\n        },\n        \"itemDetail\": {\n            \"copyPath\": \"copiar caminho para a área de transferência\",\n            \"copiedPath\": \"caminho copiado com sucesso\",\n            \"openFile\": \"mostrar faixa no gestor de ficheiros\"\n        },\n        \"playlist\": {\n            \"reorder\": \"reordenar apenas disponível quando ordenado pelo id\"\n        },\n        \"playlistList\": {\n            \"title\": \"$t(entity.playlist, {\\\"count\\\": 2})\"\n        },\n        \"setting\": {\n            \"advanced\": \"avançado\",\n            \"generalTab\": \"geral\",\n            \"hotkeysTab\": \"teclas de atalho\",\n            \"playbackTab\": \"reprodução\",\n            \"windowTab\": \"janela\"\n        },\n        \"sidebar\": {\n            \"albumArtists\": \"$t(entity.albumArtist, {\\\"count\\\": 2})\",\n            \"albums\": \"$t(entity.album, {\\\"count\\\": 2})\",\n            \"artists\": \"$t(entity.artist, {\\\"count\\\": 2})\",\n            \"folders\": \"$t(entity.folder, {\\\"count\\\": 2})\",\n            \"genres\": \"$t(entity.genre, {\\\"count\\\": 2})\",\n            \"home\": \"$t(common.home)\",\n            \"myLibrary\": \"a minha biblioteca\",\n            \"nowPlaying\": \"agora a tocar\",\n            \"playlists\": \"$t(entity.playlist, {\\\"count\\\": 2})\",\n            \"search\": \"$t(common.search)\",\n            \"settings\": \"$t(common.setting, {\\\"count\\\": 2})\",\n            \"shared\": \"$t(entity.playlist, {\\\"count\\\": 2}) partilhada\",\n            \"tracks\": \"$t(entity.track, {\\\"count\\\": 2})\"\n        },\n        \"trackList\": {\n            \"artistTracks\": \"faixas de {{artist}}\",\n            \"genreTracks\": \"\\\"{{genre}}\\\" $t(entity.track, {\\\"count\\\": 2})\",\n            \"title\": \"$t(entity.track, {\\\"count\\\": 2})\"\n        }\n    },\n    \"player\": {\n        \"addLast\": \"adicionar no final\",\n        \"addNext\": \"adicionar a seguir\",\n        \"favorite\": \"favorito\",\n        \"mute\": \"mudo\",\n        \"muted\": \"mudo\",\n        \"next\": \"próximo\",\n        \"play\": \"tocar\",\n        \"playbackFetchCancel\": \"isto demora um pouco... feche a notificação para cancelar\",\n        \"playbackFetchInProgress\": \"a carregar músicas…\",\n        \"playbackFetchNoResults\": \"nenhuma música encontrada\",\n        \"playbackSpeed\": \"velocidade de reprodução\",\n        \"playRandom\": \"tocar aleatório\",\n        \"playSimilarSongs\": \"tocar músicas similares\",\n        \"previous\": \"anterior\",\n        \"queue_clear\": \"limpar fila\",\n        \"queue_moveToBottom\": \"mover selecionados para o topo\",\n        \"queue_moveToTop\": \"mover selecionados para o fim\",\n        \"queue_remove\": \"remover selecionados\",\n        \"repeat\": \"repetir\",\n        \"repeat_all\": \"repetir tudo\",\n        \"repeat_off\": \"repetição desativada\",\n        \"shuffle\": \"tocar aleatório\",\n        \"shuffle_off\": \"aleatório desativado\",\n        \"skip\": \"pular\",\n        \"skip_back\": \"retroceder\",\n        \"skip_forward\": \"avançar\",\n        \"stop\": \"parar\",\n        \"toggleFullscreenPlayer\": \"alternar player de ecrã cheio\",\n        \"unfavorite\": \"remover favorito\",\n        \"pause\": \"pausar\",\n        \"viewQueue\": \"ver fila\"\n    },\n    \"setting\": {\n        \"accentColor\": \"cor de realce\",\n        \"accentColor_description\": \"define a cor de realce para a aplicação\",\n        \"albumBackground\": \"imagem de fundo do álbum\",\n        \"albumBackground_description\": \"adiciona uma imagem de fundo contendo a arte do álbum para a página de álbum\",\n        \"albumBackgroundBlur\": \"tamanho de desfoque da imagem de fundo do álbum\",\n        \"albumBackgroundBlur_description\": \"ajusta a quantidade de desfoque aplicada para a imagem de fundo do álbum\",\n        \"applicationHotkeys\": \"teclas de atalho da aplicação\",\n        \"applicationHotkeys_description\": \"configure as teclas de atalho da aplicação. clique na caixa de seleção para definir como tecla de atalho global (somente desktop)\",\n        \"artistConfiguration\": \"configuração da página de artista de álbum\",\n        \"artistConfiguration_description\": \"configure quais elementos serão mostrados, e em qual ordem, na página de artista de álbum\",\n        \"audioDevice\": \"dispositivo de áudio\",\n        \"audioDevice_description\": \"selecione o dispositivo de áudio usado para reprodução (somente player web)\",\n        \"audioExclusiveMode\": \"modo de áudio exclusivo\",\n        \"audioExclusiveMode_description\": \"ativar modo de saída exclusiva. Neste modo, o sistema é geralmente bloqueado, e apenas mpv terá saída de áudio\",\n        \"audioPlayer\": \"player de áudio\",\n        \"audioPlayer_description\": \"selecione o player de áudio usado para reprodução\",\n        \"buttonSize\": \"tamanho do botão da barra de reprodução\",\n        \"buttonSize_description\": \"o tamanho dos botões da barra de reprodução\",\n        \"clearCache\": \"limpar cache do navegador\",\n        \"clearCache_description\": \"uma 'limpeza geral' do feishin. em adição a limpar o cache do feishin, limpa o cache do navegador (imagens gravadas e outros recursos). as credenciais de servidor e as configurações serão mantidas\",\n        \"clearQueryCache\": \"limpar cache do feishin\",\n        \"clearQueryCache_description\": \"uma 'limpeza leve' do feishin. isto irá renovar playlists, metadados de faixas, e resetar letras gravadas. as configurações, as credenciais de servidor e o cache de imagens serão mantidos\",\n        \"clearCacheSuccess\": \"cache limpo com sucesso\",\n        \"contextMenu\": \"configuração do menu de contexto (clique do botão direito do rato)\",\n        \"contextMenu_description\": \"permite esconder elementos exibidos no menu quando clica num elemento com o botão direito. elementos não selecionados serão escondidos\",\n        \"crossfadeDuration\": \"duraçao de crossfade\",\n        \"crossfadeDuration_description\": \"define a duração do efeito crossfade\",\n        \"crossfadeStyle_description\": \"seleciona qual estilo de crossfade usado no player de áudio\",\n        \"customCssEnable\": \"ativar css customizado\",\n        \"customCssEnable_description\": \"permite escrever css customizado\",\n        \"customCssNotice\": \"Aviso: apesar de existir alguma higienização (url() e content: não são permitidas), o uso de css personalizado ainda pode representar riscos ao alterar a interface\",\n        \"customCss\": \"css customizado\",\n        \"disableLibraryUpdateOnStartup\": \"desativar a verificação de novas versões na inicialização\",\n        \"discordApplicationId\": \"{{discord}} ID da aplicação\",\n        \"discordIdleStatus_description\": \"quando ativado, atualiza o estado enquanto o player está ocioso\",\n        \"discordUpdateInterval_description\": \"o tempo em segundos entre cada atualização (mínimo 15 segundos)\",\n        \"playButtonBehavior_description\": \"define o comportamento padrão do botão play ao adicionar músicas à fila\"\n    },\n    \"table\": {\n        \"column\": {\n            \"discNumber\": \"disco\",\n            \"size\": \"$t(common.size)\",\n            \"title\": \"titulo\"\n        },\n        \"config\": {\n            \"label\": {\n                \"discNumber\": \"numero do disco\",\n                \"titleCombined\": \"$t(common.title) (combinado)\"\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "src/i18n/locales/ro.json",
    "content": "{\n    \"common\": {\n        \"confirm\": \"confirmă\",\n        \"create\": \"creează\",\n        \"biography\": \"biografie\",\n        \"areYouSure\": \"ești sigur?\",\n        \"no\": \"nu\",\n        \"name\": \"nume\",\n        \"ok\": \"ok\",\n        \"note\": \"notă\",\n        \"yes\": \"da\",\n        \"explicit\": \"explicit\",\n        \"year\": \"an\",\n        \"menu\": \"meniu\"\n    },\n    \"filter\": {\n        \"biography\": \"biografie\"\n    }\n}\n"
  },
  {
    "path": "src/i18n/locales/ru.json",
    "content": "{\n    \"action\": {\n        \"editPlaylist\": \"редактировать $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"goToPage\": \"перейти на страницу\",\n        \"moveToTop\": \"вверх\",\n        \"clearQueue\": \"очистить очередь\",\n        \"addToFavorites\": \"добавить в $t(entity.favorite, {\\\"count\\\": 2})\",\n        \"addToPlaylist\": \"добавить в $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"createPlaylist\": \"создать $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"removeFromPlaylist\": \"удалить из $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"viewPlaylists\": \"показать $t(entity.playlist, {\\\"count\\\": 2})\",\n        \"refresh\": \"$t(common.refresh)\",\n        \"deletePlaylist\": \"удалить $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"removeFromQueue\": \"удалить из очереди\",\n        \"deselectAll\": \"снять выделение\",\n        \"moveToBottom\": \"вниз\",\n        \"setRating\": \"оценить\",\n        \"toggleSmartPlaylistEditor\": \"вкл./откл. редактор $t(entity.smartPlaylist)\",\n        \"removeFromFavorites\": \"удалить из $t(entity.favorite, {\\\"count\\\": 2})\",\n        \"openIn\": {\n            \"lastfm\": \"открыть на Last.fm\",\n            \"musicbrainz\": \"открыть на MusicBrainz\"\n        },\n        \"moveToNext\": \"следующий\",\n        \"addOrRemoveFromSelection\": \"добавить или удалить из выделения\",\n        \"createRadioStation\": \"создать $t(entity.radioStation, {\\\"count\\\": 1})\",\n        \"deleteRadioStation\": \"удалить $t(entity.radioStation, {\\\"count\\\": 1})\",\n        \"selectAll\": \"выбрать все\",\n        \"downloadStarted\": \"Начата загрузка {{count}} предметов\",\n        \"moveUp\": \"перейти вверх\",\n        \"moveDown\": \"перейти вниз\",\n        \"holdToMoveToTop\": \"Удержать для перехода на верх\",\n        \"holdToMoveToBottom\": \"удержать для перехода вниз\",\n        \"moveItems\": \"переместить элементы\",\n        \"shuffle\": \"Перемешать\",\n        \"shuffleAll\": \"перемешать все\",\n        \"shuffleSelected\": \"перемешать выбранные\",\n        \"viewMore\": \"Посмотреть больше\",\n        \"openApplicationDirectory\": \"открыть папку приложения\",\n        \"selectRangeOfItems\": \"выбрать диапазон элементов\",\n        \"goToCurrent\": \"перейти к текущему элементу\"\n    },\n    \"common\": {\n        \"backward\": \"назад\",\n        \"increase\": \"увеличить\",\n        \"rating\": \"рейтинг\",\n        \"bpm\": \"уд./мин.\",\n        \"refresh\": \"обновить\",\n        \"unknown\": \"неизвестно\",\n        \"areYouSure\": \"вы уверены?\",\n        \"edit\": \"изменить\",\n        \"favorite\": \"любимый\",\n        \"left\": \"лево\",\n        \"save\": \"сохранить\",\n        \"right\": \"право\",\n        \"currentSong\": \"текущий $t(entity.track, {\\\"count\\\": 1})\",\n        \"collapse\": \"закрыть\",\n        \"trackNumber\": \"трек\",\n        \"descending\": \"по убыванию\",\n        \"add\": \"добавить\",\n        \"gap\": \"промежуток\",\n        \"ascending\": \"по возрастанию\",\n        \"dismiss\": \"отклонить\",\n        \"year\": \"год\",\n        \"manage\": \"управление\",\n        \"limit\": \"ограничение\",\n        \"minimize\": \"свернуть\",\n        \"modified\": \"изменено\",\n        \"duration\": \"длительность\",\n        \"name\": \"имя\",\n        \"maximize\": \"развернуть\",\n        \"decrease\": \"уменьшить\",\n        \"ok\": \"ок\",\n        \"description\": \"описание\",\n        \"configure\": \"настроить\",\n        \"path\": \"путь\",\n        \"center\": \"центр\",\n        \"no\": \"нет\",\n        \"owner\": \"владелец\",\n        \"enable\": \"включить\",\n        \"clear\": \"очистить\",\n        \"forward\": \"вперёд\",\n        \"delete\": \"удалить\",\n        \"cancel\": \"отменить\",\n        \"forceRestartRequired\": \"перезапустите приложение, чтобы применить изменения... закройте уведомление для перезапуска\",\n        \"setting\": \"настройка\",\n        \"setting_one\": \"настройка\",\n        \"setting_few\": \"настройки\",\n        \"setting_many\": \"настроек\",\n        \"version\": \"версия\",\n        \"title\": \"название\",\n        \"filter_one\": \"фильтр\",\n        \"filter_few\": \"фильтра\",\n        \"filter_many\": \"фильтров\",\n        \"filters\": \"фильтры\",\n        \"create\": \"создать\",\n        \"bitrate\": \"битрейт\",\n        \"saveAndReplace\": \"сохранить и заменить\",\n        \"action_one\": \"действие\",\n        \"action_few\": \"действия\",\n        \"action_many\": \"действий\",\n        \"playerMustBePaused\": \"необходимо остановить воспроизведение\",\n        \"confirm\": \"подтвердить\",\n        \"resetToDefault\": \"сбросить настройки\",\n        \"home\": \"главная\",\n        \"comingSoon\": \"скоро…\",\n        \"reset\": \"сбросить\",\n        \"channel_one\": \"канал\",\n        \"channel_few\": \"канала\",\n        \"channel_many\": \"каналов\",\n        \"disable\": \"отключить\",\n        \"sortOrder\": \"порядок\",\n        \"menu\": \"меню\",\n        \"restartRequired\": \"необходим перезапуск приложения\",\n        \"previousSong\": \"предыдущий $t(entity.track, {\\\"count\\\": 1})\",\n        \"noResultsFromQuery\": \"ничего не найдено\",\n        \"quit\": \"выйти\",\n        \"expand\": \"раскрыть\",\n        \"search\": \"поиск\",\n        \"saveAs\": \"сохранить как\",\n        \"disc\": \"диск\",\n        \"yes\": \"да\",\n        \"random\": \"случайно\",\n        \"size\": \"размер\",\n        \"biography\": \"биография\",\n        \"note\": \"заметка\",\n        \"none\": \"нет\",\n        \"mbid\": \"MusicBrainz ID\",\n        \"reload\": \"перезагрузить\",\n        \"preview\": \"просмотр\",\n        \"codec\": \"кодек\",\n        \"share\": \"поделиться\",\n        \"close\": \"закрыть\",\n        \"albumGain\": \"альбом усиление\",\n        \"trackGain\": \"усиление трека\",\n        \"translation\": \"перевод\",\n        \"albumPeak\": \"пик альбома\",\n        \"trackPeak\": \"пик трека\",\n        \"additionalParticipants\": \"Другие участники\",\n        \"newVersion\": \"установлена новая версия ({{version}})\",\n        \"viewReleaseNotes\": \"Список изменений\",\n        \"bitDepth\": \"Разрядность\",\n        \"sampleRate\": \"частота дискретизации\",\n        \"tags\": \"теги\",\n        \"countSelected\": \"{{count}} выбрано\",\n        \"faster\": \"быстрее\",\n        \"filter_single\": \"один\",\n        \"filter_multiple\": \"несколько\",\n        \"mood\": \"настроение\",\n        \"noFilters\": \"фильтры не настроены\",\n        \"private\": \"приватный\",\n        \"public\": \"открытый\",\n        \"retry\": \"повторить\",\n        \"recordLabel\": \"лейбл звукозаписи\",\n        \"releaseType\": \"тип выпуска\",\n        \"slower\": \"медленее\",\n        \"sort\": \"сортировать\",\n        \"clean\": \"очистить\",\n        \"gridRows\": \"Строки в сетке\",\n        \"tableColumns\": \"Столбцы таблицы\",\n        \"doNotShowAgain\": \"не показывать снова\",\n        \"itemsMore\": \"{{count}} более\",\n        \"view\": \"посмотреть\",\n        \"example\": \"пример\",\n        \"rename\": \"переименовать\",\n        \"explicit\": \"нецензурная лексика\",\n        \"externalLinks\": \"внешние ссылки\",\n        \"explicitStatus\": \"признак нецензурного контента\",\n        \"newVersionAvailable\": \"доступна новая версия\"\n    },\n    \"entity\": {\n        \"album_one\": \"альбом\",\n        \"album_few\": \"альбома\",\n        \"album_many\": \"альбомов\",\n        \"genre_one\": \"жанр\",\n        \"genre_few\": \"жанра\",\n        \"genre_many\": \"жанров\",\n        \"playlistWithCount_one\": \"{{count}} плейлист\",\n        \"playlistWithCount_few\": \"{{count}} плейлиста\",\n        \"playlistWithCount_many\": \"{{count}} плейлистов\",\n        \"playlist_one\": \"плейлист\",\n        \"playlist_few\": \"плейлиста\",\n        \"playlist_many\": \"плейлистов\",\n        \"play\": \"{{count}} прослушиваний\",\n        \"play_one\": \"{{count}} прослушивание\",\n        \"play_few\": \"{{count}} прослушивание\",\n        \"play_many\": \"{{count}} прослушивание\",\n        \"artist_one\": \"исполнитель\",\n        \"artist_few\": \"исполнителя\",\n        \"artist_many\": \"исполнителей\",\n        \"folderWithCount_one\": \"{{count}} папка\",\n        \"folderWithCount_few\": \"{{count}} папки\",\n        \"folderWithCount_many\": \"{{count}} папок\",\n        \"albumArtist_one\": \"исполнитель альбома\",\n        \"albumArtist_few\": \"исполнители альбома\",\n        \"albumArtist_many\": \"исполнителей альбома\",\n        \"track_one\": \"трек\",\n        \"track_few\": \"трека\",\n        \"track_many\": \"треков\",\n        \"song_one\": \"песня\",\n        \"song_few\": \"песни\",\n        \"song_many\": \"песен\",\n        \"albumArtistCount_one\": \"{{count}} автор альбома\",\n        \"albumArtistCount_few\": \"{{count}} автора альбома\",\n        \"albumArtistCount_many\": \"{{count}} авторов альбома\",\n        \"albumWithCount_one\": \"{{count}} альбом\",\n        \"albumWithCount_few\": \"{{count}} альбома\",\n        \"albumWithCount_many\": \"{{count}} альбомов\",\n        \"favorite_one\": \"избранное\",\n        \"favorite_few\": \"избранное\",\n        \"favorite_many\": \"избранные\",\n        \"artistWithCount_one\": \"{{count}} автор\",\n        \"artistWithCount_few\": \"{{count}} автора\",\n        \"artistWithCount_many\": \"{{count}} авторов\",\n        \"folder_one\": \"папка\",\n        \"folder_few\": \"папки\",\n        \"folder_many\": \"папок\",\n        \"smartPlaylist\": \"умный $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"genreWithCount_one\": \"{{count}} жанр\",\n        \"genreWithCount_few\": \"{{count}} жанра\",\n        \"genreWithCount_many\": \"{{count}} жанров\",\n        \"trackWithCount_one\": \"{{count}} трек\",\n        \"trackWithCount_few\": \"{{count}} трека\",\n        \"trackWithCount_many\": \"{{count}} треков\",\n        \"radioStation_one\": \"радиостанция\",\n        \"radioStation_few\": \"радиостанции\",\n        \"radioStation_many\": \"радиостанции\",\n        \"radioStationWithCount_one\": \"{{count}} радиостанция\",\n        \"radioStationWithCount_few\": \"{{count}} радиостанции\",\n        \"radioStationWithCount_many\": \"{{count}} радиостанций\"\n    },\n    \"table\": {\n        \"config\": {\n            \"view\": {\n                \"table\": \"таблица\"\n            },\n            \"general\": {\n                \"displayType\": \"тип отображения\",\n                \"gap\": \"$t(common.gap)\",\n                \"tableColumns\": \"столбцы таблицы\",\n                \"autoFitColumns\": \"автоматически расставить столбцы\",\n                \"followCurrentSong\": \"следовать за исполняемым треком\",\n                \"size\": \"$t(common.size)\",\n                \"itemSize\": \"размер элементов (px)\",\n                \"itemGap\": \"отступ между элементами (px)\"\n            },\n            \"label\": {\n                \"releaseDate\": \"дата выхода\",\n                \"title\": \"$t(common.title)\",\n                \"duration\": \"$t(common.duration)\",\n                \"titleCombined\": \"$t(common.title) (комбинированный)\",\n                \"dateAdded\": \"дата добавления\",\n                \"size\": \"$t(common.size)\",\n                \"bpm\": \"$t(common.bpm)\",\n                \"lastPlayed\": \"последний\",\n                \"trackNumber\": \"номер трека\",\n                \"rowIndex\": \"номер строки\",\n                \"rating\": \"$t(common.rating)\",\n                \"note\": \"$t(common.note)\",\n                \"biography\": \"$t(common.biography)\",\n                \"owner\": \"$t(common.owner)\",\n                \"path\": \"$t(common.path)\",\n                \"channels\": \"$t(common.channel_other)\",\n                \"playCount\": \"количество воспроизведений\",\n                \"bitrate\": \"$t(common.bitrate)\",\n                \"actions\": \"$t(common.action_other)\",\n                \"discNumber\": \"номер диска\",\n                \"favorite\": \"$t(common.favorite)\",\n                \"year\": \"$t(common.year)\",\n                \"codec\": \"$t(common.codec)\",\n                \"titleArtist\": \"$t(common.title) (артист)\"\n            }\n        },\n        \"column\": {\n            \"rating\": \"рейтинг\",\n            \"favorite\": \"любимый\",\n            \"playCount\": \"воспроизведений\",\n            \"releaseYear\": \"год\",\n            \"lastPlayed\": \"последний\",\n            \"releaseDate\": \"дата выхода\",\n            \"title\": \"название\",\n            \"trackNumber\": \"трек\",\n            \"path\": \"путь\",\n            \"discNumber\": \"диск\",\n            \"size\": \"$t(common.size)\",\n            \"dateAdded\": \"дата добавления\",\n            \"album\": \"альбом\",\n            \"albumArtist\": \"исполнитель альбома\",\n            \"biography\": \"биография\",\n            \"codec\": \"$t(common.codec)\",\n            \"comment\": \"комментарий\",\n            \"bitrate\": \"битрейт\",\n            \"channels\": \"$t(common.channel_other)\",\n            \"bpm\": \"bpm\"\n        }\n    },\n    \"error\": {\n        \"remotePortWarning\": \"необходимо перезапустить сервер для применения нового порта\",\n        \"systemFontError\": \"произошла ошибка при попытке получить системные шрифты\",\n        \"playbackError\": \"произошла ошибка при попытке проигрывания медиа\",\n        \"endpointNotImplementedError\": \"запрос {{endpoint}} не реализован для {{serverType}}\",\n        \"remotePortError\": \"произошла ошибка при попытке установить порт удаленного сервера\",\n        \"serverRequired\": \"сервер не выбран\",\n        \"authenticationFailed\": \"не удалось авторизироваться\",\n        \"apiRouteError\": \"не удалось выполнить запрос\",\n        \"genericError\": \"произошла ошибка\",\n        \"credentialsRequired\": \"введите данные для входа\",\n        \"sessionExpiredError\": \"ваш сеанс истёк\",\n        \"remoteEnableError\": \"произошла ошибка при попытке $t(common.enable) удалённый сервер\",\n        \"localFontAccessDenied\": \"не получилось получить доступ к шрифтам\",\n        \"serverNotSelectedError\": \"не выбран сервер\",\n        \"remoteDisableError\": \"произошла ошибка при попытке $t(common.disable) удалённый сервер\",\n        \"mpvRequired\": \"необходим MPV\",\n        \"audioDeviceFetchError\": \"произошла ошибка с аудиоустройством\",\n        \"invalidServer\": \"недействительный сервер\",\n        \"loginRateError\": \"превышено максимальное количество попыток входа, пожалуйста, попробуйте ещё раз через несколько секунд\",\n        \"openError\": \"не удалось открыть файл\",\n        \"badAlbum\": \"вы видите эту страницу из-за того, что эта песня не входит в альбом. скорее всего, вы видите эту ошибку, так как песня находится в корневой директории папки с музыкой. Jellyfin группирует треки только по папкам\",\n        \"networkError\": \"возникла ошибка сети\",\n        \"badValue\": \"Недопустимый параметр «{{value}}». Это значение больше не существует\",\n        \"notificationDenied\": \"Доступ к уведомлениям запрещен. Настройка не работает\",\n        \"multipleServerSaveQueueError\": \"в очереди воспроизведения присутствует одна или несколько песен, которые не загружены с текущего сервера. это не поддерживается\",\n        \"noNetwork\": \"сервер недоступен\",\n        \"noNetworkDescription\": \"Не удалось подключиться к серверу\",\n        \"saveQueueFailed\": \"Не удалось сохранить очередь\",\n        \"settingsSyncError\": \"обнаружены несоответствия между настройками рендерера и основным процессом. перезапустите приложение, чтобы изменения вступили в силу\",\n        \"invalidJson\": \"невалидный JSON\",\n        \"serverLockSingleServer\": \"при заблокированном сервере разрешается использовать только один сервер\",\n        \"playbackPausedDueToError\": \"воспроизведение было приостановлено из-за ошибки\"\n    },\n    \"filter\": {\n        \"isCompilation\": \"сборник\",\n        \"isRated\": \"оценён\",\n        \"bitrate\": \"битрейт\",\n        \"dateAdded\": \"дата добавления\",\n        \"communityRating\": \"рейтинг сообщества\",\n        \"favorited\": \"любимый\",\n        \"isFavorited\": \"любимые\",\n        \"bpm\": \"уд./мин.\",\n        \"disc\": \"диск\",\n        \"biography\": \"биография\",\n        \"duration\": \"длительность\",\n        \"fromYear\": \"год\",\n        \"criticRating\": \"рейтинг критиков\",\n        \"mostPlayed\": \"слушают чаще всего\",\n        \"comment\": \"комментировать\",\n        \"playCount\": \"количество воспроизведений\",\n        \"recentlyUpdated\": \"обновленные недавно\",\n        \"recentlyPlayed\": \"проигрывались недавно\",\n        \"owner\": \"$t(common.owner)\",\n        \"title\": \"название\",\n        \"rating\": \"рейтинг\",\n        \"search\": \"поиск\",\n        \"recentlyAdded\": \"недавно добавленные\",\n        \"note\": \"заметка\",\n        \"name\": \"название\",\n        \"releaseDate\": \"дата выхода\",\n        \"albumCount\": \"количество $t(entity.album, {\\\"count\\\": 2})\",\n        \"path\": \"путь\",\n        \"isRecentlyPlayed\": \"недавно проигрывался\",\n        \"releaseYear\": \"год выхода\",\n        \"id\": \"№\",\n        \"songCount\": \"количество песен\",\n        \"isPublic\": \"публичный\",\n        \"random\": \"случайно\",\n        \"lastPlayed\": \"последний раз проигрывалась\",\n        \"toYear\": \"до года\",\n        \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n        \"trackNumber\": \"трек\",\n        \"matchAnd\": \"и\",\n        \"matchOr\": \"или\",\n        \"sortName\": \"сортировка по имени\",\n        \"albumArtist\": \"$t(entity.albumArtist, {\\\"count\\\": 1})\",\n        \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n        \"channels\": \"$t(common.channel, {\\\"count\\\": 2})\",\n        \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n        \"explicitStatus\": \"$t(common.explicitStatus)\"\n    },\n    \"player\": {\n        \"repeat_all\": \"повторять все\",\n        \"stop\": \"остановить\",\n        \"repeat\": \"повторять текущий\",\n        \"queue_remove\": \"удалить выбранное\",\n        \"playRandom\": \"играть случайные песни\",\n        \"playSimilarSongs\": \"играть похожие песни\",\n        \"skip\": \"пропустить\",\n        \"previous\": \"предыдущий\",\n        \"toggleFullscreenPlayer\": \"включить полноэкранный режим\",\n        \"skip_back\": \"назад\",\n        \"favorite\": \"любимый\",\n        \"next\": \"следующий\",\n        \"shuffle\": \"перемешать\",\n        \"playbackFetchNoResults\": \"песни не найдены\",\n        \"playbackFetchInProgress\": \"загрузка песен…\",\n        \"addNext\": \"воспроизвести следующим\",\n        \"playbackSpeed\": \"скорость воспроизведения\",\n        \"playbackFetchCancel\": \"пожалуйста, подождите немного... закройте уведомление для отмены\",\n        \"play\": \"играть\",\n        \"repeat_off\": \"повтор выключен\",\n        \"pause\": \"пауза\",\n        \"queue_clear\": \"очистить очередь\",\n        \"muted\": \"звук отключён\",\n        \"unfavorite\": \"убрать из избранного\",\n        \"queue_moveToTop\": \"переместить выделенное вниз\",\n        \"queue_moveToBottom\": \"переместить выделенное вверх\",\n        \"shuffle_off\": \"перемешивание выключено\",\n        \"addLast\": \"последний\",\n        \"mute\": \"отключить звук\",\n        \"skip_forward\": \"вперёд\",\n        \"viewQueue\": \"показать очередь\",\n        \"addLastShuffled\": \"последний (смешанный)\",\n        \"addNextShuffled\": \"следующий (смешанный)\",\n        \"artistRadio\": \"Радио артист\",\n        \"holdToShuffle\": \"удержать для смешивания\",\n        \"lyrics\": \"тексты песен\",\n        \"restoreQueueFromServer\": \"восстановить очередь с сервера\",\n        \"saveQueueToServer\": \"сохранить очередь на сервер\",\n        \"trackRadio\": \"трек радио\",\n        \"albumRadio\": \"Радио по альбому\",\n        \"sleepTimer\": \"таймер сна\",\n        \"sleepTimer_endOfSong\": \"конец текущей песни\",\n        \"sleepTimer_minutes\": \"{{count}} минут\",\n        \"sleepTimer_hours\": \"{{count}} часов\",\n        \"sleepTimer_off\": \"выключено\",\n        \"sleepTimer_timeRemaining\": \"{{time}} осталось\",\n        \"sleepTimer_setCustom\": \"установить таймер\",\n        \"sleepTimer_custom\": \"пользовательский\",\n        \"sleepTimer_cancel\": \"отменить таймер\"\n    },\n    \"page\": {\n        \"sidebar\": {\n            \"nowPlaying\": \"сейчас играет\",\n            \"search\": \"$t(common.search)\",\n            \"home\": \"$t(common.home)\",\n            \"myLibrary\": \"Моя библиотека\",\n            \"shared\": \"Публичные плейлисты $t(entity.playlist, {\\\"count\\\": 2})\",\n            \"collections\": \"коллекции\"\n        },\n        \"fullscreenPlayer\": {\n            \"config\": {\n                \"showLyricMatch\": \"показать слова песни\",\n                \"dynamicBackground\": \"динамический фон\",\n                \"synchronized\": \"синхронизировано\",\n                \"followCurrentLyric\": \"следовать за текущими словами песни\",\n                \"opacity\": \"непрозрачность\",\n                \"lyricSize\": \"размер слов\",\n                \"showLyricProvider\": \"показать источник слов\",\n                \"unsynchronized\": \"не синхронизировано\",\n                \"lyricAlignment\": \"выравнивание слов песни\",\n                \"lyricOffset\": \"задержка слов (мсек)\",\n                \"useImageAspectRatio\": \"использовать соотношение сторон изображения\",\n                \"lyricGap\": \"пробел между словами\",\n                \"dynamicIsImage\": \"включить фоновое изображение\",\n                \"dynamicImageBlur\": \"сила размытия изображения\"\n            },\n            \"upNext\": \"играет\",\n            \"lyrics\": \"слова\",\n            \"related\": \"похожие\",\n            \"visualizer\": \"визуализатор\",\n            \"noLyrics\": \"слова для песни не найдены\"\n        },\n        \"appMenu\": {\n            \"selectServer\": \"список серверов\",\n            \"version\": \"версия {{version}}\",\n            \"manageServers\": \"редактировать список серверов\",\n            \"expandSidebar\": \"развернуть боковую панель\",\n            \"collapseSidebar\": \"Скрыть боковую панель\",\n            \"openBrowserDevtools\": \"открыть инструменты разработчика\",\n            \"quit\": \"$t(common.quit)\",\n            \"goBack\": \"назад\",\n            \"goForward\": \"вперёд\",\n            \"privateModeOff\": \"Выключить приватный режим\",\n            \"privateModeOn\": \"Включить приватный режим\",\n            \"selectMusicFolder\": \"выбрать папку с музыкой\",\n            \"noMusicFolder\": \"папка с музыкой не выбрана\",\n            \"multipleMusicFolders\": \"{{count}} выбрано музыкальных папок\",\n            \"commandPalette\": \"открыть командную строку\"\n        },\n        \"manageServers\": {\n            \"title\": \"сервера\",\n            \"serverDetails\": \"информация о сервере\",\n            \"url\": \"адрес\",\n            \"username\": \"пользователь\",\n            \"editServerDetailsTooltip\": \"изменить настройки сервера\",\n            \"removeServer\": \"удалить сервер\"\n        },\n        \"contextMenu\": {\n            \"addToPlaylist\": \"$t(action.addToPlaylist)\",\n            \"addToFavorites\": \"$t(action.addToFavorites)\",\n            \"setRating\": \"$t(action.setRating)\",\n            \"moveToTop\": \"$t(action.moveToTop)\",\n            \"deletePlaylist\": \"$t(action.deletePlaylist)\",\n            \"moveToBottom\": \"$t(action.moveToBottom)\",\n            \"createPlaylist\": \"$t(action.createPlaylist)\",\n            \"removeFromPlaylist\": \"$t(action.removeFromPlaylist)\",\n            \"removeFromFavorites\": \"$t(action.removeFromFavorites)\",\n            \"addNext\": \"$t(player.addNext)\",\n            \"deselectAll\": \"$t(action.deselectAll)\",\n            \"download\": \"скачать\",\n            \"addLast\": \"$t(player.addLast)\",\n            \"addFavorite\": \"$t(action.addToFavorites)\",\n            \"play\": \"$t(player.play)\",\n            \"numberSelected\": \"{{count}} выбрано\",\n            \"removeFromQueue\": \"$t(action.removeFromQueue)\",\n            \"showDetails\": \"получить информацию\",\n            \"shareItem\": \"поделиться\",\n            \"goToAlbum\": \"Перейти к $t(entity.album, {\\\"count\\\": 1})\",\n            \"goToAlbumArtist\": \"Перейти к $t(entity.albumArtist, {\\\"count\\\": 1})\",\n            \"goTo\": \"перейти в\",\n            \"moveToNext\": \"$t(action.moveToNext)\",\n            \"playShuffled\": \"$t(player.shuffle)\",\n            \"moveItems\": \"$t(action.moveItems)\"\n        },\n        \"home\": {\n            \"mostPlayed\": \"слушают чаще всего\",\n            \"newlyAdded\": \"недавно добавленные релизы\",\n            \"title\": \"$t(common.home)\",\n            \"explore\": \"откройте новое\",\n            \"recentlyPlayed\": \"игралось недавно\",\n            \"recentlyReleased\": \"Новинки\"\n        },\n        \"albumDetail\": {\n            \"moreFromArtist\": \"больше от $t(entity.artist, {\\\"count\\\": 1})\",\n            \"moreFromGeneric\": \"больше из {{item}}\",\n            \"released\": \"выпущен\"\n        },\n        \"setting\": {\n            \"playbackTab\": \"воспроизведение\",\n            \"generalTab\": \"общее\",\n            \"hotkeysTab\": \"горячие клавиши\",\n            \"windowTab\": \"окно\",\n            \"advanced\": \"расширенные\",\n            \"analytics\": \"аналитика\",\n            \"updates\": \"обновить\",\n            \"cache\": \"кэш\",\n            \"application\": \"приложение\",\n            \"theme\": \"тема\",\n            \"controls\": \"элементы управления\",\n            \"sidebar\": \"боковая панель\",\n            \"remote\": \"удаленный\",\n            \"exportImport\": \"импорт/экспорт\",\n            \"audio\": \"аудио\",\n            \"lyrics\": \"тексты песен\",\n            \"lyricsDisplay\": \"отображение текстов песен\",\n            \"transcoding\": \"транскодирование\",\n            \"scrobble\": \"скробблер\",\n            \"logger\": \"Отладка\",\n            \"playerFilters\": \"фильтры проигрывателя\",\n            \"queryBuilder\": \"конструктор очереди\",\n            \"discord\": \"дискорд\"\n        },\n        \"genreList\": {\n            \"showAlbums\": \"показать $t(entity.genre, {\\\"count\\\": 1}) $t(entity.album, {\\\"count\\\": 2})\",\n            \"showTracks\": \"показать $t(entity.genre, {\\\"count\\\": 1}) $t(entity.track, {\\\"count\\\": 2})\"\n        },\n        \"trackList\": {\n            \"artistTracks\": \"Треки {{artist}}\"\n        },\n        \"globalSearch\": {\n            \"commands\": {\n                \"serverCommands\": \"команды сервера\",\n                \"goToPage\": \"перейти на страницу\",\n                \"searchFor\": \"поиск {{query}}\"\n            },\n            \"title\": \"комманды\"\n        },\n        \"playlist\": {\n            \"reorder\": \"сортировка доступна только по ID\"\n        },\n        \"albumList\": {\n            \"artistAlbums\": \"альбомы {{artist}}\",\n            \"genreAlbums\": \"\\\"{{genre}}\\\"\\n$t(entity.album, {\\\"count\\\": 2})\",\n            \"title\": \"$t(entity.album, {\\\"count\\\": 2})\"\n        },\n        \"albumArtistDetail\": {\n            \"topSongs\": \"популярные треки\",\n            \"viewAll\": \"посмотреть всё\",\n            \"appearsOn\": \"появляется в\",\n            \"viewDiscography\": \"посмотреть дискографию\",\n            \"relatedArtists\": \"похож на $t(entity.artist, {\\\"count\\\": 2})\",\n            \"viewAllTracks\": \"посмотреть все $t(entity.track, {\\\"count\\\": 2})\",\n            \"recentReleases\": \"недавние релизы\",\n            \"about\": \"О {{artist}}\",\n            \"topSongsFrom\": \"популярные треки из {{title}}\",\n            \"groupingTypeAll\": \"все типы выпусков\",\n            \"groupingTypePrimary\": \"основные типы выпусков\",\n            \"favoriteSongs\": \"любимые треки\",\n            \"topSongsCommunity\": \"сообщество\",\n            \"topSongsPersonal\": \"личное\",\n            \"favoriteSongsFrom\": \"любимые треки от {{title}}\"\n        },\n        \"itemDetail\": {\n            \"copyPath\": \"скопировать путь в буфер обмена\",\n            \"openFile\": \"открыть трек в менеджере файлов\",\n            \"copiedPath\": \"путь успешно скопирован\"\n        },\n        \"radioList\": {\n            \"title\": \"радиостанции\"\n        },\n        \"windowBar\": {\n            \"privateMode\": \"(Режим приватности)\",\n            \"paused\": \"(Приостановлено) \"\n        },\n        \"collections\": {\n            \"saveAsCollection\": \"сохранить коллекцией\",\n            \"overrideExisting\": \"переопределить существующий\"\n        },\n        \"releasenotes\": {\n            \"commitsSinceStable\": \"коммито после {{stable}}\",\n            \"noStableReleaseToCompare\": \"нет стабильной версии, с которой можно было бы сравнить\",\n            \"noNewCommits\": \"изменения в этом диапазоне отсутствуют\"\n        },\n        \"albumArtistList\": {\n            \"title\": \"$t(entity.albumArtist, {\\\"count\\\": 2})\"\n        },\n        \"favorites\": {\n            \"title\": \"$t(entity.favorite, {\\\"count\\\": 2})\"\n        }\n    },\n    \"form\": {\n        \"deletePlaylist\": {\n            \"title\": \"удалить $t(entity.playlist, {\\\"count\\\": 1})\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) успешно удалён\",\n            \"input_confirm\": \"напишите название $t(entity.playlist, {\\\"count\\\": 1}) для подтверждения\"\n        },\n        \"createPlaylist\": {\n            \"input_description\": \"$t(common.description)\",\n            \"title\": \"создать $t(entity.playlist, {\\\"count\\\": 1})\",\n            \"input_public\": \"публичный\",\n            \"input_name\": \"$t(common.name)\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) успешно создан\",\n            \"input_owner\": \"$t(common.owner)\"\n        },\n        \"addServer\": {\n            \"title\": \"добавить сервер\",\n            \"input_username\": \"пользователь\",\n            \"input_url\": \"адрес\",\n            \"input_password\": \"пароль\",\n            \"input_legacyAuthentication\": \"включить старую авторизацию\",\n            \"input_name\": \"название сервера\",\n            \"success\": \"сервер успешно добавлен\",\n            \"input_savePassword\": \"сохранить пароль\",\n            \"ignoreSsl\": \"игнорировать ssl ($t(common.restartRequired))\",\n            \"ignoreCors\": \"игнорировать CORS ($t(common.restartRequired))\",\n            \"error_savePassword\": \"произошла ошибка при сохранении пароля\",\n            \"input_preferInstantMix\": \"Предпочитать автоподборку\",\n            \"input_preferInstantMixDescription\": \"Использовать быстрый микс только для поиска похожих композиций. Полезно, если у вас есть плагины, которые изменяют это поведение\",\n            \"input_preferRemoteUrl\": \"предпочитать публичный url\",\n            \"input_remoteUrl\": \"публичный url\",\n            \"input_remoteUrlPlaceholder\": \"необязательно: публичный гкд-адрес для доступа к внешним функциям\"\n        },\n        \"addToPlaylist\": {\n            \"success\": \"добавлено: $t(entity.trackWithCount, {\\\"count\\\": {{message}} }) в $t(entity.playlistWithCount, {\\\"count\\\": {{numOfPlaylists}} })\",\n            \"title\": \"добавить в $t(entity.playlist, {\\\"count\\\": 1})\",\n            \"input_skipDuplicates\": \"не добавлять дубликаты\",\n            \"create\": \"создать $t(entity.playlist, {\\\"count\\\": 1}) {{playlist}}\",\n            \"searchOrCreate\": \"для создания нового списка выполните поиск по $t(entity.playlist, {\\\"count\\\": 2}) или введите соответствующий текст\",\n            \"input_playlists\": \"$t(entity.playlist, {\\\"count\\\": 2})\"\n        },\n        \"updateServer\": {\n            \"title\": \"обновление сервера\",\n            \"success\": \"сервер успешно обновлён\"\n        },\n        \"queryEditor\": {\n            \"input_optionMatchAll\": \"сопоставить все\",\n            \"input_optionMatchAny\": \"сопоставить любой\",\n            \"title\": \"Редактор запросов\",\n            \"addRuleGroup\": \"добавить группу правил\",\n            \"removeRuleGroup\": \"удалить группу правил\",\n            \"resetToDefault\": \"сбросить на настройки по умолчанию\",\n            \"clearFilters\": \"очистить фильтры\"\n        },\n        \"lyricSearch\": {\n            \"input_name\": \"$t(common.name)\",\n            \"title\": \"поиск слов песни\",\n            \"input_artist\": \"$t(entity.artist, {\\\"count\\\": 1})\"\n        },\n        \"editPlaylist\": {\n            \"title\": \"редактировать $t(entity.playlist, {\\\"count\\\": 1})\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) обновлён успешно\",\n            \"publicJellyfinNote\": \"Jellyfin по какой-то причине не предоставляет информацию о том, публичный плейлист или нет. Если вы хотите, чтобы он остался публичным, выберите следующую опцию\",\n            \"editNote\": \"редактирование больших плейлистов вручную не рекомендуется. Вы уверены, что готовы принять риск потери данных, который может возникнуть в результате перезаписи существующего плейлиста?\"\n        },\n        \"shareItem\": {\n            \"success\": \"ссылка скопирована в буфер обмена (нажмите здесь, чтобы открыть)\",\n            \"expireInvalid\": \"время истечения срока действия должно быть в будущем\",\n            \"createFailed\": \"не удалось создать ссылку для общего доступа (проверьте, включен ли общий доступ?)\",\n            \"allowDownloading\": \"разрешить скачивание\",\n            \"setExpiration\": \"установить срок действия\",\n            \"description\": \"описание\",\n            \"copyToClipboard\": \"Скопировано в буфер обмена: Ctrl+C, Enter\",\n            \"successMustClick\": \"Ссылка создана успешо. Нажимите, чтобы окрыть\"\n        },\n        \"privateMode\": {\n            \"enabled\": \"Приватный режим включен. Статус воспроизведения скрыт от внешних интеграций\",\n            \"disabled\": \"Приватный режим отключен. Статус воспроизведения теперь виден внешним интеграциям\",\n            \"title\": \"Приватный режим\"\n        },\n        \"largeFetchConfirmation\": {\n            \"title\": \"добавить элементы в очередь\",\n            \"description\": \"Это действие добавит все элементы в текущий отфильтрованный вид\"\n        },\n        \"createRadioStation\": {\n            \"success\": \"радиостанция успешно создана\",\n            \"title\": \"создать радиостанцию\",\n            \"input_homepageUrl\": \"домашняя страница\",\n            \"input_name\": \"имя\",\n            \"input_streamUrl\": \"ссылка потока\"\n        },\n        \"lyricsExport\": {\n            \"export\": \"экспортировать тексты песен\",\n            \"input_synced\": \"экспорт синхронизированных текстов песен\",\n            \"input_offset\": \"$t(setting.lyricOffset)\"\n        },\n        \"saveQueue\": {\n            \"success\": \"сохранена очередь воспроизведения на сервере\"\n        },\n        \"shuffleAll\": {\n            \"title\": \"Случайное воспроизведение\",\n            \"input_limit\": \"сколько песен?\",\n            \"input_minYear\": \"от года\",\n            \"input_maxYear\": \"до года\",\n            \"input_played\": \"воспроизвести фильтр\",\n            \"input_played_optionAll\": \"все треки\",\n            \"input_played_optionUnplayed\": \"только не игранные треки\",\n            \"input_played_optionPlayed\": \"только воспроизведённые треки\",\n            \"input_genre\": \"$t(entity.genre, {\\\"count\\\": 1})\"\n        }\n    },\n    \"setting\": {\n        \"accentColor\": \"цвет акцента\",\n        \"accentColor_description\": \"устанавливает цвет акцента для приложения\",\n        \"albumBackground\": \"фоновое изображение альбомов\",\n        \"albumBackground_description\": \"добавляет фоновое изображение для страниц альбомов, содержащих обложку\",\n        \"albumBackgroundBlur\": \"размытие фонового изображения альбома\",\n        \"albumBackgroundBlur_description\": \"определяет степень размытия фонового изображения на странице альбомов\",\n        \"applicationHotkeys\": \"горячие клавиши приложения\",\n        \"crossfadeStyle_description\": \"выберите вид эффекта crossfade для аудиоплеера\",\n        \"customCssEnable\": \"использовать кастомные css\",\n        \"customCssEnable_description\": \"разрешить использование кастомных css\",\n        \"enableRemote_description\": \"включает сервер удалённого управления для управления воспроизведением с помощью других устройств\",\n        \"fontType_optionSystem\": \"системный\",\n        \"mpvExecutablePath_description\": \"укажите папку, в которой находится исполняющий файл аудиоплеера MPV. если оставить пустым, будет использоваться путь по умолчанию\",\n        \"fontType_optionBuiltIn\": \"встроенный\",\n        \"disableLibraryUpdateOnStartup\": \"отключить проверку новых версий при запуске приложения\",\n        \"minimizeToTray_description\": \"сворачивать приложение в панель уведомлений\",\n        \"audioPlayer_description\": \"укажите, какой аудиоплеер использовать для воспроизведения\",\n        \"exitToTray_description\": \"При закрытии приложения - оно останется в панели уведомлений\",\n        \"fontType_optionCustom\": \"пользовательский\",\n        \"remotePassword\": \"пароль к серверу удалённого управления\",\n        \"font\": \"Шрифт\",\n        \"crossfadeDuration_description\": \"Укажите длительность эффекта crossfade\",\n        \"mpvExecutablePath\": \"папка с аудиоплеером MPV\",\n        \"exitToTray\": \"сворачивать в панель уведомлений при закрытии\",\n        \"enableRemote\": \"включить сервер удалённого управления\",\n        \"fontType\": \"тип шрифта\",\n        \"crossfadeDuration\": \"Длительность эффекта crossfade\",\n        \"audioPlayer\": \"Аудиоплеер\",\n        \"minimizeToTray\": \"сворачивать в панель уведомлений\",\n        \"font_description\": \"Выберите, какой шрифт использовать в приложении\",\n        \"remoteUsername\": \"имя пользователя для доступа к серверу удалённого управления\",\n        \"buttonSize_description\": \"размер кнопок в панели управления воспроизведением\",\n        \"clearCache\": \"очистить кэш браузера\",\n        \"clearQueryCache\": \"очистить кэш feishin\",\n        \"audioDevice\": \"устройство воспроизведения\",\n        \"audioDevice_description\": \"выберите устройство воспроизведения\",\n        \"buttonSize\": \"размер кнопок панели управления воспроизведением\",\n        \"hotkey_volumeDown\": \"уменьшить громкость\",\n        \"playButtonBehavior_optionAddLast\": \"$t(player.addLast)\",\n        \"theme_description\": \"устанавливает тему, которая будет использоваться в приложении\",\n        \"passwordStore\": \"хранилище паролей/секретов\",\n        \"sidebarPlaylistList\": \"список плейлистов в боковой панели\",\n        \"windowBarStyle_description\": \"выберите стиль заголовка окна\",\n        \"followLyric\": \"следовать за текстом трека\",\n        \"volumeWheelStep\": \"шаг регулировки громкости колёсиком мыши\",\n        \"windowBarStyle\": \"стиль заголовка окна\",\n        \"hotkey_zoomOut\": \"уменьшить масштаб\",\n        \"playbackStyle_optionCrossFade\": \"затухание\",\n        \"replayGainMode\": \"режим {{ReplayGain}}\",\n        \"replayGainMode_optionNone\": \"$t(common.none)\",\n        \"clearQueryCache_description\": \"так называемая \\\"мягкая очистка\\\" feishin: обновляются плейлисты, метаданные треков, но сохранённые тексты треков сбрасываются. настройки, учётные данные и кэшированные изображения сохраняются\",\n        \"hotkey_favoriteCurrentSong\": \"добавить $t(common.currentSong) в избранное\",\n        \"globalMediaHotkeys\": \"глобальные мультимедийные горячие клавиши\",\n        \"hotkey_browserForward\": \"кнопка браузера \\\"вперёд\\\"\",\n        \"hotkey_favoritePreviousSong\": \"добавить $t(common.previousSong) в избранное\",\n        \"hotkey_globalSearch\": \"глобальный поиск\",\n        \"hotkey_playbackNext\": \"следующий трек\",\n        \"hotkey_playbackPause\": \"пауза\",\n        \"hotkey_playbackPlay\": \"играть\",\n        \"hotkey_playbackPlayPause\": \"играть / пауза\",\n        \"hotkey_playbackPrevious\": \"предыдущий трек\",\n        \"hotkey_playbackStop\": \"остановить\",\n        \"hotkey_rate0\": \"убрать оценку\",\n        \"hotkey_rate1\": \"оценить в 1 звезду\",\n        \"hotkey_rate2\": \"оценить в 2 звезды\",\n        \"hotkey_rate3\": \"оценить в 3 звезды\",\n        \"hotkey_rate4\": \"оценить в 4 звезды\",\n        \"hotkey_rate5\": \"оценить в 5 звёзд\",\n        \"hotkey_skipForward\": \"перемотать вперёд\",\n        \"hotkey_toggleCurrentSongFavorite\": \"добавить/удалить $t(common.currentSong) в избранное\",\n        \"hotkey_toggleFullScreenPlayer\": \"включение/выключение полноэкранного плеера\",\n        \"hotkey_togglePreviousSongFavorite\": \"добавить/удалить $t(common.previousSong) в избранное\",\n        \"hotkey_toggleRepeat\": \"переключить режим повтора\",\n        \"hotkey_toggleShuffle\": \"переключить перемешивание\",\n        \"hotkey_unfavoriteCurrentSong\": \"удалить $t(common.currentSong) из избранного\",\n        \"hotkey_unfavoritePreviousSong\": \"удалить $t(common.previousSong) из избранного\",\n        \"hotkey_volumeUp\": \"увеличить громкость\",\n        \"hotkey_zoomIn\": \"увеличить масштаб\",\n        \"lyricFetchProvider_description\": \"выберите источники для получения текстов песен. порядок источников соответствует очередности их запросов\",\n        \"minimumScrobblePercentage_description\": \"минимальный процент прослушивания трека, прежде чем он будет засчитан как прослушанный\",\n        \"minimumScrobbleSeconds_description\": \"минимальное время прослушивания трека в секундах, прежде чем он будет засчитан как прослушанный\",\n        \"playbackStyle_optionNormal\": \"нормальный\",\n        \"mpvExtraParameters_help\": \"по одному на строчку\",\n        \"playbackStyle_description\": \"выберите стиль воспроизведения, который будет использоваться аудиоплеером\",\n        \"playButtonBehavior_description\": \"устанавливает поведение кнопки воспроизведения при добавлении треков в очередь\",\n        \"playButtonBehavior\": \"поведение кнопки воспроизведения\",\n        \"playButtonBehavior_optionAddNext\": \"$t(player.addNext)\",\n        \"playButtonBehavior_optionPlay\": \"$t(player.play)\",\n        \"playerbarOpenDrawer\": \"полноэкранный переключатель по панели проигрывателя\",\n        \"playerbarOpenDrawer_description\": \"позволяет перейти в полноэкранный режим воспроизведения нажатием на панель проигрывателя\",\n        \"remotePort\": \"порт сервера удалённого управления\",\n        \"remotePort_description\": \"устанавливает порт для сервера удалённого управления\",\n        \"replayGainClipping\": \"{{ReplayGain}} клиппинг\",\n        \"replayGainFallback\": \"{{ReplayGain}} по умолчанию\",\n        \"sampleRate_description\": \"выберите выходную частоту дискретизации, которая будет использоваться, если выбранная частота дискретизации отличается от частоты дискретизации текущего трека. при значении меньше 8000 будет использоваться частота по умолчанию\",\n        \"savePlayQueue_description\": \"сохранять очередь воспроизведения при закрытии приложения и восстанавливать при запуске приложения\",\n        \"showSkipButton_description\": \"показывать или скрывать кнопки перемотки на панели управления воспроизведением\",\n        \"sidebarConfiguration\": \"конфигурация боковой панели\",\n        \"sidebarConfiguration_description\": \"выбрать элементы и порядок их отображения на боковой панели\",\n        \"sidebarCollapsedNavigation\": \"кнопки навигации в боковой панели (в свёрнутом режиме)\",\n        \"showSkipButtons\": \"показывать кнопки перемотки\",\n        \"showSkipButtons_description\": \"показывать или скрывать кнопки перемотки на панели управления воспроизведением\",\n        \"sidebarPlaylistList_description\": \"показать или скрыть список плейлистов на боковой панели\",\n        \"sidePlayQueueStyle\": \"вид отображения боковой очереди\",\n        \"sidePlayQueueStyle_description\": \"определяет вид отображения боковой очереди\",\n        \"sidePlayQueueStyle_optionAttached\": \"закрепленная\",\n        \"sidePlayQueueStyle_optionDetached\": \"плавающая\",\n        \"skipDuration\": \"время перемотки\",\n        \"skipDuration_description\": \"задает время перемотки при использовании кнопок перемотки на панели проигрывателя\",\n        \"useSystemTheme\": \"использовать тему системы\",\n        \"themeLight\": \"тема (светлая)\",\n        \"themeLight_description\": \"устанавливает светлую тему приложения\",\n        \"transcode_description\": \"активирует транскодинг в другие форматы\",\n        \"transcodeBitrate\": \"битрейт транскодинга\",\n        \"transcodeBitrate_description\": \"выберите битрейт транскодинга. 0 - автоматическое определение сервером\",\n        \"transcodeFormat\": \"формат транкодинга\",\n        \"useSystemTheme_description\": \"использует тему, заданную в системе (светлую/тёмную)\",\n        \"zoom\": \"процент масштабирования\",\n        \"zoom_description\": \"устанавливает процент масштабирования приложения\",\n        \"globalMediaHotkeys_description\": \"включить или отключить использование системных мультимедийных горячих клавиш для управления воспроизведением\",\n        \"homeConfiguration_description\": \"позволяет настроить видимость и порядок элементов на домашней странице\",\n        \"homeFeature\": \"улучшенная карусель на главной\",\n        \"homeFeature_description\": \"определяет, показывать ли улучшенную карусель на главной странице\",\n        \"hotkey_toggleQueue\": \"показать/скрыть очередь воспроизведения\",\n        \"imageAspectRatio\": \"использовать оригинальное соотношение сторон обложки\",\n        \"imageAspectRatio_description\": \"если эта опция включена, обложки будут отображаться в соответствии с их собственным соотношением сторон. для обложек не 1:1 оставшееся пространство будет пустым\",\n        \"minimumScrobblePercentage\": \"минимальное время для скробблинга (в процентах)\",\n        \"playbackStyle\": \"стиль воспроизведения\",\n        \"remotePassword_description\": \"задает пароль для сервера удалённого управления. По умолчанию эти учетные данные передаются небезопасным способом, поэтому следует использовать уникальный пароль, который вам неважен\",\n        \"replayGainClipping_description\": \"Предотвращение клиппинга, вызванного {{ReplayGain}}, путём автоматического снижения усиления\",\n        \"replayGainFallback_description\": \"усиление в db для применения, если у файла нет тегов {{ReplayGain}}\",\n        \"replayGainMode_description\": \"регулировать усиление громкости в соответствии со значениями {{ReplayGain}}, хранящимися в метаданных файла\",\n        \"savePlayQueue\": \"сохранять очередь воспроизведения\",\n        \"showSkipButton\": \"показывать кнопки перемотки\",\n        \"theme\": \"тема\",\n        \"themeDark\": \"тема (тёмная)\",\n        \"externalLinks\": \"показывать ссылки на внешние ресурсы\",\n        \"gaplessAudio\": \"бесшовное воспроизведение\",\n        \"gaplessAudio_optionWeak\": \"слабое (рекомендуется)\",\n        \"gaplessAudio_description\": \"устанавливает настройку mpv для бесшовного воспроизведение\",\n        \"hotkey_browserBack\": \"кнопка браузера \\\"назад\\\"\",\n        \"hotkey_localSearch\": \"поиск на странице\",\n        \"hotkey_skipBackward\": \"перемотать назад\",\n        \"startMinimized\": \"запуск в свёрнутом режиме\",\n        \"themeDark_description\": \"устанавливает тёмную тему приложения\",\n        \"hotkey_volumeMute\": \"отключить звук\",\n        \"clearCache_description\": \"\\\"жесткая очистка\\\" feishin: кроме очистки кэша feishin, также очищает кэш браузера (сохранённые картинки и другие ресурсы). учётные данные и настройки сохраняются\",\n        \"clearCacheSuccess\": \"кэш успешно удалён\",\n        \"contextMenu\": \"конфигурация контекстного меню (нажатие правой кнопкой мыши)\",\n        \"contextMenu_description\": \"позволяет скрыть элементы, отображаемые в меню, появляющемся при нажатии правой кнопки мыши на элемент. все, что не отмечено, будет скрыто\",\n        \"customFontPath\": \"путь к пользовательскому шрифту\",\n        \"customFontPath_description\": \"укажите путь к пользовательскому шрифту, который будет использоваться в приложении\",\n        \"externalLinks_description\": \"включает отображение внешних ссылок (Last.fm, MusicBrainz) на страницах альбомов и артистов\",\n        \"followLyric_description\": \"прокручивать текст трека до текущей позиции воспроизведения\",\n        \"language_description\": \"устанавливает язык приложения ($t(common.restartRequired))\",\n        \"lyricFetch_description\": \"получать тексты треков из различных интернет-источников\",\n        \"lyricFetchProvider\": \"источник текстов треков\",\n        \"minimumScrobbleSeconds\": \"минимальное время для скробблинга (в секундах)\",\n        \"replayGainPreamp\": \"предусиление {{ReplayGain}} (дБ)\",\n        \"sidebarCollapsedNavigation_description\": \"показать или скрыть кнопки навигации в свёрнутой боковой панели\",\n        \"homeConfiguration\": \"конфигурация домашней страницы\",\n        \"remoteUsername_description\": \"задает имя пользователя для сервера удалённого управления. если имя пользователя и пароль пусты, аутентификация будет отключена\",\n        \"scrobble\": \"скробблинг\",\n        \"replayGainPreamp_description\": \"настройка усиления предусилителя, применяемого к значениям {{ReplayGain}}\",\n        \"passwordStore_description\": \"какое хранилище паролей/секретов использовать. измените это значение, если у вас есть проблемы с хранением паролей\",\n        \"lyricFetch\": \"получать тексты треков из интернета\",\n        \"sampleRate\": \"частота дискретизации\",\n        \"scrobble_description\": \"скробблинг треков на вашем медиасервере\",\n        \"startMinimized_description\": \"запуск приложения в области уведомлений\",\n        \"volumeWheelStep_description\": \"количество громкости, изменяемое при прокрутке колёсика мыши над ползунком громкости\",\n        \"volumeWidth\": \"ширина слайдера звука\",\n        \"volumeWidth_description\": \"ширина слайдера звука (в px)\",\n        \"webAudio\": \"использовать веб аудио\",\n        \"webAudio_description\": \"использование веб аудио. включение активирует продвинутые возможности (например, replaygain). отключите, если вам это не нужно\",\n        \"discordApplicationId\": \"{{discord}} application id\",\n        \"discordApplicationId_description\": \"application id приложения {{discord}} которое будет отображаться в статусе профиля (по умолчанию {{defaultId}})\",\n        \"discordIdleStatus\": \"показывать состояние простоя\",\n        \"discordIdleStatus_description\": \"если включено, то обновляет статус, когда пользователь бездействует\",\n        \"discordUpdateInterval\": \"интервал обновления статуса профиля {{discord}}\",\n        \"discordUpdateInterval_description\": \"время в секундах между каждым обновлением (минимум 15 секунд)\",\n        \"lyricOffset_description\": \"Смещение появления текста треков на указанное количество миллисекунд\",\n        \"skipPlaylistPage\": \"пропускать страницу плейлиста\",\n        \"applicationHotkeys_description\": \"настройка горячих клавиш приложения. поставьте галочку, чтобы сделать горячую клавишу глобальной (только для ПК)\",\n        \"artistConfiguration\": \"конфигурация страницы альбомов исполнителей\",\n        \"artistConfiguration_description\": \"позволяет настроить видимость и порядок элементов на странице альбомов исполнителей\",\n        \"fontType_description\": \"встроенный позволяет выбрать один из шрифтов, предоставляемых feishin. системный позволяет выбрать любой шрифт, предоставляемый вашей операционной системой. пользовательский позволяет выбрать свой собственный шрифт\",\n        \"discordRichPresence_description\": \"включить статус воспроизведения в статус профиля в {{discord}}. Ключи изображений: {{icon}}, {{playing}} и {{paused}}\",\n        \"lyricOffset\": \"синхронизация текста треков (мс)\",\n        \"audioExclusiveMode\": \"эксклюзивный режим аудио\",\n        \"audioExclusiveMode_description\": \"включить режим эксклюзивного вывода. В этом режиме система обычно блокируется, и только mpv сможет выводить звук\",\n        \"artistBackground\": \"Фоновое изображение исполнителя\",\n        \"artistBackground_description\": \"Добавляет фоновое изображение для страниц исполнителя, содержащих обложку исполнителя\",\n        \"artistBackgroundBlur\": \"процент размытия обложки исполнителя\",\n        \"artistBackgroundBlur_description\": \"регулирует процент размытия к заднему фону исполнителя\",\n        \"autoDJ_description\": \"автоматически добавлять похожие песни в очередь воспроизведения\",\n        \"autoDJ_itemCount\": \"количество элементов\",\n        \"autoDJ_itemCount_description\": \"количество элементов, которые пытаются добавить в очередь при включенной функции автоматического диджеинга\",\n        \"autoDJ_timing\": \"расчетное время\",\n        \"autoDJ_timing_description\": \"количество песен, оставшихся в очереди до срабатывания автоматического диджея\",\n        \"useThemeAccentColor\": \"использовать цвет темы\",\n        \"useThemeAccentColor_description\": \"используйте основной цвет определенный в выбранной теме вместо пользовательского акцентного цвета\",\n        \"analyticsDisable\": \"Отказаться от аналитики на основе использования\",\n        \"analyticsDisable_description\": \"Анонимизированные данные об использовании отправляются разработчику для улучшения приложения\",\n        \"crossfadeStyle\": \"стиль перехода\",\n        \"customCss_description\": \"пользовательский CSS-контент. Примечание: свойства content и remote urls не допускаются. Предварительный просмотр вашего контента показан ниже. Дополнительные поля, которые вы не задали, присутствуют из-за проверки на наличие ошибок\",\n        \"customCss\": \"Пользовательский CSS\",\n        \"customCssNotice\": \"Предупреждение: несмотря на некоторую очистку (запрет использования url() и content:), использование пользовательских CSS-стилей всё ещё может представлять риски, изменяя интерфейс\",\n        \"releaseChannel_optionBeta\": \"Бета\",\n        \"releaseChannel_optionLatest\": \"последний\",\n        \"releaseChannel\": \"Тип релиза\",\n        \"releaseChannel_description\": \"Выберите между стабильной, бета или альфа (ночной) версией для автоматического обновления\",\n        \"discordDisplayType_artistname\": \"Имя (имена) исполнителя\",\n        \"discordDisplayType_description\": \"это меняет то, что вы слушаете в своем статусе\",\n        \"discordDisplayType_songname\": \"имя песни\",\n        \"discordDisplayType\": \"{{discord}} тип отображения\",\n        \"autosave\": \"автоматическое сохранение очереди воспроизведения\",\n        \"autosave_description\": \"включите автоматическое сохранение очереди воспроизведения на вашем сервере. это возможно только при использовании Navidrome/Subsonic, и у вас не может быть смешанной очереди воспроизведения.\",\n        \"autosaveCount_description\": \"количество изменений трека перед сохранением очереди. 1 (минимум) означает каждое изменение песни\",\n        \"useThemePrimaryShade\": \"используйте основной оттенок темы\",\n        \"useThemePrimaryShade_description\": \"используйте основной оттенок, определенный в выбранной теме, для выбора основного цвета\",\n        \"primaryShade\": \"основной оттенок\",\n        \"primaryShade_description\": \"переопределите основной оттенок (0-9), используемый для кнопок, ссылок и других элементов основного цвета\",\n        \"analyticsEnable\": \"Отправлять аналитику использования\",\n        \"analyticsEnable_description\": \"Анонимные данные использования отправляются разработчику с целью улучшения приложения\",\n        \"artistReleaseTypeConfiguration\": \"настройка типов релизов исполнителя\",\n        \"artistReleaseTypeConfiguration_description\": \"настройте, какие типы релизов отображаются и в каком порядке на странице исполнителя\",\n        \"automaticUpdates\": \"Автообновления\",\n        \"automaticUpdates_description\": \"Проверять и устанавливать обновления автоматически\",\n        \"discordLinkType_description\": \"добавляет ссылки на {{lastfm}} / {{musicbrainz}} в Rich Presence {{discord}} для треков и исполнителей. {{musicbrainz}} точнее, но зависит от тегов и не даёт ссылок на артистов {{lastfm}} почти всегда предоставляет ссылку. Без дополнительных сетевых запросов.\",\n        \"blurExplicitImages\": \"скрывать нецензурные изображения размытием\",\n        \"blurExplicitImages_description\": \"обложки с нецензурным контентом будут размываются\",\n        \"autosaveCount\": \"частота автоматического сохранения очереди воспроизведения\",\n        \"discordLinkType_mbz_lastfm\": \"{{musicbrainz}} (запасной источник: {{lastfm}} )\",\n        \"discordLinkType\": \"интеграция {{discord}} статуса\",\n        \"discordListening_description\": \"Показывать статус \\\"Слушает\\\" вместо \\\"Играет\\\"\",\n        \"discordListening\": \"показывать статус \\\"Слушает\\\"\",\n        \"discordPausedStatus_description\": \"если включено, статус будет отображаться даже когда воспроизведение на паузе\",\n        \"discordPausedStatus\": \"показывать расширенный статус при паузе\",\n        \"discordRichPresence\": \"{{discord}}: расширенный статус\",\n        \"discordStateIcon\": \"показывать иконку воспроизведения\",\n        \"enableAutoTranslation_description\": \"включить автоматический перевод при получении текста\",\n        \"enableAutoTranslation\": \"включить автоперевод\",\n        \"exportImportSettings_control_description\": \"экспорт/импорт настроек в JSON\",\n        \"exportImportSettings_control_exportText\": \"экспорт настроек\",\n        \"exportImportSettings_control_importText\": \"импорт настроек\",\n        \"exportImportSettings_control_title\": \"импорт/экспорт настроек\",\n        \"exportImportSettings_destructiveWarning\": \"Импорт настроек полностью заменит ваши текущие настройки. Убедитесь, что все данные выше верны, перед тем как нажать кнопку «Импорт»!\",\n        \"exportImportSettings_importBtn\": \"Импорт настроек\",\n        \"exportImportSettings_importModalTitle\": \"Импорт настроек Feishin\",\n        \"exportImportSettings_importSuccess\": \"Настройки успешно импортированы!\",\n        \"exportImportSettings_notValidJSON\": \"Некорректный JSON-файл\",\n        \"exportImportSettings_offendingKeyError\": \"Неверный ключ \\\"{{offendingKey}}\\\": {{reason}}\",\n        \"followCurrentSong_description\": \"Автоматически прокручивать очередь до текущего трека\",\n        \"followCurrentSong\": \"следить за текущим треком\",\n        \"homeFeatureStyle_description\": \"настройка стиля карусели на главном экране\",\n        \"homeFeatureStyle\": \"стиль карусели на главной\",\n        \"homeFeatureStyle_optionMultiple\": \"несколько\",\n        \"language\": \"Язык интерфейса\",\n        \"autoDJ\": \"авто DJ\",\n        \"releaseChannel_optionAlpha\": \"альфа (ночная версия)\",\n        \"discordServeImage\": \"предоставить {{discord}} изображения с сервера\",\n        \"discordServeImage_description\": \"получать обложки треков для {{discord}} rich presence непосредственно с сервера, доступно только для Jellyfin и Navidrome. {{discord}} использует бота для получения картинок, поэтому ваш сервер должен быть доступен из общедоступной сети\",\n        \"discordStateIcon_description\": \"показывать иконку \\\"играет\\\" в статусе. иконка паузы показывается всегда когда опция \\\"Показывать расширенный статус при паузе\\\" включена\",\n        \"homeFeatureStyle_optionSingle\": \"одиночный\",\n        \"hotkey_navigateHome\": \"перейти на главную\",\n        \"lastfm_description\": \"показывать ссылки Last.fm на страницах артистов и альбомов\",\n        \"lastfm\": \"показывать ссылки last.fm\",\n        \"lastfmApiKey_description\": \"API ключ для {{lastfm}}. необходим для обложек\",\n        \"lastfmApiKey\": \"API ключ {{lastfm}}\",\n        \"logLevel\": \"детализация логов\",\n        \"logLevel_description\": \"определяет степень детализации логов. \\\"отладка\\\" отображает все логи, \\\"ошибка\\\" отображает только ошибки\",\n        \"logLevel_optionDebug\": \"отладка\",\n        \"logLevel_optionError\": \"ошибка\",\n        \"logLevel_optionInfo\": \"инфо\",\n        \"logLevel_optionWarn\": \"предупреждение\",\n        \"mpvExtraParameters\": \"дополнительные параметры mpv\",\n        \"mpvExtraParameters_description\": \"дополнительные аргументы, передаваемые mpv\",\n        \"musicbrainz_description\": \"показывать ссылки MusicBrainz на страницах артистов и альбомов, где есть ID MusicBrainz\",\n        \"musicbrainz\": \"показывать ссылки MusicBrainz\",\n        \"neteaseTranslation_description\": \"Если включено, получает и отображает переведённые текста песен с NetEase по возможности\",\n        \"neteaseTranslation\": \"Включить переводы NetEase\",\n        \"notify\": \"включить уведомления о песнях\",\n        \"notify_description\": \"показывать уведомления при смене песни\",\n        \"pathReplace\": \"замена пути к файлу\",\n        \"pathReplace_description\": \"заменяет стандартный путь сервера\",\n        \"pathReplace_optionRemovePrefix\": \"убрать префикс\",\n        \"pathReplace_optionAddPrefix\": \"добавить префикс\",\n        \"playerFilters\": \"Фильтр песен в очереди\",\n        \"playerFilters_description\": \"пропускает песни при добавлении в очередь, основываясь на заданном критерии\",\n        \"artistRadioCount_description\": \"определяет количество песен для добавления в радио по артисту/треку\",\n        \"artistRadioCount\": \"кол-во радио по артисту/треку\",\n        \"imageResolution\": \"разрешение изображения\",\n        \"imageResolution_description\": \"разрешение изображений, используемых в приложении. при значении \\\"0\\\" будет использоваться исходное разрешение\",\n        \"imageResolution_optionItemCard\": \"карточка элемента\",\n        \"imageResolution_optionSidebar\": \"боковая панель\",\n        \"imageResolution_optionHeader\": \"заголовок\",\n        \"imageResolution_optionFullScreenPlayer\": \"полноэкранный проигрыватель\",\n        \"playerbarSlider\": \"ползунок проигрывателя\",\n        \"playerbarSlider_description\": \"waveform не рекомендуется при слабом подключении к интернету\",\n        \"playerbarSliderType_optionSlider\": \"ползунок\",\n        \"playerbarSliderType_optionWaveform\": \"waveform\",\n        \"playerbarWaveformAlign\": \"положение waveform\",\n        \"playerbarWaveformAlign_optionTop\": \"верх\",\n        \"playerbarWaveformAlign_optionCenter\": \"центр\",\n        \"playerbarWaveformAlign_optionBottom\": \"низ\",\n        \"playerbarWaveformBarWidth\": \"ширина элемента waveform\",\n        \"playerbarWaveformGap\": \"промежутки waveform\",\n        \"playerbarWaveformRadius\": \"радиус waveform\",\n        \"preferLocalLyrics_description\": \"по возможности предпочитать локальные текста песен загружаемым\",\n        \"preferLocalLyrics\": \"предпочтитать локальные текста песен\",\n        \"showLyricsInSidebar_description\": \"к очереди воспроизведения будет добавлена панель, отображающая текст песни\",\n        \"showLyricsInSidebar\": \"показывать текст песни в боковой панели проигрывателя\",\n        \"showRatings_description\": \"определяет, отображается ли в интерфейсе функция звёздного рейтинга\",\n        \"showRatings\": \"показывать звёздный рейтинг\",\n        \"enableGridMultiSelect\": \"включить множественное выделение\",\n        \"enableGridMultiSelect_description\": \"если включено, то позволяет выделять несколько элементов в таблицах. если отключено, то нажатие на элемент таблицы откроет страницу элемента\",\n        \"showVisualizerInSidebar_description\": \"к боковой части проигрывателя будет добавлена панель, показывающая визуализатор\",\n        \"showVisualizerInSidebar\": \"показывать визуализатор в боковой панели\",\n        \"combinedLyricsAndVisualizer_description\": \"Объединяет текст песни и визуализатор в одну панель заместо двух\",\n        \"combinedLyricsAndVisualizer\": \"объединить текст и визуализатор в одну панель\",\n        \"preservePitch_description\": \"сохраняет тональность при изменении скорости воспроизведения\",\n        \"preservePitch\": \"сохранять тональность\",\n        \"audioFadeOnStatusChange\": \"плавное изменение звука\",\n        \"audioFadeOnStatusChange_description\": \"включает эффекты затухания и появления звука при изменении статуса (пауза/проигрывание)\",\n        \"preventSleepOnPlayback_description\": \"запрещает спящий режим экрана, пока играет музыка\",\n        \"preventSleepOnPlayback\": \"не переходить в спящий режим\"\n    },\n    \"releaseType\": {\n        \"secondary\": {\n            \"demo\": \"демо\",\n            \"audiobook\": \"аудиокнига\",\n            \"compilation\": \"подборка\",\n            \"interview\": \"интервью\",\n            \"remix\": \"ремикс\",\n            \"live\": \"прямой эфир\",\n            \"soundtrack\": \"саундтрек\",\n            \"spokenWord\": \"Художественная декламация\",\n            \"audioDrama\": \"радиопостановка\",\n            \"fieldRecording\": \"запись вне студии\",\n            \"mixtape\": \"сборник\",\n            \"djMix\": \"dj микс\"\n        },\n        \"primary\": {\n            \"other\": \"другие\",\n            \"broadcast\": \"транслировать\",\n            \"ep\": \"эп\",\n            \"single\": \"сингл\"\n        }\n    },\n    \"datetime\": {\n        \"minuteShort\": \"м\",\n        \"secondShort\": \"с\",\n        \"hourShort\": \"ч\",\n        \"dayShort\": \"д\"\n    },\n    \"filterOperator\": {\n        \"after\": \"после\",\n        \"afterDate\": \"после (дата)\",\n        \"before\": \"это раньше\",\n        \"beforeDate\": \"это раньше (дата)\",\n        \"contains\": \"содержит\",\n        \"endsWith\": \"заканчивается\",\n        \"inPlaylist\": \"находится в\",\n        \"inTheLast\": \"находится в последнем\",\n        \"inTheRange\": \"находится в диапазоне\",\n        \"inTheRangeDate\": \"находится в диапазоне (дата)\",\n        \"is\": \"является\",\n        \"isNot\": \"не\",\n        \"isGreaterThan\": \"больше чем\",\n        \"isLessThan\": \"меньше чем\",\n        \"matchesRegex\": \"соответствует выражению\",\n        \"notContains\": \"не содержит\",\n        \"notInPlaylist\": \"не в\",\n        \"notInTheLast\": \"не в последнем\",\n        \"startsWith\": \"начинается с\"\n    },\n    \"queryBuilder\": {\n        \"standardTags\": \"стандартные теги\",\n        \"customTags\": \"пользовательские теги\"\n    },\n    \"visualizer\": {\n        \"presets\": \"Пресеты\",\n        \"selectPreset\": \"Выбрать Пресет\",\n        \"applyPreset\": \"Применить Пресет\",\n        \"saveAsPreset\": \"Сохранить пресет\",\n        \"updatePreset\": \"Обновить пресет\",\n        \"copyConfiguration\": \"Копировать Конфигурацию\",\n        \"pasteConfiguration\": \"Вставить Конфигурацию\",\n        \"pasteConfigurationPlaceholder\": \"Вставить JSON конфигурацию...\",\n        \"pasteFromClipboard\": \"Вставить из буфера обмена\",\n        \"applyConfiguration\": \"Применить Конфигурацию\",\n        \"configCopied\": \"Конфигурация скопирована в буфер обмена\",\n        \"configCopyFailed\": \"Ошибка применения конфигурации\",\n        \"configPasted\": \"Конфигурация успешно установлена\",\n        \"configPasteFailed\": \"Ошибка применения конфигурации. Проверьте формат.\",\n        \"configPasteReadFailed\": \"Ошибка чтения из буфера обмена\",\n        \"presetName\": \"Название пресета\",\n        \"presetNamePlaceholder\": \"Введите название пресета\",\n        \"general\": \"Главная\",\n        \"lineWidth\": \"Ширина линии\"\n    }\n}\n"
  },
  {
    "path": "src/i18n/locales/sk.json",
    "content": "{\n    \"action\": {\n        \"addToFavorites\": \"pridať do $t(entity.favorite, {\\\"count\\\": 2})\",\n        \"addToPlaylist\": \"pridať do $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"clearQueue\": \"vymazať frontu\",\n        \"createPlaylist\": \"vytvoriť $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"deletePlaylist\": \"odstrániť $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"deselectAll\": \"odznačiť všetko\",\n        \"editPlaylist\": \"upraviť $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"goToPage\": \"ísť na stránku\",\n        \"moveToNext\": \"prejsť na ďalší\",\n        \"moveToBottom\": \"presunúť sa na spodok\",\n        \"moveToTop\": \"presunúť sa navrch\",\n        \"refresh\": \"$t(common.refresh)\",\n        \"removeFromFavorites\": \"odstrániť z $t(entity.favorite, {\\\"count\\\": 2})\",\n        \"removeFromPlaylist\": \"odstrániť z $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"removeFromQueue\": \"odstrániť z fronty\",\n        \"setRating\": \"ohodnotiť\",\n        \"toggleSmartPlaylistEditor\": \"prepnúť $t(entity.smartPlaylist) editor\",\n        \"viewPlaylists\": \"zobraziť $t(entity.playlist, {\\\"count\\\": 2})\",\n        \"openIn\": {\n            \"lastfm\": \"Otvoriť v Last.fm\",\n            \"musicbrainz\": \"Otvoriť v MusicBrainz\"\n        },\n        \"addOrRemoveFromSelection\": \"pridať či odstrániť z vybranie\"\n    },\n    \"common\": {\n        \"action_one\": \"akcia\",\n        \"action_few\": \"akcie\",\n        \"action_other\": \"akcií\",\n        \"add\": \"pridať\",\n        \"additionalParticipants\": \"ďalší účastníci\",\n        \"newVersion\": \"bola nainštalovaná nová verzia ({{version}})\",\n        \"viewReleaseNotes\": \"zobraziť poznámky k vydaniu\",\n        \"albumGain\": \"hranosť albumu\",\n        \"albumPeak\": \"vrchol albumu\",\n        \"areYouSure\": \"ste si istý?\",\n        \"ascending\": \"vzostupne\",\n        \"backward\": \"dozadu\",\n        \"biography\": \"životopis\",\n        \"bitDepth\": \"bitová hĺbka\",\n        \"bitrate\": \"bitrate\",\n        \"bpm\": \"bpm\",\n        \"cancel\": \"zrušiť\",\n        \"center\": \"uprostred\",\n        \"channel_one\": \"kanál\",\n        \"channel_few\": \"kanály\",\n        \"channel_other\": \"kanálov\",\n        \"clear\": \"vyčistiť\",\n        \"close\": \"zavrieť\",\n        \"codec\": \"kodek\",\n        \"collapse\": \"zbaliť\",\n        \"comingSoon\": \"čoskoro…\",\n        \"configure\": \"nastaviť\",\n        \"confirm\": \"potvrdiť\",\n        \"create\": \"vytvoriť\",\n        \"currentSong\": \"aktuálne $t(entity.track, {\\\"count\\\": 1})\",\n        \"decrease\": \"znížiť\",\n        \"delete\": \"zmazať\",\n        \"descending\": \"zostupne\",\n        \"description\": \"popis\",\n        \"disable\": \"zakázať\",\n        \"disc\": \"disk\",\n        \"dismiss\": \"zamietnuť\",\n        \"duration\": \"dĺžka\",\n        \"edit\": \"zmeniť\",\n        \"enable\": \"povoliť\",\n        \"expand\": \"rozbaliť\",\n        \"favorite\": \"obľúbené\",\n        \"filter_one\": \"filter\",\n        \"filter_few\": \"filtre\",\n        \"filter_other\": \"filtrov\",\n        \"filters\": \"filtre\",\n        \"forceRestartRequired\": \"zmeny vyžadujú reštart... zavretím upozornenia sa aplikácia reštartuje\",\n        \"forward\": \"dopredu\",\n        \"gap\": \"medzera\",\n        \"home\": \"domov\",\n        \"increase\": \"zvýšiť\",\n        \"left\": \"vľavo\",\n        \"limit\": \"limit\",\n        \"manage\": \"spravovať\",\n        \"maximize\": \"maximalizovať\",\n        \"menu\": \"ponuka\",\n        \"minimize\": \"minimalizovať\",\n        \"modified\": \"zmenené\",\n        \"mbid\": \"MusicBrainz ID\",\n        \"name\": \"meno\",\n        \"no\": \"nie\",\n        \"none\": \"žiadny\",\n        \"noResultsFromQuery\": \"dopyt nevrátil žiadne výsledky\",\n        \"note\": \"poznámka\",\n        \"ok\": \"ok\",\n        \"owner\": \"majiteľ\",\n        \"path\": \"cesta\",\n        \"playerMustBePaused\": \"prehrávač musí byť pozastavený\",\n        \"preview\": \"náhľad\",\n        \"previousSong\": \"predchádzajúca $t(entity.track, {\\\"count\\\": 1})\",\n        \"quit\": \"ukončiť\",\n        \"random\": \"náhodne\",\n        \"rating\": \"hodnotenie\",\n        \"refresh\": \"obnoviť\",\n        \"reload\": \"znovu načítať\",\n        \"reset\": \"resetovať\",\n        \"resetToDefault\": \"resetovať na predvolené\",\n        \"restartRequired\": \"vyžaduje sa reštart\",\n        \"right\": \"vpravo\",\n        \"sampleRate\": \"vzorkovacia frekvencia\",\n        \"save\": \"uložiť\",\n        \"saveAndReplace\": \"uložiť a nahradiť\",\n        \"saveAs\": \"uložiť ako\",\n        \"search\": \"vyhľadať\",\n        \"setting_one\": \"nastavenie\",\n        \"setting_few\": \"\",\n        \"setting_other\": \"\",\n        \"share\": \"zdieľať\",\n        \"size\": \"veľkosť\",\n        \"sortOrder\": \"poradie\",\n        \"tags\": \"štítky\",\n        \"title\": \"názov\",\n        \"trackNumber\": \"skladba\",\n        \"trackGain\": \"hranosť skladby\",\n        \"trackPeak\": \"vrchol skladby\",\n        \"translation\": \"preklad\",\n        \"unknown\": \"neznámy\",\n        \"version\": \"verzia\",\n        \"year\": \"rok\",\n        \"yes\": \"áno\"\n    },\n    \"filter\": {\n        \"name\": \"meno\",\n        \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n        \"albumArtist\": \"$t(entity.albumArtist, {\\\"count\\\": 1})\",\n        \"albumCount\": \"$t(entity.album, {\\\"count\\\": 2}) počet\",\n        \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n        \"biography\": \"životopis\",\n        \"bitrate\": \"bitrate\",\n        \"bpm\": \"bpm\",\n        \"channels\": \"$t(common.channel_other)\",\n        \"comment\": \"komentár\",\n        \"communityRating\": \"hodnotenie komunity\",\n        \"criticRating\": \"hodnotenie kritiky\",\n        \"dateAdded\": \"dátum pridania\",\n        \"disc\": \"disk\",\n        \"duration\": \"dĺžka\",\n        \"favorited\": \"obľúbené\",\n        \"fromYear\": \"od roku\",\n        \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n        \"id\": \"id\",\n        \"isCompilation\": \"je kompilácia\",\n        \"isFavorited\": \"je obľúbený\",\n        \"isPublic\": \"je verejný\",\n        \"isRated\": \"je hodnotený\",\n        \"isRecentlyPlayed\": \"je nedávno hraný\",\n        \"lastPlayed\": \"posledne hraný\",\n        \"mostPlayed\": \"najhranejší\",\n        \"note\": \"poznámka\",\n        \"owner\": \"$t(common.owner)\",\n        \"path\": \"cesta\",\n        \"playCount\": \"počet prehraní\",\n        \"random\": \"náhodne\",\n        \"rating\": \"hodnotenie\",\n        \"recentlyAdded\": \"nedávno pridané\",\n        \"recentlyPlayed\": \"nedávno hrané\",\n        \"recentlyUpdated\": \"nedávno aktualizované\",\n        \"releaseDate\": \"dátum vydania\",\n        \"releaseYear\": \"rok vydania\",\n        \"search\": \"vyhľadať\",\n        \"songCount\": \"počet skladieb\",\n        \"title\": \"názov\",\n        \"toYear\": \"do roku\",\n        \"trackNumber\": \"stopa\"\n    },\n    \"form\": {\n        \"addServer\": {\n            \"input_name\": \"názov servera\",\n            \"input_username\": \"užívateľské meno\",\n            \"error_savePassword\": \"pri pokuse o uloženie hesla sa vyskytla chyba\",\n            \"ignoreCors\": \"ignorovať cors ($t(common.restartRequired)\",\n            \"ignoreSsl\": \"ignorovať ssl ($t(common.restartRequired))\",\n            \"input_legacyAuthentication\": \"povoliť zastarané overenie\",\n            \"input_password\": \"heslo\",\n            \"input_savePassword\": \"uložiť heslo\",\n            \"input_url\": \"url\",\n            \"success\": \"server úspešne pridaný\",\n            \"title\": \"pridať server\"\n        },\n        \"addToPlaylist\": {\n            \"input_playlists\": \"$t(entity.playlist, {\\\"count\\\": 2})\",\n            \"input_skipDuplicates\": \"preskočiť duplicity\",\n            \"success\": \"$t(entity.trackWithCount, {\\\"count\\\": {{message}} }) pridané do $t(entity.playlistWithCount, {\\\"count\\\": {{numOfPlaylists}} })\",\n            \"title\": \"pridať do $t(entity.playlist, {\\\"count\\\": 1})\"\n        },\n        \"createPlaylist\": {\n            \"input_description\": \"$t(common.description)\",\n            \"input_name\": \"$t(common.name)\",\n            \"input_owner\": \"$t(common.owner)\",\n            \"input_public\": \"verejný\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) úspešne vytvorený\",\n            \"title\": \"vytvoriť $t(entity.playlist, {\\\"count\\\": 1})\"\n        },\n        \"deletePlaylist\": {\n            \"input_confirm\": \"pre potvrdenie zadajte názov $t(entity.playlist, {\\\"count\\\": 1})\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) bol úspešne odstránený\",\n            \"title\": \"odstrániť $t(entity.playlist, {\\\"count\\\": 1})\"\n        },\n        \"editPlaylist\": {\n            \"publicJellyfinNote\": \"Jellyfin z nejakého dôvodu neinformuje, či je playlist verejný alebo nie. Ak si ho želáte ponechať ako verejný, ponechajte nasledujúci vstup ako povolený\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) úspešne aktualizovaný\",\n            \"title\": \"upraviť $t(entity.playlist, {\\\"count\\\": 1})\"\n        },\n        \"lyricSearch\": {\n            \"input_artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n            \"input_name\": \"$t(common.name)\",\n            \"title\": \"vyhľadať text skladby\"\n        },\n        \"queryEditor\": {\n            \"title\": \"editor dopytov\",\n            \"input_optionMatchAll\": \"zhoda na všetkých\",\n            \"input_optionMatchAny\": \"zhoda na ľubovoľnom\"\n        },\n        \"shareItem\": {\n            \"allowDownloading\": \"povoliť sťahovanie\",\n            \"description\": \"popis\",\n            \"setExpiration\": \"nastaviť vypršanie platnosti\",\n            \"success\": \"zdieľať link skopírovaný do schránky (alebo klinutím otvoriť tu)\",\n            \"expireInvalid\": \"vypršanie platnosti musí byť v budúcnosti\",\n            \"createFailed\": \"nepodarilo sa nazdielať (je zdieľanie povolené)\"\n        },\n        \"updateServer\": {\n            \"success\": \"server bol úspešne aktualizovaný\",\n            \"title\": \"aktualizovať server\"\n        },\n        \"privateMode\": {\n            \"enabled\": \"je zapnutý súkromný režim, status prehrávania je pre vonkajšie integrácie skrytý\",\n            \"disabled\": \"súkromný režim je vypnutý, status prehrávania je teraz pre vonkajšie integrácie povolený\",\n            \"title\": \"súkromný režim\"\n        }\n    },\n    \"entity\": {\n        \"album_one\": \"album\",\n        \"album_few\": \"albumy\",\n        \"album_other\": \"albumov\",\n        \"albumArtist_one\": \"interpret albumu\",\n        \"albumArtist_few\": \"interpreti albumu\",\n        \"albumArtist_other\": \"interpretov albumu\",\n        \"albumArtistCount_one\": \"{{count}} interpret albumu\",\n        \"albumArtistCount_few\": \"{{count}} interpreti albumu\",\n        \"albumArtistCount_other\": \"{{count}} interpretov albumu\",\n        \"albumWithCount_one\": \"{{count}} album\",\n        \"albumWithCount_few\": \"{{count}} albumy\",\n        \"albumWithCount_other\": \"{{count}} albumov\",\n        \"artist_one\": \"interpret\",\n        \"artist_few\": \"interpreti\",\n        \"artist_other\": \"interpretov\",\n        \"artistWithCount_one\": \"{{count}} interpret\",\n        \"artistWithCount_few\": \"{{count}} interpreti\",\n        \"artistWithCount_other\": \"{{count}} interpretov\",\n        \"favorite_one\": \"obľúbený\",\n        \"favorite_few\": \"obľúbení\",\n        \"favorite_other\": \"obľúbených\",\n        \"folder_one\": \"priečinok\",\n        \"folder_few\": \"priečinky\",\n        \"folder_other\": \"priečinkov\",\n        \"folderWithCount_one\": \"{{count}} priečinok\",\n        \"folderWithCount_few\": \"{{count}} priečinky\",\n        \"folderWithCount_other\": \"{{count}} priečinkov\",\n        \"genre_one\": \"žáner\",\n        \"genre_few\": \"žánre\",\n        \"genre_other\": \"žánrov\",\n        \"genreWithCount_one\": \"{{count}} žáner\",\n        \"genreWithCount_few\": \"{{count}} žánre\",\n        \"genreWithCount_other\": \"{{count}} žánrov\",\n        \"playlist_one\": \"playlist\",\n        \"playlist_few\": \"playlisty\",\n        \"playlist_other\": \"playlistov\",\n        \"play_one\": \"{{count}} prehranie\",\n        \"play_few\": \"{{count}} prehrania\",\n        \"play_other\": \"{{count}} prehraní\",\n        \"playlistWithCount_one\": \"{{count}} playlist\",\n        \"playlistWithCount_few\": \"{{count}} playlisty\",\n        \"playlistWithCount_other\": \"{{count}} playlistov\",\n        \"smartPlaylist\": \"smart $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"track_one\": \"stopa\",\n        \"track_few\": \"stopy\",\n        \"track_other\": \"stôp\",\n        \"song_one\": \"skladba\",\n        \"song_few\": \"skladby\",\n        \"song_other\": \"skladieb\",\n        \"trackWithCount_one\": \"{{count}} stopa\",\n        \"trackWithCount_few\": \"{{count}} stopy\",\n        \"trackWithCount_other\": \"{{count}} stôp\"\n    },\n    \"error\": {\n        \"apiRouteError\": \"nie je možné zaslať požiadavku\",\n        \"audioDeviceFetchError\": \"pri vyhľadávaní zvukových zariadení sa vyskytla chyba\",\n        \"authenticationFailed\": \"overenie zlyhalo\",\n        \"badAlbum\": \"túto stránku vidíte, pretože táto skladba nie je súčasťou albumu. najčastejšou príčinou tohto problému býva umiestnenie skladby priamo v priečinku hudobnej knižnice. Jellyfin navzájom prepája iba skladby, ktoré sú spolu v jednom priečinku\",\n        \"badValue\": \"neplatná možnosť \\\"{{value}}\\\". táto hodnota už neexistuje\",\n        \"credentialsRequired\": \"vyžadujú sa prihlasovacie údaje\",\n        \"endpointNotImplementedError\": \"koncový bod {{endpoint}} nie je implementovaný v {{serverType}}\",\n        \"genericError\": \"vyskyla sa chyba\",\n        \"invalidServer\": \"neplatný server\",\n        \"localFontAccessDenied\": \"prístup k lokálnym fontom bol odmietnutý\",\n        \"loginRateError\": \"príliš veľa pokusov o prihlásenie, skúste to znova o pár sekúnd\",\n        \"mpvRequired\": \"vyžaduje sa MPV\",\n        \"networkError\": \"vyskytla sa chyba siete\",\n        \"notificationDenied\": \"povolenia na oznámenia boli odmietnuté. toto nastavenie nemá žiadny účinok\",\n        \"openError\": \"súbor nebolo možné otvoriť\",\n        \"playbackError\": \"pri prehrávaní média sa vyskytla chyba\",\n        \"remoteDisableError\": \"pri pokuse o $t(common.disable) vzdialeného severa sa vyskytla chyba\",\n        \"remoteEnableError\": \"pri pokuse o $t(common.enable) vzdialeného servera sa vyskytla chyba\",\n        \"remotePortError\": \"pri nastavovaní portu vzdialeného servera sa vyskytla chyba\",\n        \"remotePortWarning\": \"pre použitie nového portu reštartujte server\",\n        \"serverNotSelectedError\": \"nebol vybraný žiadny server\",\n        \"serverRequired\": \"vyžaduje sa server\",\n        \"sessionExpiredError\": \"vaša relácia vypršala\",\n        \"systemFontError\": \"pri pokuse o získanie systémových fontov sa vyskytla chyba\"\n    },\n    \"page\": {\n        \"albumArtistDetail\": {\n            \"about\": \"O {{artist}}\",\n            \"appearsOn\": \"vyskytuje sa na\",\n            \"recentReleases\": \"posledné vydania\",\n            \"viewDiscography\": \"zobraziť diskografiu\",\n            \"relatedArtists\": \"súvisiaci s $t(entity.artist, {\\\"count\\\": 2})\",\n            \"topSongs\": \"top skladby\",\n            \"topSongsFrom\": \"top skladby z {{title}}\",\n            \"viewAll\": \"zobraziť všetko\",\n            \"viewAllTracks\": \"zobraziť všetky $t(entity.track, {\\\"count\\\": 2})\"\n        },\n        \"albumArtistList\": {\n            \"title\": \"$t(entity.albumArtist, {\\\"count\\\": 2})\"\n        },\n        \"albumDetail\": {\n            \"moreFromArtist\": \"viac od $t(entity.artist, {\\\"count\\\": 1})\",\n            \"moreFromGeneric\": \"viac z {{item}}\",\n            \"released\": \"vydané\"\n        },\n        \"albumList\": {\n            \"artistAlbums\": \"albumy {{artist}}\",\n            \"genreAlbums\": \"\\\"{{genre}}\\\" $t(entity.album, {\\\"count\\\": 2})\",\n            \"title\": \"$t(entity.album, {\\\"count\\\": 2})\"\n        },\n        \"appMenu\": {\n            \"collapseSidebar\": \"zbaliť bočnú lištu\",\n            \"expandSidebar\": \"rozbaliť bočnú lištu\",\n            \"goBack\": \"ísť naspäť\",\n            \"goForward\": \"ísť dopredu\",\n            \"manageServers\": \"spravovať servery\",\n            \"privateModeOff\": \"vypnúť súkromný režim\",\n            \"privateModeOn\": \"zapnúť súkromný režim\",\n            \"openBrowserDevtools\": \"otvoriť vývojárske nástroje prehliadača\",\n            \"quit\": \"$t(common.quit)\",\n            \"selectServer\": \"vybrať server\",\n            \"settings\": \"$t(common.setting, {\\\"count\\\": 2})\",\n            \"version\": \"verzia {{version}}\"\n        },\n        \"manageServers\": {\n            \"title\": \"spravovať servery\",\n            \"serverDetails\": \"podrobnosti servera\",\n            \"url\": \"URL\",\n            \"username\": \"užívateľské meno\",\n            \"editServerDetailsTooltip\": \"zmeniť podrobnosti servera\",\n            \"removeServer\": \"odstrániť server\"\n        },\n        \"contextMenu\": {\n            \"addFavorite\": \"$t(action.addToFavorites)\",\n            \"addLast\": \"$t(player.addLast)\",\n            \"addNext\": \"$t(player.addNext)\",\n            \"addToFavorites\": \"$t(action.addToFavorites)\",\n            \"addToPlaylist\": \"$t(action.addToPlaylist)\",\n            \"createPlaylist\": \"$t(action.createPlaylist)\",\n            \"deletePlaylist\": \"$t(action.deletePlaylist)\",\n            \"deselectAll\": \"$t(action.deselectAll)\",\n            \"download\": \"stiahnuť\",\n            \"moveToNext\": \"$t(action.moveToNext)\",\n            \"moveToBottom\": \"$t(action.moveToBottom)\",\n            \"moveToTop\": \"$t(action.moveToTop)\",\n            \"numberSelected\": \"{{count}} vybrané\",\n            \"play\": \"$t(player.play)\",\n            \"playSimilarSongs\": \"$t(player.playSimilarSongs)\",\n            \"removeFromFavorites\": \"$t(action.removeFromFavorites)\",\n            \"removeFromPlaylist\": \"$t(action.removeFromPlaylist)\",\n            \"removeFromQueue\": \"$t(action.removeFromQueue)\",\n            \"setRating\": \"$t(action.setRating)\",\n            \"playShuffled\": \"$t(player.shuffle)\",\n            \"shareItem\": \"zdieľať položku\",\n            \"showDetails\": \"získať informácie\",\n            \"goToAlbum\": \"choď na $t(entity.album, {\\\"count\\\": 1})\",\n            \"goToAlbumArtist\": \"choď na $t(entity.albumArtist, {\\\"count\\\": 1})\"\n        },\n        \"fullscreenPlayer\": {\n            \"config\": {\n                \"dynamicBackground\": \"dynamické pozadie\",\n                \"dynamicImageBlur\": \"veľkosť rozostrenia obrázku\",\n                \"dynamicIsImage\": \"povoliť obrázok pozadia\",\n                \"followCurrentLyric\": \"nasleduj aktuálny text skladby\",\n                \"lyricAlignment\": \"zarovnanie textov skladieb\",\n                \"lyricOffset\": \"posunutie textov skladieb (ms)\",\n                \"lyricGap\": \"medzera textov skladieb\",\n                \"lyricSize\": \"veľkosť textov skladieb\",\n                \"opacity\": \"opacita\",\n                \"showLyricMatch\": \"zobraziť zhodu textu skladby\",\n                \"showLyricProvider\": \"zobraziť poskytovateľa textov skladieb\",\n                \"synchronized\": \"synchronizované\",\n                \"unsynchronized\": \"nesynchronizované\",\n                \"useImageAspectRatio\": \"použiť pomer strán obrázka\"\n            },\n            \"lyrics\": \"texty skladieb\",\n            \"related\": \"súvisiace\",\n            \"upNext\": \"ďalšia nahor\",\n            \"visualizer\": \"vizualizátor\",\n            \"noLyrics\": \"nenašli sa žiadne texty\"\n        },\n        \"genreList\": {\n            \"showAlbums\": \"zobraziť $t(entity.genre, {\\\"count\\\": 1}) $t(entity.album, {\\\"count\\\": 2})\",\n            \"showTracks\": \"zobraziť $t(entity.genre, {\\\"count\\\": 1}) $t(entity.track, {\\\"count\\\": 2})\",\n            \"title\": \"$t(entity.genre, {\\\"count\\\": 2})\"\n        },\n        \"globalSearch\": {\n            \"commands\": {\n                \"goToPage\": \"ísť na stránku\",\n                \"searchFor\": \"hľadať {{query}}\",\n                \"serverCommands\": \"príkazy servera\"\n            },\n            \"title\": \"príkazy\"\n        },\n        \"home\": {\n            \"explore\": \"preskúmať tvoju knižnicu\",\n            \"mostPlayed\": \"najhranejší\",\n            \"newlyAdded\": \"novopridané vydania\",\n            \"recentlyPlayed\": \"nedávno hrané\",\n            \"title\": \"$t(common.home)\"\n        },\n        \"itemDetail\": {\n            \"copyPath\": \"skopírovať cestu do schránky\",\n            \"copiedPath\": \"cesta úspešne skopírovaná\",\n            \"openFile\": \"zobraziť stopu v správcovi súborov\"\n        },\n        \"playlist\": {\n            \"reorder\": \"zmena poradia povolená len pri zoradení podľa id\"\n        },\n        \"playlistList\": {\n            \"title\": \"$t(entity.playlist, {\\\"count\\\": 2})\"\n        },\n        \"setting\": {\n            \"advanced\": \"pokročilé\",\n            \"generalTab\": \"všeobecné\",\n            \"hotkeysTab\": \"klávesové skratky\",\n            \"playbackTab\": \"prehrávanie\",\n            \"windowTab\": \"okno\"\n        },\n        \"sidebar\": {\n            \"albumArtists\": \"$t(entity.albumArtist, {\\\"count\\\": 2})\",\n            \"albums\": \"$t(entity.album, {\\\"count\\\": 2})\",\n            \"artists\": \"$t(entity.artist, {\\\"count\\\": 2})\",\n            \"folders\": \"$t(entity.folder, {\\\"count\\\": 2})\",\n            \"genres\": \"$t(entity.genre, {\\\"count\\\": 2})\",\n            \"home\": \"$t(common.home)\",\n            \"myLibrary\": \"moja knižnica\",\n            \"nowPlaying\": \"teraz hrá\",\n            \"playlists\": \"$t(entity.playlist, {\\\"count\\\": 2})\",\n            \"search\": \"$t(common.search)\",\n            \"settings\": \"$t(common.setting, {\\\"count\\\": 2})\",\n            \"shared\": \"zdieľaný $t(entity.playlist, {\\\"count\\\": 2})\",\n            \"tracks\": \"$t(entity.track, {\\\"count\\\": 2})\"\n        },\n        \"trackList\": {\n            \"artistTracks\": \"skladby {{artist}}\",\n            \"genreTracks\": \"\\\"{{genre}}\\\" $t(entity.track, {\\\"count\\\": 2})\",\n            \"title\": \"$t(entity.track, {\\\"count\\\": 2})\"\n        }\n    },\n    \"player\": {\n        \"addLast\": \"pridať posledné\",\n        \"addNext\": \"pridať nasledujúce\",\n        \"favorite\": \"obľúbené\",\n        \"mute\": \"stíšiť\",\n        \"muted\": \"stíšené\",\n        \"next\": \"nasledujúci\",\n        \"play\": \"prehrať\",\n        \"playbackFetchCancel\": \"toto chvíľu trvá... ak to chcete zrušiť, zavrite notifikáciu\",\n        \"playbackFetchInProgress\": \"načítanie skladieb…\",\n        \"playbackFetchNoResults\": \"neboli nájdené žiadne skladby\",\n        \"playbackSpeed\": \"rýchlosť prehrávania\",\n        \"playRandom\": \"prehrávať náhodne\",\n        \"playSimilarSongs\": \"prehrávať podobné skladby\",\n        \"previous\": \"predchádzajúce\",\n        \"queue_clear\": \"vymazať frontu\",\n        \"queue_moveToBottom\": \"presunúť vybrané navrch\",\n        \"queue_moveToTop\": \"presunúť vybrané naspodok\",\n        \"queue_remove\": \"odstrániť vybrané\",\n        \"repeat\": \"opakovať\",\n        \"repeat_all\": \"opakovať všetky\",\n        \"repeat_off\": \"opakovanie vypnuté\",\n        \"shuffle\": \"prehrávať náhodne\",\n        \"shuffle_off\": \"náhodné prehrávanie vypnuté\",\n        \"skip\": \"preskočiť\",\n        \"skip_back\": \"preskočiť dozadu\",\n        \"skip_forward\": \"preskočiť dopredu\",\n        \"stop\": \"zastaviť\",\n        \"toggleFullscreenPlayer\": \"prepnúť prehrávač na celú obrazovku\",\n        \"unfavorite\": \"odstrániť z obľúbených\",\n        \"pause\": \"pozastaviť\",\n        \"viewQueue\": \"zobraziť frontu\"\n    },\n    \"setting\": {\n        \"accentColor\": \"odtieň farby\",\n        \"accentColor_description\": \"nastaviť odtieň farby aplikácie\",\n        \"albumBackground\": \"obrázok pozadia albumu\",\n        \"albumBackground_description\": \"pridáva obrázky albumov na pozadia na jednotlivých stránok albumov\",\n        \"albumBackgroundBlur\": \"veľkosť rozmazania obrázku pozadia albumu\",\n        \"albumBackgroundBlur_description\": \"upravuje mieru rozmazania obrázku pozadia albumu\",\n        \"applicationHotkeys\": \"klávesové skratky aplikácie\",\n        \"applicationHotkeys_description\": \"nastaviť klávesové skratky aplukácie. zaškrtni políčko, ak chceš nastaviť skratku ako globálnu (len desktop)\",\n        \"artistConfiguration\": \"nastavenie stránky interpreta albumu\",\n        \"artistConfiguration_description\": \"konfigurovať aké položky sa zobrazujú, a v akom poradí, na stránke interpreta albumu\",\n        \"audioDevice\": \"zvukové zariadenie\",\n        \"audioDevice_description\": \"vybrať zvukové zariadenie na prehrávanie (len webový prehrávač)\",\n        \"audioExclusiveMode\": \"exkluzívny zvukový režim\",\n        \"audioExclusiveMode_description\": \"povoliť exkluzívny režim výstupu. V tomto režime je systém zvyčajne zamknutý a len mpv je schopný poskytovať zvukový výstup\",\n        \"audioPlayer\": \"audioprehrávač\",\n        \"audioPlayer_description\": \"vybrať zvukové zariadenie, ktoré bude použité na prehrávanie\",\n        \"buttonSize\": \"veľkosť tlačidiel na paneli prehrávania\",\n        \"buttonSize_description\": \"veľkosť tlačidiel na paneli prehrávania\",\n        \"clearCache\": \"vyčistiť dočasnú pamäť prehliadača\",\n        \"imageAspectRatio_description\": \"ak je povolené, obrázok albumu sa zobrazí s originálnym pomerom strán. pre obrázky s pomerom iným ako 1:1 zostane nevyplnený priestor prázdny\",\n        \"clearCache_description\": \"'hard' vyčistenie feishin-u. okrem vyrovnávacej pamäte feishin-u sa vymaže aj vyrovnávacia pamäť prehliadača (uložené obrázky a iné súbory). prihlasovacie údaje k serveru a iné nastavenia zostávajú zachované\",\n        \"clearQueryCache\": \"vymazať vyrovnávaciu pamäť feishin-u\",\n        \"clearQueryCache_description\": \"'soft' vyčistenie feishin-u. obnovia sa playlisty, metadáta skladieb a resetujú sa uložené texty skladieb. nastavenia, prihlasovacie údaje k serveru a obrázky vo vyrovnávacej pamäti zostávajú zachované\",\n        \"clearCacheSuccess\": \"vyrovnávacia pamäť úspešne vymazaná\",\n        \"contextMenu\": \"konfigurácia kontextovej ponuky (pravé tlačítko myši)\",\n        \"contextMenu_description\": \"umožňuje vám skryť položky nachádzajúce v menu, ktoré sa zobrazí po kliknutí pravým tlačítkom myši. nezakliknuté položky budú skryté\",\n        \"crossfadeDuration\": \"dĺžka crossfade\",\n        \"crossfadeDuration_description\": \"nastavuje dĺžku trvania crossfade efektu\",\n        \"crossfadeStyle_description\": \"vybrať štýl crossfade efektu pre prehrávač\",\n        \"customCssEnable\": \"povoliť vlastné css\",\n        \"customCssEnable_description\": \"umožňuje písať vlastné css\",\n        \"customCssNotice\": \"Varovanie: hoci sa využíva istá miera sanitizaácie (deaktivácia url() a obsahu:), používanie vlastných css pri zmene rozhrania stále predstavuje riziko\",\n        \"customCss\": \"vlastné css\",\n        \"customCss_description\": \"vlastný css obsah. Poznámka: obsah a vzdialené url linky sú defaultne deaktivované.Náhľad vášho obsahu je zobrazený nižšie. Pridané polia, ktoré ste nenastavovali boli pridané pri sanitizácii\",\n        \"customFontPath\": \"cesta k vlastným fontom\",\n        \"customFontPath_description\": \"Nastaví cestu k vlastným fontom na použitie aplikáciou\",\n        \"disableLibraryUpdateOnStartup\": \"vypnúť kontrolu nových verzií pri štarte\",\n        \"discordApplicationId\": \"id aplikácie {{discord}}\",\n        \"discordApplicationId_description\": \"aplikačné id pre plnohodnotné prepojenie s {{discord}} (predvolená hodnota {{defaultId}})\",\n        \"discordPausedStatus\": \"pri pozastavení prehrávania zobrazuje 'rich presence'\",\n        \"discordPausedStatus_description\": \"pri povolení bude status zobrazovaný aj pri pozastavenom prehrávaní\",\n        \"discordIdleStatus\": \"zobraziť 'rich presence idle status'\",\n        \"discordIdleStatus_description\": \"pri povolení bude 'rich presense' status zobrazený aj pri nečinnosti\",\n        \"discordListening\": \"zobraziť status počúvanie\",\n        \"discordListening_description\": \"zobraziť status počúvanie namiesto prehrávanie\",\n        \"discordRichPresence_description\": \"povoliť status prehrávania v {{discord}} rich presence. Obrázky kláves sú: {{icon}}, {{playing}} a {{paused}}\",\n        \"discordServeImage\": \"poskytuje {{discord}} obrázky zo servera\",\n        \"discordServeImage_description\": \"zdieľať obrázok albumu na {{discord}} rich presence priamo zo servera, dostupné iba pre Jellyfin a Navidrome\",\n        \"discordUpdateInterval\": \"interval aktualizácií {{discord}} rich presence\",\n        \"discordUpdateInterval_description\": \"čas v sekundách medzi dvomi nasledujúcimi aktualizáciami (minimálne 15 sekúnd)\",\n        \"discordDisplayType\": \"typ zobrazenia {{discord}} presence\",\n        \"discordDisplayType_description\": \"mení vo vašom statuse info, čo počúvate\",\n        \"discordDisplayType_songname\": \"názov skladby\",\n        \"discordDisplayType_artistname\": \"názov interpreta(-ov)\",\n        \"enableRemote\": \"povoliť vzdialené ovládanie servera\",\n        \"enableRemote_description\": \"pomocou vzdialeného servera umožňuje ovládanie aplikácie prostredníctvom iných zariadení\",\n        \"externalLinks\": \"zobraziť externé odkazy\",\n        \"externalLinks_description\": \"umožňuje zobrazovať externé odkazy (Last.fm, MusicBrainz) na stránkach umelca/albumu\",\n        \"exitToTray\": \"ukončiť do lišty\",\n        \"exitToTray_description\": \"po zavretí sa aplikácia minimalizuje do lišty a beží ďalej\",\n        \"followLyric\": \"nasleduj aktuálny text skladby\",\n        \"followLyric_description\": \"posunúť sa v texte skladby na aktuálne prehrávanú pozíciu\",\n        \"preferLocalLyrics\": \"uprednostniť lokálne texty skladieb\",\n        \"preferLocalLyrics_description\": \"uprednostniť miestne skladby textov, ak sú dostupné, pred vzdialenými\",\n        \"font\": \"font\",\n        \"font_description\": \"nastaví font písma pre aplikáciu\",\n        \"fontType\": \"typ písma\",\n        \"fontType_description\": \"vstavané písmo vyberie jeden z fontov poskytovaných feishin-om. systémové písmo umožňuje vybrať ľubovoľný font poskytovaný vaším operačným systémom. vlastné umožňuje poskytnúť váš vlastný font\",\n        \"fontType_optionBuiltIn\": \"vstavané písmo\",\n        \"fontType_optionCustom\": \"vlastné písmo\",\n        \"fontType_optionSystem\": \"systémové písmo\",\n        \"gaplessAudio\": \"prehrávanie bez prerušení\",\n        \"gaplessAudio_description\": \"nastaví prehrávanie bez prerušení pre mpv\",\n        \"gaplessAudio_optionWeak\": \"slabo (odporúčané)\",\n        \"globalMediaHotkeys\": \"globálne klávesové skratky médií\",\n        \"globalMediaHotkeys_description\": \"povoliť alebo zakázať použitie vašich klávesových skratiek médií na ovládanie prehrávania\",\n        \"homeConfiguration\": \"konfigurácia domovskej stránky\",\n        \"homeConfiguration_description\": \"konfigurovať, aké položky sú zobrazené a v akom poradí na domovskej stránke\",\n        \"homeFeature\": \"carousel odporúčania na domovskej stránke\",\n        \"homeFeature_description\": \"povoľuje zobrazenie veľkoformátového odporúčaného carouselu na domovskej stránke\",\n        \"hotkey_browserBack\": \"naspäť v prehliadači\",\n        \"hotkey_browserForward\": \"dopredu v prehliadači\",\n        \"hotkey_favoriteCurrentSong\": \"obľúbené $t(common.currentSong)\",\n        \"hotkey_favoritePreviousSong\": \"obľúbené $t(common.previousSong)\",\n        \"hotkey_globalSearch\": \"globálne vyhľadávanie\",\n        \"hotkey_localSearch\": \"vyhľadávanie na stránke\",\n        \"hotkey_navigateHome\": \"navigovať domov\",\n        \"hotkey_playbackNext\": \"nasledujúca skladba\",\n        \"hotkey_playbackPause\": \"pozastaviť\",\n        \"hotkey_playbackPlay\": \"prehrať\",\n        \"hotkey_playbackPlayPause\": \"hrať / pozastaviť\",\n        \"hotkey_playbackPrevious\": \"predchádzajúca skladba\",\n        \"hotkey_playbackStop\": \"zastaviť\",\n        \"hotkey_rate0\": \"bez hodnotenia\",\n        \"hotkey_rate1\": \"hodnotené 1 hviezdou\",\n        \"hotkey_rate2\": \"hodnotené 2 hviezdami\",\n        \"hotkey_rate3\": \"hodnotené 3 hviezdami\",\n        \"hotkey_rate4\": \"hodotené 4 hviezdami\",\n        \"hotkey_rate5\": \"hodnotené 5 hviezdami\",\n        \"hotkey_skipBackward\": \"preskočiť dozadu\",\n        \"hotkey_skipForward\": \"preskočiť dopredu\",\n        \"hotkey_toggleCurrentSongFavorite\": \"prepnúť $t(common.currentSong) obľúbené\",\n        \"hotkey_toggleFullScreenPlayer\": \"prepnúť prehrávač na celú obrazovku\",\n        \"hotkey_togglePreviousSongFavorite\": \"prepnúť $t(common.previousSong) obľúbené\",\n        \"hotkey_toggleQueue\": \"prepnúť frontu\",\n        \"hotkey_toggleRepeat\": \"prepnúť opakovanie\",\n        \"hotkey_toggleShuffle\": \"prepnúť náhodné prehrávanie\",\n        \"hotkey_unfavoriteCurrentSong\": \"odobrať z obľúbených $t(common.currentSong)\",\n        \"hotkey_unfavoritePreviousSong\": \"odobrať z obľúbených $t(common.previousSong)\",\n        \"hotkey_volumeDown\": \"znížiť hlasitosť\",\n        \"hotkey_volumeMute\": \"stíšiť hlasitosť\",\n        \"hotkey_volumeUp\": \"zvýšiť hlasitosť\",\n        \"hotkey_zoomIn\": \"priblížiť\",\n        \"hotkey_zoomOut\": \"vzdialiť\",\n        \"imageAspectRatio\": \"použiť pôvodný pomer strán obalu albumu\",\n        \"language_description\": \"nastaví jazyk aplikácie ($t(common.restartRequired))\",\n        \"lastfm\": \"zobraziť last.fm odkazy\",\n        \"lastfm_description\": \"zobraziť Last.fm odkazy na stránky interpreta/albumu\",\n        \"lastfmApiKey\": \"{{lastfm}} API kľúč\",\n        \"lastfmApiKey_description\": \"API kľúč pre {{lastfm}}. vyžaduje sa obálky albumov\",\n        \"lyricFetch\": \"stiahnuť texty skladieb z internetu\",\n        \"lyricFetch_description\": \"stiahnuť texty skladieb z rôznych internetových zdrojov\",\n        \"lyricFetchProvider\": \"poskytovatelia pre sťahovanie textov skladieb\",\n        \"lyricFetchProvider_description\": \"vybrať poskytovateľov pre sťahovanie textov skladieb. poradie poskytovateľov určuje poradie, v ktorom sa budú používať\",\n        \"lyricOffset\": \"posunutie textu skladieb (ms)\",\n        \"lyricOffset_description\": \"posunutie textu voči skladbe vyjadrené v milisekundách\",\n        \"minimizeToTray\": \"minimalizovať do lišty\",\n        \"minimizeToTray_description\": \"minimalizovať aplikáciu do systémovej lišty\",\n        \"minimumScrobblePercentage\": \"minimálna dĺžka pre skroblovanie (percentá)\",\n        \"minimumScrobblePercentage_description\": \"minimálna časť skladby v percentách, ktorá musí byť prehraná pred tým, než je skroblovaná\",\n        \"minimumScrobbleSeconds\": \"minimálna dĺžka skroblovania (sekundy)\",\n        \"minimumScrobbleSeconds_description\": \"minimálna dĺžka časti skladby, ktorá musí byť prehraná pred tým, než je skladba skroblovaná\",\n        \"mpvExecutablePath\": \"cesta k spustiteľnému súboru mpv\",\n        \"mpvExecutablePath_description\": \"nastavuje cestu k spustiteľnému súboru mpv. ak je prázdna, použije sa predvolená cesta\",\n        \"mpvExtraParameters_help\": \"jeden na riadok\",\n        \"musicbrainz\": \"zobraziť linky na MusicBrainz\",\n        \"musicbrainz_description\": \"zobrazí linky na stránky interpreta/albumu na MusicBrainz, ak je vyplnené MusicBrainz ID\",\n        \"neteaseTranslation\": \"Povoliť NetEase preklady\",\n        \"neteaseTranslation_description\": \"Ak sú povolené, aplikácia stiahne a zobrazí preložené texty skladieb z NetEase, ak sú dostupné\",\n        \"passwordStore\": \"ukladanie hesiel/utajených údajov\",\n        \"passwordStore_description\": \"aký spôsob ukladania hesiel/utajených údajov použiť. ak máte problém s ukladaním hesiel, skúste zmeniť nastavenie\",\n        \"playbackStyle\": \"štýl prehrávania\",\n        \"playbackStyle_description\": \"vyberte štýl prehrávania pre prehrávač skladieb\",\n        \"playbackStyle_optionCrossFade\": \"crossfade\",\n        \"playbackStyle_optionNormal\": \"normálny\",\n        \"playButtonBehavior\": \"správanie sa tlačidla prehrávania\",\n        \"playButtonBehavior_description\": \"nastaví predvolené správanie sa tlačidla prehrávania pri pridávaní skladieb do fronty\",\n        \"playButtonBehavior_optionAddLast\": \"$t(player.addLast)\",\n        \"playButtonBehavior_optionAddNext\": \"$t(player.addNext)\",\n        \"playButtonBehavior_optionPlay\": \"$t(player.play)\",\n        \"playButtonBehavior_optionPlayShuffled\": \"$t(player.shuffle)\",\n        \"playerbarOpenDrawer\": \"zobrazenie na celú obrazovku panelom prehrávača\",\n        \"playerbarOpenDrawer_description\": \"umožní kliknutím na panel prehrávača prepnúť zobrazenie prehrávača na celú obrazovku\",\n        \"remotePassword\": \"heslo servera vzdialeného ovládania\",\n        \"remotePassword_description\": \"nastaví heslo pre server diaľkového ovládania. Jeho obsah je odosielaný bez zabezpečenia, preto by ste si mali zvoliť jedinečné heslo, ktoré pre vás nie je dôležité\",\n        \"remotePort\": \"port servera diaľkového ovládania\",\n        \"remotePort_description\": \"nastaví port servera diaľkového ovládania\",\n        \"remoteUsername\": \"používateľské meno servera diaľkového ovládania\",\n        \"remoteUsername_description\": \"nasstaví používateľské meno servera diaľkového ovládania. v prípade, ak sú používateľské meno aj heslo prázdne, je overovanie pri prihlásení vypnuté\",\n        \"replayGainClipping\": \"clipping {{ReplayGain}}\",\n        \"replayGainClipping_description\": \"Zabraňuje clipping-u spôsobenému {{ReplayGain}} automatickým znížením zosilenia\",\n        \"replayGainFallback\": \"fallback {{ReplayGain}}\",\n        \"replayGainFallback_description\": \"zosilenie v db, ktoré sa aplikuje, ak súbor nemá {{ReplayGain}} štítky\",\n        \"replayGainMode\": \"{{ReplayGain}} režim\",\n        \"replayGainMode_description\": \"pozmení zosilenie hlasitosti podľa hodnôt {{ReplayGain}} uložených v metadátach súboru\",\n        \"replayGainMode_optionAlbum\": \"$t(entity.album, {\\\"count\\\": 1})\",\n        \"replayGainMode_optionNone\": \"$t(common.none)\",\n        \"replayGainMode_optionTrack\": \"$t(entity.track, {\\\"count\\\": 1})\",\n        \"replayGainPreamp\": \"predzosilenie {{ReplayGain}} dB\",\n        \"replayGainPreamp_description\": \"pozmení predzosilenie použité na hodnoty {{ReplayGain}}\",\n        \"sampleRate\": \"vzorkovacia frekvencia\",\n        \"sidePlayQueueStyle_optionAttached\": \"pripojené\",\n        \"sidePlayQueueStyle_optionDetached\": \"odpojené\",\n        \"skipDuration\": \"dĺžka preskočenia\",\n        \"skipDuration_description\": \"určuje časovú dĺžku posunu pri stlačení tlačidla preskočiť na lište prehrávača\",\n        \"skipPlaylistPage\": \"preskočiť stránku playlistu\",\n        \"skipPlaylistPage_description\": \"pri navigácii v playliste, idete na výber stránky playlistu namiesto predvolenej stránky\",\n        \"startMinimized\": \"spistiť mnimalizované\",\n        \"startMinimized_description\": \"spustí aplikáciu minimalizovanú do systémovej lišty\",\n        \"preventSleepOnPlayback\": \"zabrániť spánku pri prehrávaní\",\n        \"preventSleepOnPlayback_description\": \"pri prehávaní hudby zabráni obrazovke v prechode do spánku\",\n        \"theme\": \"téma\",\n        \"theme_description\": \"nastaví tému aplikácie\",\n        \"themeDark\": \"téma (tmavá)\",\n        \"themeDark_description\": \"nastaví tmavú tému aplikácie\",\n        \"themeLight\": \"téma (svetlá)\",\n        \"themeLight_description\": \"nastaví svetlú tému aplikácie\",\n        \"transcode_description\": \"umožňuje prekódovanie do rôznych formátov\",\n        \"transcodeBitrate\": \"bitová frekvencia prekódovania\",\n        \"transcodeBitrate_description\": \"určuje bitovú frekvenciu, pri ktorej sa použije prekódovanie. 0 znamená ponechať rozhodnutie na server\",\n        \"transcodeFormat\": \"formát prekódovania\",\n        \"transcodeFormat_description\": \"učuje výstupný formát prekódovania. ak chcete ponechať rozhodnutie na server, nechajte políčko prázdne\",\n        \"translationApiProvider\": \"api poskytovateľa prekladu\",\n        \"translationApiProvider_description\": \"api poskytovateľa prekladu\",\n        \"translationApiKey\": \"api kľúč prekladu\",\n        \"translationApiKey_description\": \"api kľúč pre preklad (Podporuje iba koncové body globálnych služieb)\",\n        \"translationTargetLanguage\": \"cieľový jazyk prekladu\",\n        \"translationTargetLanguage_description\": \"cieľový jazyk, do ktorého sa prekladá\",\n        \"trayEnabled\": \"zobraziť lištu\",\n        \"trayEnabled_description\": \"zobraziť/skryť ikonu/ponuku lišty. ak nie je povolené, taktiež vypne minimalizovanie/zavretie do lišty\",\n        \"useSystemTheme\": \"použiť systémovú tému\",\n        \"useSystemTheme_description\": \"prispôsobiť výber svetlej, či tmavej témy aktuálnej systémovej téme\",\n        \"volumeWheelStep\": \"krok zmeny hlasitosti\",\n        \"volumeWheelStep_description\": \"veľkosť zmeny hlasitosti pri otočení kolieskom myši o jeden krok na ovládači hlasitosti\",\n        \"volumeWidth\": \"šírka posuvného ovládača hlasitosti\",\n        \"volumeWidth_description\": \"šírka ovládača hlasitosti\",\n        \"webAudio\": \"používať webový výstup\",\n        \"webAudio_description\": \"bude sa používať webový výstup, čím povolíte pokročilé funkcie ako replaygain. v prípade problémov voľbu vypnite\",\n        \"preservePitch\": \"zachovať výšku\",\n        \"preservePitch_description\": \"pri zmene rýchlosti prehrávania zostane výška zachovaná\",\n        \"windowBarStyle\": \"štýl okna\",\n        \"windowBarStyle_description\": \"vyberte štýl okna\",\n        \"zoom\": \"percento priblíženia\",\n        \"zoom_description\": \"nastaví percento priblíženia pre aplikáciu\",\n        \"sampleRate_description\": \"vyberte výstupnú vzorkovaciu frekvenciu, ktorá sa použije v prípade, ak je vybraná vzorkovacia frekvencia iná ako je u aktuálnej skladby. pri hodnote menšej ako 8000 sa použije predvolená frekvencia\",\n        \"savePlayQueue\": \"uložiť frontu prehrávania\",\n        \"savePlayQueue_description\": \"uloží frontu prehrávania pri ukončení aplikácie a obnoví ju opäť po jej otvorení\",\n        \"scrobble\": \"skroblovať\",\n        \"scrobble_description\": \"scroblovať vaše prehrávanie na medálny server\",\n        \"showSkipButton\": \"zobraziť tlačítka preskočenia\",\n        \"showSkipButton_description\": \"zobrazí alebo skryje tlačítka preskočenia na lište prehrávača\",\n        \"showSkipButtons\": \"zobraziť tlačítka preskočenia\",\n        \"showSkipButtons_description\": \"zobraziť alebo skryť tlačítka preskočenia na lište prehrávača\",\n        \"sidebarCollapsedNavigation\": \"navigácia bočnej lišty (zasunutá)\",\n        \"sidebarCollapsedNavigation_description\": \"zobraziť alebo skryť navigovanie na zasunutej bočnej lište\",\n        \"sidebarConfiguration\": \"nastavenie bočnej lišty\",\n        \"sidebarConfiguration_description\": \"zvoľte položky a ich poradie, v akom sa zabrazia na bočnej lište\",\n        \"sidebarPlaylistList\": \"playlist bočnej lišty\",\n        \"sidebarPlaylistList_description\": \"zobraziť alebo skryť playlist na bočnej lište\",\n        \"sidePlayQueueStyle\": \"štýl bočnej fronty prehrávania\",\n        \"sidePlayQueueStyle_description\": \"nastaví štýl bočnej fronty prehrávania\"\n    },\n    \"table\": {\n        \"column\": {\n            \"album\": \"album\",\n            \"albumArtist\": \"interpret albumu\",\n            \"albumCount\": \"$t(entity.album, {\\\"count\\\": 2})\",\n            \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n            \"biography\": \"životopis\",\n            \"bitrate\": \"bitrate\",\n            \"bpm\": \"bpm\",\n            \"channels\": \"$t(common.channel_other)\",\n            \"codec\": \"$t(common.codec)\",\n            \"comment\": \"komentár\",\n            \"dateAdded\": \"dátum pridania\",\n            \"discNumber\": \"disk\",\n            \"favorite\": \"obľúbené\",\n            \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n            \"lastPlayed\": \"posledne hraný\",\n            \"path\": \"cesta\",\n            \"playCount\": \"prehratí\",\n            \"rating\": \"hodnotenie\",\n            \"releaseDate\": \"dátum vydania\",\n            \"releaseYear\": \"rok\",\n            \"size\": \"$t(common.size)\",\n            \"songCount\": \"$t(entity.track, {\\\"count\\\": 2})\",\n            \"title\": \"názov\",\n            \"trackNumber\": \"skladba\"\n        },\n        \"config\": {\n            \"general\": {\n                \"autoFitColumns\": \"automatická šírka stĺpcov\",\n                \"followCurrentSong\": \"nasledovať aktuálnu skladbu\",\n                \"displayType\": \"typ zobrazenia\",\n                \"gap\": \"$t(common.gap)\",\n                \"itemGap\": \"medzera položky (px)\",\n                \"itemSize\": \"veľkosť položky (px)\",\n                \"size\": \"$t(common.size)\",\n                \"tableColumns\": \"stĺpce tabuľky\"\n            },\n            \"label\": {\n                \"actions\": \"$t(common.action_other)\",\n                \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n                \"albumArtist\": \"$t(entity.albumArtist, {\\\"count\\\": 1})\",\n                \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n                \"biography\": \"$t(common.biography)\",\n                \"bitrate\": \"$t(common.bitrate)\",\n                \"bpm\": \"$t(common.bpm)\",\n                \"channels\": \"$t(common.channel_other)\",\n                \"codec\": \"$t(common.codec)\",\n                \"dateAdded\": \"dátum pridania\",\n                \"discNumber\": \"číslo disku\",\n                \"duration\": \"$t(common.duration)\",\n                \"favorite\": \"$t(common.favorite)\",\n                \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n                \"lastPlayed\": \"posledne prehraté\",\n                \"note\": \"$t(common.note)\",\n                \"owner\": \"$t(common.owner)\",\n                \"path\": \"$t(common.path)\",\n                \"playCount\": \"počet prehraní\",\n                \"rating\": \"$t(common.rating)\",\n                \"releaseDate\": \"dátum vydania\",\n                \"rowIndex\": \"číslo riadku\",\n                \"size\": \"$t(common.size)\",\n                \"songCount\": \"$t(entity.track, {\\\"count\\\": 2})\",\n                \"title\": \"$t(common.title)\",\n                \"titleCombined\": \"$t(common.title) (kombinovaný)\",\n                \"trackNumber\": \"číslo skladby\",\n                \"year\": \"$t(common.year)\"\n            },\n            \"view\": {\n                \"grid\": \"mriežka\",\n                \"list\": \"zoznam\",\n                \"table\": \"tabuľka\"\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "src/i18n/locales/sl.json",
    "content": "{\n    \"action\": {\n        \"addToFavorites\": \"dodaj na $t(entity.favorite, {\\\"count\\\": 2})\",\n        \"addToPlaylist\": \"dodaj na $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"clearQueue\": \"počisti čakalno vrsto\",\n        \"createPlaylist\": \"ustvari $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"deletePlaylist\": \"izbriši $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"deselectAll\": \"odizberi vse\",\n        \"editPlaylist\": \"uredi $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"goToPage\": \"pojdi na stran\",\n        \"moveToNext\": \"pojdi na naslednjo\",\n        \"moveToBottom\": \"pojdi na dno\",\n        \"moveToTop\": \"pojdi na vrh\",\n        \"refresh\": \"$t(common.refresh)\",\n        \"removeFromFavorites\": \"odstrani iz $t(entity.favorite, {\\\"count\\\": 2})\",\n        \"removeFromPlaylist\": \"odstrani iz $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"removeFromQueue\": \"odstrani iz čakalne vrste\",\n        \"setRating\": \"nastavi oceno\",\n        \"toggleSmartPlaylistEditor\": \"preklopi urejevalnik $t(entity.smartPlaylist)\",\n        \"viewPlaylists\": \"poglej $t(entity.playlist, {\\\"count\\\": 2})\",\n        \"openIn\": {\n            \"lastfm\": \"Odpri v Last.fm\",\n            \"musicbrainz\": \"Odpri v MusicBrainz\"\n        }\n    },\n    \"common\": {\n        \"action_one\": \"dejanje\",\n        \"action_two\": \"dejanji\",\n        \"action_few\": \"dejanja\",\n        \"action_other\": \"dejanj\",\n        \"add\": \"dodaj\",\n        \"additionalParticipants\": \"dodatni udeleženci\",\n        \"newVersion\": \"nova verzija je bila nameščena ({{version}})\",\n        \"viewReleaseNotes\": \"poglej zapiske o različici\",\n        \"albumGain\": \"ojačitev albuma\",\n        \"albumPeak\": \"vrh albuma\",\n        \"areYouSure\": \"ali si prepričan?\",\n        \"ascending\": \"naraščajoče\",\n        \"backward\": \"nazaj\",\n        \"biography\": \"biografija\",\n        \"bitrate\": \"bitna hitrost\",\n        \"bpm\": \"unm\",\n        \"cancel\": \"prekliči\",\n        \"center\": \"center\",\n        \"channel_one\": \"kanal\",\n        \"channel_two\": \"kanala\",\n        \"channel_few\": \"kanali\",\n        \"channel_other\": \"kanalov\",\n        \"clear\": \"počisti\",\n        \"close\": \"zapri\",\n        \"codec\": \"kodek\",\n        \"collapse\": \"strni\",\n        \"comingSoon\": \"prihaja kmalu …\",\n        \"configure\": \"prilagodi\",\n        \"confirm\": \"potrdi\",\n        \"create\": \"ustvari\",\n        \"currentSong\": \"trenutna $t(entity.track, {\\\"count\\\": 1})\",\n        \"decrease\": \"zmanjšaj\",\n        \"delete\": \"izbriši\",\n        \"descending\": \"padajoče\",\n        \"description\": \"opis\",\n        \"disable\": \"onemogoči\",\n        \"disc\": \"disk\",\n        \"dismiss\": \"spreglej\",\n        \"duration\": \"trajanje\",\n        \"edit\": \"uredi\",\n        \"enable\": \"omogoči\",\n        \"expand\": \"razširi\",\n        \"favorite\": \"najljubša\",\n        \"filter_one\": \"filter\",\n        \"filter_two\": \"filtra\",\n        \"filter_few\": \"filtri\",\n        \"filter_other\": \"filtrov\",\n        \"filters\": \"filtri\",\n        \"forceRestartRequired\": \"znova zaženi, da potrdiš spremembe ... zapri obvestilo, da znova zaženeš\",\n        \"forward\": \"naprej\",\n        \"gap\": \"reža\",\n        \"home\": \"domov\",\n        \"increase\": \"povišaj\",\n        \"limit\": \"omeji\",\n        \"manage\": \"upravljaj\",\n        \"maximize\": \"maksimiziraj\",\n        \"menu\": \"meni\",\n        \"minimize\": \"pomanjšaj\",\n        \"modified\": \"spremenjeno\",\n        \"mbid\": \"MusicBrainz identifikator (ID)\",\n        \"left\": \"levo\",\n        \"no\": \"ne\",\n        \"none\": \"noben\",\n        \"noResultsFromQuery\": \"poizvedba ni vrnila rezultatov\",\n        \"note\": \"opomba\",\n        \"ok\": \"ok\",\n        \"owner\": \"lastnik\",\n        \"path\": \"pot\",\n        \"playerMustBePaused\": \"predvajalnik mora biti ustavljen\",\n        \"preview\": \"predogled\",\n        \"previousSong\": \"prejšnja $t(entity.track, {\\\"count\\\": 1})\",\n        \"quit\": \"izhod\",\n        \"random\": \"naključno\",\n        \"rating\": \"ocena\",\n        \"refresh\": \"osveži\",\n        \"reload\": \"ponovno naloži\",\n        \"reset\": \"ponastavi\",\n        \"resetToDefault\": \"ponastavi na privzeto\",\n        \"restartRequired\": \"zahtevan je ponovni zagon\",\n        \"right\": \"desno\",\n        \"save\": \"shrani\",\n        \"saveAndReplace\": \"shrani in zamenjaj\",\n        \"saveAs\": \"shrani kot\",\n        \"search\": \"išči\",\n        \"setting_one\": \"nastavitev\",\n        \"setting_two\": \"\",\n        \"setting_few\": \"\",\n        \"setting_other\": \"\",\n        \"share\": \"deli\",\n        \"size\": \"velikost\",\n        \"sortOrder\": \"vrstni red\",\n        \"tags\": \"oznake\",\n        \"title\": \"naslov\",\n        \"trackNumber\": \"skladba\",\n        \"trackGain\": \"glasnost skladbe\",\n        \"trackPeak\": \"vrhunec skladbe\",\n        \"translation\": \"prevod\",\n        \"unknown\": \"neznan\",\n        \"version\": \"verzija\",\n        \"year\": \"leto\",\n        \"yes\": \"da\",\n        \"name\": \"ime\"\n    },\n    \"entity\": {\n        \"album_one\": \"album\",\n        \"album_two\": \"albuma\",\n        \"album_few\": \"albumi\",\n        \"album_other\": \"albumov\",\n        \"albumArtist_one\": \"izvajalec albuma\",\n        \"albumArtist_two\": \"izvajalec albumov\",\n        \"albumArtist_few\": \"izvajalec albumov\",\n        \"albumArtist_other\": \"izvajalec albumov\",\n        \"albumArtistCount_one\": \"{{count}} izvajalec albuma\",\n        \"albumArtistCount_two\": \"{{count}} izvajalca albuma\",\n        \"albumArtistCount_few\": \"{{count}} izvajalci albuma\",\n        \"albumArtistCount_other\": \"{{count}} izvajalcev albuma\",\n        \"albumWithCount_one\": \"{{count}} album\",\n        \"albumWithCount_two\": \"{{count}} albuma\",\n        \"albumWithCount_few\": \"{{count}} albumi\",\n        \"albumWithCount_other\": \"{{count}} albumov\",\n        \"artist_one\": \"izvajalec\",\n        \"artist_two\": \"izvajalca\",\n        \"artist_few\": \"izvajalci\",\n        \"artist_other\": \"izvajalcev\",\n        \"artistWithCount_one\": \"{{count}} izvajalec\",\n        \"artistWithCount_two\": \"{{count}} izvajalca\",\n        \"artistWithCount_few\": \"{{count}} izvajalci\",\n        \"artistWithCount_other\": \"{{count}} izvajalcev\",\n        \"favorite_one\": \"priljubljen\",\n        \"favorite_two\": \"priljubljena\",\n        \"favorite_few\": \"priljubljeni\",\n        \"favorite_other\": \"priljubljenih\",\n        \"folder_one\": \"mapa\",\n        \"folder_two\": \"mapi\",\n        \"folder_few\": \"mape\",\n        \"folder_other\": \"map\",\n        \"folderWithCount_one\": \"{{count}} mapa\",\n        \"folderWithCount_two\": \"{{count}} mapi\",\n        \"folderWithCount_few\": \"{{count}} mape\",\n        \"folderWithCount_other\": \"{{count}} map\",\n        \"genre_one\": \"zvrst\",\n        \"genre_two\": \"zvrsti\",\n        \"genre_few\": \"zvrsti\",\n        \"genre_other\": \"zvrsti\",\n        \"genreWithCount_one\": \"{{count}} zvrst\",\n        \"genreWithCount_two\": \"{{count}} zvrsti\",\n        \"genreWithCount_few\": \"{{count}} zvrsti\",\n        \"genreWithCount_other\": \"{{count}} zvrsti\",\n        \"playlist_one\": \"seznam predvajanja\",\n        \"playlist_two\": \"seznama predvajanja\",\n        \"playlist_few\": \"seznami predvajanja\",\n        \"playlist_other\": \"seznamov predvajanja\",\n        \"play_one\": \"{{count}} predvajanje\",\n        \"play_two\": \"{{count}} predvajanji\",\n        \"play_few\": \"{{count}} predvajanja\",\n        \"play_other\": \"{{count}} predvajanj\",\n        \"playlistWithCount_one\": \"{{count}} seznam predvajanja\",\n        \"playlistWithCount_two\": \"{{count}} seznama predvajanja\",\n        \"playlistWithCount_few\": \"{{count}} seznami predvajanja\",\n        \"playlistWithCount_other\": \"{{count}} seznamov predvajanja\",\n        \"smartPlaylist\": \"pametni $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"track_one\": \"skladba\",\n        \"track_two\": \"skladbi\",\n        \"track_few\": \"skladbe\",\n        \"track_other\": \"skladb\",\n        \"song_one\": \"pesem\",\n        \"song_two\": \"pesmi\",\n        \"song_few\": \"pesmi\",\n        \"song_other\": \"pesmi\",\n        \"trackWithCount_one\": \"{{count}} skladba\",\n        \"trackWithCount_two\": \"{{count}} skladbi\",\n        \"trackWithCount_few\": \"{{count}} skladbe\",\n        \"trackWithCount_other\": \"{{count}} skladb\"\n    },\n    \"error\": {\n        \"apiRouteError\": \"preusmeritev zahteve ni bila mogoča\",\n        \"audioDeviceFetchError\": \"napaka pri poskusu pridobivanja avdio naprav\",\n        \"authenticationFailed\": \"napaka pri avtentikaciji\",\n        \"badAlbum\": \"ta stran je prikazana ker skladba ne pripada nobenemu albumu. skladba se verjetno nahaja na vrhu datotečne strukture direktorija z glasbo. Jellyfin razporedi skladbe v skupine samo v primeru, ko se nahajajo v direktoriju\",\n        \"badValue\": \"neveljavna možnost \\\"{{value}}\\\". ta vrednost ne obstaja več\",\n        \"credentialsRequired\": \"zahtevana prijava\",\n        \"endpointNotImplementedError\": \"{{serverType}} ne implementira končne točke {{endpoint}}\",\n        \"genericError\": \"prišlo je do napake\",\n        \"invalidServer\": \"neveljaven strežnik\",\n        \"localFontAccessDenied\": \"dostop do lokalnih pisav je bil zavrnjen\",\n        \"loginRateError\": \"preveč poskusov prijave, prosimo, poskusite čez nekaj sekund\",\n        \"mpvRequired\": \"obvezen MPV\",\n        \"networkError\": \"prišlo je do mrežne napake\",\n        \"openError\": \"datoteke ni mogoče odpreti\",\n        \"playbackError\": \"prišlo je do napake pri poskusu predvajanja skladbe\",\n        \"remoteDisableError\": \"oddaljenega strežnika ni bilo mogoče $t(common.disable)ti\",\n        \"remoteEnableError\": \"oddaljenega strežnika ni bilo mogoče $t(common.enable)ti\",\n        \"remotePortError\": \"pri nastavljanju vrat oddaljenega strežnika je prišlo do napake\",\n        \"remotePortWarning\": \"ponovno zaženite strežnik da aplicirate spremembo strežniških vrat\",\n        \"serverNotSelectedError\": \"izbran ni bil noben strežnik\",\n        \"serverRequired\": \"strežnik zahtevan\",\n        \"sessionExpiredError\": \"vaša seja se je iztekla\",\n        \"systemFontError\": \"napaka pri pridobivanju sistemskih pisav\"\n    },\n    \"filter\": {\n        \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n        \"albumArtist\": \"$t(entity.albumArtist, {\\\"count\\\": 1})\",\n        \"albumCount\": \"število $t(entity.album, {\\\"count\\\": 2})\",\n        \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n        \"biography\": \"biografija\",\n        \"bitrate\": \"bitna hitrost\",\n        \"bpm\": \"bpm\",\n        \"channels\": \"$t(common.channel_other)\",\n        \"comment\": \"komentar\",\n        \"communityRating\": \"ocena skupnosti\",\n        \"criticRating\": \"ocena kritikov\",\n        \"dateAdded\": \"dodano\",\n        \"disc\": \"disk\",\n        \"duration\": \"trajanje\",\n        \"favorited\": \"priljubljeno\",\n        \"fromYear\": \"od leta\",\n        \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n        \"id\": \"identifikator\",\n        \"isCompilation\": \"je kompilacija\",\n        \"isFavorited\": \"je dodan med priljubljene\",\n        \"isPublic\": \"je javno\",\n        \"isRated\": \"je ocenjen\",\n        \"isRecentlyPlayed\": \"je bil nedavno predvajan\",\n        \"lastPlayed\": \"zadnje predvajano\",\n        \"mostPlayed\": \"najpogosteje predvajano\",\n        \"name\": \"ime\",\n        \"note\": \"opomba\",\n        \"owner\": \"$t(common.owner)\",\n        \"path\": \"pot\",\n        \"playCount\": \"število predvajanj\",\n        \"random\": \"naključno\",\n        \"rating\": \"ocena\",\n        \"recentlyAdded\": \"nedavno dodano\",\n        \"recentlyPlayed\": \"nedavno predvajano\",\n        \"recentlyUpdated\": \"nedavno posodobljeno\",\n        \"releaseDate\": \"datum izida\",\n        \"releaseYear\": \"leto izida\",\n        \"search\": \"išči\",\n        \"songCount\": \"število pesmi\",\n        \"title\": \"naslov\",\n        \"toYear\": \"do leta\",\n        \"trackNumber\": \"skladba\"\n    },\n    \"form\": {\n        \"addServer\": {\n            \"error_savePassword\": \"pri shranjevanju gesla je prišlo do napake\",\n            \"ignoreCors\": \"ignoriraj cors $t(common.restartRequired)\",\n            \"ignoreSsl\": \"ignoriraj ssl $t(common.restartRequired)\",\n            \"input_legacyAuthentication\": \"omogoči legacy avtentikacijo\",\n            \"input_name\": \"ime strežnika\",\n            \"input_password\": \"geslo\",\n            \"input_savePassword\": \"shrani geslo\",\n            \"input_url\": \"url\",\n            \"input_username\": \"uporabniško ime\",\n            \"success\": \"dodajanje strežnika uspešno\",\n            \"title\": \"dodaj strežnik\"\n        },\n        \"addToPlaylist\": {\n            \"input_playlists\": \"$t(entity.playlist, {\\\"count\\\": 2})\",\n            \"input_skipDuplicates\": \"preskoči duplikate\",\n            \"success\": \"$t(entity.trackWithCount, {\\\"count\\\": {{message}} }) dodan v $t(entity.playlistWithCount, {\\\"count\\\": {{numOfPlaylists}} })\",\n            \"title\": \"dodaj v $t(entity.playlist, {\\\"count\\\": 1})\"\n        },\n        \"createPlaylist\": {\n            \"input_description\": \"$t(common.description)\",\n            \"input_name\": \"$t(common.name)\",\n            \"input_owner\": \"$t(common.owner)\",\n            \"input_public\": \"javno\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) je bil uspešno ustvarjen\",\n            \"title\": \"ustvari $t(entity.playlist, {\\\"count\\\": 1})\"\n        },\n        \"deletePlaylist\": {\n            \"input_confirm\": \"vpišite ime $t(entity.playlist, {\\\"count\\\": 1}) za potrditev\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) uspešno izbrisan\",\n            \"title\": \"izbriši $t(entity.playlist, {\\\"count\\\": 1})\"\n        },\n        \"editPlaylist\": {\n            \"publicJellyfinNote\": \"Jellyfin ne poda informacij o tem, ali gre za javni ali zasebni seznam predvajanja. Če želite, da seznam predvajanja ostane javen, izberite naslednji vnos\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) uspešno posodobljen\",\n            \"title\": \"uredi $t(entity.playlist, {\\\"count\\\": 1})\"\n        },\n        \"lyricSearch\": {\n            \"input_artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n            \"input_name\": \"$t(common.name)\",\n            \"title\": \"iskanje po besedilu\"\n        },\n        \"queryEditor\": {\n            \"title\": \"urejevalnik poizvedb\",\n            \"input_optionMatchAll\": \"ujemanje vseh\",\n            \"input_optionMatchAny\": \"ujemanje z najmanj enim\"\n        },\n        \"shareItem\": {\n            \"allowDownloading\": \"dovoli prenašanje\",\n            \"description\": \"opis\",\n            \"setExpiration\": \"nastavi datum poteka veljavnosti\",\n            \"success\": \"deli povezavo v odložišču (ali klikni tukaj za odpiranje)\",\n            \"expireInvalid\": \"datum poteka veljavnosti mora biti v prihodnosti\",\n            \"createFailed\": \"deljenje ni uspelo (je deljenje omogočeno?)\"\n        },\n        \"updateServer\": {\n            \"success\": \"strežnik uspešno posodobljen\",\n            \"title\": \"posodobi strežnik\"\n        }\n    },\n    \"page\": {\n        \"albumArtistDetail\": {\n            \"about\": \"O {{artist}}\",\n            \"appearsOn\": \"se pojavi na\",\n            \"recentReleases\": \"zadnje izdaje\",\n            \"viewDiscography\": \"poglej diskografijo\",\n            \"relatedArtists\": \"sorodni $t(entity.artist, {\\\"count\\\": 2})\",\n            \"topSongs\": \"najboljše skladbe\",\n            \"topSongsFrom\": \"najboljše skladbe iz {{title}}\",\n            \"viewAll\": \"poglej vse\",\n            \"viewAllTracks\": \"poglej vse $t(entity.track, {\\\"count\\\": 2})\"\n        },\n        \"albumArtistList\": {\n            \"title\": \"$t(entity.albumArtist, {\\\"count\\\": 2})\"\n        },\n        \"albumDetail\": {\n            \"moreFromArtist\": \"več od $t(entity.artist, {\\\"count\\\": 1})\",\n            \"moreFromGeneric\": \"več iz {{item}}\",\n            \"released\": \"izdano\"\n        },\n        \"albumList\": {\n            \"artistAlbums\": \"albumi izvajalca {{artist}}\",\n            \"genreAlbums\": \"\\\"{{genre}}\\\" $t(entity.album, {\\\"count\\\": 2})\",\n            \"title\": \"$t(entity.album, {\\\"count\\\": 2})\"\n        },\n        \"appMenu\": {\n            \"collapseSidebar\": \"skrij stransko vrstico\",\n            \"expandSidebar\": \"razširi stransko vrstico\",\n            \"goBack\": \"nazaj\",\n            \"goForward\": \"naprej\",\n            \"manageServers\": \"urejanje strežnikov\",\n            \"openBrowserDevtools\": \"odpri orodja za razvijalce brskalnika\",\n            \"quit\": \"$t(common.quit)\",\n            \"selectServer\": \"izberi strežnik\",\n            \"settings\": \"$t(common.setting, {\\\"count\\\": 2})\",\n            \"version\": \"verzija {{version}}\"\n        },\n        \"manageServers\": {\n            \"title\": \"urejanje strežnikov\",\n            \"serverDetails\": \"podrobosti o strežniku\",\n            \"url\": \"URL\",\n            \"username\": \"uporabniško ime\",\n            \"editServerDetailsTooltip\": \"urejanje podrobnosti strežnika\",\n            \"removeServer\": \"odstrani strežnik\"\n        },\n        \"contextMenu\": {\n            \"addFavorite\": \"$t(action.addToFavorites)\",\n            \"addLast\": \"$t(player.addLast)\",\n            \"addNext\": \"$t(player.addNext)\",\n            \"addToFavorites\": \"$t(action.addToFavorites)\",\n            \"addToPlaylist\": \"$t(action.addToPlaylist)\",\n            \"createPlaylist\": \"$t(action.createPlaylist)\",\n            \"deletePlaylist\": \"$t(action.deletePlaylist)\",\n            \"deselectAll\": \"$t(action.deselectAll)\",\n            \"download\": \"prenesi\",\n            \"moveToNext\": \"$t(action.moveToNext)\",\n            \"moveToBottom\": \"$t(action.moveToBottom)\",\n            \"moveToTop\": \"$t(action.moveToTop)\",\n            \"numberSelected\": \"{{count}} izbranih\",\n            \"play\": \"$t(player.play)\",\n            \"playSimilarSongs\": \"$t(player.playSimilarSongs)\",\n            \"removeFromFavorites\": \"$t(action.removeFromFavorites)\",\n            \"removeFromPlaylist\": \"$t(action.removeFromPlaylist)\",\n            \"removeFromQueue\": \"$t(action.removeFromQueue)\",\n            \"setRating\": \"$t(action.setRating)\",\n            \"playShuffled\": \"$t(player.shuffle)\",\n            \"shareItem\": \"deli\",\n            \"showDetails\": \"pridobi informacije\"\n        },\n        \"fullscreenPlayer\": {\n            \"config\": {\n                \"dynamicBackground\": \"dinamično ozadje\",\n                \"dynamicImageBlur\": \"velikost zameglitve slike\",\n                \"dynamicIsImage\": \"omogoči sliko v ozadju\",\n                \"followCurrentLyric\": \"sledi besedilu\",\n                \"lyricAlignment\": \"poravnava besedila\",\n                \"lyricOffset\": \"zamik besedila (ms)\",\n                \"lyricGap\": \"razmik besedila\",\n                \"lyricSize\": \"velikost besedila\",\n                \"opacity\": \"prosojnost\",\n                \"showLyricMatch\": \"prikaži ujemanje besedila\",\n                \"showLyricProvider\": \"pokaži ponudnika besedila\",\n                \"synchronized\": \"sinhronizirano\",\n                \"unsynchronized\": \"nesinhronizirano\",\n                \"useImageAspectRatio\": \"uporabi razmerje stranic slike\"\n            },\n            \"lyrics\": \"besedilo\",\n            \"related\": \"sorodno\",\n            \"upNext\": \"sledi\",\n            \"visualizer\": \"vizualizator\",\n            \"noLyrics\": \"ni bilo najdenih besedil\"\n        },\n        \"genreList\": {\n            \"showAlbums\": \"prikaži $t(entity.genre, {\\\"count\\\": 1}) $t(entity.album, {\\\"count\\\": 2})\",\n            \"showTracks\": \"prikaži $t(entity.genre, {\\\"count\\\": 1}) $t(entity.track, {\\\"count\\\": 2})\",\n            \"title\": \"$t(entity.genre, {\\\"count\\\": 2})\"\n        },\n        \"globalSearch\": {\n            \"commands\": {\n                \"goToPage\": \"pojdi na stran\",\n                \"searchFor\": \"išči {{query}}\",\n                \"serverCommands\": \"strežniški ukazi\"\n            },\n            \"title\": \"ukazi\"\n        },\n        \"home\": {\n            \"explore\": \"razišči knjižnico\",\n            \"mostPlayed\": \"najpogosteje predvajano\",\n            \"newlyAdded\": \"zadnje dodane izdaje\",\n            \"recentlyPlayed\": \"nedavno predvajano\",\n            \"title\": \"$t(common.home)\"\n        },\n        \"itemDetail\": {\n            \"copyPath\": \"kopiraj v odložišče\",\n            \"copiedPath\": \"kopiranje poti uspešno\",\n            \"openFile\": \"prikaži skladbo v upravitelju datotek\"\n        },\n        \"playlist\": {\n            \"reorder\": \"preurejanje je omogočeno samo pri razvrščanju po identifikatorju\"\n        },\n        \"playlistList\": {\n            \"title\": \"$t(entity.playlist, {\\\"count\\\": 2})\"\n        },\n        \"setting\": {\n            \"advanced\": \"napredno\",\n            \"generalTab\": \"splošno\",\n            \"hotkeysTab\": \"blžnjice\",\n            \"playbackTab\": \"predvajanje\",\n            \"windowTab\": \"okno\"\n        },\n        \"sidebar\": {\n            \"albumArtists\": \"$t(entity.albumArtist, {\\\"count\\\": 2})\",\n            \"albums\": \"$t(entity.album, {\\\"count\\\": 2})\",\n            \"artists\": \"$t(entity.artist, {\\\"count\\\": 2})\",\n            \"folders\": \"$t(entity.folder, {\\\"count\\\": 2})\",\n            \"genres\": \"$t(entity.genre, {\\\"count\\\": 2})\",\n            \"home\": \"$t(common.home)\",\n            \"myLibrary\": \"moja knjižnica\",\n            \"nowPlaying\": \"trenutno se predvaja\",\n            \"playlists\": \"$t(entity.playlist, {\\\"count\\\": 2})\",\n            \"search\": \"$t(common.search)\",\n            \"settings\": \"$t(common.setting, {\\\"count\\\": 2})\",\n            \"shared\": \"deljen $t(entity.playlist, {\\\"count\\\": 2})\",\n            \"tracks\": \"$t(entity.track, {\\\"count\\\": 2})\"\n        },\n        \"trackList\": {\n            \"artistTracks\": \"skladbe po {{artist}}\",\n            \"genreTracks\": \"\\\"{{genre}}\\\" $t(entity.track, {\\\"count\\\": 2})\",\n            \"title\": \"$t(entity.track, {\\\"count\\\": 2})\"\n        }\n    },\n    \"player\": {\n        \"addLast\": \"dodaj zadnje\",\n        \"addNext\": \"dodaj naslednje\",\n        \"favorite\": \"dodaj med priljubljene\",\n        \"mute\": \"utišaj\",\n        \"muted\": \"utišano\",\n        \"next\": \"naslednje\",\n        \"play\": \"predvajaj\",\n        \"playbackFetchCancel\": \"akcija traja dlje časa... zaprite obvestilo za preklic\",\n        \"playbackFetchInProgress\": \"nalaganje pesmi…\",\n        \"playbackFetchNoResults\": \"nobena pesem ni bila najdena\",\n        \"playbackSpeed\": \"hitrost predvajanja\",\n        \"playRandom\": \"predvajaj naključno\",\n        \"playSimilarSongs\": \"predvajaj sorodne pesmi\",\n        \"previous\": \"prejšnje\",\n        \"queue_clear\": \"počisti čakalno vrsto\",\n        \"queue_moveToBottom\": \"premakni izbrano na vrh\",\n        \"queue_moveToTop\": \"premakni izbrano na dno\",\n        \"queue_remove\": \"odstrani izbrano\",\n        \"repeat\": \"ponovi\",\n        \"repeat_all\": \"ponovi vse\",\n        \"repeat_off\": \"ne ponavljaj\",\n        \"shuffle\": \"predvajaj v naključnem vrstnem redu\",\n        \"shuffle_off\": \"prevajanje v naključnem vrstnem redu izključeno\",\n        \"skip\": \"preskoči\",\n        \"skip_back\": \"preskoči nazaj\",\n        \"skip_forward\": \"preskoči naprej\",\n        \"stop\": \"ustavi\",\n        \"toggleFullscreenPlayer\": \"preklopi predvajalnik v celozaslonski način\",\n        \"unfavorite\": \"odstrani iz priljubljenih\",\n        \"pause\": \"premor\",\n        \"viewQueue\": \"poglej čakalno vrsto\"\n    },\n    \"setting\": {\n        \"accentColor\": \"barva poudarka\",\n        \"accentColor_description\": \"nastavi barva poudarka aplikacije\",\n        \"albumBackground\": \"slika ozadja albuma\",\n        \"albumBackground_description\": \"doda sliko ozadja za strani albuma\",\n        \"albumBackgroundBlur\": \"velikost zameglitve slike ozadja albuma\",\n        \"albumBackgroundBlur_description\": \"spremeni moč zameglitve slike ozadja albuma\",\n        \"applicationHotkeys\": \"bližnjične tipke aplikacije\",\n        \"applicationHotkeys_description\": \"konfigurira bližnjične tipke aplikacije. obkljukajte da nastavite globalne bližnjico na tipkovnici (samo na namizju)\",\n        \"artistConfiguration\": \"konfiguracija strani izvajalca albuma\",\n        \"artistConfiguration_description\": \"konfiguriranje vsebine in vrstnega reda prikaza na strani izvajalca albuma\",\n        \"audioDevice\": \"avdio naprava\",\n        \"audioDevice_description\": \"izberite avdio napravo za predvajanje (samo v spletnem predvajalniku)\",\n        \"audioExclusiveMode\": \"avdio način\",\n        \"audioExclusiveMode_description\": \"omogoči način ekskluzivnega predvajanja. V tem načinu je sistem običajno zaklenjen in samo mpv lahko oddaja zvok\",\n        \"audioPlayer\": \"avdio predvajalnik\",\n        \"audioPlayer_description\": \"izberite avdio predvajalnik za predvajanje\",\n        \"buttonSize\": \"velikost gumbov vrstice predvajalnika\",\n        \"buttonSize_description\": \"velikost gumbov v vrstici predvajalnika\",\n        \"clearCache\": \"izbriši začasni pomnilnik\",\n        \"clearCache_description\": \"poleg brisanja feishinovega začasnega pomnilnika bo izbrisan tudi začasni pomnilnik brskalnika. nastavitve in prijavni podatki strežnikov se ohranijo\",\n        \"clearQueryCache\": \"počisti feishinov začasni pomnilnik\",\n        \"clearQueryCache_description\": \"osveži sezname predvajanja, metapodatke in ponastavi shranjena besedila. nastavitve, prijavni podatki za strežnike in slike se ohranijo\",\n        \"clearCacheSuccess\": \"začasni pomnilnik uspešno izbrisan\",\n        \"contextMenu\": \"konfiguracija kontekstnega menija (desni klik)\",\n        \"contextMenu_description\": \"omogoči skrivanje vrstic v meniju, prikazanem ob desnem kliku. odznačeni predmeti bodo skriti\",\n        \"crossfadeDuration\": \"trajanje prehoda\",\n        \"crossfadeDuration_description\": \"nastavi čas trajanja prehoda med pesmimi\",\n        \"crossfadeStyle_description\": \"izbira tipa efekta prehoda\",\n        \"customCssEnable\": \"omogoči css po meri\",\n        \"customCssEnable_description\": \"omogoča urejanje css-ja po meri\",\n        \"customCssNotice\": \"Opozorilo: kljub določenim varnostnim ukrepom (prepoved url() in content:) lahko uporaba css po meri s spreminjanjem vmesnika še vedno predstavlja tveganje\",\n        \"customCss\": \"css po meri\",\n        \"customCss_description\": \"vsebina css po meri. Opomba: vsebina in oddaljeni url-ji so prepovedane lastnosti. Spodaj je prikazan predogled vaše vsebine. Dodatna polja, ki jih niste nastavili, so prisotna zaradi prečiščevanja\",\n        \"customFontPath\": \"pot za pisavo po meri\",\n        \"customFontPath_description\": \"nastavi pot do pisave po meri\",\n        \"disableLibraryUpdateOnStartup\": \"onemogoči prevejranje novih verzij ob zagonu\",\n        \"discordApplicationId\": \"{{discord}} identifikator aplikacije\",\n        \"discordApplicationId_description\": \"identifikator aplikacije za {{discord}} bogato prezenco (privzeto {{defaultId}})\",\n        \"discordPausedStatus\": \"prikaži bogato prezenco med ustavljenim predvajanjem\",\n        \"discordPausedStatus_description\": \"ko je nastavitev omogočena, se bo status prikazal tudi ko je predvajanje začasno zaustavljeno\",\n        \"discordIdleStatus\": \"prikaže stanje mirovanja v bogati prezenci\",\n        \"discordIdleStatus_description\": \"ko je nastavitev omogočena, se bo status posodabljal ko predvajalnik miruje\",\n        \"discordListening\": \"prikaži status poslušanja\",\n        \"discordListening_description\": \"prikaži status poslušanja namesto predvajanja\",\n        \"discordRichPresence_description\": \"omogoči prikaz statusa predvajanja v {{discord}} bogati prezenci. Oznake slike so: {{icon}}, {{playing}} in {{paused}}\",\n        \"discordServeImage\": \"pošiljaj {{discord}} u slike iz strežnika\",\n        \"discordServeImage_description\": \"deli naslovne slike za {{discord}} bogato prisotnost iz samega strežnika, na voljo samo za Jellyfin in Navidrome\",\n        \"discordUpdateInterval\": \"interval posodabljanja {{discord}} bogate prezence\",\n        \"discordUpdateInterval_description\": \"čas v sekundah med posameznimi posodobitvami (najmanj 15 sekund)\",\n        \"enableRemote\": \"omogoči oddaljeno upravljanje strežnika\",\n        \"enableRemote_description\": \"omogoči oddaljeno nadzorovanje strežnika in s tem dovoli drugim napravam da upravljajo aplikacijo\",\n        \"externalLinks\": \"prikaži zunanje povezave\",\n        \"externalLinks_description\": \"omogoči prikaz zunanjih povezav (Last.fm, MusicBrainz) na straneh albumov,izvajalcev\",\n        \"exitToTray\": \"minimiziraj\",\n        \"exitToTray_description\": \"ob izhodu se aplikacija minimizira v opravilno vrstico\",\n        \"followLyric\": \"sledenje besedilu\",\n        \"followLyric_description\": \"pomaknite besedilo pesmi do trenutnega položaja predvajanja\",\n        \"preferLocalLyrics\": \"prioritiziraj lokalna besedila\",\n        \"preferLocalLyrics_description\": \"prioritiziraj lokalna besedila pred oddaljenimi, kadar so na voljo\",\n        \"font\": \"pisava\",\n        \"font_description\": \"nastavi pisavo, ki jo bo aplikacija uporabljala\",\n        \"fontType\": \"tip pisave\",\n        \"fontType_description\": \"vgrajena pisava izbere eno od pisav, ki jih ponuja feishin. sistemska pisava vam omogoča, da izberete katero koli pisavo, ki jo ponuja vaš operacijski sistem. po meri lahko izberete svojo pisavo\",\n        \"fontType_optionBuiltIn\": \"vgrajena pisava\",\n        \"fontType_optionCustom\": \"pisava po meri\",\n        \"fontType_optionSystem\": \"sistemska pisava\",\n        \"gaplessAudio\": \"neprekinjen avdio\",\n        \"gaplessAudio_description\": \"nastavi neprekinjen avdio za mpv\",\n        \"gaplessAudio_optionWeak\": \"šibko (priporočeno)\",\n        \"globalMediaHotkeys\": \"globalne bližnjične tipke za vsebino\",\n        \"globalMediaHotkeys_description\": \"omogočite ali onemogočite uporabo bližnjic za sistemske medije za nadzor predvajanja\",\n        \"homeConfiguration\": \"konfiguracija domače strani\",\n        \"homeConfiguration_description\": \"konfigurirajte, kateri elementi so prikazani na domači strani in v kakšnem vrstnem redu\",\n        \"homeFeature\": \"tekoči trak na domači strani\",\n        \"homeFeature_description\": \"nadzoruje, ali naj se na domači strani prikaže velik tekoči trak\",\n        \"hotkey_browserBack\": \"nazaj (brskalnik)\",\n        \"hotkey_browserForward\": \"naprej (brskalnik)\",\n        \"hotkey_favoriteCurrentSong\": \"dodaj $t(common.currentSong) med priljubljene\",\n        \"hotkey_favoritePreviousSong\": \"dodaj $t(common.previousSong) med priljubljene\",\n        \"hotkey_globalSearch\": \"globalno iskanje\",\n        \"hotkey_localSearch\": \"iskanje na strani\",\n        \"hotkey_playbackNext\": \"naslednja skladba\",\n        \"hotkey_playbackPause\": \"pavza\",\n        \"hotkey_playbackPlay\": \"predvajaj\",\n        \"hotkey_playbackPlayPause\": \"predvajaj / pavza\",\n        \"hotkey_playbackPrevious\": \"prejšnja skladba\",\n        \"hotkey_playbackStop\": \"ustavi\",\n        \"hotkey_rate0\": \"počisti oceno\",\n        \"hotkey_rate1\": \"oceni z 1 zvezdico\",\n        \"hotkey_rate2\": \"oceni z 2 zvezdicama\",\n        \"hotkey_rate3\": \"oceni s 3 zvezdicami\",\n        \"hotkey_rate4\": \"oceni s 4 zvezdicami\",\n        \"hotkey_rate5\": \"oceni s 5 zvezdicami\",\n        \"hotkey_skipBackward\": \"preskoči nazaj\",\n        \"hotkey_skipForward\": \"preskoči naprej\",\n        \"hotkey_toggleCurrentSongFavorite\": \"dodaj/odstrani $t(common.currentSong) iz seznama priljubljenih\",\n        \"hotkey_toggleFullScreenPlayer\": \"preklopi predvajalnik na celozaslonski način\",\n        \"hotkey_togglePreviousSongFavorite\": \"dodaj/odstrani $t(common.previousSong) iz seznama priljubljenih\",\n        \"hotkey_toggleQueue\": \"preklopi čakalno vrsto\",\n        \"hotkey_toggleRepeat\": \"preklopi ponovitve\",\n        \"hotkey_toggleShuffle\": \"preklopi naključni vrstni red predvajanja\",\n        \"hotkey_unfavoriteCurrentSong\": \"odstrani $t(common.currentSong) iz seznama priljubljenih\",\n        \"hotkey_unfavoritePreviousSong\": \"odstrani $t(common.previousSong) iz seznama priljubljenih\",\n        \"hotkey_volumeDown\": \"znižaj glasnost\",\n        \"hotkey_volumeMute\": \"utišaj\",\n        \"hotkey_volumeUp\": \"povišaj glasnost\",\n        \"hotkey_zoomIn\": \"povečaj\",\n        \"hotkey_zoomOut\": \"pomanjšaj\",\n        \"imageAspectRatio\": \"uporabi razmerje stranic izvorne naslovnice\",\n        \"imageAspectRatio_description\": \"če je omogočeno, bo naslovnica prikazana z izvornim razmerjem stranic. za slike, ki niso 1:1, bo preostali prostor prazen\",\n        \"language_description\": \"nastavi jezik aplikacije ($t(common.restartRequired))\",\n        \"lastfm\": \"prikaži last.fm povezave\",\n        \"lastfm_description\": \"prikaži povezave do Last.fm na straneh izvajalcev/albumov\",\n        \"lastfmApiKey\": \"API ključ {{lastfm}}\",\n        \"lastfmApiKey_description\": \"API ključ za {{lastfm}}. potreben za naslovnico albuma\",\n        \"lyricFetch\": \"pridobi besedila iz interneta\",\n        \"lyricFetch_description\": \"pridobivanje besedil iz različnih internetnih virov\",\n        \"lyricFetchProvider\": \"ponudniki za pridobivanje besedil\",\n        \"lyricFetchProvider_description\": \"izberite ponudnike, od katerih želite pridobiti besedila. vrstni red ponudnikov je vrstni red, v katerem bodo poizvedovani\",\n        \"lyricOffset\": \"zamik besedila (ms)\",\n        \"lyricOffset_description\": \"zamakni besedilo za določeno število milisekund\",\n        \"minimizeToTray\": \"minimiziraj v sistemsko vrstico\",\n        \"minimizeToTray_description\": \"minimizirajte aplikacijo v sistemsko vrstico\"\n    }\n}\n"
  },
  {
    "path": "src/i18n/locales/sr.json",
    "content": "{\n    \"player\": {\n        \"repeat_all\": \"ponavljaj sve\",\n        \"stop\": \"zaustavi\",\n        \"repeat\": \"ponavljaj jednu\",\n        \"queue_remove\": \"ukloni izabrane\",\n        \"playRandom\": \"slučajna reprodukcija\",\n        \"skip\": \"preskoči\",\n        \"previous\": \"prethodna\",\n        \"toggleFullscreenPlayer\": \"prebaci u puni ekran\",\n        \"skip_back\": \"preskoči unazad\",\n        \"favorite\": \"omiljeno\",\n        \"next\": \"sledeća\",\n        \"shuffle\": \"mešaj\",\n        \"playbackFetchNoResults\": \"nema pronađenih pesama\",\n        \"playbackFetchInProgress\": \"učitavanje pesama…\",\n        \"addNext\": \"dodaj sledeći\",\n        \"playbackSpeed\": \"brzina reprodukcije\",\n        \"playbackFetchCancel\": \"ovo traje... zatvorite obaveštenje da biste otkazali\",\n        \"play\": \"pusti\",\n        \"repeat_off\": \"ponavljanje isključeno\",\n        \"pause\": \"pauziraj\",\n        \"queue_clear\": \"isprazni red\",\n        \"muted\": \"isključeno\",\n        \"unfavorite\": \"ukloni iz omiljenih\",\n        \"queue_moveToTop\": \"pomeri izabrane na dno\",\n        \"queue_moveToBottom\": \"pomeri izabrane na vrh\",\n        \"shuffle_off\": \"mešanje isključeno\",\n        \"addLast\": \"dodaj poslednji\",\n        \"mute\": \"isključi ton\",\n        \"skip_forward\": \"preskoči unapred\"\n    },\n    \"setting\": {\n        \"crossfadeStyle_description\": \"izaberite stil prelaska za audio plejer\",\n        \"remotePort_description\": \"postavlja port za daljinsku kontrolu servera\",\n        \"hotkey_skipBackward\": \"preskoči unazad\",\n        \"replayGainMode_description\": \"prilagođava jačinu glasnoće prema vrednostima {{ReplayGain}} koje se nalaze u metapodacima datoteke\",\n        \"volumeWheelStep_description\": \"količina promene glasnoće pri okretanju točkića miša na traci za glasnoću\",\n        \"audioDevice_description\": \"izaberite audio uređaj za reprodukciju (samo veb plejer)\",\n        \"theme_description\": \"postavlja temu za aplikaciju\",\n        \"hotkey_playbackPause\": \"pauza\",\n        \"replayGainFallback\": \"{{ReplayGain}} alternativa\",\n        \"sidebarCollapsedNavigation_description\": \"prikaži ili sakrij navigaciju u sklopljenoj bočnoj traci\",\n        \"hotkey_volumeUp\": \"pojačaj glasnoću\",\n        \"skipDuration\": \"dužina preskakanja\",\n        \"discordIdleStatus_description\": \"kada je omogućeno, ažurira status dok je plejer u mirovanju\",\n        \"showSkipButtons\": \"prikaži dugmad za preskakanje\",\n        \"playButtonBehavior_optionPlay\": \"$t(player.play)\",\n        \"minimumScrobblePercentage\": \"minimum trajanja za bilježenje (u procentima)\",\n        \"lyricFetch\": \"preuzimanje tekstova sa interneta\",\n        \"scrobble\": \"bilježi\",\n        \"skipDuration_description\": \"postavlja dužinu preskakanja kada koristite dugmad za preskakanje na traci za reprodukciju\",\n        \"enableRemote_description\": \"omogućava daljinsku kontrolu servera kako bi omogućili drugim uređajima da kontrolišu aplikaciju\",\n        \"fontType_optionSystem\": \"sistemski font\",\n        \"mpvExecutablePath_description\": \"postavlja putanju do izvršne datoteke mpv player-a\",\n        \"replayGainClipping_description\": \"Smanjuje preklapanje uzrokovano {{ReplayGain}} automatskim smanjenjem glasnoće\",\n        \"replayGainPreamp\": \"{{ReplayGain}} pojačalo (dB)\",\n        \"hotkey_favoriteCurrentSong\": \"omiljena $t(common.currentSong)\",\n        \"sampleRate\": \"sample rate\",\n        \"sidePlayQueueStyle_optionAttached\": \"priložena\",\n        \"sidebarConfiguration\": \"konfiguracija bočne trake\",\n        \"sampleRate_description\": \"izaberite izlazni sample rate koji će se koristiti ako je sample rate drugačiji od onog u trenutnom mediju\",\n        \"replayGainMode_optionNone\": \"$t(common.none)\",\n        \"replayGainClipping\": \"{{ReplayGain}} smanjenje\",\n        \"hotkey_zoomIn\": \"uvećaj\",\n        \"scrobble_description\": \"bilježi reprodukciju na vašem serverskom uređaju\",\n        \"hotkey_browserForward\": \"napred u pregledaču\",\n        \"audioExclusiveMode_description\": \"omogućava ekskluzivan režim izlaza. U ovom režimu, sistem je obično zaključan, i samo mpv će moći da izlazi zvuk\",\n        \"discordUpdateInterval\": \"{{discord}} interval ažuriranja bogatog prikaza\",\n        \"themeLight\": \"tema (svetla)\",\n        \"fontType_optionBuiltIn\": \"ugrađeni font\",\n        \"hotkey_playbackPlayPause\": \"reprodukcija / pauza\",\n        \"hotkey_rate1\": \"oceni sa 1 zvezdicom\",\n        \"hotkey_skipForward\": \"preskoči unapred\",\n        \"disableLibraryUpdateOnStartup\": \"onemogući proveru za nove verzije pri pokretanju\",\n        \"discordApplicationId_description\": \"ID aplikacije za {{discord}} bogat prikaz (podrazumevano je {{defaultId}})\",\n        \"sidePlayQueueStyle\": \"stil bočne liste za reprodukciju\",\n        \"gaplessAudio\": \"bez pauze zvuka\",\n        \"playButtonBehavior_optionAddLast\": \"$t(player.addLast)\",\n        \"zoom\": \"stepen zumiranja\",\n        \"minimizeToTray_description\": \"minimizira aplikaciju u sistemsku traku kada se zatvori i vraća je kada se ponovo otvori\",\n        \"hotkey_playbackPlay\": \"pusti\",\n        \"hotkey_togglePreviousSongFavorite\": \"promeni omiljenu pesmu $t(common.previousSong)\",\n        \"hotkey_volumeDown\": \"smanji glasnoću\",\n        \"hotkey_unfavoritePreviousSong\": \"ukloni omiljenu pesmu $t(common.previousSong)\",\n        \"audioPlayer_description\": \"izaberite audio plejer za reprodukciju\",\n        \"globalMediaHotkeys\": \"globalni medijski tasteri\",\n        \"hotkey_globalSearch\": \"globalno pretraživanje\",\n        \"gaplessAudio_description\": \"postavlja opciju bez pauze zvuka za mpv (preporučeno: slabo)\",\n        \"remoteUsername_description\": \"postavlja korisničko ime za daljinsku kontrolu servera. Ako su i korisničko ime i lozinka prazni, autentifikacija će biti onemogućena\",\n        \"exitToTray_description\": \"izlazak aplikacije u sistemsku traku\",\n        \"followLyric_description\": \"pomera tekst pesme na trenutnu poziciju reprodukcije\",\n        \"hotkey_favoritePreviousSong\": \"omiljena $t(common.previousSong)\",\n        \"replayGainMode_optionAlbum\": \"$t(entity.album, {\\\"count\\\": 1})\",\n        \"lyricOffset\": \"pomeraj teksta (ms)\",\n        \"discordUpdateInterval_description\": \"vreme u sekundama između svakog ažuriranja (minimum 15 sekundi)\",\n        \"fontType_optionCustom\": \"prilagođeni font\",\n        \"themeDark_description\": \"postavlja tamnu temu za aplikaciju\",\n        \"audioExclusiveMode\": \"ekskluzivni audio režim\",\n        \"remotePassword\": \"lozinka za daljinsku kontrolu servera\",\n        \"lyricFetchProvider\": \"pružatelji tekstova za preuzimanje\",\n        \"language_description\": \"postavlja jezik za aplikaciju ($t(common.restartRequired))\",\n        \"playbackStyle_optionCrossFade\": \"prelazak sa preklapanjem\",\n        \"hotkey_rate3\": \"oceni sa 3 zvezdice\",\n        \"font\": \"font\",\n        \"replayGainMode_optionTrack\": \"$t(entity.track, {\\\"count\\\": 1})\",\n        \"themeLight_description\": \"postavlja svetlu temu za aplikaciju\",\n        \"hotkey_toggleFullScreenPlayer\": \"prebaci na prikaz na celom ekranu\",\n        \"hotkey_localSearch\": \"pretraživanje na stranici\",\n        \"hotkey_toggleQueue\": \"promeni listu za reprodukciju\",\n        \"zoom_description\": \"postavlja stepen zumiranja za aplikaciju\",\n        \"remotePassword_description\": \"postavlja lozinku za daljinsku kontrolu servera. Ove informacije se prenose nezaštićeno, pa biste trebali koristiti jedinstvenu lozinku koja vam nije važna\",\n        \"hotkey_rate5\": \"oceni sa 5 zvezdica\",\n        \"hotkey_playbackPrevious\": \"prethodna pesma\",\n        \"showSkipButtons_description\": \"prikaži ili sakrij dugmad za preskakanje na traci za reprodukciju\",\n        \"crossfadeDuration_description\": \"postavi trajanje efekta prelaza\",\n        \"playbackStyle\": \"stil reprodukcije\",\n        \"hotkey_toggleShuffle\": \"promeni slučajan redosled\",\n        \"theme\": \"tema\",\n        \"playbackStyle_description\": \"izaberite stil reprodukcije za audio plejer\",\n        \"discordRichPresence_description\": \"omogućava status reprodukcije u {{discord}} bogatom prikazu. Ključevi slika su: {{icon}}, {{playing}}, i {{paused}}\",\n        \"mpvExecutablePath\": \"putanja do mpv izvršne datoteke\",\n        \"audioDevice\": \"audio uređaj\",\n        \"hotkey_rate2\": \"oceni sa 2 zvezdice\",\n        \"playButtonBehavior_description\": \"postavlja zadano ponašanje dugmeta za reprodukciju pri dodavanju pesama u listu za reprodukciju\",\n        \"minimumScrobblePercentage_description\": \"minimalni procenat pesme koji mora da bude reprodukovan pre nego što se zabeleži\",\n        \"exitToTray\": \"izlazak u oblast za traku\",\n        \"hotkey_rate4\": \"oceni sa 4 zvezdice\",\n        \"enableRemote\": \"omogući daljinsku kontrolu servera\",\n        \"showSkipButton_description\": \"prikaži ili sakrij dugmad za preskakanje na traci za reprodukciju\",\n        \"savePlayQueue\": \"sačuvaj listu za reprodukciju\",\n        \"minimumScrobbleSeconds_description\": \"minimalno trajanje pesme u sekundama koje mora biti reprodukovano pre nego što se zabeleži\",\n        \"skipPlaylistPage_description\": \"kada idete na plejlistu, idi na stranicu sa pesmama plejliste umesto na podrazumevanu stranicu\",\n        \"fontType_description\": \"ugrađeni font bira jedan od fontova koje pruža feishin. sistemski font vam omogućava da izaberete bilo koji font koji nudi vaš operativni sistem. prilagođeni vam omogućava da koristite svoj font\",\n        \"playButtonBehavior\": \"ponašanje dugmeta za reprodukciju\",\n        \"volumeWheelStep\": \"korak točkića za glasnoću\",\n        \"sidebarPlaylistList_description\": \"prikaži ili sakrij listu plejlista na bočnoj traci\",\n        \"accentColor\": \"akcentna boja\",\n        \"sidePlayQueueStyle_description\": \"postavlja stil bočne liste za reprodukciju\",\n        \"accentColor_description\": \"postavi akcentnu boju za aplikaciju\",\n        \"replayGainMode\": \"{{ReplayGain}} režim\",\n        \"playbackStyle_optionNormal\": \"normalno\",\n        \"windowBarStyle\": \"stil trake prozora\",\n        \"replayGainFallback_description\": \"jačina u dB koja će se primeniti ako datoteka nema {{ReplayGain}} oznake\",\n        \"replayGainPreamp_description\": \"prilagođava pojačalo za {{ReplayGain}} vrednosti\",\n        \"hotkey_toggleRepeat\": \"promeni ponavljanje\",\n        \"lyricOffset_description\": \"pomera tekst za navedeni broj milisekundi\",\n        \"sidebarConfiguration_description\": \"izaberite stavke i redosled u kojem se pojavljuju u bočnoj traci\",\n        \"fontType\": \"tip fonta\",\n        \"remotePort\": \"port za daljinsku kontrolu servera\",\n        \"applicationHotkeys\": \"prečice za aplikaciju\",\n        \"hotkey_playbackNext\": \"sledeća pesma\",\n        \"useSystemTheme_description\": \"prati sistemski određene postavke za svetlu ili tamnu temu\",\n        \"playButtonBehavior_optionAddNext\": \"$t(player.addNext)\",\n        \"lyricFetch_description\": \"preuzimanje tekstova sa različitih izvora na internetu\",\n        \"lyricFetchProvider_description\": \"izaberite pružatelje tekstova za preuzimanje. Redosled pružatelja je redosled upita\",\n        \"globalMediaHotkeys_description\": \"omogućava ili onemogućava korišćenje medijskih tastera sistema za kontrolu reprodukcije\",\n        \"customFontPath\": \"prilagođena putanja fonta\",\n        \"followLyric\": \"prati trenutni tekst pesme\",\n        \"crossfadeDuration\": \"trajanje prelaza\",\n        \"discordIdleStatus\": \"prikaži status u mirovanju na Diskordu\",\n        \"sidePlayQueueStyle_optionDetached\": \"odvojena\",\n        \"audioPlayer\": \"audio plejer\",\n        \"hotkey_zoomOut\": \"umanji\",\n        \"hotkey_unfavoriteCurrentSong\": \"ukloni omiljenu pesmu $t(common.currentSong)\",\n        \"hotkey_rate0\": \"obrisati ocenu\",\n        \"discordApplicationId\": \"{{discord}} ID aplikacije\",\n        \"applicationHotkeys_description\": \"konfiguriši prečice za aplikaciju. uključite opciju za postavljanje kao globalne prečice (samo na radnoj površini)\",\n        \"hotkey_volumeMute\": \"isključi zvuk\",\n        \"hotkey_toggleCurrentSongFavorite\": \"promeni omiljenu pesmu $t(common.currentSong)\",\n        \"remoteUsername\": \"korisničko ime za daljinsku kontrolu servera\",\n        \"hotkey_browserBack\": \"nazad u pregledaču\",\n        \"showSkipButton\": \"prikaži dugmad za preskakanje\",\n        \"sidebarPlaylistList\": \"lista plejlista na bočnoj traci\",\n        \"minimizeToTray\": \"minimiziraj u sistemsku traku\",\n        \"skipPlaylistPage\": \"preskoči stranicu plejliste\",\n        \"themeDark\": \"tema (tamna)\",\n        \"sidebarCollapsedNavigation\": \"navigacija (skupljena bočna traka)\",\n        \"customFontPath_description\": \"postavlja putanju do prilagođenog fonta za aplikaciju\",\n        \"gaplessAudio_optionWeak\": \"slabo (preporučeno)\",\n        \"minimumScrobbleSeconds\": \"minimalno trajanje za bilježenje (u sekundama)\",\n        \"hotkey_playbackStop\": \"zaustavi\",\n        \"windowBarStyle_description\": \"izaberite stil trake prozora\",\n        \"font_description\": \"postavlja font koji se koristi za aplikaciju\",\n        \"savePlayQueue_description\": \"sačuva listu za reprodukciju kada se aplikacija zatvori i obnovi je pri ponovnom otvaranju aplikacije\",\n        \"useSystemTheme\": \"koristi sistemsku temu\"\n    },\n    \"action\": {\n        \"editPlaylist\": \"izmeni $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"goToPage\": \"idi na stranu\",\n        \"moveToTop\": \"idi na vrh\",\n        \"clearQueue\": \"očisti listu\",\n        \"addToFavorites\": \"dodaj u $t(entity.favorite, {\\\"count\\\": 2})\",\n        \"addToPlaylist\": \"dodaj u $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"createPlaylist\": \"napravi $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"removeFromPlaylist\": \"ukloni iz $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"viewPlaylists\": \"vidi $t(entity.playlist, {\\\"count\\\": 2})\",\n        \"refresh\": \"$t(common.refresh)\",\n        \"deletePlaylist\": \"obriši $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"removeFromQueue\": \"ukloni iz liste\",\n        \"deselectAll\": \"deselektuj sve\",\n        \"moveToBottom\": \"idi na dno\",\n        \"setRating\": \"oceni\",\n        \"toggleSmartPlaylistEditor\": \"pokreni $t(entity.smartPlaylist) editor\",\n        \"removeFromFavorites\": \"ukloni iz $t(entity.favorite, {\\\"count\\\": 2})\",\n        \"openIn\": {\n            \"lastfm\": \"Otvori u Last.fm\",\n            \"musicbrainz\": \"Otvori u MusicBrainz\"\n        }\n    },\n    \"common\": {\n        \"backward\": \"nazad\",\n        \"increase\": \"povećaj\",\n        \"rating\": \"ocena\",\n        \"bpm\": \"bpm\",\n        \"refresh\": \"osveži\",\n        \"unknown\": \"nepoznato\",\n        \"areYouSure\": \"da li si siguran/na?\",\n        \"edit\": \"izmeni\",\n        \"favorite\": \"favorit\",\n        \"left\": \"levo\",\n        \"save\": \"sačuvaj\",\n        \"right\": \"desno\",\n        \"currentSong\": \"trenutno $t(entity.track, {\\\"count\\\": 1})\",\n        \"collapse\": \"sklopi\",\n        \"trackNumber\": \"pesma\",\n        \"descending\": \"silazno\",\n        \"add\": \"dodaj\",\n        \"gap\": \"procep\",\n        \"ascending\": \"uzlazno\",\n        \"dismiss\": \"odbaci\",\n        \"year\": \"godina\",\n        \"manage\": \"upravljaj\",\n        \"limit\": \"limit\",\n        \"minimize\": \"minimiziraj\",\n        \"modified\": \"modifikovan\",\n        \"duration\": \"trajanje\",\n        \"name\": \"ime\",\n        \"maximize\": \"maksimiziraj\",\n        \"decrease\": \"smanji\",\n        \"ok\": \"ok\",\n        \"description\": \"opis\",\n        \"configure\": \"konfiguriši\",\n        \"path\": \"putanja\",\n        \"center\": \"centar\",\n        \"no\": \"ne\",\n        \"owner\": \"vlasnik\",\n        \"enable\": \"uključi\",\n        \"clear\": \"očisti\",\n        \"forward\": \"napred\",\n        \"delete\": \"obriši\",\n        \"cancel\": \"otkaži\",\n        \"forceRestartRequired\": \"restartuj da primeniš izmene… zatvori notifikaciju za restart\",\n        \"setting_one\": \"podešavanje\",\n        \"setting_few\": \"\",\n        \"setting_other\": \"\",\n        \"version\": \"verzija\",\n        \"title\": \"naziv\",\n        \"filter_one\": \"filter\",\n        \"filter_few\": \"filteri\",\n        \"filter_other\": \"filtera\",\n        \"filters\": \"filteri\",\n        \"create\": \"napravi\",\n        \"bitrate\": \"bitrejt\",\n        \"saveAndReplace\": \"sačuvaj i zameni\",\n        \"action_one\": \"akcija\",\n        \"action_few\": \"akcije\",\n        \"action_other\": \"akcija\",\n        \"playerMustBePaused\": \"plejer mora biti pauziran\",\n        \"confirm\": \"potvrdi\",\n        \"resetToDefault\": \"reset na fabrička podešavanja\",\n        \"home\": \"kuća\",\n        \"comingSoon\": \"stiže uskoro…\",\n        \"reset\": \"reset\",\n        \"channel_one\": \"kanal\",\n        \"channel_few\": \"kanali\",\n        \"channel_other\": \"kanala\",\n        \"disable\": \"onemogući\",\n        \"sortOrder\": \"redosled\",\n        \"none\": \"nijedan\",\n        \"menu\": \"meni\",\n        \"restartRequired\": \"restart potreban\",\n        \"previousSong\": \"prethodna $t(entity.track, {\\\"count\\\": 1})\",\n        \"noResultsFromQuery\": \"upit je bez rezultata\",\n        \"quit\": \"izađi\",\n        \"expand\": \"proširi\",\n        \"search\": \"pretraga\",\n        \"saveAs\": \"sačuvaj kao\",\n        \"disc\": \"disk\",\n        \"yes\": \"da\",\n        \"random\": \"nasumično\",\n        \"size\": \"veličina\",\n        \"biography\": \"biografija\",\n        \"note\": \"notacija\"\n    },\n    \"table\": {\n        \"config\": {\n            \"view\": {\n                \"table\": \"tabela\"\n            },\n            \"general\": {\n                \"displayType\": \"tip prikaza\",\n                \"gap\": \"$t(common.gap)\",\n                \"tableColumns\": \"tabela kolona\",\n                \"autoFitColumns\": \"automatski uklopi kolone\",\n                \"size\": \"$t(common.size)\"\n            },\n            \"label\": {\n                \"releaseDate\": \"datum objavljivanja\",\n                \"title\": \"$t(common.title)\",\n                \"duration\": \"$t(common.duration)\",\n                \"titleCombined\": \"$t(common.title) (kombinovano)\",\n                \"dateAdded\": \"datum dodavanja\",\n                \"size\": \"$t(common.size)\",\n                \"bpm\": \"$t(common.bpm)\",\n                \"lastPlayed\": \"zadnje puštana\",\n                \"trackNumber\": \"broj pesme\",\n                \"rowIndex\": \"indeks reda\",\n                \"rating\": \"$t(common.rating)\",\n                \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n                \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n                \"note\": \"$t(common.note)\",\n                \"biography\": \"$t(common.biography)\",\n                \"owner\": \"$t(common.owner)\",\n                \"path\": \"$t(common.path)\",\n                \"channels\": \"$t(common.channel_other)\",\n                \"playCount\": \"broj puštanja\",\n                \"bitrate\": \"$t(common.bitrate)\",\n                \"actions\": \"$t(common.action_other)\",\n                \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n                \"discNumber\": \"disk broj\",\n                \"favorite\": \"$t(common.favorite)\",\n                \"year\": \"$t(common.year)\",\n                \"albumArtist\": \"$t(entity.albumArtist, {\\\"count\\\": 1})\"\n            }\n        },\n        \"column\": {\n            \"comment\": \"komentar\",\n            \"album\": \"album\",\n            \"rating\": \"rejting\",\n            \"favorite\": \"favorit\",\n            \"playCount\": \"puštanja\",\n            \"albumCount\": \"$t(entity.album, {\\\"count\\\": 2})\",\n            \"releaseYear\": \"godina\",\n            \"lastPlayed\": \"zadnje puštana\",\n            \"biography\": \"biografija\",\n            \"releaseDate\": \"datum objavljivanja\",\n            \"bitrate\": \"bitrate\",\n            \"title\": \"naziv\",\n            \"bpm\": \"bpm\",\n            \"dateAdded\": \"datum dodavanja\",\n            \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n            \"songCount\": \"$t(entity.track, {\\\"count\\\": 2})\",\n            \"trackNumber\": \"pesma\",\n            \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n            \"albumArtist\": \"album artist\",\n            \"path\": \"putanja\",\n            \"discNumber\": \"disk\",\n            \"channels\": \"$t(common.channel_other)\",\n            \"size\": \"$t(common.size)\"\n        }\n    },\n    \"error\": {\n        \"remotePortWarning\": \"ponovo pokrenite server kako biste primenili novi port\",\n        \"systemFontError\": \"došlo je do greške prilikom pokušaja dobijanja sistema fontova\",\n        \"playbackError\": \"došlo je do greške prilikom pokušaja reprodukcije medija\",\n        \"endpointNotImplementedError\": \"krajnja tačka {{endpoint}} nije implementirana za {{serverType}}\",\n        \"remotePortError\": \"došlo je do greške prilikom postavljanja porta udaljenog servera\",\n        \"serverRequired\": \"potreban je server\",\n        \"authenticationFailed\": \"neuspešna autentikacija\",\n        \"apiRouteError\": \"nije moguće usmeriti zahtev\",\n        \"genericError\": \"došlo je do greške\",\n        \"credentialsRequired\": \"potrebni su pristupni podaci\",\n        \"sessionExpiredError\": \"vaša sesija je istekla\",\n        \"remoteEnableError\": \"došlo je do greške prilikom pokušaja omogućavanja udaljenog servera\",\n        \"localFontAccessDenied\": \"pristup lokalnim fontovima odbijen\",\n        \"serverNotSelectedError\": \"nije izabran nijedan server\",\n        \"remoteDisableError\": \"došlo je do greške prilikom pokušaja onemogućavanja udaljenog servera\",\n        \"mpvRequired\": \"MPV je obavezan\",\n        \"audioDeviceFetchError\": \"došlo je do greške prilikom pokušaja dobijanja audio uređaja\",\n        \"invalidServer\": \"neispravan server\",\n        \"loginRateError\": \"previše pokušaja prijave, molimo pokušajte ponovo za nekoliko sekundi\"\n    },\n    \"filter\": {\n        \"mostPlayed\": \"najviše puštana\",\n        \"comment\": \"komentar\",\n        \"playCount\": \"broj slušanja\",\n        \"recentlyUpdated\": \"skorije ažurirana\",\n        \"channels\": \"$t(common.channel_other)\",\n        \"isCompilation\": \"je kompilacija\",\n        \"recentlyPlayed\": \"skorije puštana\",\n        \"isRated\": \"je ocenjena\",\n        \"owner\": \"$t(common.owner)\",\n        \"title\": \"naziv\",\n        \"rating\": \"rejting\",\n        \"search\": \"pretraga\",\n        \"bitrate\": \"bitrejt\",\n        \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n        \"recentlyAdded\": \"skorije dodata\",\n        \"note\": \"notacija\",\n        \"name\": \"ime\",\n        \"dateAdded\": \"datum dodavanja\",\n        \"releaseDate\": \"datum izdavanja\",\n        \"albumCount\": \"$t(entity.album, {\\\"count\\\": 2}) albuma\",\n        \"communityRating\": \"ocena zajednice\",\n        \"path\": \"putanja\",\n        \"favorited\": \"favoriti\",\n        \"albumArtist\": \"$t(entity.albumArtist, {\\\"count\\\": 1})\",\n        \"isRecentlyPlayed\": \"je skorije puštana\",\n        \"isFavorited\": \"je favorit\",\n        \"bpm\": \"bpm\",\n        \"releaseYear\": \"godina izdavanja\",\n        \"id\": \"id\",\n        \"disc\": \"disk\",\n        \"biography\": \"biografija\",\n        \"songCount\": \"broj pesama\",\n        \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n        \"duration\": \"trajanje\",\n        \"isPublic\": \"je javna\",\n        \"random\": \"nasumično\",\n        \"lastPlayed\": \"zadnje puštana\",\n        \"toYear\": \"do godine\",\n        \"fromYear\": \"iz godine\",\n        \"criticRating\": \"ocena kritičara\",\n        \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n        \"trackNumber\": \"pesma\"\n    },\n    \"page\": {\n        \"sidebar\": {\n            \"nowPlaying\": \"trenutno pušta\",\n            \"playlists\": \"$t(entity.playlist, {\\\"count\\\": 2})\",\n            \"search\": \"$t(common.search)\",\n            \"tracks\": \"$t(entity.track, {\\\"count\\\": 2})\",\n            \"albums\": \"$t(entity.album, {\\\"count\\\": 2})\",\n            \"genres\": \"$t(entity.genre, {\\\"count\\\": 2})\",\n            \"folders\": \"$t(entity.folder, {\\\"count\\\": 2})\",\n            \"settings\": \"$t(common.setting, {\\\"count\\\": 2})\",\n            \"home\": \"$t(common.home)\",\n            \"artists\": \"$t(entity.artist, {\\\"count\\\": 2})\",\n            \"albumArtists\": \"$t(entity.albumArtist, {\\\"count\\\": 2})\"\n        },\n        \"fullscreenPlayer\": {\n            \"config\": {\n                \"showLyricMatch\": \"prikaži poklapanje teksta\",\n                \"dynamicBackground\": \"dinamička pozadina\",\n                \"synchronized\": \"s sinhronizacijom\",\n                \"followCurrentLyric\": \"prati trenutni tekst pesme\",\n                \"opacity\": \"providnost\",\n                \"lyricSize\": \"veličina teksta pesme\",\n                \"showLyricProvider\": \"prikaži pružatelja teksta pesme\",\n                \"unsynchronized\": \"bez sinhronizacije\",\n                \"lyricAlignment\": \"poravnanje teksta pesme\",\n                \"useImageAspectRatio\": \"koristi odnos stranica slike\",\n                \"lyricGap\": \"razmak između stihova\"\n            },\n            \"upNext\": \"sledi\",\n            \"lyrics\": \"tekst pesme\",\n            \"related\": \"povezano\"\n        },\n        \"appMenu\": {\n            \"selectServer\": \"izaberi server\",\n            \"version\": \"verzija {{version}}\",\n            \"settings\": \"$t(common.setting, {\\\"count\\\": 2})\",\n            \"manageServers\": \"upravljaj serverima\",\n            \"expandSidebar\": \"proširi bočnu traku\",\n            \"collapseSidebar\": \"skloni bočnu traku\",\n            \"openBrowserDevtools\": \"otvori alatke za razvoj pretraživača\",\n            \"quit\": \"$t(common.quit)\",\n            \"goBack\": \"idi nazad\",\n            \"goForward\": \"idi napred\"\n        },\n        \"contextMenu\": {\n            \"addToPlaylist\": \"$t(action.addToPlaylist)\",\n            \"addToFavorites\": \"$t(action.addToFavorites)\",\n            \"setRating\": \"$t(action.setRating)\",\n            \"moveToTop\": \"$t(action.moveToTop)\",\n            \"deletePlaylist\": \"$t(action.deletePlaylist)\",\n            \"moveToBottom\": \"$t(action.moveToBottom)\",\n            \"createPlaylist\": \"$t(action.createPlaylist)\",\n            \"removeFromPlaylist\": \"$t(action.removeFromPlaylist)\",\n            \"removeFromFavorites\": \"$t(action.removeFromFavorites)\",\n            \"addNext\": \"$t(player.addNext)\",\n            \"deselectAll\": \"$t(action.deselectAll)\",\n            \"addLast\": \"$t(player.addLast)\",\n            \"addFavorite\": \"$t(action.addToFavorites)\",\n            \"play\": \"$t(player.play)\",\n            \"numberSelected\": \"{{count}} izabrano\",\n            \"removeFromQueue\": \"$t(action.removeFromQueue)\"\n        },\n        \"home\": {\n            \"mostPlayed\": \"najviše puštano\",\n            \"newlyAdded\": \"nedavno dodate pesme\",\n            \"title\": \"$t(common.home)\",\n            \"explore\": \"istraži iz tvoje biblioteke\",\n            \"recentlyPlayed\": \"nedavno puštane pesme\"\n        },\n        \"albumDetail\": {\n            \"moreFromArtist\": \"još od ovog $t(entity.artist, {\\\"count\\\": 1})\",\n            \"moreFromGeneric\": \"još od {{item}}\"\n        },\n        \"setting\": {\n            \"playbackTab\": \"reprodukcija\",\n            \"generalTab\": \"opšte\",\n            \"hotkeysTab\": \"prečice\",\n            \"windowTab\": \"prozor\"\n        },\n        \"albumArtistList\": {\n            \"title\": \"$t(entity.albumArtist, {\\\"count\\\": 2})\"\n        },\n        \"genreList\": {\n            \"title\": \"$t(entity.genre, {\\\"count\\\": 2})\"\n        },\n        \"trackList\": {\n            \"title\": \"$t(entity.track, {\\\"count\\\": 2})\"\n        },\n        \"globalSearch\": {\n            \"commands\": {\n                \"serverCommands\": \"komande servera\",\n                \"goToPage\": \"idi na stranicu\",\n                \"searchFor\": \"pretraži za {{query}}\"\n            },\n            \"title\": \"komande\"\n        },\n        \"playlistList\": {\n            \"title\": \"$t(entity.playlist, {\\\"count\\\": 2})\"\n        },\n        \"albumList\": {\n            \"title\": \"$t(entity.album, {\\\"count\\\": 2})\"\n        }\n    },\n    \"form\": {\n        \"deletePlaylist\": {\n            \"title\": \"obriši $t(entity.playlist, {\\\"count\\\": 1})\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) uspešno obrisan\",\n            \"input_confirm\": \"unesite ime $t(entity.playlist, {\\\"count\\\": 1}) za potvrdu\"\n        },\n        \"createPlaylist\": {\n            \"input_description\": \"$t(common.description)\",\n            \"title\": \"kreiraj $t(entity.playlist, {\\\"count\\\": 1})\",\n            \"input_public\": \"javno\",\n            \"input_name\": \"$t(common.name)\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) uspešno kreiran\",\n            \"input_owner\": \"$t(common.owner)\"\n        },\n        \"addServer\": {\n            \"title\": \"dodaj server\",\n            \"input_username\": \"korisničko ime\",\n            \"input_url\": \"URL\",\n            \"input_password\": \"lozinka\",\n            \"input_legacyAuthentication\": \"omogući staru autentikaciju\",\n            \"input_name\": \"ime servera\",\n            \"success\": \"server uspešno dodat\",\n            \"input_savePassword\": \"sačuvaj lozinku\",\n            \"ignoreSsl\": \"ignoriši SSL ($t(common.restartRequired))\",\n            \"ignoreCors\": \"ignoriši CORS ($t(common.restartRequired))\",\n            \"error_savePassword\": \"došlo je do greške prilikom pokušaja čuvanja lozinke\"\n        },\n        \"addToPlaylist\": {\n            \"success\": \"dodato {{message}} $t(entity.track, {\\\"count\\\": 2}) u {{numOfPlaylists}} $t(entity.playlist, {\\\"count\\\": 2})\",\n            \"title\": \"dodaj u $t(entity.playlist, {\\\"count\\\": 1})\",\n            \"input_skipDuplicates\": \"preskoči duplikate\",\n            \"input_playlists\": \"$t(entity.playlist, {\\\"count\\\": 2})\"\n        },\n        \"updateServer\": {\n            \"title\": \"ažuriraj server\",\n            \"success\": \"server uspešno ažuriran\"\n        },\n        \"queryEditor\": {\n            \"input_optionMatchAll\": \"pronađi sve\",\n            \"input_optionMatchAny\": \"pronađi bilo koji\"\n        },\n        \"lyricSearch\": {\n            \"input_name\": \"$t(common.name)\",\n            \"input_artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n            \"title\": \"pretraga teksta pesme\"\n        },\n        \"editPlaylist\": {\n            \"title\": \"izmeni $t(entity.playlist, {\\\"count\\\": 1})\"\n        }\n    },\n    \"entity\": {\n        \"genre_one\": \"žanr\",\n        \"genre_few\": \"žanrova\",\n        \"genre_other\": \"žanrova\",\n        \"playlistWithCount_one\": \"{{count}} plejlista\",\n        \"playlistWithCount_few\": \"{{count}} plejlista\",\n        \"playlistWithCount_other\": \"{{count}} plejlista\",\n        \"playlist_one\": \"plejlista\",\n        \"playlist_few\": \"plejlista\",\n        \"playlist_other\": \"plejlista\",\n        \"artist_one\": \"umetnik\",\n        \"artist_few\": \"umetnika\",\n        \"artist_other\": \"umetnika\",\n        \"folderWithCount_one\": \"{{count}} folder\",\n        \"folderWithCount_few\": \"{{count}} foldera\",\n        \"folderWithCount_other\": \"{{count}} foldera\",\n        \"albumArtist_one\": \"album umetnika\",\n        \"albumArtist_few\": \"albuma umetnika\",\n        \"albumArtist_other\": \"albuma umetnika\",\n        \"track_one\": \"pesma\",\n        \"track_few\": \"pesama\",\n        \"track_other\": \"pesama\",\n        \"albumArtistCount_one\": \"{{count}} album umetnika\",\n        \"albumArtistCount_few\": \"{{count}} albuma umetnika\",\n        \"albumArtistCount_other\": \"{{count}} albuma umetnika\",\n        \"albumWithCount_one\": \"{{count}} album\",\n        \"albumWithCount_few\": \"{{count}} albuma\",\n        \"albumWithCount_other\": \"{{count}} albuma\",\n        \"favorite_one\": \"favorit\",\n        \"favorite_few\": \"favorita\",\n        \"favorite_other\": \"favorita\",\n        \"artistWithCount_one\": \"{{count}} umetnik\",\n        \"artistWithCount_few\": \"{{count}} umetnika\",\n        \"artistWithCount_other\": \"{{count}} umetnika\",\n        \"folder_one\": \"folder\",\n        \"folder_few\": \"foldera\",\n        \"folder_other\": \"foldera\",\n        \"smartPlaylist\": \"pametna $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"album_one\": \"album\",\n        \"album_few\": \"albumi\",\n        \"album_other\": \"albuma\",\n        \"genreWithCount_one\": \"{{count}} žanr\",\n        \"genreWithCount_few\": \"{{count}} žanrova\",\n        \"genreWithCount_other\": \"{{count}} žanrova\",\n        \"trackWithCount_one\": \"{{count}} pesma\",\n        \"trackWithCount_few\": \"{{count}} pesama\",\n        \"trackWithCount_other\": \"{{count}} pesama\"\n    }\n}\n"
  },
  {
    "path": "src/i18n/locales/sv.json",
    "content": "{\n    \"action\": {\n        \"editPlaylist\": \"redigera $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"goToPage\": \"gå till sida\",\n        \"moveToTop\": \"flytta till toppen\",\n        \"clearQueue\": \"rensa kö\",\n        \"addToFavorites\": \"lägg till $t(entity.favorite, {\\\"count\\\": 2})\",\n        \"addToPlaylist\": \"lägg till $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"createPlaylist\": \"skapa $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"removeFromPlaylist\": \"ta bort från $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"viewPlaylists\": \"visa $t(entity.playlist, {\\\"count\\\": 2})\",\n        \"refresh\": \"$t(common.refresh)\",\n        \"deletePlaylist\": \"ta bort $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"removeFromQueue\": \"ta bort från kö\",\n        \"deselectAll\": \"avmarkera alla\",\n        \"moveToBottom\": \"flytta till botten\",\n        \"setRating\": \"sätt betyg\",\n        \"toggleSmartPlaylistEditor\": \"växla $t(entity.smartPlaylist) redigerare\",\n        \"removeFromFavorites\": \"ta bort från $t(entity.favorite, {\\\"count\\\": 2})\",\n        \"downloadStarted\": \"startade nedladdning av {{count}} objekt\",\n        \"moveToNext\": \"flytta till nästa\",\n        \"moveUp\": \"flytta upp\",\n        \"moveDown\": \"flytta ner\",\n        \"holdToMoveToTop\": \"håll för att flytta till toppen\",\n        \"holdToMoveToBottom\": \"håll för att flytta till botten\",\n        \"moveItems\": \"flytta objekt\",\n        \"shuffle\": \"slumpa\",\n        \"shuffleAll\": \"slumpa alla\",\n        \"shuffleSelected\": \"slumpa valda\",\n        \"viewMore\": \"visa mer\",\n        \"openIn\": {\n            \"lastfm\": \"Öppna i Last.fm\",\n            \"musicbrainz\": \"Öppna i MusicBrainz\"\n        },\n        \"createRadioStation\": \"skapa $t(entity.radioStation, {\\\"count\\\": 1})\",\n        \"deleteRadioStation\": \"ta bort $t(entity.radioStation, {\\\"count\\\": 1})\",\n        \"addOrRemoveFromSelection\": \"lägg till eller ta bort från markerade\",\n        \"selectRangeOfItems\": \"välj en mängd objekt\",\n        \"selectAll\": \"markera alla\",\n        \"openApplicationDirectory\": \"öppna applikationskatalog\"\n    },\n    \"common\": {\n        \"backward\": \"bakåt\",\n        \"increase\": \"öka\",\n        \"rating\": \"betyg\",\n        \"bpm\": \"bpm\",\n        \"refresh\": \"laddaom\",\n        \"unknown\": \"okänd\",\n        \"areYouSure\": \"är du säker?\",\n        \"edit\": \"redigera\",\n        \"favorite\": \"favorit\",\n        \"left\": \"vänster\",\n        \"save\": \"spara\",\n        \"right\": \"höger\",\n        \"currentSong\": \"aktuell $t(entity.track, {\\\"count\\\": 1})\",\n        \"collapse\": \"kollaps\",\n        \"trackNumber\": \"spår\",\n        \"descending\": \"fallande\",\n        \"add\": \"lägg till\",\n        \"gap\": \"avstånd\",\n        \"ascending\": \"stigande\",\n        \"dismiss\": \"avskeda\",\n        \"year\": \"år\",\n        \"manage\": \"hantera\",\n        \"limit\": \"gräns\",\n        \"minimize\": \"minimera\",\n        \"modified\": \"modifierad\",\n        \"duration\": \"längd\",\n        \"name\": \"namn\",\n        \"maximize\": \"maximera\",\n        \"decrease\": \"minska\",\n        \"ok\": \"ok\",\n        \"description\": \"beskrivning\",\n        \"configure\": \"konfigurera\",\n        \"path\": \"sökväg\",\n        \"no\": \"nej\",\n        \"owner\": \"ägare\",\n        \"enable\": \"aktivera\",\n        \"clear\": \"töm\",\n        \"forward\": \"framåt\",\n        \"delete\": \"ta bort\",\n        \"cancel\": \"avbryt\",\n        \"forceRestartRequired\": \"starta om för att tillämpa ändringar... Stäng meddelandet för att starta om\",\n        \"setting_one\": \"inställning\",\n        \"setting_other\": \"\",\n        \"version\": \"version\",\n        \"title\": \"titel\",\n        \"filter_one\": \"filter\",\n        \"filter_other\": \"filter\",\n        \"filters\": \"filter\",\n        \"create\": \"skapa\",\n        \"bitrate\": \"bithastighet\",\n        \"saveAndReplace\": \"spara och skrivöver\",\n        \"action_one\": \"handling\",\n        \"action_other\": \"handlingar\",\n        \"playerMustBePaused\": \"spelaren måste pausas\",\n        \"confirm\": \"bekräfta\",\n        \"resetToDefault\": \"återställ till standard\",\n        \"home\": \"hem\",\n        \"comingSoon\": \"kommer snart…\",\n        \"reset\": \"nollställ\",\n        \"channel_one\": \"kanal\",\n        \"channel_other\": \"kanaler\",\n        \"disable\": \"inaktivera\",\n        \"sortOrder\": \"ordning\",\n        \"none\": \"ingen\",\n        \"menu\": \"meny\",\n        \"restartRequired\": \"omstart krävs\",\n        \"previousSong\": \"föregående $t(entity.track, {\\\"count\\\": 1})\",\n        \"noResultsFromQuery\": \"frågan returnerade inga resultat\",\n        \"quit\": \"avsluta\",\n        \"expand\": \"expandera\",\n        \"search\": \"sök\",\n        \"saveAs\": \"spara som\",\n        \"disc\": \"skiva\",\n        \"yes\": \"ja\",\n        \"random\": \"slumpmässig\",\n        \"size\": \"storlek\",\n        \"biography\": \"biografi\",\n        \"note\": \"anteckning\",\n        \"center\": \"center\",\n        \"explicitStatus\": \"olämplig status\",\n        \"additionalParticipants\": \"ytterligare medverkare\",\n        \"newVersion\": \"en ny version har installerats {{version}}\",\n        \"viewReleaseNotes\": \"se utgåveinformation\",\n        \"bitDepth\": \"bitdjup\",\n        \"close\": \"stäng\",\n        \"codec\": \"kodek\",\n        \"doNotShowAgain\": \"visa inte detta igen\",\n        \"view\": \"visa\",\n        \"externalLinks\": \"externa länkar\",\n        \"faster\": \"snabbare\",\n        \"mbid\": \"MusicBrainz ID\",\n        \"noFilters\": \"inga filter konfigurerade\",\n        \"preview\": \"förhandsvisa\",\n        \"private\": \"privat\",\n        \"public\": \"allmän\",\n        \"recordLabel\": \"skivbolag\",\n        \"releaseType\": \"utgåvetyp\",\n        \"reload\": \"ladda om\",\n        \"sampleRate\": \"samplingstakt\",\n        \"slower\": \"långsammare\",\n        \"share\": \"dela\",\n        \"sort\": \"sortera\",\n        \"tags\": \"taggar\",\n        \"translation\": \"översättning\",\n        \"explicit\": \"olämplig\",\n        \"clean\": \"städad\",\n        \"gridRows\": \"rutnätsrader\",\n        \"tableColumns\": \"tabellkolumner\",\n        \"itemsMore\": \"{{count}} fler\",\n        \"countSelected\": \"{{count}} markerade\"\n    },\n    \"error\": {\n        \"remotePortWarning\": \"starta om servern för att tillämpa den nya porten\",\n        \"systemFontError\": \"ett fel uppstod vid försök att hämta systemteckensnitt\",\n        \"playbackError\": \"ett fel uppstod vid försök att spela upp media\",\n        \"endpointNotImplementedError\": \"endpoint {{endpoint}} är inte implementerad för {{serverType}}\",\n        \"remotePortError\": \"ett fel uppstod vid försök att ange serverporten\",\n        \"serverRequired\": \"server krävs\",\n        \"authenticationFailed\": \"autentiseringen misslyckades\",\n        \"apiRouteError\": \"det går inte att dirigera begäran\",\n        \"genericError\": \"ett fel uppstod\",\n        \"credentialsRequired\": \"autentiseringsuppgifter som krävs\",\n        \"sessionExpiredError\": \"din session har löpt ut\",\n        \"remoteEnableError\": \"Ett fel uppstod vid försök att $t(common.enable) servern\",\n        \"localFontAccessDenied\": \"åtkomst nekad till lokala teckensnitt\",\n        \"serverNotSelectedError\": \"ingen server vald\",\n        \"remoteDisableError\": \"ett fel uppstod vid försök av $t(common.disable) servern\",\n        \"mpvRequired\": \"MPV krävs\",\n        \"audioDeviceFetchError\": \"ett fel uppstod vid hämtning av ljudenheter\",\n        \"invalidServer\": \"ogiltig server\",\n        \"loginRateError\": \"för många inloggningsförsök, försök igen om några sekunder\",\n        \"badAlbum\": \"du ser denna sidan eftersom denna låten inte är en del av ett album. du ser troligtvis detta problemet för att du har en låt på toppnivån i din musikmapp. Jellyfin grupperar bara låtar om de finns i en mapp\",\n        \"badValue\": \"felaktigt alternativ \\\"{{value}}\\\". detta värde existerar inte längre\",\n        \"multipleServerSaveQueueError\": \"spelningskön har en eller flera låtar som inte är från den nuvarande valda servern. detta är inte stöttat\",\n        \"networkError\": \"en nätverksfel uppstod\",\n        \"notificationDenied\": \"åtkomst till notifieringarna var nekad. inställningen har ingen verkan\",\n        \"openError\": \"kunde inte öppna filen\",\n        \"settingsSyncError\": \"diskrepans hittades mellan inställningarna för renderingsprocessen och huvudprocessen. starta om applikationen för att ändringarna ska tillämpas\"\n    },\n    \"filter\": {\n        \"mostPlayed\": \"mest spelade\",\n        \"comment\": \"kommentar\",\n        \"playCount\": \"antal spelningar\",\n        \"recentlyUpdated\": \"nyligen uppdaterad\",\n        \"channels\": \"$t(common.channel_other)\",\n        \"isCompilation\": \"är kompilering\",\n        \"recentlyPlayed\": \"nyligen spelad\",\n        \"isRated\": \"är betygsatt\",\n        \"owner\": \"$t(common.owner)\",\n        \"title\": \"titel\",\n        \"rating\": \"betyg\",\n        \"search\": \"sök\",\n        \"bitrate\": \"bithastighet\",\n        \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n        \"recentlyAdded\": \"nyligen tillagda\",\n        \"note\": \"anteckning\",\n        \"name\": \"namn\",\n        \"dateAdded\": \"datum tillagt\",\n        \"releaseDate\": \"utgivningsdag\",\n        \"communityRating\": \"betyg från communityn\",\n        \"path\": \"sökväg\",\n        \"favorited\": \"favoritmärkt\",\n        \"albumArtist\": \"$t(entity.albumArtist, {\\\"count\\\": 1})\",\n        \"isRecentlyPlayed\": \"spelas nyligen\",\n        \"isFavorited\": \"är favoritmärkt\",\n        \"bpm\": \"bpm\",\n        \"releaseYear\": \"utgivningsår\",\n        \"id\": \"id\",\n        \"disc\": \"skiva\",\n        \"biography\": \"biografi\",\n        \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n        \"duration\": \"längd\",\n        \"isPublic\": \"är offentlig\",\n        \"random\": \"slumpmässig\",\n        \"lastPlayed\": \"senast spelad\",\n        \"toYear\": \"till år\",\n        \"fromYear\": \"från år\",\n        \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n        \"trackNumber\": \"spår\",\n        \"songCount\": \"sångräkning\",\n        \"criticRating\": \"kritikerbetyg\",\n        \"albumCount\": \"$t(entity.album, {\\\"count\\\": 2}) antal\",\n        \"explicitStatus\": \"$t(common.explicitStatus)\"\n    },\n    \"form\": {\n        \"deletePlaylist\": {\n            \"title\": \"ta bort $t(entity.playlist, {\\\"count\\\": 1})\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) har tagits bort\",\n            \"input_confirm\": \"Skriv namnet på $t(entity.playlist, {\\\"count\\\": 1}) för att bekräfta\"\n        },\n        \"createPlaylist\": {\n            \"input_description\": \"$t(common.description)\",\n            \"title\": \"skapa $t(entity.playlist, {\\\"count\\\": 1})\",\n            \"input_public\": \"offentlig\",\n            \"input_name\": \"$t(common.name)\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) skapad\",\n            \"input_owner\": \"$t(common.owner)\"\n        },\n        \"addServer\": {\n            \"title\": \"lägg till server\",\n            \"input_username\": \"användarnamn\",\n            \"input_url\": \"länk\",\n            \"input_password\": \"lösenord\",\n            \"input_legacyAuthentication\": \"aktivera äldre autentisering\",\n            \"input_name\": \"server namn\",\n            \"success\": \"servern har lagts till\",\n            \"input_savePassword\": \"spara lösenord\",\n            \"ignoreSsl\": \"ignorera ssl ($t(common.restartRequired))\",\n            \"ignoreCors\": \"ignorera cors ($t(common.restartRequired))\",\n            \"error_savePassword\": \"ett fel uppstod när lösenordet skulle sparas\",\n            \"input_preferInstantMix\": \"föredra instant mixning\",\n            \"input_preferInstantMixDescription\": \"använd bara instant mixning för att få liknande låtar. användbar om du har plugin för att förändra detta beteendet\"\n        },\n        \"addToPlaylist\": {\n            \"success\": \"lade till $t(entity.trackWithCount, {\\\"count\\\": {{message}} }) till $t(entity.playlistWithCount, {\\\"count\\\": {{numOfPlaylists}} })\",\n            \"title\": \"lägg till i $t(entity.playlist, {\\\"count\\\": 1})\",\n            \"input_skipDuplicates\": \"hoppa över dubbletter\",\n            \"input_playlists\": \"$t(entity.playlist, {\\\"count\\\": 2})\",\n            \"create\": \"skapa $t(entity.playlist, {\\\"count\\\": 1}) {{playlist}}\",\n            \"searchOrCreate\": \"sök $t(entity.playlist, {\\\"count\\\": 2}) eller skriv för att skapa en ny\"\n        },\n        \"updateServer\": {\n            \"title\": \"uppdatera server\",\n            \"success\": \"servern har uppdaterats\"\n        },\n        \"queryEditor\": {\n            \"input_optionMatchAll\": \"matcha alla\",\n            \"input_optionMatchAny\": \"matcha något\"\n        },\n        \"lyricSearch\": {\n            \"input_name\": \"$t(common.name)\",\n            \"input_artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n            \"title\": \"sångtext sök\"\n        },\n        \"editPlaylist\": {\n            \"title\": \"redigera $t(entity.playlist, {\\\"count\\\": 1})\",\n            \"publicJellyfinNote\": \"Jellyfin visar av någon anledning inte om en spellista är publik eller inte. Om du önskar att denna ska förbli publik, så får du ha följande indata markerade\"\n        },\n        \"largeFetchConfirmation\": {\n            \"title\": \"lägg till objekt till kön\",\n            \"description\": \"Åtgärden kommer att lägga till alla objekt till den nuvarande filtrerade vyn\"\n        },\n        \"createRadioStation\": {\n            \"success\": \"radiostation skapades\",\n            \"title\": \"skapa radiostation\",\n            \"input_homepageUrl\": \"hemside-URL\",\n            \"input_name\": \"namn\",\n            \"input_streamUrl\": \"stream url\"\n        }\n    },\n    \"page\": {\n        \"fullscreenPlayer\": {\n            \"config\": {\n                \"showLyricMatch\": \"Visa låttext matchning\",\n                \"dynamicBackground\": \"dynamisk bakgrund\",\n                \"followCurrentLyric\": \"följ aktuell låttext\",\n                \"opacity\": \"ogenomskinlighet\",\n                \"lyricSize\": \"låttext storlek\",\n                \"lyricAlignment\": \"låttext justering\",\n                \"lyricGap\": \"låttext mellanrum\",\n                \"synchronized\": \"synkroniserad\",\n                \"showLyricProvider\": \"visa sångtextleverantör\",\n                \"unsynchronized\": \"osynkroniserad\"\n            },\n            \"lyrics\": \"sångtext\",\n            \"related\": \"relaterad\"\n        },\n        \"appMenu\": {\n            \"selectServer\": \"välj server\",\n            \"version\": \"version {{version}}\",\n            \"settings\": \"$t(common.setting, {\\\"count\\\": 2})\",\n            \"manageServers\": \"hantera servrar\",\n            \"expandSidebar\": \"expandera sidofältet\",\n            \"openBrowserDevtools\": \"öppna webbläsarens utvecklingsverktyg\",\n            \"quit\": \"$t(common.quit)\",\n            \"goBack\": \"gå tillbaka\",\n            \"goForward\": \"gå framåt\",\n            \"collapseSidebar\": \"växla sidofältet\"\n        },\n        \"contextMenu\": {\n            \"addToPlaylist\": \"$t(action.addToPlaylist)\",\n            \"addToFavorites\": \"$t(action.addToFavorites)\",\n            \"setRating\": \"$t(action.setRating)\",\n            \"moveToTop\": \"$t(action.moveToTop)\",\n            \"deletePlaylist\": \"$t(action.deletePlaylist)\",\n            \"moveToBottom\": \"$t(action.moveToBottom)\",\n            \"createPlaylist\": \"$t(action.createPlaylist)\",\n            \"removeFromPlaylist\": \"$t(action.removeFromPlaylist)\",\n            \"removeFromFavorites\": \"$t(action.removeFromFavorites)\",\n            \"addNext\": \"$t(player.addNext)\",\n            \"deselectAll\": \"$t(action.deselectAll)\",\n            \"addLast\": \"$t(player.addLast)\",\n            \"addFavorite\": \"$t(action.addToFavorites)\",\n            \"play\": \"$t(player.play)\",\n            \"numberSelected\": \"{{count}} vald\",\n            \"removeFromQueue\": \"$t(action.removeFromQueue)\",\n            \"download\": \"ladda ner\",\n            \"moveItems\": \"$t(action.moveItems)\",\n            \"moveToNext\": \"$t(action.moveToNext)\",\n            \"playSimilarSongs\": \"$t(player.playSimilarSongs)\",\n            \"playShuffled\": \"$t(player.shuffle)\",\n            \"shareItem\": \"dela objekt\",\n            \"goTo\": \"gå till\",\n            \"goToAlbum\": \"gå till $t(entity.album, {\\\"count\\\": 1})\",\n            \"goToAlbumArtist\": \"gå till $t(entity.albumArtist, {\\\"count\\\": 1})\",\n            \"showDetails\": \"hämta information\"\n        },\n        \"albumDetail\": {\n            \"moreFromArtist\": \"mer från $t(entity.artist, {\\\"count\\\": 1})\",\n            \"moreFromGeneric\": \"mer från {{item}}\"\n        },\n        \"albumArtistList\": {\n            \"title\": \"$t(entity.albumArtist, {\\\"count\\\": 2})\"\n        },\n        \"albumList\": {\n            \"title\": \"$t(entity.album, {\\\"count\\\": 2})\"\n        },\n        \"sidebar\": {\n            \"nowPlaying\": \"nu spelas\"\n        },\n        \"home\": {\n            \"mostPlayed\": \"mest spelade\",\n            \"newlyAdded\": \"nytillkomna utgåvor\",\n            \"explore\": \"utforska från ditt bibliotek\",\n            \"recentlyPlayed\": \"nyligen spelat\"\n        },\n        \"setting\": {\n            \"playbackTab\": \"uppspelning\",\n            \"generalTab\": \"allmänt\",\n            \"hotkeysTab\": \"snabbtangenter\",\n            \"windowTab\": \"fönster\"\n        },\n        \"globalSearch\": {\n            \"commands\": {\n                \"serverCommands\": \"serverkommandon\",\n                \"goToPage\": \"gå till sidan\",\n                \"searchFor\": \"sök efter {{query}}\"\n            },\n            \"title\": \"kommandon\"\n        },\n        \"manageServers\": {\n            \"url\": \"URL\",\n            \"username\": \"användarnamn\",\n            \"editServerDetailsTooltip\": \"redigera serverinställningar\",\n            \"removeServer\": \"ta bort server\"\n        }\n    },\n    \"entity\": {\n        \"playlist_one\": \"spellista\",\n        \"playlist_other\": \"spellistor\",\n        \"artist_one\": \"artist\",\n        \"artist_other\": \"artister\",\n        \"albumArtist_one\": \"albumartist\",\n        \"albumArtist_other\": \"albumartister\",\n        \"albumArtistCount_one\": \"{{count}} Albumartist\",\n        \"albumArtistCount_other\": \"{{count}} Albumartister\",\n        \"albumWithCount_one\": \"{{count}} album\",\n        \"albumWithCount_other\": \"{{count}} album\",\n        \"favorite_one\": \"favorit\",\n        \"favorite_other\": \"favoriter\",\n        \"folder_one\": \"mapp\",\n        \"folder_other\": \"mappar\",\n        \"album_one\": \"album\",\n        \"album_other\": \"album\",\n        \"playlistWithCount_one\": \"{{count}} spellista\",\n        \"playlistWithCount_other\": \"{{count}} spellistor\",\n        \"folderWithCount_one\": \"{{count}} mapp\",\n        \"folderWithCount_other\": \"{{count}} mappar\",\n        \"track_one\": \"spår\",\n        \"track_other\": \"spår\",\n        \"trackWithCount_one\": \"{{count}} spår\",\n        \"trackWithCount_other\": \"{{count}} spår\",\n        \"artistWithCount_one\": \"{{count}} artist\",\n        \"artistWithCount_other\": \"{{count}} artister\",\n        \"genre_one\": \"genre\",\n        \"genre_other\": \"genrer\",\n        \"genreWithCount_one\": \"{{count}} genre\",\n        \"genreWithCount_other\": \"{{count}} genrer\",\n        \"play_one\": \"{{count}} spelning\",\n        \"play_other\": \"{{count}} spelningar\",\n        \"smartPlaylist\": \"smart $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"song_one\": \"låt\",\n        \"song_other\": \"låtar\",\n        \"radioStation_one\": \"radiostation\",\n        \"radioStation_other\": \"radiostationer\",\n        \"radioStationWithCount_one\": \"{{count}} radiostation\",\n        \"radioStationWithCount_other\": \"{{count}} radiostationer\"\n    },\n    \"player\": {\n        \"repeat_all\": \"repetera alla\",\n        \"repeat\": \"repetera\",\n        \"queue_remove\": \"ta bort markerad\",\n        \"playRandom\": \"spela slumpmässigt\",\n        \"previous\": \"föregående\",\n        \"favorite\": \"favorit\",\n        \"next\": \"nästa\",\n        \"shuffle\": \"blanda\",\n        \"playbackFetchNoResults\": \"inga låtar hittades\",\n        \"playbackFetchInProgress\": \"laddar låtar…\",\n        \"addNext\": \"lägg till nästa\",\n        \"playbackSpeed\": \"uppspelningshastighet\",\n        \"playbackFetchCancel\": \"det här tar ett tag... stäng aviseringen för att avbryta\",\n        \"play\": \"spela\",\n        \"repeat_off\": \"repetera inaktiverad\",\n        \"queue_clear\": \"rensa kö\",\n        \"muted\": \"mutad\",\n        \"queue_moveToTop\": \"flytta markerad till botten\",\n        \"queue_moveToBottom\": \"flytta markerad till toppen\",\n        \"addLast\": \"lägg till sist\",\n        \"mute\": \"muta\"\n    },\n    \"datetime\": {\n        \"minuteShort\": \"min\",\n        \"secondShort\": \"sek\",\n        \"hourShort\": \"h\",\n        \"dayShort\": \"dag\"\n    },\n    \"filterOperator\": {\n        \"after\": \"är efter\",\n        \"afterDate\": \"är efter (datum)\",\n        \"before\": \"är före\",\n        \"beforeDate\": \"är före (datum)\",\n        \"contains\": \"innehåller\",\n        \"endsWith\": \"slutar med\",\n        \"inPlaylist\": \"är inom\",\n        \"inTheLast\": \"är i den sista\",\n        \"inTheRange\": \"är i spannet\",\n        \"inTheRangeDate\": \"är i spannet (datum)\",\n        \"is\": \"är\",\n        \"isNot\": \"är inte\",\n        \"isGreaterThan\": \"är större än\",\n        \"isLessThan\": \"är mindre än\",\n        \"matchesRegex\": \"matchar regex\",\n        \"notContains\": \"innehåller inte\",\n        \"notInPlaylist\": \"är inte inom\",\n        \"notInTheLast\": \"är inte inom den sista\",\n        \"startsWith\": \"startar med\"\n    }\n}\n"
  },
  {
    "path": "src/i18n/locales/ta.json",
    "content": "{\n    \"action\": {\n        \"addToFavorites\": \"$t(entity.favorite, {\\\"count\\\": 2}) இல் சேர்க்கவும்\",\n        \"clearQueue\": \"தெளிவான வரிசை\",\n        \"goToPage\": \"பக்கத்திற்குச் செல்லுங்கள்\",\n        \"moveToBottom\": \"கீழே செல்லுங்கள்\",\n        \"moveToTop\": \"மேலே செல்லுங்கள்\",\n        \"refresh\": \"$t(common.refresh)\",\n        \"removeFromFavorites\": \"$t(entity.favorite, {\\\"count\\\": 2})இலிருந்து அகற்று\",\n        \"removeFromPlaylist\": \"$t(entity.playlist, {\\\"count\\\": 1}) இலிருந்து அகற்று\",\n        \"removeFromQueue\": \"வரிசையிலிருந்து அகற்று\",\n        \"setRating\": \"மதிப்பீட்டை அமைக்கவும்\",\n        \"toggleSmartPlaylistEditor\": \"மாற்று $t(entity.smartPlaylist) ஆசிரியர்\",\n        \"viewPlaylists\": \"$t(entity.playlist, {\\\"count\\\": 2}) காண்க\",\n        \"addToPlaylist\": \"$t(entity.playlist, {\\\"count\\\": 1})இல் சேர்க்கவும்\",\n        \"createPlaylist\": \"$t(entity.playlist, {\\\"count\\\": 1})ஐ உருவாக்கவும்\",\n        \"deletePlaylist\": \"$t(entity.playlist, {\\\"count\\\": 1})ஐ நீக்கு\",\n        \"deselectAll\": \"அனைத்தையும் தேர்வு செய்யுங்கள்\",\n        \"editPlaylist\": \"திருத்து $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"moveToNext\": \"அடுத்து செல்லுங்கள்\",\n        \"openIn\": {\n            \"lastfm\": \"Last.fm இல் திறந்திருக்கும்\",\n            \"musicbrainz\": \"மியூசிக் பிரைன்ச் திறந்திருக்கும்\"\n        },\n        \"addOrRemoveFromSelection\": \"தேர்வில் இருந்து சேர்க்கவும் அல்லது நீக்கவும்\",\n        \"selectRangeOfItems\": \"உருப்படிகளின் வரம்பைத் தேர்ந்தெடுக்கவும்\",\n        \"createRadioStation\": \"$t(entity.radioStation, {\\\"count\\\": 1}) உருவாக்கவும்\",\n        \"deleteRadioStation\": \"$t(entity.radioStation, {\\\"count\\\": 1}) நீக்கு\",\n        \"selectAll\": \"அனைத்தையும் தெரிவுசெய்\",\n        \"downloadStarted\": \"{{count}} உருப்படிகளின் பதிவிறக்கம் தொடங்கியது\",\n        \"moveUp\": \"மேலே செல்ல\",\n        \"moveDown\": \"கீழே நகர\",\n        \"holdToMoveToTop\": \"மேலே செல்ல அழுத்திப் பிடிக்கவும்\",\n        \"holdToMoveToBottom\": \"கீழே நகர்த்த பிடிக்கவும்\",\n        \"moveItems\": \"பொருட்களை நகர்த்த\",\n        \"shuffle\": \"கலக்கு\",\n        \"shuffleAll\": \"அனைத்தையும் கலக்கவும்\",\n        \"shuffleSelected\": \"கலக்கு தேர்ந்தெடுக்கப்பட்டது\",\n        \"viewMore\": \"மேலும் பார்க்க\",\n        \"openApplicationDirectory\": \"பயன்பாட்டு கோப்பகத்தைத் திறக்கவும்\"\n    },\n    \"common\": {\n        \"description\": \"விவரம்\",\n        \"minimize\": \"குறைக்கவும்\",\n        \"modified\": \"மாற்றியமைக்கப்பட்ட\",\n        \"noResultsFromQuery\": \"வினவல் எந்த முடிவுகளும் திரும்பவில்லை\",\n        \"note\": \"குறிப்பு\",\n        \"ok\": \"சரி\",\n        \"configure\": \"உள்ளமைக்கவும்\",\n        \"confirm\": \"உறுதிப்படுத்தவும்\",\n        \"create\": \"உருவாக்கு\",\n        \"currentSong\": \"தற்போதைய $t(entity.track, {\\\"count\\\": 1})\",\n        \"decrease\": \"குறைவு\",\n        \"action_one\": \"செயல்\",\n        \"action_other\": \"செயல்கள்\",\n        \"add\": \"கூட்டு\",\n        \"albumGain\": \"ஆல்பம் ஆதாயம்\",\n        \"albumPeak\": \"ஆல்பம் உச்சம்\",\n        \"areYouSure\": \"நீங்கள் உறுதியாக இருக்கிறீர்களா?\",\n        \"ascending\": \"ஏறுதல்\",\n        \"backward\": \"பின்னோக்கு\",\n        \"biography\": \"சுயசரிதை\",\n        \"bitrate\": \"பிட்ரேட்\",\n        \"bpm\": \"பிபிஎம்\",\n        \"cancel\": \"ரத்துசெய்\",\n        \"center\": \"நடுவண்\",\n        \"channel_one\": \"வாய்க்கால்\",\n        \"channel_other\": \"சேனல்கள்\",\n        \"clear\": \"தெளிவான\",\n        \"close\": \"மூடு\",\n        \"codec\": \"புரிப்பு\",\n        \"collapse\": \"சரிவு\",\n        \"comingSoon\": \"விரைவில் வருகிறது…\",\n        \"delete\": \"நீக்கு\",\n        \"descending\": \"இறங்கு\",\n        \"disable\": \"முடக்கு\",\n        \"disc\": \"வட்டு\",\n        \"dismiss\": \"தள்ளுபடி\",\n        \"duration\": \"காலம்\",\n        \"edit\": \"தொகு\",\n        \"enable\": \"இயக்கு\",\n        \"saveAs\": \"என சேமி\",\n        \"expand\": \"விரிவாக்கு\",\n        \"favorite\": \"பிடித்த\",\n        \"filter_one\": \"வடிப்பி\",\n        \"filter_other\": \"வடிகட்டிகள்\",\n        \"filters\": \"வடிப்பான்கள்\",\n        \"forceRestartRequired\": \"மாற்றங்களைப் பயன்படுத்த மறுதொடக்கம் செய்… மறுதொடக்கம் செய்ய அறிவிப்பை மூடு\",\n        \"forward\": \"முன்னோக்கி\",\n        \"gap\": \"இடைவெளி\",\n        \"home\": \"வீடு\",\n        \"increase\": \"அதிகரிப்பு\",\n        \"left\": \"இடது\",\n        \"limit\": \"வரம்பு\",\n        \"manage\": \"நிர்வகிக்கவும்\",\n        \"maximize\": \"அதிகரிக்கவும்\",\n        \"menu\": \"பட்டியல்\",\n        \"mbid\": \"மியூசிக் பிரேன்ச் ஐடி\",\n        \"name\": \"பெயர்\",\n        \"no\": \"இல்லை\",\n        \"none\": \"எதுவுமில்லை\",\n        \"owner\": \"உரிமையாளர்\",\n        \"path\": \"பாதை\",\n        \"playerMustBePaused\": \"வீரர் இடைநிறுத்தப்பட வேண்டும்\",\n        \"preview\": \"முன்னோட்டம்\",\n        \"previousSong\": \"முந்தைய $t(entity.track, {\\\"count\\\": 1})\",\n        \"quit\": \"வெளியேறு\",\n        \"random\": \"சீரற்ற\",\n        \"rating\": \"செயல்வரம்பு\",\n        \"refresh\": \"புதுப்பிப்பு\",\n        \"reload\": \"ஏற்றவும்\",\n        \"reset\": \"மீட்டமை\",\n        \"resetToDefault\": \"இயல்புநிலைக்கு மீட்டமைக்கவும்\",\n        \"restartRequired\": \"மறுதொடக்கம் தேவை\",\n        \"right\": \"வலது\",\n        \"save\": \"சேமி\",\n        \"saveAndReplace\": \"சேமித்து மாற்றவும்\",\n        \"search\": \"தேடல்\",\n        \"setting_one\": \"அமைத்தல்\",\n        \"setting_other\": \"அமைப்புகள்\",\n        \"share\": \"பங்கு\",\n        \"size\": \"அளவு\",\n        \"sortOrder\": \"ஒழுங்கு\",\n        \"unknown\": \"தெரியவில்லை\",\n        \"version\": \"பதிப்பு\",\n        \"year\": \"ஆண்டு\",\n        \"yes\": \"ஆம்\",\n        \"title\": \"தலைப்பு\",\n        \"trackNumber\": \"மின்தடம்\",\n        \"trackGain\": \"தடமறிதல்\",\n        \"trackPeak\": \"ட்ராக் பீக்\",\n        \"translation\": \"மொழிபெயர்ப்பு\",\n        \"additionalParticipants\": \"கூடுதல் பங்கேற்பாளர்கள்\",\n        \"newVersion\": \"புதிய பதிப்பு நிறுவப்பட்டுள்ளது ({{version}})\",\n        \"viewReleaseNotes\": \"வெளியீட்டு குறிப்புகளைக் காண்க\",\n        \"bitDepth\": \"பிட் ஆழம்\",\n        \"sampleRate\": \"மாதிரி வீதம்\",\n        \"tags\": \"குறிச்சொற்கள்\",\n        \"countSelected\": \"{{count}} தேர்ந்தெடுக்கப்பட்டது\",\n        \"explicitStatus\": \"வெளிப்படையான நிலை\",\n        \"doNotShowAgain\": \"இதை மீண்டும் காட்டாதே\",\n        \"view\": \"பார்வை\",\n        \"example\": \"சான்று\",\n        \"externalLinks\": \"வெளிப்புற இணைப்புகள்\",\n        \"faster\": \"வேகமாக\",\n        \"filter_single\": \"ஒற்றை\",\n        \"filter_multiple\": \"பல\",\n        \"mood\": \"மனநிலை\",\n        \"noFilters\": \"வடிப்பான்கள் எதுவும் கட்டமைக்கப்படவில்லை\",\n        \"private\": \"தனிப்பட்ட\",\n        \"public\": \"பொது\",\n        \"retry\": \"மீண்டும் முயற்சிக்கவும்\",\n        \"recordLabel\": \"பதிவு சிட்டை\",\n        \"releaseType\": \"வெளியீட்டு வகை\",\n        \"rename\": \"மறுபெயரிடுங்கள்\",\n        \"slower\": \"மெதுவாக\",\n        \"sort\": \"வரிசைப்படுத்து\",\n        \"explicit\": \"வெளிப்படையான\",\n        \"clean\": \"தூய்மையான\",\n        \"gridRows\": \"கட்டம் வரிசைகள்\",\n        \"tableColumns\": \"அட்டவணை நெடுவரிசைகள்\",\n        \"itemsMore\": \"மேலும் {{count}}\"\n    },\n    \"entity\": {\n        \"folderWithCount_one\": \"{{count}} கோப்புறை\",\n        \"folderWithCount_other\": \"{{count}} கோப்புறைகள்\",\n        \"genre_one\": \"வகை\",\n        \"genre_other\": \"வகைகள்\",\n        \"genreWithCount_one\": \"{{count}} வகை\",\n        \"genreWithCount_other\": \"{{count}} வகைகள்\",\n        \"album_one\": \"ஆல்பம்\",\n        \"album_other\": \"ஆல்பம்\",\n        \"albumArtist_one\": \"ஆல்பம் கலைஞர்\",\n        \"albumArtist_other\": \"ஆல்பம் கலைஞர்கள்\",\n        \"albumArtistCount_one\": \"{{count}} ஆல்பம் கலைஞர்\",\n        \"albumArtistCount_other\": \"{{count}} ஆல்பம் கலைஞர்கள்\",\n        \"albumWithCount_one\": \"{{count}} ஆல்பம்\",\n        \"albumWithCount_other\": \"{{count}} ஆல்பங்கள்\",\n        \"artist_one\": \"கலைஞர்\",\n        \"artist_other\": \"கலைஞர்கள்\",\n        \"artistWithCount_one\": \"{{count}} கலைஞர்\",\n        \"artistWithCount_other\": \"{{count}} கலைஞர்கள்\",\n        \"favorite_one\": \"பிடித்தது\",\n        \"favorite_other\": \"பிடித்தவை\",\n        \"folder_one\": \"கோப்புறை\",\n        \"folder_other\": \"கோப்புறைகள்\",\n        \"playlist_one\": \"பிளேலிச்ட்\",\n        \"playlist_other\": \"பிளேலிச்ட்கள்\",\n        \"play_one\": \"{{count}} விளையாடு\",\n        \"play_other\": \"{{count}} நாடகங்கள்\",\n        \"playlistWithCount_one\": \"{{count}} பிளேலிச்ட்\",\n        \"playlistWithCount_other\": \"{{count}} பிளேலிச்ட்கள்\",\n        \"smartPlaylist\": \"அறிவுள்ள $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"track_one\": \"மின்தடம்\",\n        \"track_other\": \"தடங்கள்\",\n        \"song_one\": \"பாடல்\",\n        \"song_other\": \"பாடல்கள்\",\n        \"trackWithCount_one\": \"{{count}} தடம்\",\n        \"trackWithCount_other\": \"{{count}} தடங்கள்\",\n        \"radioStation_one\": \"வானொலி நிலையம்\",\n        \"radioStation_other\": \"வானொலி நிலையங்கள்\",\n        \"radioStationWithCount_one\": \"{{count}} வானொலி நிலையம்\",\n        \"radioStationWithCount_other\": \"{{count}} வானொலி நிலையங்கள்\"\n    },\n    \"error\": {\n        \"mpvRequired\": \"MPV தேவை\",\n        \"remotePortError\": \"தொலை சேவையக துறைமுகத்தை அமைக்க முயற்சிக்கும்போது பிழை ஏற்பட்டது\",\n        \"remotePortWarning\": \"புதிய துறைமுகத்தைப் பயன்படுத்த சேவையகத்தை மறுதொடக்கம் செய்யுங்கள்\",\n        \"serverNotSelectedError\": \"சேவையகம் எதுவும் தேர்ந்தெடுக்கப்படவில்லை\",\n        \"serverRequired\": \"சேவையகம் தேவை\",\n        \"remoteEnableError\": \"தொலைநிலை சேவையகத்தை $t(common.enable) முயற்சிக்கும்போது பிழை ஏற்பட்டது\",\n        \"apiRouteError\": \"பாதை கோரிக்கை செய்ய முடியவில்லை\",\n        \"audioDeviceFetchError\": \"ஆடியோ சாதனங்களைப் பெற முயற்சிக்கும்போது பிழை ஏற்பட்டது\",\n        \"authenticationFailed\": \"ஏற்பு தோல்வியடைந்தது\",\n        \"badAlbum\": \"இந்த பாடல் ஆல்பத்தின் பகுதியாக இல்லாததால் இந்தப் பக்கத்தைப் பார்க்கிறீர்கள். உங்கள் இசை கோப்புறையின் மேல் மட்டத்தில் ஒரு பாடல் இருந்தால் இந்த சிக்கலைப் பார்க்கிறீர்கள். செல்லிஃபின் ஒரு கோப்புறையில் இருந்தால் தடங்களை மட்டுமே குழுக்கள்\",\n        \"credentialsRequired\": \"நற்சான்றிதழ்கள் தேவை\",\n        \"endpointNotImplementedError\": \"Endpoint {{endpoint}} {{serverType}} க்கு செயல்படுத்தப்படவில்லை\",\n        \"genericError\": \"பிழை ஏற்பட்டது\",\n        \"invalidServer\": \"தவறான சேவையகம்\",\n        \"localFontAccessDenied\": \"உள்ளக எழுத்துருக்களுக்கு மறுக்கப்பட்டது\",\n        \"loginRateError\": \"பல உள்நுழைவு முயற்சிகள், தயவுசெய்து சில நொடிகளில் மீண்டும் முயற்சிக்கவும்\",\n        \"networkError\": \"பிணைய பிழை ஏற்பட்டது\",\n        \"openError\": \"கோப்பைத் திறக்க முடியவில்லை\",\n        \"playbackError\": \"ஊடகங்களை விளையாட முயற்சிக்கும்போது பிழை ஏற்பட்டது\",\n        \"remoteDisableError\": \"தொலைநிலை சேவையகத்தை $t(common.disable) முயற்சிக்கும்போது பிழை ஏற்பட்டது\",\n        \"sessionExpiredError\": \"உங்கள் அமர்வு காலாவதியானது\",\n        \"systemFontError\": \"கணினி எழுத்துருக்களைப் பெற முயற்சிக்கும்போது பிழை ஏற்பட்டது\",\n        \"badValue\": \"தவறான விருப்பம் \\\"{{value}}\\\". இந்த மதிப்பு இனி இல்லை\",\n        \"notificationDenied\": \"அறிவிப்புகளுக்கான அனுமதிகள் மறுக்கப்பட்டன. இந்த அமைப்பு எந்த விளைவையும் ஏற்படுத்தாது\",\n        \"invalidJson\": \"தவறான சாதொபொகு\",\n        \"multipleServerSaveQueueError\": \"நாடக வரிசையில் ஒன்று அல்லது அதற்கு மேற்பட்ட பாடல்கள் உள்ளன, அவை தற்போதைய சேவையகத்திலிருந்து இல்லை. இது ஆதரிக்கப்படவில்லை\",\n        \"noNetwork\": \"சர்வர் கிடைக்கவில்லை\",\n        \"noNetworkDescription\": \"இந்த சேவையகத்துடன் இணைக்க முடியவில்லை\",\n        \"saveQueueFailed\": \"வரிசையைச் சேமிக்க முடியவில்லை\",\n        \"serverLockSingleServer\": \"சேவையகம் பூட்டப்பட்டிருக்கும் போது ஒரு சேவையகம் மட்டுமே அனுமதிக்கப்படும்\",\n        \"settingsSyncError\": \"ரெண்டரரில் உள்ள அமைப்புகளுக்கும் முக்கிய செயல்முறைக்கும் இடையே முரண்பாடுகள் கண்டறியப்பட்டன. மாற்றங்களைப் பயன்படுத்த பயன்பாட்டை மறுதொடக்கம் செய்யுங்கள்\"\n    },\n    \"filter\": {\n        \"albumArtist\": \"$t(entity.albumArtist, {\\\"count\\\": 1})\",\n        \"albumCount\": \"$t(entity.album, {\\\"count\\\": 2}) எண்ணிக்கை\",\n        \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n        \"biography\": \"சுயசரிதை\",\n        \"bitrate\": \"பிட்ரேட்\",\n        \"bpm\": \"பிபிஎம்\",\n        \"channels\": \"$t(common.channel, {\\\"count\\\": 2})\",\n        \"comment\": \"கருத்து\",\n        \"communityRating\": \"சமூக மதிப்பீடு\",\n        \"path\": \"பாதை\",\n        \"playCount\": \"விளையாட்டு எண்ணிக்கை\",\n        \"random\": \"சீரற்ற\",\n        \"rating\": \"செயல்வரம்பு\",\n        \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n        \"criticRating\": \"விமர்சகர் மதிப்பீடு\",\n        \"dateAdded\": \"தேதி சேர்க்கப்பட்டது\",\n        \"disc\": \"வட்டு\",\n        \"duration\": \"காலம்\",\n        \"favorited\": \"பிடித்தது\",\n        \"fromYear\": \"ஆண்டு முதல்\",\n        \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n        \"id\": \"ஐடி\",\n        \"isCompilation\": \"தொகுப்பு\",\n        \"isFavorited\": \"பிடித்தது\",\n        \"isPublic\": \"பொது\",\n        \"isRated\": \"மதிப்பிடப்படுகிறது\",\n        \"isRecentlyPlayed\": \"அண்மைக் காலத்தில் விளையாடியது\",\n        \"lastPlayed\": \"கடைசியாக விளையாடியது\",\n        \"mostPlayed\": \"அதிகம் விளையாடியது\",\n        \"name\": \"பெயர்\",\n        \"note\": \"குறிப்பு\",\n        \"owner\": \"$t(common.owner)\",\n        \"recentlyAdded\": \"அண்மைக் காலத்தில் சேர்க்கப்பட்டது\",\n        \"recentlyPlayed\": \"அண்மைக் காலத்தில் விளையாடியது\",\n        \"recentlyUpdated\": \"அண்மைக் காலத்தில் புதுப்பிக்கப்பட்டது\",\n        \"releaseDate\": \"வெளியீட்டு தேதி\",\n        \"releaseYear\": \"வெளியீட்டு ஆண்டு\",\n        \"search\": \"தேடல்\",\n        \"songCount\": \"பாடல் எண்ணிக்கை\",\n        \"title\": \"தலைப்பு\",\n        \"toYear\": \"ஆண்டு\",\n        \"trackNumber\": \"மின்தடம்\",\n        \"matchAnd\": \"மற்றும்\",\n        \"matchOr\": \"அல்லது\",\n        \"sortName\": \"வரிசை பெயர்\",\n        \"explicitStatus\": \"$t(common.explicitStatus)\"\n    },\n    \"form\": {\n        \"addServer\": {\n            \"input_password\": \"கடவுச்சொல்\",\n            \"error_savePassword\": \"கடவுச்சொல்லை சேமிக்க முயற்சிக்கும்போது பிழை ஏற்பட்டது\",\n            \"ignoreCors\": \"CORS ஐ புறக்கணிக்கவும் ($t(common.restartRequired))\",\n            \"ignoreSsl\": \"SSL ஐ புறக்கணிக்கவும் ($t(common.restartRequired))\",\n            \"input_legacyAuthentication\": \"மரபு அங்கீகாரத்தை இயக்கவும்\",\n            \"input_name\": \"சேவையக பெயர்\",\n            \"input_savePassword\": \"கடவுச்சொல்லைச் சேமிக்கவும்\",\n            \"input_url\": \"முகவரி\",\n            \"input_username\": \"பயனர்பெயர்\",\n            \"success\": \"சேவையகம் வெற்றிகரமாக சேர்க்கப்பட்டது\",\n            \"title\": \"சேவையகத்தைச் சேர்க்கவும்\",\n            \"input_preferInstantMix\": \"உடனடி கலவையை விரும்புகிறது\",\n            \"input_preferInstantMixDescription\": \"ஒரே மாதிரியான பாடல்களைப் பெற உடனடி கலவையை மட்டுமே பயன்படுத்தவும். இந்த நடத்தையை மாற்றும் செருகுநிரல்கள் உங்களிடம் இருந்தால் பயனுள்ளதாக இருக்கும்\",\n            \"input_preferRemoteUrl\": \"பொது முகவரி ஐ விரும்பு\",\n            \"input_remoteUrl\": \"பொது முகவரி\",\n            \"input_remoteUrlPlaceholder\": \"விருப்பத்தேர்வு: வெளிப்புற அம்சங்களுக்கான பொது முகவரி\"\n        },\n        \"deletePlaylist\": {\n            \"input_confirm\": \"உறுதிப்படுத்த $t(entity.playlist, {\\\"count\\\": 1}) பெயரைத் தட்டச்சு செய்க\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) வெற்றிகரமாக நீக்கப்பட்டது\",\n            \"title\": \"$t(entity.playlist, {\\\"count\\\": 1})ஐ நீக்கு\"\n        },\n        \"editPlaylist\": {\n            \"title\": \"திருத்து $t(entity.playlist, {\\\"count\\\": 1})\",\n            \"publicJellyfinNote\": \"சில காரணங்களால் செல்லிஃபின் ஒரு பிளேலிச்ட் பொதுவில் இல்லையா என்பதை அம்பலப்படுத்தவில்லை. இது பொதுவில் இருக்க விரும்பினால், தயவுசெய்து பின்வரும் உள்ளீட்டைத் தேர்ந்தெடுக்கவும்\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) வெற்றிகரமாகப் புதுப்பிக்கப்பட்டது\",\n            \"editNote\": \"பெரிய பிளேலிச்ட்களுக்கு கைமுறை திருத்தங்கள் பரிந்துரைக்கப்படவில்லை. ஏற்கனவே உள்ள பிளேலிச்ட்டை மேலெழுதுவதால் ஏற்படும் தரவு இழப்பின் அபாயத்தை நிச்சயமாக ஏற்றுக்கொள்கிறீர்களா?\"\n        },\n        \"lyricSearch\": {\n            \"input_artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n            \"input_name\": \"$t(common.name)\",\n            \"title\": \"பாடல் தேடல்\"\n        },\n        \"queryEditor\": {\n            \"input_optionMatchAll\": \"அனைத்தையும் பொருத்துங்கள்\",\n            \"input_optionMatchAny\": \"எந்த பொருத்தவும்\",\n            \"title\": \"வினவல் ஆசிரியர்\",\n            \"addRuleGroup\": \"விதி குழுவைச் சேர்க்கவும்\",\n            \"removeRuleGroup\": \"விதி குழுவை அகற்று\",\n            \"resetToDefault\": \"இயல்புநிலைக்கு மீட்டமைக்கவும்\",\n            \"clearFilters\": \"தெளிவான வடிகட்டிகள்\"\n        },\n        \"shareItem\": {\n            \"description\": \"விவரம்\",\n            \"setExpiration\": \"காலாவதியை அமைக்கவும்\",\n            \"expireInvalid\": \"காலாவதி எதிர்காலத்தில் இருக்க வேண்டும்\",\n            \"allowDownloading\": \"பதிவிறக்க அனுமதிக்கவும்\",\n            \"success\": \"இடைநிலைப்பலகைக்கு நகலெடுக்கப்பட்ட இணைப்பைப் பகிரவும் (அல்லது திறக்க இங்கே சொடுக்கு செய்க)\",\n            \"createFailed\": \"பங்கை உருவாக்கத் தவறிவிட்டது (பகிர்வு இயக்கப்பட்டதா?)\",\n            \"copyToClipboard\": \"இடைநிலைப்பலகைக்கு நகலெடு: Ctrl+C, உள்ளிடவும்\",\n            \"successMustClick\": \"பகிர்வு வெற்றிகரமாக உருவாக்கப்பட்டது. திறக்க இங்கே சொடுக்கு செய்யவும்\"\n        },\n        \"createPlaylist\": {\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) வெற்றிகரமாக உருவாக்கப்பட்டது\",\n            \"title\": \"$t(entity.playlist, {\\\"count\\\": 1}) ஐ உருவாக்கவும்\",\n            \"input_description\": \"$t(common.description)\",\n            \"input_name\": \"$t(common.name)\",\n            \"input_owner\": \"$t(common.owner)\",\n            \"input_public\": \"பொது\"\n        },\n        \"addToPlaylist\": {\n            \"input_playlists\": \"$t(entity.playlist, {\\\"count\\\": 2})\",\n            \"input_skipDuplicates\": \"நகல்களைத் தவிர்க்கவும்\",\n            \"success\": \"$t(entity.trackWithCount, {\\\"count\\\": {{message}} }) இதற்கு $t(entity.playlistWithCount, {\\\"count\\\": {{numOfPlaylists}} }) சேர்க்கப்பட்டது\",\n            \"title\": \"$t(entity.playlist, {\\\"count\\\": 1}) இல் சேர்\",\n            \"create\": \"$t(entity.playlist, {\\\"count\\\": 1}) {{playlist}} உருவாக்கு\",\n            \"searchOrCreate\": \"$t(entity.playlist, {\\\"count\\\": 2}) தேடவும் அல்லது புதிய ஒன்றை உருவாக்க தட்டச்சு செய்யவும்\"\n        },\n        \"updateServer\": {\n            \"success\": \"சேவையகம் வெற்றிகரமாக புதுப்பிக்கப்பட்டது\",\n            \"title\": \"புதுப்பிப்பு சேவையகம்\"\n        },\n        \"largeFetchConfirmation\": {\n            \"title\": \"பொருட்களை வரிசையில் சேர்க்கவும்\",\n            \"description\": \"இந்தச் செயல் தற்போதைய வடிகட்டப்பட்ட காட்சியில் உள்ள அனைத்து உருப்படிகளையும் சேர்க்கும்\"\n        },\n        \"createRadioStation\": {\n            \"success\": \"வானொலி நிலையம் வெற்றிகரமாக உருவாக்கப்பட்டது\",\n            \"title\": \"வானொலி நிலையத்தை உருவாக்குங்கள்\",\n            \"input_homepageUrl\": \"முகப்பு முகவரி\",\n            \"input_name\": \"பெயர்\",\n            \"input_streamUrl\": \"ச்ட்ரீம் முகவரி\"\n        },\n        \"lyricsExport\": {\n            \"export\": \"ஏற்றுமதி பாடல் வரிகள்\",\n            \"input_synced\": \"ஒத்திசைக்கப்பட்ட பாடல் வரிகளை ஏற்றுமதி செய்யவும்\",\n            \"input_offset\": \"$t(setting.lyricOffset)\"\n        },\n        \"saveQueue\": {\n            \"success\": \"சேவையகத்தில் விளையாடும் வரிசை சேமிக்கப்பட்டது\"\n        },\n        \"shuffleAll\": {\n            \"title\": \"சீரற்ற விளையாட\",\n            \"input_genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n            \"input_limit\": \"எத்தனை பாடல்கள்?\",\n            \"input_minYear\": \"ஆண்டு முதல்\",\n            \"input_maxYear\": \"ஆண்டுக்கு\",\n            \"input_played\": \"விளையாடு வடிகட்டி\",\n            \"input_played_optionAll\": \"அனைத்து தடங்கள்\",\n            \"input_played_optionUnplayed\": \"இயக்கப்படாத தடங்கள் மட்டுமே\",\n            \"input_played_optionPlayed\": \"டிராக்குகளை மட்டுமே இயக்கியது\"\n        },\n        \"privateMode\": {\n            \"enabled\": \"தனிப்பட்ட பயன்முறை இயக்கப்பட்டது, பின்னணி நிலை இப்போது வெளிப்புற ஒருங்கிணைப்புகளிலிருந்து மறைக்கப்பட்டுள்ளது\",\n            \"disabled\": \"தனிப்பட்ட பயன்முறை முடக்கப்பட்டுள்ளது, இயக்கப்பட்ட வெளிப்புற ஒருங்கிணைப்புகளுக்கு இப்போது பின்னணி நிலை தெரியும்\",\n            \"title\": \"தனிப்பட்ட முறை\"\n        }\n    },\n    \"page\": {\n        \"albumArtistDetail\": {\n            \"about\": \"{{artist}} பற்றி\",\n            \"appearsOn\": \"தோன்றும்\",\n            \"recentReleases\": \"அண்மைக் கால வெளியீடுகள்\",\n            \"viewDiscography\": \"டிச்கோகிராஃபி காண்க\",\n            \"topSongs\": \"சிறந்த பாடல்கள்\",\n            \"viewAllTracks\": \"அனைத்தையும் காண்க $t(entity.track, {\\\"count\\\": 2})\",\n            \"relatedArtists\": \"தொடர்புடைய $t(entity.artist, {\\\"count\\\": 2})\",\n            \"topSongsFrom\": \"{{title}} இலிருந்து சிறந்த பாடல்கள்\",\n            \"viewAll\": \"அனைத்தையும் காண்க\",\n            \"favoriteSongs\": \"பிடித்த பாடல்கள்\",\n            \"groupingTypeAll\": \"அனைத்து வகையான வெளியீடுகள்\",\n            \"groupingTypePrimary\": \"முதன்மை வெளியீட்டு வகைகள்\",\n            \"topSongsCommunity\": \"சமூகம்\",\n            \"topSongsPersonal\": \"தனிப்பட்ட\",\n            \"favoriteSongsFrom\": \"{{title}} இலிருந்து பிடித்த பாடல்கள்\"\n        },\n        \"appMenu\": {\n            \"goBack\": \"திரும்பிச் செல்லுங்கள்\",\n            \"collapseSidebar\": \"பக்கப்பட்டி சரிவு\",\n            \"expandSidebar\": \"பக்கப்பட்டியை விரிவாக்குங்கள்\",\n            \"goForward\": \"முன்னோக்கிச் செல்லுங்கள்\",\n            \"manageServers\": \"சேவையகங்களை நிர்வகிக்கவும்\",\n            \"openBrowserDevtools\": \"திறந்த உலாவி தேவ்டூல்கள்\",\n            \"quit\": \"$t(common.quit)\",\n            \"selectServer\": \"சேவையகத்தைத் தேர்ந்தெடுக்கவும்\",\n            \"settings\": \"$t(common.setting, {\\\"count\\\": 2})\",\n            \"version\": \"பதிப்பு {{version}}\",\n            \"commandPalette\": \"கட்டளை தட்டு திறக்க\",\n            \"privateModeOff\": \"தனிப்பட்ட பயன்முறையை அணைக்கவும்\",\n            \"privateModeOn\": \"தனிப்பட்ட பயன்முறையை இயக்கவும்\",\n            \"selectMusicFolder\": \"இசை கோப்புறையைத் தேர்ந்தெடுக்கவும்\",\n            \"noMusicFolder\": \"இசை கோப்புறை எதுவும் தேர்ந்தெடுக்கப்படவில்லை\",\n            \"multipleMusicFolders\": \"{{count}} இசை கோப்புறைகள் தேர்ந்தெடுக்கப்பட்டன\"\n        },\n        \"manageServers\": {\n            \"url\": \"முகவரி\",\n            \"title\": \"சேவையகங்களை நிர்வகிக்கவும்\",\n            \"serverDetails\": \"சேவையக விவரங்கள்\",\n            \"username\": \"பயனர்பெயர்\",\n            \"editServerDetailsTooltip\": \"சேவையக விவரங்களைத் திருத்தவும்\",\n            \"removeServer\": \"சேவையகத்தை அகற்று\"\n        },\n        \"contextMenu\": {\n            \"addNext\": \"$t(player.addNext)\",\n            \"deletePlaylist\": \"$t(action.deletePlaylist)\",\n            \"deselectAll\": \"$t(action.deselectAll)\",\n            \"download\": \"பதிவிறக்கம்\",\n            \"moveToBottom\": \"$t(action.moveToBottom)\",\n            \"moveToTop\": \"$t(action.moveToTop)\",\n            \"numberSelected\": \"{{count}} தேர்ந்தெடுக்கப்பட்டது\",\n            \"playSimilarSongs\": \"$t(player.playSimilarSongs)\",\n            \"removeFromFavorites\": \"$t(action.removeFromFavorites)\",\n            \"removeFromPlaylist\": \"$t(action.removeFromPlaylist)\",\n            \"removeFromQueue\": \"$t(action.removeFromQueue)\",\n            \"setRating\": \"$t(action.setRating)\",\n            \"playShuffled\": \"$t(player.shuffle)\",\n            \"addFavorite\": \"$t(action.addToFavorites)\",\n            \"addLast\": \"$t(player.addLast)\",\n            \"moveToNext\": \"$t(action.moveToNext)\",\n            \"play\": \"$t(player.play)\",\n            \"shareItem\": \"உருப்படியைப் பகிரவும்\",\n            \"showDetails\": \"தகவலைப் பெறுங்கள்\",\n            \"addToFavorites\": \"$t(action.addToFavorites)\",\n            \"addToPlaylist\": \"$t(action.addToPlaylist)\",\n            \"createPlaylist\": \"$t(action.createPlaylist)\",\n            \"moveItems\": \"$t(action.moveItems)\",\n            \"goTo\": \"செல்\",\n            \"goToAlbum\": \"$t(entity.album, {\\\"count\\\": 1}) க்குச் செல்\",\n            \"goToAlbumArtist\": \"$t(entity.albumArtist, {\\\"count\\\": 1}) க்குச் செல்\"\n        },\n        \"fullscreenPlayer\": {\n            \"config\": {\n                \"followCurrentLyric\": \"தற்போதைய பாடலைப் பின்பற்றுங்கள்\",\n                \"lyricAlignment\": \"பாடல் சீரமைப்பு\",\n                \"lyricOffset\": \"பாடல் ஆஃப்செட் (எம்.எச்)\",\n                \"synchronized\": \"ஒத்திசைக்கப்பட்டது\",\n                \"dynamicBackground\": \"மாறும் பின்னணி\",\n                \"dynamicImageBlur\": \"பட மங்கலான அளவு\",\n                \"dynamicIsImage\": \"பின்னணி படத்தை இயக்கவும்\",\n                \"lyricGap\": \"பாடல் இடைவெளி\",\n                \"lyricSize\": \"பாடல் அளவு\",\n                \"opacity\": \"ஒளிபுகாநிலை\",\n                \"showLyricMatch\": \"பாடல் போட்டியைக் காட்டு\",\n                \"showLyricProvider\": \"பாடல் வழங்குநரைக் காட்டு\",\n                \"unsynchronized\": \"ஒத்திசைக்கப்படாதது\",\n                \"useImageAspectRatio\": \"பட விகித விகிதத்தைப் பயன்படுத்தவும்\"\n            },\n            \"upNext\": \"அடுத்து\",\n            \"visualizer\": \"காட்சிப்படுத்தல்\",\n            \"noLyrics\": \"பாடல் வரிகள் இல்லை\",\n            \"lyrics\": \"பாடல்\",\n            \"related\": \"தொடர்புடைய\"\n        },\n        \"genreList\": {\n            \"showAlbums\": \"காட்டு $t(entity.genre, {\\\"count\\\": 1}) $t(entity.album, {\\\"count\\\": 2})\",\n            \"showTracks\": \"காட்டு $t(entity.genre, {\\\"count\\\": 1}) $t(entity.track, {\\\"count\\\": 2})\",\n            \"title\": \"$t(entity.genre, {\\\"count\\\": 2})\"\n        },\n        \"globalSearch\": {\n            \"commands\": {\n                \"goToPage\": \"பக்கத்திற்குச் செல்லுங்கள்\",\n                \"searchFor\": \"{{query}} ஐத் தேடுங்கள்\",\n                \"serverCommands\": \"சேவையக கட்டளைகள்\"\n            },\n            \"title\": \"கட்டளைகள்\"\n        },\n        \"home\": {\n            \"explore\": \"உங்கள் நூலகத்திலிருந்து ஆராயுங்கள்\",\n            \"mostPlayed\": \"அதிகம் விளையாடியது\",\n            \"newlyAdded\": \"புதிதாக சேர்க்கப்பட்ட வெளியீடுகள்\",\n            \"recentlyPlayed\": \"அண்மைக் காலத்தில் விளையாடியது\",\n            \"title\": \"$t(common.home)\",\n            \"genres\": \"$t(entity.genre, {\\\"count\\\": 2})\",\n            \"recentlyReleased\": \"அண்மைக் காலத்தில் வெளியானது\"\n        },\n        \"itemDetail\": {\n            \"copyPath\": \"இடைநிலைப்பலகைக்கு பாதையை நகலெடுக்கவும்\",\n            \"copiedPath\": \"பாதை வெற்றிகரமாக நகலெடுக்கப்பட்டது\",\n            \"openFile\": \"கோப்பு மேலாளரில் தடத்தைக் காட்டு\"\n        },\n        \"playlist\": {\n            \"reorder\": \"ஐடியால் வரிசைப்படுத்தும்போது மட்டுமே மறுசீரமைப்பு இயக்கப்பட்டது\"\n        },\n        \"playlistList\": {\n            \"title\": \"$t(entity.playlist, {\\\"count\\\": 2})\"\n        },\n        \"setting\": {\n            \"advanced\": \"மேம்பட்ட\",\n            \"generalTab\": \"பொது\",\n            \"hotkeysTab\": \"ஆட்கீச்\",\n            \"playbackTab\": \"பின்னணி\",\n            \"windowTab\": \"சாளரம்\",\n            \"analytics\": \"பகுப்பாய்வு\",\n            \"updates\": \"புதுப்பிப்பு\",\n            \"cache\": \"தற்காலிக சேமிப்பு\",\n            \"application\": \"விண்ணப்பம்\",\n            \"queryBuilder\": \"வினவல் கட்டுபவர்\",\n            \"theme\": \"கருப்பொருள்\",\n            \"controls\": \"கட்டுப்பாடுகள்\",\n            \"sidebar\": \"பக்கப்பட்டி\",\n            \"remote\": \"தொலைவில்\",\n            \"exportImport\": \"இறக்குமதி/ஏற்றுமதி\",\n            \"scrobble\": \"சுருள்\",\n            \"audio\": \"ஆடியோ\",\n            \"lyrics\": \"பாடல் வரிகள்\",\n            \"lyricsDisplay\": \"பாடல் வரிகள் காட்சி\",\n            \"transcoding\": \"டிரான்ச்கோடிங்\",\n            \"discord\": \"முரண்பாடு\",\n            \"logger\": \"மரம் வெட்டுபவர்\",\n            \"playerFilters\": \"பிளேயர் வடிப்பான்கள்\"\n        },\n        \"sidebar\": {\n            \"folders\": \"$t(entity.folder, {\\\"count\\\": 2})\",\n            \"genres\": \"$t(entity.genre, {\\\"count\\\": 2})\",\n            \"home\": \"$t(common.home)\",\n            \"nowPlaying\": \"இப்போது விளையாடுகிறது\",\n            \"playlists\": \"$t(entity.playlist, {\\\"count\\\": 2})\",\n            \"search\": \"$t(common.search)\",\n            \"settings\": \"$t(common.setting, {\\\"count\\\": 2})\",\n            \"albumArtists\": \"$t(entity.albumArtist, {\\\"count\\\": 2})\",\n            \"albums\": \"$t(entity.album, {\\\"count\\\": 2})\",\n            \"artists\": \"$t(entity.artist, {\\\"count\\\": 2})\",\n            \"shared\": \"$t(entity.playlist, {\\\"count\\\": 2}) பகிரப்பட்டது\",\n            \"tracks\": \"$t(entity.track, {\\\"count\\\": 2})\",\n            \"myLibrary\": \"எனது நூலகம்\",\n            \"collections\": \"சேகரிப்புகள்\",\n            \"favorites\": \"$t(entity.favorite, {\\\"count\\\": 2})\",\n            \"radio\": \"$t(entity.radioStation, {\\\"count\\\": 2})\"\n        },\n        \"trackList\": {\n            \"title\": \"$t(entity.track, {\\\"count\\\": 2})\",\n            \"genreTracks\": \"\\\"{{genre}}\\\" $t(entity.track, {\\\"count\\\": 2})\",\n            \"artistTracks\": \"{{artist}}\"\n        },\n        \"albumArtistList\": {\n            \"title\": \"$t(entity.albumArtist, {\\\"count\\\": 2})\"\n        },\n        \"albumDetail\": {\n            \"moreFromArtist\": \"இந்த $t(entity.artist, {\\\"count\\\": 1}) இலிருந்து மேலும்\",\n            \"moreFromGeneric\": \"{{item}} இலிருந்து மேலும்\",\n            \"released\": \"வெளியிடப்பட்டது\"\n        },\n        \"albumList\": {\n            \"artistAlbums\": \"ஆல்பங்கள் {{artist}}\",\n            \"genreAlbums\": \"\\\"{{genre}}\\\" $t(entity.album, {\\\"count\\\": 2})\",\n            \"title\": \"$t(entity.album, {\\\"count\\\": 2})\"\n        },\n        \"radioList\": {\n            \"title\": \"வானொலி நிலையங்கள்\"\n        },\n        \"releasenotes\": {\n            \"commitsSinceStable\": \"{{stable}} முதல் உறுதியளிக்கிறது\",\n            \"noNewCommits\": \"இந்த வரம்பில் புதிய பொறுப்புகள் எதுவும் இல்லை\",\n            \"noStableReleaseToCompare\": \"ஒப்பிடுவதற்கு நிலையான வெளியீடு இல்லை\"\n        },\n        \"favorites\": {\n            \"title\": \"$t(entity.favorite, {\\\"count\\\": 2})\"\n        },\n        \"windowBar\": {\n            \"paused\": \"(இடைநிறுத்தப்பட்டது) \",\n            \"privateMode\": \"(தனிப்பட்ட முறை)\"\n        },\n        \"folderList\": {\n            \"title\": \"$t(entity.folder, {\\\"count\\\": 2})\"\n        },\n        \"collections\": {\n            \"overrideExisting\": \"ஏற்கனவே உள்ளதை மேலெழுதவும்\",\n            \"saveAsCollection\": \"சேகரிப்பாக சேமிக்கவும்\"\n        }\n    },\n    \"player\": {\n        \"addLast\": \"கடைசி\",\n        \"addNext\": \"அடுத்தது\",\n        \"favorite\": \"பிடித்த\",\n        \"mute\": \"ஒலிமுடக்கு\",\n        \"muted\": \"முடக்கிய\",\n        \"next\": \"அடுத்தது\",\n        \"play\": \"விளையாடுங்கள்\",\n        \"playSimilarSongs\": \"ஒத்த பாடல்களை வாசிக்கவும்\",\n        \"previous\": \"முந்தைய\",\n        \"queue_clear\": \"தெளிவான வரிசை\",\n        \"queue_remove\": \"தேர்ந்தெடுக்கப்பட்டதை அகற்று\",\n        \"repeat\": \"மீண்டும்\",\n        \"repeat_all\": \"அனைத்தையும் மீண்டும் செய்யவும்\",\n        \"repeat_off\": \"முடக்கப்பட்டதை மீண்டும் செய்யவும்\",\n        \"shuffle\": \"விளையாடு (குலைக்கப்பட்டது)\",\n        \"shuffle_off\": \"கலக்கு முடக்கப்பட்டது\",\n        \"skip\": \"தவிர்\",\n        \"playbackFetchCancel\": \"இது சிறிது நேரம் ஆகும்… ரத்து செய்ய அறிவிப்பை மூடு\",\n        \"playbackFetchInProgress\": \"பாடல்களை ஏற்றுகிறது…\",\n        \"playbackFetchNoResults\": \"பாடல்கள் எதுவும் கிடைக்கவில்லை\",\n        \"playbackSpeed\": \"பிளேபேக் விரைவு\",\n        \"playRandom\": \"சீரற்ற முறையில் விளையாடுங்கள்\",\n        \"queue_moveToBottom\": \"மேலே தேர்ந்தெடுக்கப்பட்ட நகர்த்து\",\n        \"queue_moveToTop\": \"தேர்ந்தெடுக்கப்பட்டதை கீழே நகர்த்தவும்\",\n        \"skip_back\": \"பின்னோக்கி தவிர்க்கவும்\",\n        \"skip_forward\": \"முன்னோக்கி தவிர்க்கவும்\",\n        \"stop\": \"நிறுத்து\",\n        \"toggleFullscreenPlayer\": \"முழுத்திரை பிளேயரை மாற்றவும்\",\n        \"unfavorite\": \"மாறாத\",\n        \"pause\": \"இடைநிறுத்தம்\",\n        \"viewQueue\": \"வரிசையைக் காண்க\",\n        \"addLastShuffled\": \"கடைசியாக (குறைக்கப்பட்டது)\",\n        \"addNextShuffled\": \"அடுத்தது (குலைக்கப்பட்டது)\",\n        \"albumRadio\": \"ஆல்பம் வானொலி\",\n        \"artistRadio\": \"கலைஞர் வானொலி\",\n        \"holdToShuffle\": \"கலக்க பிடி\",\n        \"lyrics\": \"பாடல் வரிகள்\",\n        \"restoreQueueFromServer\": \"சேவையகத்திலிருந்து வரிசையை மீட்டமை\",\n        \"saveQueueToServer\": \"சேவையகத்தில் வரிசையைச் சேமிக்கவும்\",\n        \"trackRadio\": \"டிராக் ரேடியோ\",\n        \"sleepTimer\": \"தூக்க நேரம்\",\n        \"sleepTimer_endOfSong\": \"தற்போதைய பாடலின் முடிவு\",\n        \"sleepTimer_minutes\": \"{{count}} மணித்துளி\",\n        \"sleepTimer_hours\": \"{{count}} மணி\",\n        \"sleepTimer_custom\": \"தனிப்பயன்\",\n        \"sleepTimer_off\": \"அணை\",\n        \"sleepTimer_timeRemaining\": \"{{time}} மீதமுள்ளது\",\n        \"sleepTimer_setCustom\": \"டைமரை அமைக்கவும்\",\n        \"sleepTimer_cancel\": \"நேரங்குறிகருவி ரத்து\"\n    },\n    \"setting\": {\n        \"accentColor\": \"உச்சரிப்பு நிறம்\",\n        \"accentColor_description\": \"பயன்பாட்டிற்கான உச்சரிப்பு வண்ணத்தை அமைக்கிறது\",\n        \"albumBackground\": \"ஆல்பம் பின்னணி படம்\",\n        \"applicationHotkeys\": \"பயன்பாட்டு ஆட்கீச்\",\n        \"applicationHotkeys_description\": \"பயன்பாட்டு ஆட்கீசை உள்ளமைக்கவும். உலகளாவிய ஆட்ச்கியாக அமைக்க தேர்வுப்பெட்டியை மாற்றவும் (டெச்க்டாப் மட்டும்)\",\n        \"artistConfiguration\": \"ஆல்பம் கலைஞர் பக்க உள்ளமைவு\",\n        \"audioDevice_description\": \"பிளேபேக்கிற்குப் பயன்படுத்த ஆடியோ சாதனத்தைத் தேர்ந்தெடுக்கவும்\",\n        \"audioExclusiveMode\": \"ஆடியோ பிரத்தியேக பயன்முறை\",\n        \"audioPlayer\": \"ஆடியோ பிளேயர்\",\n        \"audioPlayer_description\": \"பிளேபேக்கிற்கு பயன்படுத்த ஆடியோ பிளேயரைத் தேர்ந்தெடுக்கவும்\",\n        \"customCssEnable_description\": \"தனிப்பயன் சிஎச்எச் ஐ எழுத அனுமதிக்கவும்\",\n        \"customCss\": \"தனிப்பயன் சிஎச்எச்\",\n        \"customFontPath\": \"தனிப்பயன் எழுத்துரு பாதை\",\n        \"customFontPath_description\": \"பயன்பாட்டிற்கு பயன்படுத்த தனிப்பயன் எழுத்துருவுக்கு பாதையை அமைக்கிறது\",\n        \"disableLibraryUpdateOnStartup\": \"தொடக்கத்தில் புதிய பதிப்புகளைச் சரிபார்ப்பதை முடக்கு\",\n        \"discordApplicationId\": \"{{discord}} பயன்பாட்டு ஐடி\",\n        \"discordListening\": \"கேட்பது என நிலையைக் காட்டுங்கள்\",\n        \"exitToTray_description\": \"கணினி தட்டில் பயன்பாட்டிலிருந்து வெளியேறவும்\",\n        \"followLyric\": \"தற்போதைய பாடலைப் பின்பற்றுங்கள்\",\n        \"followLyric_description\": \"தற்போதைய விளையாட்டு நிலைக்கு பாடலை உருட்டவும்\",\n        \"font\": \"எழுத்துரு\",\n        \"font_description\": \"பயன்பாட்டிற்கு பயன்படுத்த எழுத்துருவை அமைக்கிறது\",\n        \"fontType\": \"எழுத்துரு வகை\",\n        \"fontType_description\": \"உள்ளமைக்கப்பட்ட எழுத்துரு ஃபீசின் வழங்கிய எழுத்துருக்களில் ஒன்றைத் தேர்ந்தெடுக்கிறது. உங்கள் இயக்க முறைமை வழங்கிய எந்த எழுத்துருவையும் தேர்ந்தெடுக்க கணினி எழுத்துரு உங்களை அனுமதிக்கிறது. உங்கள் சொந்த எழுத்துருவை வழங்க தனிப்பயன் உங்களை அனுமதிக்கிறது\",\n        \"fontType_optionBuiltIn\": \"உள்ளமைக்கப்பட்ட எழுத்துரு\",\n        \"fontType_optionCustom\": \"தனிப்பயன் எழுத்துரு\",\n        \"fontType_optionSystem\": \"கணினி எழுத்துரு\",\n        \"gaplessAudio\": \"இடைவெளி இல்லாத ஆடியோ\",\n        \"gaplessAudio_description\": \"MPV க்கான இடைவெளி இல்லாத ஆடியோ அமைப்பை அமைக்கிறது\",\n        \"gaplessAudio_optionWeak\": \"பலவீனமான (பரிந்துரைக்கப்படுகிறது)\",\n        \"globalMediaHotkeys_description\": \"பிளேபேக்கைக் கட்டுப்படுத்த உங்கள் கணினி மீடியா ஆட்கீசின் பயன்பாட்டை இயக்கவும் அல்லது முடக்கவும்\",\n        \"homeConfiguration\": \"முகப்பு பக்க உள்ளமைவு\",\n        \"homeFeature\": \"வீட்டில் கொணர்வி இடம்பெற்றது\",\n        \"hotkey_favoriteCurrentSong\": \"பிடித்த $t(common.currentSong)\",\n        \"hotkey_globalSearch\": \"உலக தேடல்\",\n        \"hotkey_playbackPrevious\": \"முந்தைய பாடல்\",\n        \"hotkey_playbackStop\": \"நிறுத்து\",\n        \"hotkey_rate0\": \"மதிப்பீடு தெளிவாக\",\n        \"hotkey_rate1\": \"மதிப்பீடு 1 விண்மீன்\",\n        \"hotkey_rate2\": \"மதிப்பீடு 2 நட்சத்திரங்கள்\",\n        \"hotkey_rate3\": \"மதிப்பீடு 3 நட்சத்திரங்கள்\",\n        \"hotkey_rate4\": \"மதிப்பீடு 4 நட்சத்திரங்கள்\",\n        \"hotkey_rate5\": \"மதிப்பீடு 5 நட்சத்திரங்கள்\",\n        \"hotkey_toggleFullScreenPlayer\": \"முழு திரை பிளேயரை மாற்றவும்\",\n        \"hotkey_togglePreviousSongFavorite\": \"மாற்றவும் $t(common.previousSong) பிடித்தது\",\n        \"hotkey_toggleQueue\": \"வரிசையை மாற்றவும்\",\n        \"hotkey_toggleRepeat\": \"மாற்று மறுநிகழ்வு\",\n        \"hotkey_toggleShuffle\": \"கலக்கு மாற்று\",\n        \"hotkey_unfavoriteCurrentSong\": \"பிடிக்காத $t(common.currentSong)\",\n        \"hotkey_unfavoritePreviousSong\": \"பிடிக்காத $t(common.previousSong)\",\n        \"hotkey_volumeDown\": \"தொகுதி கீழே\",\n        \"hotkey_volumeMute\": \"தொகுதி முடக்கு\",\n        \"hotkey_volumeUp\": \"தொகுதி\",\n        \"language_description\": \"பயன்பாட்டிற்கான மொழியை அமைக்கிறது ($t(common.restartRequired))\",\n        \"lastfmApiKey\": \"{{lastfm}} பநிஇ key\",\n        \"lastfmApiKey_description\": \"{{lastfm}} க்கான பநிஇ விசை. கவர் கலைக்குத் தேவை\",\n        \"lyricFetch\": \"இணையத்திலிருந்து வரிகளை பெறுங்கள்\",\n        \"lyricFetchProvider_description\": \"பாடல் வரிகளைப் பெற வழங்குநர்களைத் தேர்ந்தெடுக்கவும்\",\n        \"lyricOffset\": \"பாடல் ஆஃப்செட் (எம்.எச்)\",\n        \"minimizeToTray\": \"தட்டில் குறைக்கவும்\",\n        \"minimumScrobblePercentage\": \"குறைந்தபட்ச துணிச்சல் காலம் (சதவீதம்)\",\n        \"minimumScrobblePercentage_description\": \"பாடலின் குறைந்தபட்ச விழுக்காடு அதைத் துடைப்பதற்கு முன்பு இசைக்க வேண்டும்\",\n        \"minimumScrobbleSeconds\": \"குறைந்தபட்ச தோண்டல் (விநாடிகள்)\",\n        \"minimumScrobbleSeconds_description\": \"பாடலின் விநாடிகளில் குறைந்தபட்ச காலம் அது வேட்டையாடப்படுவதற்கு முன்பு இசைக்கப்பட வேண்டும்\",\n        \"mpvExecutablePath\": \"MPV இயங்கக்கூடிய பாதை\",\n        \"mpvExecutablePath_description\": \"MPV இயங்கக்கூடிய பாதையை அமைக்கிறது. காலியாக இருந்தால், இயல்புநிலை பாதை பயன்படுத்தப்படும்\",\n        \"mpvExtraParameters_help\": \"ஒரு வரிக்கு ஒன்று\",\n        \"passwordStore\": \"கடவுச்சொற்கள்/ரகசிய கடை\",\n        \"passwordStore_description\": \"என்ன கடவுச்சொல்/ரகசிய கடை பயன்படுத்த வேண்டும். கடவுச்சொற்களை சேமிப்பதில் சிக்கல்கள் இருந்தால் இதை மாற்றவும்\",\n        \"playbackStyle\": \"பிளேபேக் பாணி\",\n        \"playbackStyle_description\": \"ஆடியோ பிளேயருக்கு பயன்படுத்த பிளேபேக் பாணியைத் தேர்ந்தெடுக்கவும்\",\n        \"playbackStyle_optionCrossFade\": \"கிராச்ஃபேட்\",\n        \"playbackStyle_optionNormal\": \"சாதாரண\",\n        \"playButtonBehavior\": \"பொத்தான் நடத்தை விளையாடுங்கள்\",\n        \"playButtonBehavior_description\": \"வரிசையில் பாடல்களைச் சேர்க்கும்போது ப்ளே பொத்தானின் இயல்புநிலை நடத்தை அமைக்கிறது\",\n        \"playButtonBehavior_optionAddLast\": \"$t(player.addLast)\",\n        \"playButtonBehavior_optionAddNext\": \"$t(player.addNext)\",\n        \"playerbarOpenDrawer\": \"பிளேயர்பார் முழுத்திரை மாற்று\",\n        \"playerbarOpenDrawer_description\": \"முழு திரை பிளேயரைத் திறக்க பிளேயர்பாரைக் சொடுக்கு செய்ய அனுமதிக்கிறது\",\n        \"remotePassword\": \"ரிமோட் கண்ட்ரோல் சர்வர் கடவுச்சொல்\",\n        \"remotePassword_description\": \"ரிமோட் கண்ட்ரோல் சேவையகத்திற்கான கடவுச்சொல்லை அமைக்கிறது. இந்த நற்சான்றிதழ்கள் இயல்பாகவே பாதுகாப்பற்ற முறையில் மாற்றப்படுகின்றன, எனவே நீங்கள் கவலைப்படாத தனிப்பட்ட கடவுச்சொல்லைப் பயன்படுத்த வேண்டும்\",\n        \"remotePort\": \"ரிமோட் கண்ட்ரோல் சர்வர் துறைமுகம்\",\n        \"remotePort_description\": \"ரிமோட் கண்ட்ரோல் சேவையகத்திற்கான துறைமுகத்தை அமைக்கிறது\",\n        \"remoteUsername\": \"ரிமோட் கண்ட்ரோல் சர்வர் பயனர்பெயர்\",\n        \"remoteUsername_description\": \"ரிமோட் கண்ட்ரோல் சேவையகத்திற்கான பயனர்பெயரை அமைக்கிறது. பயனர்பெயர் மற்றும் கடவுச்சொல் இரண்டும் காலியாக இருந்தால், ஏற்பு முடக்கப்படும்\",\n        \"replayGainClipping\": \"{{ReplayGain}} கிளிப்பிங்\",\n        \"replayGainClipping_description\": \"ஆதாயத்தைத் தானாகவே குறைப்பதன் மூலம் {{ReplayGain}} காரணமாக ஏற்படும் கிளிப்பிங்கைத் தடுக்கவும்\",\n        \"replayGainFallback\": \"{{ReplayGain}} பின்னடைவு\",\n        \"replayGainFallback_description\": \"கோப்பில் {{ReplayGain}} குறிச்சொற்கள் இல்லையென்றால் விண்ணப்பிக்க DB இல் ஆதாயம்\",\n        \"replayGainMode\": \"{{ReplayGain}} பயன்முறை\",\n        \"replayGainMode_description\": \"{{ReplayGain}}} மதிப்புகளின் படி தொகுதி ஆதாயத்தை சரிசெய்யவும் மேனிலை தரவு கோப்பு\",\n        \"replayGainMode_optionAlbum\": \"$t(entity.album, {\\\"count\\\": 1})\",\n        \"replayGainMode_optionNone\": \"$t(common.none)\",\n        \"replayGainMode_optionTrack\": \"$t(entity.track, {\\\"count\\\": 1})\",\n        \"replayGainPreamp\": \"{{ReplayGain}} preamp (db)\",\n        \"replayGainPreamp_description\": \"{{ReplayGain}}} மதிப்புகளுக்கு பயன்படுத்தப்படும் Preamp ஆதாயத்தை சரிசெய்யவும்\",\n        \"sampleRate\": \"மாதிரி வீதம்\",\n        \"sampleRate_description\": \"தேர்ந்தெடுக்கப்பட்ட மாதிரி அதிர்வெண் தற்போதைய மீடியாவிலிருந்து வேறுபட்டால் பயன்படுத்த வேண்டிய வெளியீட்டு மாதிரி வீதத்தைத் தேர்ந்தெடுக்கவும். 8000 க்கும் குறைவான மதிப்பு இயல்புநிலை அதிர்வெண்ணைப் பயன்படுத்தும்\",\n        \"themeLight_description\": \"பயன்பாட்டிற்கு பயன்படுத்த ஒளி கருப்பொருள் அமைக்கிறது\",\n        \"transcode_description\": \"வெவ்வேறு வடிவங்களுக்கு மாற்றுவதை செயல்படுத்துகிறது\",\n        \"transcodeBitrate\": \"டிரான்ச்கோடிற்கு பிட்ரேட்\",\n        \"transcodeBitrate_description\": \"டிரான்ச்கோடிற்கு பிட்ரேட்டைத் தேர்ந்தெடுக்கிறது. 0 என்றால் சேவையகம் எடுக்கட்டும்\",\n        \"transcodeFormat\": \"டிரான்ச்கோடுக்கு வடிவம்\",\n        \"transcodeFormat_description\": \"டிரான்ச்கோடிற்கு வடிவமைப்பைத் தேர்ந்தெடுக்கிறது. சேவையகம் தீர்மானிக்க காலியாக விடவும்\",\n        \"translationApiProvider\": \"மொழிபெயர்ப்பு பநிஇ வழங்குநர்\",\n        \"translationApiProvider_description\": \"மொழிபெயர்ப்புக்கான பநிஇ வழங்குநர்\",\n        \"translationApiKey\": \"மொழிபெயர்ப்பு பநிஇ விசை\",\n        \"translationApiKey_description\": \"மொழிபெயர்ப்பிற்கான பநிஇ விசை (உலகளாவிய பணி இறுதிப்புள்ளியை மட்டும் ஆதரிக்கவும்)\",\n        \"translationTargetLanguage\": \"மொழிபெயர்ப்பு இலக்கு மொழி\",\n        \"translationTargetLanguage_description\": \"மொழிபெயர்ப்பிற்கான இலக்கு மொழி\",\n        \"trayEnabled\": \"தட்டில் காட்டு\",\n        \"trayEnabled_description\": \"தட்டு ஐகான்/மெனுவைக் காட்டவும்/மறைக்கவும். முடக்கப்பட்டால், தட்டில் குறைக்க/வெளியேறவும் முடக்குகிறது\",\n        \"volumeWidth_description\": \"தொகுதி ச்லைடரின் அகலம்\",\n        \"webAudio\": \"வலை ஆடியோவைப் பயன்படுத்தவும்\",\n        \"webAudio_description\": \"வலை ஆடியோவைப் பயன்படுத்தவும். இது ரீப்ளே கெய்ன் போன்ற மேம்பட்ட அம்சங்களை செயல்படுத்துகிறது. நீங்கள் வேறுவிதமாக அனுபவித்தால் முடக்கு\",\n        \"artistConfiguration_description\": \"எந்த உருப்படிகள் காண்பிக்கப்படுகின்றன, எந்த வரிசையில், ஆல்பம் கலைஞர் பக்கத்தில் உள்ளமைக்கவும்\",\n        \"audioDevice\": \"ஆடியோ சாதனம்\",\n        \"audioExclusiveMode_description\": \"பிரத்யேக வெளியீட்டு பயன்முறையை இயக்கவும். இந்த பயன்முறையில், கணினி வழக்கமாக பூட்டப்படுகிறது, மேலும் MPV மட்டுமே ஆடியோவை வெளியிட முடியும்\",\n        \"buttonSize\": \"பிளேயர் பார் பொத்தான் அளவு\",\n        \"buttonSize_description\": \"பிளேயர் பார் பொத்தான்களின் அளவு\",\n        \"clearCache\": \"தெளிவான உலாவி தற்காலிக சேமிப்பு\",\n        \"clearCache_description\": \"ஃபீசினின் ஒரு 'கடினமான தெளிவான'. ஃபெசினின் தற்காலிக சேமிப்பை அழிப்பதைத் தவிர, உலாவி தற்காலிக சேமிப்பை (சேமித்த படங்கள் மற்றும் பிற சொத்துக்கள்) வெறுமை செய்யுங்கள். சேவையக நற்சான்றிதழ்கள் மற்றும் அமைப்புகள் பாதுகாக்கப்படுகின்றன\",\n        \"albumBackground_description\": \"ஆல்பம் கலை கொண்ட ஆல்பம் பக்கங்களுக்கு பின்னணி படத்தை சேர்க்கிறது\",\n        \"albumBackgroundBlur\": \"ஆல்பம் பின்னணி பட மங்கலான அளவு\",\n        \"albumBackgroundBlur_description\": \"ஆல்பத்தின் பின்னணி படத்திற்கு பயன்படுத்தப்படும் மங்கலின் அளவை சரிசெய்கிறது\",\n        \"clearQueryCache\": \"தெளிவான ஃபைசின் கேச்\",\n        \"clearQueryCache_description\": \"ஃபீசினின் 'மென்மையான தெளிவான'. இது பிளேலிச்ட்களைப் புதுப்பிக்கும், மெட்டாடேட்டாவைக் கண்காணிக்கும் மற்றும் சேமித்த பாடல் வரிகளை மீட்டமைக்கும். அமைப்புகள், சேவையக நற்சான்றிதழ்கள் மற்றும் தற்காலிக சேமிப்பு படங்கள் பாதுகாக்கப்படுகின்றன\",\n        \"clearCacheSuccess\": \"கேச் வெற்றிகரமாக அழிக்கப்பட்டது\",\n        \"contextMenu\": \"சூழல் பட்டியல் (வலது கிளிக்) உள்ளமைவு\",\n        \"crossfadeDuration\": \"கிராச்ஃபேட் காலம்\",\n        \"crossfadeDuration_description\": \"கிராச்ஃபேட் விளைவின் காலத்தை அமைக்கிறது\",\n        \"crossfadeStyle_description\": \"ஆடியோ பிளேயருக்கு பயன்படுத்த கிராச்ஃபேட் பாணியைத் தேர்ந்தெடுக்கவும்\",\n        \"customCssEnable\": \"தனிப்பயன் சிஎச்எச் ஐ இயக்கவும்\",\n        \"customCssNotice\": \"எச்சரிக்கை: சில சுத்திகரிப்பு (URL () மற்றும் உள்ளடக்கத்தை அனுமதிக்காதது :) இருக்கும்போது, தனிப்பயன் சிஎச்எச் ஐப் பயன்படுத்துவது இடைமுகத்தை மாற்றுவதன் மூலம் ஆபத்துக்களை ஏற்படுத்தக்கூடும்\",\n        \"contextMenu_description\": \"நீங்கள் ஒரு உருப்படியை வலது சொடுக்கு செய்யும் போது பட்டியலில் காட்டப்பட்டுள்ள உருப்படிகளை மறைக்க உங்களை அனுமதிக்கிறது. சரிபார்க்கப்படாத உருப்படிகள் மறைக்கப்படும்\",\n        \"discordApplicationId_description\": \"{{discord}} பணக்கார இருப்புக்கான பயன்பாட்டு ஐடி (இயல்புநிலை {{defaultId}})\",\n        \"discordIdleStatus\": \"பணக்கார இருப்பு செயலற்ற நிலையைக் காட்டுங்கள்\",\n        \"discordIdleStatus_description\": \"இயக்கப்பட்டால், பிளேயர் சும்மா இருக்கும்போது நிலையைப் புதுப்பிக்கவும்\",\n        \"discordListening_description\": \"விளையாடுவதற்குப் பதிலாக கேட்பது என்று அந்த நிலையைக் காட்டுங்கள்\",\n        \"discordRichPresence_description\": \"{{discord}} பணக்கார இருப்பில் பின்னணி நிலையை இயக்கவும். பட விசைகள்: {{icon}}, {{playing}}, மற்றும் {{paused}}\",\n        \"customCss_description\": \"தனிப்பயன் சிஎச்எச் உள்ளடக்கம். குறிப்பு: உள்ளடக்கம் மற்றும் தொலைநிலை முகவரி கள் அனுமதிக்கப்படாத பண்புகள். உங்கள் உள்ளடக்கத்தின் முன்னோட்டம் கீழே காட்டப்பட்டுள்ளது. நீங்கள் அமைக்காத கூடுதல் புலங்கள் சுத்திகரிப்பு காரணமாக உள்ளன\",\n        \"enableRemote\": \"ரிமோட் கண்ட்ரோல் சேவையகத்தை இயக்கவும்\",\n        \"enableRemote_description\": \"பயன்பாட்டைக் கட்டுப்படுத்த மற்ற சாதனங்களை அனுமதிக்க ரிமோட் கண்ட்ரோல் சேவையகத்தை இயக்குகிறது\",\n        \"externalLinks\": \"வெளிப்புற இணைப்புகளைக் காட்டு\",\n        \"externalLinks_description\": \"கலைஞர்/ஆல்பம் பக்கங்களில் வெளிப்புற இணைப்புகளை (last.fm, மியூசிக் ப்ரெய்ன்ச்) காண்பிக்க உதவுகிறது\",\n        \"exitToTray\": \"தட்டில் வெளியேறவும்\",\n        \"globalMediaHotkeys\": \"உலகளாவிய மீடியா ஆட்கீச்\",\n        \"discordUpdateInterval\": \"{{discord}} பணக்கார இருப்பு புதுப்பிப்பு இடைவெளி\",\n        \"discordUpdateInterval_description\": \"ஒவ்வொரு புதுப்பிப்புக்கும் இடையிலான விநாடிகளில் நேரம் (குறைந்தபட்சம் 15 வினாடிகள்)\",\n        \"homeConfiguration_description\": \"என்னென்ன உருப்படிகள் காட்டப்படுகின்றன, எந்த வரிசையில், முகப்பு பக்கத்தில் உள்ளமைக்கவும்\",\n        \"homeFeature_description\": \"முகப்பு பக்கத்தில் பெரிய பிரத்யேக கொணர்வி காட்ட வேண்டுமா என்பதைக் கட்டுப்படுத்துகிறது\",\n        \"hotkey_browserBack\": \"உலாவி மீண்டும்\",\n        \"hotkey_browserForward\": \"முன்னோக்கி உலாவி\",\n        \"hotkey_favoritePreviousSong\": \"பிடித்த $t(common.previousSong)\",\n        \"hotkey_localSearch\": \"பக்க தேடல்\",\n        \"hotkey_playbackNext\": \"அடுத்த பாடல்\",\n        \"hotkey_playbackPause\": \"இடைநிறுத்தம்\",\n        \"hotkey_playbackPlay\": \"விளையாடுங்கள்\",\n        \"hotkey_playbackPlayPause\": \"விளையாடு / இடைநிறுத்தம்\",\n        \"hotkey_skipBackward\": \"பின்தங்கிய நிலையில் தவிர்க்கவும்\",\n        \"hotkey_zoomIn\": \"பெரிதாக்கு\",\n        \"hotkey_zoomOut\": \"சிறிதாக்கு\",\n        \"imageAspectRatio\": \"சொந்த கவர் கலை விகித விகிதத்தைப் பயன்படுத்தவும்\",\n        \"imageAspectRatio_description\": \"இயக்கப்பட்டால், கவர் கலை அவற்றின் சொந்த விகித விகிதத்தைப் பயன்படுத்தி காண்பிக்கப்படும். 1: 1 இல்லாத கலைக்கு, மீதமுள்ள இடம் காலியாக இருக்கும்\",\n        \"lyricFetch_description\": \"பல்வேறு இணைய மூலங்களிலிருந்து பாடல் வரிகள்\",\n        \"lyricFetchProvider\": \"பாடல் பெற வழங்குநர்கள்\",\n        \"lyricOffset_description\": \"குறிப்பிட்ட அளவு மில்லி விநாடிகளால் பாடலை ஈடுசெய்யவும்\",\n        \"hotkey_skipForward\": \"முன்னோக்கி செல்லுங்கள்\",\n        \"hotkey_toggleCurrentSongFavorite\": \"மாற்று $t(common.currentSong) பிடித்தது\",\n        \"minimizeToTray_description\": \"கணினி தட்டில் பயன்பாட்டைக் குறைக்கவும்\",\n        \"playButtonBehavior_optionPlay\": \"$t(player.play)\",\n        \"playButtonBehavior_optionPlayShuffled\": \"$t(player.shuffle)\",\n        \"savePlayQueue\": \"விளையாட்டு வரிசையை சேமிக்கவும்\",\n        \"savePlayQueue_description\": \"பயன்பாடு மூடப்படும் போது ப்ளே வரிசையை சேமித்து, பயன்பாடு திறக்கப்படும் போது அதை மீட்டெடுக்கவும்\",\n        \"scrobble\": \"ச்க்ரோபில்\",\n        \"scrobble_description\": \"உங்கள் மீடியா சேவையகத்திற்கு ச்க்ரோபில் விளையாடுகிறது\",\n        \"showSkipButton\": \"ச்கிப் பொத்தான்களைக் காட்டு\",\n        \"showSkipButton_description\": \"பிளேயர் பட்டியில் ச்கிப் பொத்தான்களைக் காட்டவும் அல்லது மறைக்கவும்\",\n        \"sidebarConfiguration\": \"பக்கப்பட்டி உள்ளமைவு\",\n        \"sidebarConfiguration_description\": \"பக்கப்பட்டியில் தோன்றும் உருப்படிகள் மற்றும் வரிசையைத் தேர்ந்தெடுக்கவும்\",\n        \"showSkipButtons\": \"ச்கிப் பொத்தான்களைக் காட்டு\",\n        \"showSkipButtons_description\": \"பிளேயர் பட்டியில் ச்கிப் பொத்தான்களைக் காட்டவும் அல்லது மறைக்கவும்\",\n        \"sidebarCollapsedNavigation\": \"பக்கப்பட்டி (சரிந்த) வழிசெலுத்தல்\",\n        \"sidebarCollapsedNavigation_description\": \"சரிந்த பக்கப்பட்டியில் வழிசெலுத்தலைக் காட்டவும் அல்லது மறைக்கவும்\",\n        \"sidebarPlaylistList\": \"பக்கப்பட்டி பிளேலிச்ட் பட்டியல்\",\n        \"sidebarPlaylistList_description\": \"பக்கப்பட்டியில் பிளேலிச்ட் பட்டியலைக் காட்டவும் அல்லது மறைக்கவும்\",\n        \"sidePlayQueueStyle\": \"சைட் பிளே வரிசை பாணி\",\n        \"sidePlayQueueStyle_description\": \"பக்க நாடக வரிசையின் பாணியை அமைக்கிறது\",\n        \"sidePlayQueueStyle_optionAttached\": \"இணைக்கப்பட்டுள்ளது\",\n        \"sidePlayQueueStyle_optionDetached\": \"பிரிக்கப்பட்டது\",\n        \"theme_description\": \"பயன்பாட்டிற்கு பயன்படுத்த கருப்பொருள் அமைக்கிறது\",\n        \"themeDark\": \"கருப்பொருள் (இருண்ட)\",\n        \"themeDark_description\": \"பயன்பாட்டிற்கு பயன்படுத்த இருண்ட கருப்பொருள் அமைக்கிறது\",\n        \"skipDuration\": \"கால அளவைத் தவிர்க்கவும்\",\n        \"skipDuration_description\": \"பிளேயர் பட்டியில் தவிர் பொத்தான்களைப் பயன்படுத்தும் போது தவிர்க்க வேண்டிய காலத்தை அமைக்கிறது\",\n        \"skipPlaylistPage\": \"பிளேலிச்ட் பக்கத்தைத் தவிர்க்கவும்\",\n        \"skipPlaylistPage_description\": \"பிளேலிச்ட்டுக்கு செல்லும்போது, இயல்புநிலை பக்கத்திற்கு பதிலாக பிளேலிச்ட் பாடல் பட்டியல் பக்கத்திற்குச் செல்லவும்\",\n        \"startMinimized\": \"குறைக்கத் தொடங்குங்கள்\",\n        \"startMinimized_description\": \"கணினி தட்டில் பயன்பாட்டைத் தொடங்கவும்\",\n        \"theme\": \"கருப்பொருள்\",\n        \"themeLight\": \"கருப்பொருள் (ஒளி)\",\n        \"volumeWheelStep\": \"தொகுதி சக்கர படி\",\n        \"volumeWheelStep_description\": \"தொகுதி ச்லைடரில் சுட்டி சக்கரத்தை ச்க்ரோலிங் செய்யும் போது மாற்ற வேண்டிய அளவின் அளவு\",\n        \"volumeWidth\": \"தொகுதி ச்லைடர் அகலம்\",\n        \"windowBarStyle\": \"சாளரம் பார் பாணி\",\n        \"windowBarStyle_description\": \"சாளர பட்டியின் பாணியைத் தேர்ந்தெடுக்கவும்\",\n        \"useSystemTheme\": \"கணினி கருப்பொருளைப் பயன்படுத்தவும்\",\n        \"useSystemTheme_description\": \"கணினி வரையறுக்கப்பட்ட ஒளி அல்லது இருண்ட விருப்பத்தைப் பின்பற்றவும்\",\n        \"zoom\": \"சூம் விழுக்காடு\",\n        \"zoom_description\": \"பயன்பாட்டிற்கான சூம் சதவீதத்தை அமைக்கிறது\",\n        \"discordPausedStatus\": \"இடைநிறுத்தப்படும்போது பணக்கார இருப்பைக் காட்டுங்கள்\",\n        \"discordPausedStatus_description\": \"இயக்கப்பட்டால், பிளேயர் இடைநிறுத்தப்படும்போது நிலை காண்பிக்கப்படும்\",\n        \"discordServeImage\": \"சேவையகத்திலிருந்து {{discord}} படங்களை பரிமாறவும்\",\n        \"discordServeImage_description\": \"சேவையகத்திலிருந்தே {{discord}} சிறந்த இருப்புக்கான கவர் ஆர்ட்டைப் பகிரவும், செல்லிஃபின் மற்றும் நவிட்ரோமுக்கு மட்டுமே கிடைக்கும். படங்களைப் பெற {{discord}} ஒரு போட்டைப் பயன்படுத்துகிறது, எனவே உங்கள் சர்வர் பொது இணையத்திலிருந்து அணுகக்கூடியதாக இருக்க வேண்டும்\",\n        \"preferLocalLyrics\": \"உள்ளக பாடல்களை விரும்புங்கள்\",\n        \"preferLocalLyrics_description\": \"கிடைக்கும்போது தொலைநிலை பாடல்களை விட உள்ளக பாடல்களை விரும்புங்கள்\",\n        \"lastfm\": \"last.fm இணைப்புகளைக் காட்டு\",\n        \"lastfm_description\": \"கலைஞர்/ஆல்பம் பக்கங்களில் Last.fm க்கான இணைப்புகளைக் காட்டு\",\n        \"musicbrainz\": \"மியூசிக் பிரேன்ச் இணைப்புகளைக் காட்டு\",\n        \"musicbrainz_description\": \"கலைஞர்/ஆல்பம் பக்கங்களில் மியூசிக் பிரைன்ச் இணைப்புகளைக் காட்டு, அங்கு மியூசிக் பிரைன்ச் ID உள்ளது\",\n        \"neteaseTranslation\": \"நெட்ச் மொழிபெயர்ப்புகளை இயக்கவும்\",\n        \"neteaseTranslation_description\": \"இயக்கப்பட்டால், கிடைத்தால் நெட்சிலிருந்து மொழிபெயர்க்கப்பட்ட பாடல்களைப் பெறுகிறது மற்றும் காட்சிப்படுத்துகிறது\",\n        \"preservePitch\": \"சுருதியைப் பாதுகாக்கவும்\",\n        \"preservePitch_description\": \"பின்னணி வேகத்தை மாற்றும்போது சுருதியைப் பாதுகாக்கிறது\",\n        \"autoDJ\": \"ஆட்டோ டி.சே\",\n        \"autoDJ_description\": \"தானாக வரிசையில் ஒத்த பாடல்களைச் சேர்க்கவும்\",\n        \"autoDJ_itemCount\": \"பொருள் எண்ணிக்கை\",\n        \"autoDJ_itemCount_description\": \"ஆட்டோ DJ இயக்கப்பட்டிருக்கும் போது, வரிசையில் சேர்க்க முயற்சிக்கும் உருப்படிகளின் எண்ணிக்கை\",\n        \"autoDJ_timing\": \"நேரவிவரம்\",\n        \"autoDJ_timing_description\": \"ஆட்டோ டிசேக்கு முன் வரிசையில் மீதமுள்ள பாடல்களின் எண்ணிக்கை தூண்டப்படுகிறது\",\n        \"useThemeAccentColor\": \"கருப்பொருள் உச்சரிப்பு நிறத்தைப் பயன்படுத்தவும்\",\n        \"useThemeAccentColor_description\": \"தனிப்பயன் உச்சரிப்பு நிறத்திற்குப் பதிலாக தேர்ந்தெடுக்கப்பட்ட தீமில் வரையறுக்கப்பட்ட முதன்மை வண்ணத்தைப் பயன்படுத்தவும்\",\n        \"analyticsDisable\": \"பயன்பாடு அடிப்படையிலான பகுப்பாய்வுகளில் இருந்து விலகுதல்\",\n        \"analyticsDisable_description\": \"பயன்பாட்டை மேம்படுத்த உதவ டெவெலப்பருக்கு அநாமதேய பயன்பாட்டுத் தரவு அனுப்பப்படுகிறது\",\n        \"analyticsEnable\": \"பயன்பாட்டு அடிப்படையிலான பகுப்பாய்வுகளை அனுப்பவும்\",\n        \"analyticsEnable_description\": \"பயன்பாட்டை மேம்படுத்த உதவ டெவெலப்பருக்கு அநாமதேய பயன்பாட்டுத் தரவு அனுப்பப்படுகிறது\",\n        \"artistBackground\": \"கலைஞர் பின்னணி படம்\",\n        \"artistBackground_description\": \"கலைஞர் கலையை உள்ளடக்கிய கலைஞர் பக்கங்களுக்கு பின்னணி படத்தை சேர்க்கிறது\",\n        \"artistBackgroundBlur\": \"கலைஞர் பின்னணி படம் மங்கலான அளவு\",\n        \"artistBackgroundBlur_description\": \"கலைஞரின் பின்னணி படத்தில் பயன்படுத்தப்படும் மங்கலின் அளவை சரிசெய்கிறது\",\n        \"artistReleaseTypeConfiguration\": \"கலைஞர் வெளியீட்டு வகை கட்டமைப்பு\",\n        \"artistReleaseTypeConfiguration_description\": \"ஆல்பம் கலைஞர் பக்கத்தில் என்ன வெளியீட்டு வகைகள் காட்டப்படுகின்றன, எந்த வரிசையில் உள்ளன என்பதை உள்ளமைக்கவும்\",\n        \"crossfadeStyle\": \"குறுக்குவழி பாணி\",\n        \"automaticUpdates\": \"தானியங்கி புதுப்பிப்புகள்\",\n        \"automaticUpdates_description\": \"புதுப்பிப்புகளை தானாக சரிபார்த்து நிறுவவும்\",\n        \"releaseChannel_optionAlpha\": \"ஆல்பா (இரவு)\",\n        \"releaseChannel_optionBeta\": \"பீட்டா\",\n        \"releaseChannel_optionLatest\": \"அண்மைக் கால\",\n        \"releaseChannel\": \"வெளியீடு சேனல்\",\n        \"releaseChannel_description\": \"தானியங்கி புதுப்பிப்புகளுக்கு நிலையான, பீட்டா அல்லது ஆல்பா (இரவு) வெளியீடுகளுக்கு இடையே தேர்வு செய்யவும்\",\n        \"discordDisplayType_artistname\": \"கலைஞர் பெயர்(கள்)\",\n        \"discordDisplayType_description\": \"உங்கள் நிலையில் நீங்கள் கேட்பதை மாற்றுகிறது\",\n        \"discordDisplayType_songname\": \"பாடல் பெயர்\",\n        \"discordDisplayType\": \"{{discord}} இருப்பு காட்சி வகை\",\n        \"discordLinkType_description\": \"{{discord}} சிறந்த முன்னிலையில் பாடல் மற்றும் கலைஞர் புலங்களுக்கு {{lastfm}} அல்லது {{musicbrainz}} வெளிப்புற இணைப்புகளைச் சேர்க்கிறது. {{musicbrainz}} மிகவும் துல்லியமானது ஆனால் குறிச்சொற்கள் தேவை மற்றும் கலைஞர் இணைப்புகளை வழங்காது, {{lastfm}} எப்போதும் இணைப்பை வழங்க வேண்டும். கூடுதல் பிணைய கோரிக்கைகளை செய்யாது\",\n        \"discordLinkType_mbz_lastfm\": \"{{musicbrainz}} உடன் {{lastfm}} ஃபால்பேக்\",\n        \"discordLinkType_none\": \"$t(common.none)\",\n        \"discordLinkType\": \"{{discord}} இருப்பு இணைப்புகள்\",\n        \"discordRichPresence\": \"{{discord}} பணக்கார இருப்பு\",\n        \"discordStateIcon\": \"விளையாடும் ஐகானைக் காட்டு\",\n        \"discordStateIcon_description\": \"பணக்கார இருப்பு நிலையில் சிறிய விளையாடும் ஐகானைக் காட்டு. இடைநிறுத்தப்பட்ட படவுரு எப்போதும் \\\"இடைநிறுத்தப்பட்ட போது பணக்கார இருப்பைக் காட்டு\\\" இயக்கப்பட்டிருக்கும் போது காண்பிக்கப்படும்\",\n        \"enableAutoTranslation_description\": \"பாடல் வரிகள் ஏற்றப்படும் போது தானாகவே மொழிபெயர்ப்பை இயக்கவும்\",\n        \"enableAutoTranslation\": \"தானியங்கு மொழிபெயர்ப்பை இயக்கு\",\n        \"exportImportSettings_control_description\": \"சாதொபொகு வழியாக ஏற்றுமதி மற்றும் இறக்குமதி அமைப்புகளை\",\n        \"exportImportSettings_control_exportText\": \"ஏற்றுமதி அமைப்புகள்\",\n        \"exportImportSettings_control_importText\": \"இறக்குமதி அமைப்புகள்\",\n        \"exportImportSettings_control_title\": \"இறக்குமதி / ஏற்றுமதி அமைப்புகள்\",\n        \"exportImportSettings_destructiveWarning\": \"அமைப்புகளை இறக்குமதி செய்வது அழிவுகரமானது, கீழே உள்ள \\\"இறக்குமதி\\\" என்பதைக் சொடுக்கு செய்வதற்கு முன் மேலே உள்ளவற்றை மதிப்பாய்வு செய்யவும்!\",\n        \"exportImportSettings_importBtn\": \"இறக்குமதி அமைப்புகள்\",\n        \"exportImportSettings_importModalTitle\": \"feishin அமைப்புகளை இறக்குமதி செய்யவும்\",\n        \"exportImportSettings_importSuccess\": \"அமைப்புகள் வெற்றிகரமாக இறக்குமதி செய்யப்பட்டன!\",\n        \"exportImportSettings_notValidJSON\": \"அனுப்பப்பட்ட கோப்பு சாதொபொகு செல்லுபடியாகாது\",\n        \"exportImportSettings_offendingKeyError\": \"\\\"{{offendingKey}}\\\" தவறானது - {{reason}}\",\n        \"followCurrentSong_description\": \"தானாக விளையாடும் வரிசையை தற்போதைய பாடலுக்கு உருட்டும்\",\n        \"followCurrentSong\": \"தற்போதைய பாடலைப் பின்பற்றவும்\",\n        \"homeFeatureStyle_description\": \"வீட்டில் இடம்பெற்றுள்ள கொணர்வியின் பாணியைக் கட்டுப்படுத்துகிறது\",\n        \"homeFeatureStyle\": \"வீட்டில் இடம்பெற்றது கொணர்வி பாணி\",\n        \"homeFeatureStyle_optionMultiple\": \"பல\",\n        \"homeFeatureStyle_optionSingle\": \"ஒற்றை\",\n        \"hotkey_listNavigateToPage\": \"பட்டியல் உருப்படி பக்கத்திற்கு செல்லவும்\",\n        \"hotkey_listPlayDefault\": \"பட்டியல் நாடகம்\",\n        \"hotkey_listPlayLast\": \"பட்டியல் கடைசியாக விளையாடு\",\n        \"hotkey_listPlayNext\": \"பட்டியல் அடுத்து விளையாடு\",\n        \"hotkey_listPlayNow\": \"பட்டியல் விளையாட இப்போது\",\n        \"hotkey_navigateHome\": \"வீட்டிற்கு செல்லவும்\",\n        \"language\": \"மொழி\",\n        \"logLevel\": \"பதிவு நிலை\",\n        \"logLevel_description\": \"காண்பிக்க குறைந்தபட்ச பதிவு அளவை அமைக்கிறது. பிழைத்திருத்தம் அனைத்து பதிவுகளையும் காட்டுகிறது, பிழை பிழைகளை மட்டுமே காட்டுகிறது\",\n        \"logLevel_optionDebug\": \"பிழைத்திருத்தம்\",\n        \"logLevel_optionError\": \"பிழை\",\n        \"logLevel_optionInfo\": \"தகவல்\",\n        \"logLevel_optionWarn\": \"முன்னறிவிப்பு\",\n        \"mpvExtraParameters\": \"mpv கூடுதல் அளவுருக்கள்\",\n        \"mpvExtraParameters_description\": \"mpv க்கு அனுப்ப கூடுதல் வாதங்கள்\",\n        \"notify\": \"பாடல் அறிவிப்புகளை இயக்கவும்\",\n        \"notify_description\": \"தற்போதைய பாடலை மாற்றும்போது அறிவிப்புகளைக் காட்டு\",\n        \"pathReplace\": \"கோப்பு பாதை மாற்று\",\n        \"pathReplace_description\": \"உங்கள் சேவையகத்தின் இயல்புநிலை கோப்பு பாதையை மாற்றவும்\",\n        \"pathReplace_optionRemovePrefix\": \"முன்னொட்டை அகற்று\",\n        \"pathReplace_optionAddPrefix\": \"முன்னொட்டு சேர்க்கவும்\",\n        \"playerFilters\": \"வரிசையில் இருந்து பாடல்களை வடிகட்டவும்\",\n        \"playerFilters_description\": \"பின்வரும் அளவுகோல்களின் அடிப்படையில் பாடல்களை வரிசையில் சேர்க்காமல் தவிர்க்கவும்\",\n        \"artistRadioCount_description\": \"கலைஞர் வானொலி மற்றும் ட்ராக் வானொலிக்கான பாடல்களின் எண்ணிக்கையை அமைக்கிறது\",\n        \"artistRadioCount\": \"கலைஞர்/டிராக் ரேடியோ எண்ணிக்கை\",\n        \"imageResolution\": \"படத்தின் தீர்மானம்\",\n        \"imageResolution_description\": \"பயன்பாட்டைச் சுற்றிப் பயன்படுத்தப்படும் படங்களுக்கான தீர்மானம். 0 இன் மதிப்பைப் பயன்படுத்துவது இயல்பான படத் தீர்மானத்திற்கு இயல்புநிலையாக இருக்கும்\",\n        \"imageResolution_optionTable\": \"அட்டவணை\",\n        \"imageResolution_optionItemCard\": \"பொருள் அட்டை\",\n        \"imageResolution_optionSidebar\": \"பக்கப்பட்டி\",\n        \"imageResolution_optionHeader\": \"தலைப்பி\",\n        \"imageResolution_optionFullScreenPlayer\": \"முழுத்திரை பிளேயர்\",\n        \"playerbarSlider\": \"பிளேயர்பார் ச்லைடர்\",\n        \"playerbarSlider_description\": \"மெதுவான அல்லது மீட்டர் இணைய இணைப்பில் இருந்தால் அலைவடிவம் பரிந்துரைக்கப்படுவதில்லை\",\n        \"playerbarSliderType_optionSlider\": \"ச்லைடர்\",\n        \"playerbarSliderType_optionWaveform\": \"அலைவடிவம், அலைப்படம்\",\n        \"playerbarWaveformAlign\": \"அலை வடிவ சீரமைப்பு\",\n        \"playerbarWaveformAlign_optionTop\": \"மேலே\",\n        \"playerbarWaveformAlign_optionCenter\": \"நடுவண்\",\n        \"playerbarWaveformAlign_optionBottom\": \"கீழே\",\n        \"playerbarWaveformBarWidth\": \"அலைவடிவ பட்டை அகலம்\",\n        \"playerbarWaveformGap\": \"அலைவடிவ இடைவெளி\",\n        \"playerbarWaveformRadius\": \"அலைவடிவ ஆரம்\",\n        \"showLyricsInSidebar_description\": \"பாடல் வரிகளைக் காண்பிக்கும் இணைக்கப்பட்ட நாடக வரிசையில் ஒரு குழு சேர்க்கப்படும்\",\n        \"showLyricsInSidebar\": \"பிளேயர் பக்கப்பட்டியில் பாடல் வரிகளைக் காட்டு\",\n        \"showRatings_description\": \"நட்சத்திர மதிப்பீடு நற்பொருத்தம் இடைமுகத்தில் காட்டப்பட்டால் கட்டுப்படுத்துகிறது\",\n        \"showRatings\": \"நட்சத்திர மதிப்பீடுகளைக் காட்டு\",\n        \"blurExplicitImages\": \"வெளிப்படையான படங்களை மங்கலாக்கும்\",\n        \"blurExplicitImages_description\": \"வெளிப்படையாகக் குறியிடப்பட்ட ஆல்பம் மற்றும் பாடல் கலைப்படைப்புகள் மங்கலாக்கப்படும்\",\n        \"enableGridMultiSelect\": \"கட்டம் பல தேர்வை இயக்கவும்\",\n        \"enableGridMultiSelect_description\": \"இயக்கப்பட்டால், கட்டக் காட்சிகளில் பல உருப்படிகளைத் தேர்ந்தெடுக்க அனுமதிக்கிறது. முடக்கப்பட்டிருக்கும் போது, கிரிட் உருப்படி படங்களைக் சொடுக்கு செய்வதன் மூலம் உருப்படி பக்கத்திற்குச் செல்லும்\",\n        \"showVisualizerInSidebar_description\": \"விசுவலைசரைக் காட்டும் பிளேயர் பக்கப்பட்டியில் ஒரு பேனல் சேர்க்கப்படும்\",\n        \"showVisualizerInSidebar\": \"பிளேயர் பக்கப்பட்டியில் காட்சிப்படுத்தலைக் காட்டு\",\n        \"combinedLyricsAndVisualizer_description\": \"பாடல் வரிகளையும் காட்சிப்படுத்தலையும் ஒரே பேனலில் இணைக்கவும்\",\n        \"combinedLyricsAndVisualizer\": \"பிளேயர் பக்கப்பட்டியில் பாடல் வரிகள் மற்றும் காட்சிப்படுத்தல் ஆகியவற்றை இணைக்கவும்\",\n        \"audioFadeOnStatusChange\": \"நிலை மாறும்போது ஆடியோ மங்குகிறது\",\n        \"audioFadeOnStatusChange_description\": \"ப்ளே/இடைநிறுத்தம் நிலை மாறும்போது ஃபேட் அவுட் மற்றும் ஃபேட் இன் செயல்படுத்துகிறது\",\n        \"preventSleepOnPlayback_description\": \"இசை இயங்கும் போது காட்சி தூங்குவதைத் தடுக்கிறது\",\n        \"preventSleepOnPlayback\": \"பிளேபேக்கில் தூக்கத்தைத் தடுக்கவும்\",\n        \"sidebarPlaylistSorting_description\": \"இயல்புநிலை சர்வர் ஆர்டருக்குப் பதிலாக இழுத்து விடுவதைப் பயன்படுத்தி பக்கப்பட்டியில் கைமுறையாக பிளேலிச்ட்டை வரிசைப்படுத்த அனுமதிக்கிறது\",\n        \"sidebarPlaylistSorting\": \"பக்கப்பட்டி பிளேலிச்ட் வரிசையாக்கம்\",\n        \"sidebarPlaylistListFilterRegex_description\": \"இந்த வழக்கமான வெளிப்பாட்டுடன் பொருந்தக்கூடிய பிளேலிச்ட்களை பக்கப்பட்டியில் மறைக்கவும்\",\n        \"sidebarPlaylistListFilterRegex_placeholder\": \"எ.கா. ^தினசரி கலவை.*\",\n        \"sidebarPlaylistListFilterRegex\": \"பிளேலிச்ட் வடிகட்டி வழக்கவெளி\",\n        \"mediaSession_description\": \"மீடியா அமர்வு ஒருங்கிணைப்பை செயல்படுத்துகிறது, மீடியா கட்டுப்பாடுகள் மற்றும் மெட்டாடேட்டாவை கணினி தொகுதி மேலடுக்கு மற்றும் பூட்டுத் திரையில் காண்பிக்கும்\",\n        \"mediaSession\": \"ஊடக அமர்வை இயக்கவும்\",\n        \"transcode\": \"டிரான்ச்கோடிங்கை இயக்கவும்\",\n        \"queryBuilder\": \"வினவல் கட்டுபவர்\",\n        \"queryBuilderCustomFields_inputLabel\": \"சிட்டை\",\n        \"queryBuilderCustomFields_inputTag\": \"குறிச்சொல்\",\n        \"queryBuilderCustomFields\": \"விருப்ப புலங்கள்\",\n        \"queryBuilderCustomFields_description\": \"வினவல் பில்டர்களில் பயன்படுத்த தனிப்பயன் புலங்களைச் சேர்க்கவும்\"\n    },\n    \"table\": {\n        \"config\": {\n            \"label\": {\n                \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n                \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n                \"biography\": \"$t(common.biography)\",\n                \"bitrate\": \"$t(common.bitrate)\",\n                \"bpm\": \"$t(common.bpm)\",\n                \"channels\": \"$t(common.channel, {\\\"count\\\": 2})\",\n                \"codec\": \"$t(common.codec)\",\n                \"dateAdded\": \"தேதி சேர்க்கப்பட்டது\",\n                \"rating\": \"$t(common.rating)\",\n                \"releaseDate\": \"வெளியீட்டு தேதி\",\n                \"rowIndex\": \"வரிசை அட்டவணை\",\n                \"size\": \"$t(common.size)\",\n                \"trackNumber\": \"ட்ராக் எண்\",\n                \"year\": \"$t(common.year)\",\n                \"lastPlayed\": \"கடைசியாக விளையாடியது\",\n                \"note\": \"$t(common.note)\",\n                \"owner\": \"$t(common.owner)\",\n                \"actions\": \"$t(common.action, {\\\"count\\\": 2})\",\n                \"albumArtist\": \"$t(entity.albumArtist, {\\\"count\\\": 1})\",\n                \"discNumber\": \"வட்டு எண்\",\n                \"duration\": \"$t(common.duration)\",\n                \"favorite\": \"$t(common.favourite)\",\n                \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n                \"path\": \"$t(common.path)\",\n                \"playCount\": \"விளையாட்டு எண்ணிக்கை\",\n                \"songCount\": \"$t(entity.track, {\\\"count\\\": 2})\",\n                \"title\": \"$t(common.title)\",\n                \"titleCombined\": \"$t(common.title) (இணைந்தது)\",\n                \"albumGroup\": \"ஆல்பம் குழு\",\n                \"albumCount\": \"$t(entity.album, {\\\"count\\\": 2})\",\n                \"bitDepth\": \"$t(common.bitDepth)\",\n                \"composer\": \"இசையமைப்பாளர்\",\n                \"genreBadge\": \"$t(entity.genre, {\\\"count\\\": 1}) (பேட்ச்கள்)\",\n                \"image\": \"படம்\",\n                \"sampleRate\": \"$t(common.sampleRate)\",\n                \"titleArtist\": \"$t(common.title) (கலைஞர்)\"\n            },\n            \"view\": {\n                \"table\": \"அட்டவணை\",\n                \"grid\": \"வலைவாய்\",\n                \"list\": \"பட்டியல்\",\n                \"detail\": \"விவரம்\"\n            },\n            \"general\": {\n                \"autoFitColumns\": \"ஆட்டோ பொருத்தம் நெடுவரிசைகள்\",\n                \"followCurrentSong\": \"தற்போதைய பாடலைப் பின்தொடரவும்\",\n                \"displayType\": \"காட்சி வகை\",\n                \"gap\": \"$t(common.gap)\",\n                \"itemGap\": \"உருப்படி இடைவெளி (பிஎக்ச்)\",\n                \"itemSize\": \"உருப்படி அளவு (பிஎக்ச்)\",\n                \"size\": \"$t(common.size)\",\n                \"tableColumns\": \"அட்டவணை நெடுவரிசைகள்\",\n                \"advancedSettings\": \"மேம்பட்ட அமைப்புகள்\",\n                \"autosize\": \"தானியங்கு அளவு\",\n                \"moveUp\": \"மேலே செல்ல\",\n                \"moveDown\": \"கீழே நகர\",\n                \"pinToLeft\": \"இடப்புறம் முள்\",\n                \"pinToRight\": \"வலதுபுறமாக முள்\",\n                \"alignLeft\": \"இடதுபுறம் சீரமைக்கவும்\",\n                \"alignCenter\": \"மையத்தை சீரமைக்கவும்\",\n                \"alignRight\": \"வலது சீரமை\",\n                \"itemsPerRow\": \"ஒரு வரிசைக்கு பொருட்கள்\",\n                \"size_default\": \"இயல்புநிலை\",\n                \"size_compact\": \"கச்சிதமான\",\n                \"size_large\": \"பெரிய\",\n                \"pagination\": \"பேசினேசன்\",\n                \"pagination_itemsPerPage\": \"ஒரு பக்கத்திற்கு உருப்படிகள்\",\n                \"pagination_infinite\": \"கந்தழி, முடிவிலி\",\n                \"pagination_paginate\": \"பக்கமாக\",\n                \"alternateRowColors\": \"மாற்று வரிசை வண்ணங்கள்\",\n                \"horizontalBorders\": \"வரிசை எல்லைகள்\",\n                \"rowHoverHighlight\": \"வரிசை மிதவை ஐலைட்\",\n                \"showHeader\": \"தலைப்பு நிகழ்ச்சி\",\n                \"verticalBorders\": \"நெடுவரிசை எல்லைகள்\"\n            }\n        },\n        \"column\": {\n            \"album\": \"ஆல்பம்\",\n            \"albumArtist\": \"ஆல்பம் கலைஞர்\",\n            \"albumCount\": \"$t(entity.album, {\\\"count\\\": 2})\",\n            \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n            \"biography\": \"சுயசரிதை\",\n            \"bitrate\": \"பிட்ரேட்\",\n            \"bpm\": \"பிபிஎம்\",\n            \"channels\": \"$t(common.channel, {\\\"count\\\": 2})\",\n            \"codec\": \"$t(common.codec)\",\n            \"comment\": \"கருத்து\",\n            \"dateAdded\": \"தேதி சேர்க்கப்பட்டது\",\n            \"discNumber\": \"வட்டு\",\n            \"favorite\": \"பிடித்த\",\n            \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n            \"lastPlayed\": \"கடைசியாக விளையாடியது\",\n            \"path\": \"பாதை\",\n            \"playCount\": \"நாடகங்கள்\",\n            \"rating\": \"செயல்வரம்பு\",\n            \"releaseDate\": \"வெளியீட்டு தேதி\",\n            \"releaseYear\": \"ஆண்டு\",\n            \"size\": \"$t(common.size)\",\n            \"songCount\": \"$t(entity.track, {\\\"count\\\": 2})\",\n            \"title\": \"தலைப்பு\",\n            \"trackNumber\": \"மின்தடம்\",\n            \"bitDepth\": \"$t(common.bitDepth)\",\n            \"sampleRate\": \"$t(common.sampleRate)\",\n            \"owner\": \"உரிமையாளர்\"\n        }\n    },\n    \"datetime\": {\n        \"minuteShort\": \"மீ\",\n        \"secondShort\": \"கள்\",\n        \"hourShort\": \"ம\",\n        \"dayShort\": \"டி\"\n    },\n    \"filterOperator\": {\n        \"after\": \"பிறகு உள்ளது\",\n        \"afterDate\": \"பிறகு (தேதி)\",\n        \"before\": \"முன்பு உள்ளது\",\n        \"beforeDate\": \"முன் (தேதி)\",\n        \"contains\": \"கொண்டுள்ளது\",\n        \"endsWith\": \"உடன் முடிகிறது\",\n        \"inPlaylist\": \"உள்ளது\",\n        \"inTheLast\": \"கடைசியில் உள்ளது\",\n        \"inTheRange\": \"வரம்பில் உள்ளது\",\n        \"inTheRangeDate\": \"வரம்பில் உள்ளது (தேதி)\",\n        \"is\": \"உள்ளது\",\n        \"isNot\": \"இல்லை\",\n        \"isGreaterThan\": \"விட அதிகமாக உள்ளது\",\n        \"isLessThan\": \"விட குறைவாக உள்ளது\",\n        \"matchesRegex\": \"ரெசெக்சுடன் பொருந்துகிறது\",\n        \"notContains\": \"கொண்டிருக்கவில்லை\",\n        \"notInPlaylist\": \"உள்ளே இல்லை\",\n        \"notInTheLast\": \"கடைசியில் இல்லை\",\n        \"startsWith\": \"உடன் தொடங்குகிறது\"\n    },\n    \"queryBuilder\": {\n        \"standardTags\": \"நிலையான குறிச்சொற்கள்\",\n        \"customTags\": \"விருப்ப குறிச்சொற்கள்\"\n    },\n    \"releaseType\": {\n        \"primary\": {\n            \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n            \"broadcast\": \"ஒளிபரப்பு\",\n            \"ep\": \"எபி\",\n            \"other\": \"மற்றொன்று\",\n            \"single\": \"ஒற்றை\"\n        },\n        \"secondary\": {\n            \"audiobook\": \"ஒலிப்புத்தகம்\",\n            \"audioDrama\": \"ஆடியோ நாடகம்\",\n            \"compilation\": \"தொகுத்தல்\",\n            \"djMix\": \"dj கலவை\",\n            \"demo\": \"டெமோ\",\n            \"fieldRecording\": \"களப்பதிவு\",\n            \"interview\": \"நேர்காணல்\",\n            \"live\": \"வாழ்க\",\n            \"mixtape\": \"கலவை\",\n            \"remix\": \"ரீமிக்ச்\",\n            \"soundtrack\": \"ஒலிப்பதிவு\",\n            \"spokenWord\": \"பேசப்பட்ட சொல்\"\n        }\n    },\n    \"dragDropZone\": {\n        \"error_oneFileOnly\": \"1 கோப்பை மட்டும் தேர்ந்தெடுக்கவும்\",\n        \"error_readingFile\": \"கோப்பைப் படிப்பதில் சிக்கல் உள்ளது: {{errorMessage}}\",\n        \"mainText\": \"ஒரு கோப்பை இங்கே விடுங்கள்\"\n    },\n    \"visualizer\": {\n        \"visualizerType\": \"விசுவலைசர் வகை\",\n        \"cyclePresets\": \"சுழற்சி முன்னமைவுகள்\",\n        \"cycleTime\": \"சுழற்சி நேரம் (வினாடிகள்)\",\n        \"includeAllPresets\": \"அனைத்து முன்னமைவுகளையும் சேர்க்கவும்\",\n        \"ignoredPresets\": \"புறக்கணிக்கப்பட்ட முன்னமைவுகள்\",\n        \"selectedPresets\": \"தேர்ந்தெடுக்கப்பட்ட முன்னமைவுகள்\",\n        \"randomizeNextPreset\": \"அடுத்த முன்னமைவை சீரமைக்கவும்\",\n        \"blendTime\": \"கலப்பு நேரம்\",\n        \"presets\": \"முன்னமைவுகள்\",\n        \"selectPreset\": \"முன்னமைவைத் தேர்ந்தெடுக்கவும்\",\n        \"applyPreset\": \"முன்னமைவைப் பயன்படுத்தவும்\",\n        \"saveAsPreset\": \"முன்னமைவாக சேமிக்கவும்\",\n        \"updatePreset\": \"முன்னமைவைப் புதுப்பிக்கவும்\",\n        \"copyConfiguration\": \"நகல் கட்டமைப்பு\",\n        \"pasteConfiguration\": \"ஒட்டு கட்டமைப்பு\",\n        \"pasteConfigurationPlaceholder\": \"சாதொபொகு உள்ளமைவை இங்கே ஒட்டவும்...\",\n        \"pasteFromClipboard\": \"கிளிப்போர்டில் இருந்து ஒட்டவும்\",\n        \"applyConfiguration\": \"உள்ளமைவைப் பயன்படுத்து\",\n        \"configCopied\": \"உள்ளமைவு இடைநிலைப்பலகைக்கு நகலெடுக்கப்பட்டது\",\n        \"configCopyFailed\": \"உள்ளமைவை நகலெடுக்க முடியவில்லை\",\n        \"configPasted\": \"உள்ளமைவு வெற்றிகரமாக பயன்படுத்தப்பட்டது\",\n        \"configPasteFailed\": \"உள்ளமைவைப் பயன்படுத்துவதில் தோல்வி. தயவுசெய்து வடிவமைப்பைச் சரிபார்க்கவும்.\",\n        \"configPasteReadFailed\": \"கிளிப்போர்டில் இருந்து படிக்க முடியவில்லை\",\n        \"presetName\": \"முன்னமைக்கப்பட்ட பெயர்\",\n        \"presetNamePlaceholder\": \"முன்னமைக்கப்பட்ட பெயரை உள்ளிடவும்\",\n        \"general\": \"பொது\",\n        \"mode\": \"பயன்முறை\",\n        \"mode1To8\": \"முறை 1 - 8\",\n        \"mode10\": \"முறை 10\",\n        \"barSpace\": \"பார் இடம்\",\n        \"lineWidth\": \"வரி அகலம்\",\n        \"fillAlpha\": \"ஆல்ஃபாவை நிரப்பவும்\",\n        \"channelLayout\": \"சேனல் தளவமைப்பு\",\n        \"maxFPS\": \"அதிகபட்ச FPS\",\n        \"opacity\": \"ஒளிபுகாநிலை\",\n        \"customGradients\": \"தனிப்பயன் சாய்வு\",\n        \"addCustomGradient\": \"தனிப்பயன் சாய்வு சேர்க்கவும்\",\n        \"gradientName\": \"சாய்வு பெயர்\",\n        \"gradientNamePlaceholder\": \"சாய்வு பெயர்\",\n        \"vertical\": \"செங்குத்து\",\n        \"horizontal\": \"கிடைமட்ட\",\n        \"colorStops\": \"வண்ண நிறுத்தங்கள்\",\n        \"addColor\": \"வண்ணத்தைச் சேர்க்கவும்\",\n        \"position\": \"பதவி\",\n        \"level\": \"நிலை\",\n        \"remove\": \"அகற்று\",\n        \"pasteGradient\": \"சாய்வு ஒட்டவும்\",\n        \"pasteGradientPlaceholder\": \"இங்கே சாய்வு சாதொபொகு ஒட்டவும்...\",\n        \"custom\": \"தனிப்பயன்\",\n        \"builtIn\": \"உள்ளமைக்கப்பட்ட\",\n        \"colors\": \"நிறங்கள்\",\n        \"colorMode\": \"வண்ண முறை\",\n        \"gradient\": \"சரிவு\",\n        \"gradientLeft\": \"கிரேடியன்ட் இடது\",\n        \"gradientRight\": \"கிரேடியன்ட் ரைட்\",\n        \"fft\": \"FFT\",\n        \"fftSize\": \"FFT அளவு\",\n        \"smoothing\": \"மென்மையாக்கும்\",\n        \"frequencyRangeAndScaling\": \"அதிர்வெண் வரம்பு மற்றும் அளவிடுதல்\",\n        \"minimumFrequency\": \"குறைந்தபட்ச அதிர்வெண்\",\n        \"maximumFrequency\": \"அதிகபட்ச அதிர்வெண்\",\n        \"frequencyScale\": \"அதிர்வெண் அளவுகோல்\",\n        \"sensitivity\": \"உணர்திறன்\",\n        \"weightingFilter\": \"எடை வடிகட்டி\",\n        \"minimumDecibels\": \"குறைந்தபட்ச டெசிபல்கள்\",\n        \"maximumDecibels\": \"அதிகபட்ச டெசிபல்கள்\",\n        \"linearAmplitude\": \"நேரியல் அலைவீச்சு\",\n        \"linearBoost\": \"லீனியர் பூச்ட்\",\n        \"peakBehavior\": \"உச்ச நடத்தை\",\n        \"showPeaks\": \"சிகரங்களைக் காட்டு\",\n        \"fadePeaks\": \"மங்கலான சிகரங்கள்\",\n        \"peakLine\": \"உச்சக் கோடு\",\n        \"gravity\": \"புவியீர்ப்பு\",\n        \"peakFadeTime\": \"பீக் ஃபேட் நேரம் (மிவி)\",\n        \"peakHoldTime\": \"பீக் ஓல்ட் நேரம் (மிவி)\",\n        \"radialSpectrum\": \"ரேடியல் ச்பெக்ட்ரம்\",\n        \"radial\": \"ரேடியல்\",\n        \"radialInvert\": \"ரேடியல் தலைகீழ்\",\n        \"spinSpeed\": \"சுழல் விரைவு\",\n        \"radius\": \"ஆரம்\",\n        \"reflexMirror\": \"ரிஃப்ளெக்ச் மிரர்\",\n        \"reflexFit\": \"ரிஃப்ளெக்ச் ஃபிட்\",\n        \"reflexRatio\": \"பிரதிபலிப்பு விகிதம்\",\n        \"reflexAlpha\": \"ரிஃப்ளெக்ச் ஆல்பா\",\n        \"reflexBrightness\": \"ரிஃப்ளெக்ச் ஒளி\",\n        \"mirror\": \"கண்ணாடி\",\n        \"miscellaneousSettings\": \"இதர அமைப்புகள்\",\n        \"alphaBars\": \"ஆல்பா பார்கள்\",\n        \"ansiBands\": \"ANSI பட்டைகள்\",\n        \"ledBars\": \"LED பார்கள்\",\n        \"trueLeds\": \"உண்மையான எல்.ஈ\",\n        \"lumiBars\": \"லுமி பார்கள்\",\n        \"outlineBars\": \"அவுட்லைன் பார்கள்\",\n        \"roundBars\": \"சுற்று பார்கள்\",\n        \"lowResolution\": \"குறைந்த தெளிவுத்திறன்\",\n        \"splitGradient\": \"பிளவு சாய்வு\",\n        \"showFPS\": \"FPS ஐக் காட்டு\",\n        \"showScaleX\": \"ஃச் அளவைக் காட்டு\",\n        \"noteLabels\": \"குறிப்பு லேபிள்கள்\",\n        \"showScaleY\": \"ஒய் அளவைக் காட்டு\",\n        \"options\": {\n            \"mode\": {\n                \"0\": \"[0] தனித்துவமான அதிர்வெண்கள்\",\n                \"1\": \"[1] 1/24வது ஆக்டேவ் / 240 பட்டைகள்\",\n                \"2\": \"[2] 1/12வது ஆக்டேவ் / 120 பட்டைகள்\",\n                \"3\": \"[3] 1/8 ஆக்டேவ் / 80 பட்டைகள்\",\n                \"4\": \"[4] 1/6வது ஆக்டேவ் / 60 பட்டைகள்\",\n                \"5\": \"[5] 1/4வது ஆக்டேவ் / 40 பட்டைகள்\",\n                \"6\": \"[6] 1/3 ஆக்டேவ் / 30 பட்டைகள்\",\n                \"7\": \"[7] அரை ஆக்டேவ் / 20 பட்டைகள்\",\n                \"8\": \"[8] முழு ஆக்டேவ் / 10 பட்டைகள்\",\n                \"10\": \"[10] கோடு / பகுதி வரைபடம்\"\n            },\n            \"colorMode\": {\n                \"gradient\": \"சரிவு\",\n                \"barIndex\": \"பார்-இண்டெக்ச்\",\n                \"barLevel\": \"பட்டை-நிலை\"\n            },\n            \"gradient\": {\n                \"classic\": \"கிளாசிக்\",\n                \"prism\": \"அரியம், பட்டகம்\",\n                \"rainbow\": \"வானவில்\",\n                \"steelblue\": \"இரும்புநீலம்\",\n                \"orangered\": \"ஆரஞ்சுசிவப்பு\"\n            },\n            \"channelLayout\": {\n                \"single\": \"ஒற்றை\",\n                \"dualCombined\": \"இரட்டை-இணைந்த\",\n                \"dualHorizontal\": \"இரட்டை-கிடைமட்ட\",\n                \"dualVertical\": \"இரட்டை-செங்குத்து\"\n            },\n            \"frequencyScale\": {\n                \"none\": \"எதுவுமில்லை\",\n                \"bark\": \"பட்டை அளவு\",\n                \"linear\": \"நேரியல் அளவுகோல்\",\n                \"log\": \"பதிவு அளவுகோல்\",\n                \"mel\": \"மெல் அளவுகோல்\"\n            },\n            \"weightingFilter\": {\n                \"none\": \"எதுவுமில்லை\",\n                \"a\": \"ஏ\",\n                \"b\": \"பி\",\n                \"c\": \"சி\",\n                \"d\": \"டி\",\n                \"z\": \"சட்\"\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "src/i18n/locales/tr.json",
    "content": "{\n    \"action\": {\n        \"moveToBottom\": \"alttakine geç\",\n        \"moveToTop\": \"başa dön\",\n        \"removeFromFavorites\": \"$t(entity.favorite, {\\\"count\\\": 2})lerden kaldır\",\n        \"removeFromPlaylist\": \"$t(entity.playlist, {\\\"count\\\": 1}) listesinden kaldır\",\n        \"removeFromQueue\": \"sıradan kaldır\",\n        \"setRating\": \"oyla\",\n        \"viewPlaylists\": \"$t(entity.playlist, {\\\"count\\\": 2}) listesini görüntüle\",\n        \"openIn\": {\n            \"lastfm\": \"Last.fm'de aç\",\n            \"musicbrainz\": \"MusicBrainz'da aç\"\n        },\n        \"addToFavorites\": \"$t(entity.favorite, {\\\"count\\\": 2}) listesine ekle\",\n        \"addToPlaylist\": \"$t(entity.playlist, {\\\"count\\\": 1}) listesine ekle\",\n        \"clearQueue\": \"sırayı temizle\",\n        \"createPlaylist\": \"$t(entity.playlist, {\\\"count\\\": 1}) listesini oluştur\",\n        \"deletePlaylist\": \"$t(entity.playlist, {\\\"count\\\": 1}) listesini sil\",\n        \"deselectAll\": \"seçimleri kaldır\",\n        \"editPlaylist\": \"$t(entity.playlist, {\\\"count\\\": 1}) listesini düzenle\",\n        \"goToPage\": \"sayfaya git\",\n        \"moveToNext\": \"sonrakine geç\",\n        \"refresh\": \"$t(common.refresh)\",\n        \"toggleSmartPlaylistEditor\": \"$t(entity.smartPlaylist) düzenleyiciye geç\",\n        \"addOrRemoveFromSelection\": \"seçime ekle veya seçimi kaldır\",\n        \"selectRangeOfItems\": \"bir dizi öğe seçin\",\n        \"createRadioStation\": \"$t(entity.radioStation, {\\\"count\\\": 1}) oluştur\",\n        \"deleteRadioStation\": \"$t(entity.radioStation, {\\\"count\\\": 1}) istasyonunu sil\",\n        \"selectAll\": \"tümünü seç\",\n        \"downloadStarted\": \"{{count}} öğenin indirilmesine başlandı\",\n        \"moveUp\": \"yukarı kaydır\",\n        \"moveDown\": \"aşağı kaydır\"\n    },\n    \"common\": {\n        \"action_one\": \"eylem\",\n        \"action_other\": \"eylemler\",\n        \"add\": \"ekle\",\n        \"additionalParticipants\": \"ek katılımcılar\",\n        \"newVersion\": \"yeni bir sürüm ({{version}}) yüklendi\",\n        \"viewReleaseNotes\": \"sürüm notlarını görüntüle\",\n        \"areYouSure\": \"emin misin?\",\n        \"backward\": \"geri\",\n        \"biography\": \"biyografi\",\n        \"bitDepth\": \"bit derinliği\",\n        \"bitrate\": \"bit hızı\",\n        \"bpm\": \"bpm\",\n        \"cancel\": \"iptal et\",\n        \"center\": \"merkez\",\n        \"channel_one\": \"kanal\",\n        \"channel_other\": \"kanallar\",\n        \"clear\": \"temizle\",\n        \"close\": \"kapat\",\n        \"codec\": \"codec\",\n        \"comingSoon\": \"çok yakında…\",\n        \"configure\": \"yapılandır\",\n        \"confirm\": \"onayla\",\n        \"create\": \"oluştur\",\n        \"currentSong\": \"şu anki parça $t(entity.track, {\\\"count\\\": 1})\",\n        \"decrease\": \"azalt\",\n        \"delete\": \"sil\",\n        \"descending\": \"azalan\",\n        \"description\": \"açıklama\",\n        \"disable\": \"devre dışı\",\n        \"disc\": \"disk\",\n        \"duration\": \"süre\",\n        \"edit\": \"düzenle\",\n        \"enable\": \"etkinleştir\",\n        \"expand\": \"genişlet\",\n        \"favorite\": \"favori\",\n        \"filter_one\": \"filtre\",\n        \"filter_other\": \"filtreler\",\n        \"filters\": \"filtreler\",\n        \"forceRestartRequired\": \"değişiklikleri uygulamak için yeniden başlatın... yeniden başlatmak için bildirimi kapatın\",\n        \"forward\": \"ileri\",\n        \"gap\": \"boşluk\",\n        \"home\": \"ana sayfa\",\n        \"left\": \"sol\",\n        \"manage\": \"yönet\",\n        \"increase\": \"arttır\",\n        \"limit\": \"sınır\",\n        \"maximize\": \"ekranı kapla\",\n        \"menu\": \"menü\",\n        \"minimize\": \"simge durumuna küçült\",\n        \"modified\": \"değiştirilmiş\",\n        \"mbid\": \"MusicBrainz ID\",\n        \"name\": \"isim\",\n        \"no\": \"hayır\",\n        \"none\": \"hiçbiri\",\n        \"noResultsFromQuery\": \"arama sorguları için sonuç bulunamadı\",\n        \"note\": \"not\",\n        \"ok\": \"tamam\",\n        \"owner\": \"sahip\",\n        \"path\": \"yol\",\n        \"playerMustBePaused\": \"oynatıcı duraklatılmalı\",\n        \"preview\": \"önizleme\",\n        \"previousSong\": \"önceki $t(entity.track, {\\\"count\\\": 1})\",\n        \"quit\": \"çık\",\n        \"random\": \"rastgele\",\n        \"rating\": \"oylama\",\n        \"refresh\": \"yenile\",\n        \"reload\": \"yeniden yükle\",\n        \"reset\": \"sıfırla\",\n        \"resetToDefault\": \"varsayılana sıfırla\",\n        \"restartRequired\": \"yeniden başlatma gerekli\",\n        \"right\": \"sağ\",\n        \"sampleRate\": \"örnekleme hızı\",\n        \"save\": \"kaydet\",\n        \"saveAndReplace\": \"kaydet ve değiştir\",\n        \"saveAs\": \"farklı kaydet\",\n        \"search\": \"arama\",\n        \"setting_one\": \"ayarlar\",\n        \"setting_other\": \"\",\n        \"share\": \"paylaş\",\n        \"size\": \"boyut\",\n        \"sortOrder\": \"sıralama düzeni\",\n        \"tags\": \"etiketler\",\n        \"title\": \"başlık\",\n        \"trackNumber\": \"parça\",\n        \"albumGain\": \"albüm kazancı\",\n        \"albumPeak\": \"albüm zirvesi\",\n        \"ascending\": \"artan\",\n        \"collapse\": \"daralt\",\n        \"dismiss\": \"kapat\",\n        \"translation\": \"çeviri\",\n        \"unknown\": \"bilinmeyen\",\n        \"version\": \"sürüm\",\n        \"year\": \"yıl\",\n        \"yes\": \"evet\",\n        \"trackGain\": \"parça kazancı\",\n        \"trackPeak\": \"parça zirvesi\",\n        \"private\": \"gizli\",\n        \"clean\": \"temiz\",\n        \"countSelected\": \"{{count}} adet seçildi\",\n        \"public\": \"herkese açık\"\n    },\n    \"entity\": {\n        \"album_one\": \"albüm\",\n        \"album_other\": \"albümler\",\n        \"albumArtist_one\": \"albüm sanatçısı\",\n        \"albumArtist_other\": \"albüm sanatçıları\",\n        \"albumArtistCount_one\": \"{{count}} albüm sanatçısı\",\n        \"albumArtistCount_other\": \"{{count}} albüm sanatçıları\",\n        \"albumWithCount_one\": \"{{count}} albüm\",\n        \"albumWithCount_other\": \"{{count}} albüm\",\n        \"artist_one\": \"sanatçı\",\n        \"artist_other\": \"sanatçılar\",\n        \"artistWithCount_one\": \"{{count}} sanatçı\",\n        \"artistWithCount_other\": \"{{count}} sanatçı\",\n        \"favorite_one\": \"favori\",\n        \"favorite_other\": \"favoriler\",\n        \"folder_one\": \"klasör\",\n        \"folder_other\": \"klasörler\",\n        \"folderWithCount_one\": \"{{count}} klasör\",\n        \"folderWithCount_other\": \"{{count}} klasör\",\n        \"genre_one\": \"tür\",\n        \"genre_other\": \"türler\",\n        \"genreWithCount_one\": \"{{count}} tür\",\n        \"genreWithCount_other\": \"{{count}} türler\",\n        \"playlist_one\": \"çalma listesi\",\n        \"playlist_other\": \"çalma listeleri\",\n        \"play_one\": \"{{count}} oynat\",\n        \"play_other\": \"{{count}} oynatma\",\n        \"playlistWithCount_one\": \"{{count}} oynatma listesi\",\n        \"playlistWithCount_other\": \"{{count}} oynatma listesi\",\n        \"smartPlaylist\": \"akıllı $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"track_one\": \"parça\",\n        \"track_other\": \"parçalar\",\n        \"song_one\": \"şarkı\",\n        \"song_other\": \"şarkılar\",\n        \"trackWithCount_one\": \"{{count}} parça\",\n        \"trackWithCount_other\": \"{{count}} parça\",\n        \"radioStation_one\": \"radyo istasyonu\",\n        \"radioStation_other\": \"radyo istasyonları\"\n    },\n    \"error\": {\n        \"apiRouteError\": \"istek yönlendirilemiyor\",\n        \"audioDeviceFetchError\": \"ses aygıtları alınmaya çalışılırken bir hata oluştu\",\n        \"authenticationFailed\": \"kimlik doğrulaması başarısız\",\n        \"badAlbum\": \"bu sayfayı görüyorsunuz çünkü bu şarkı bir albümün parçası değil. büyük olasılıkla müzik klasörünüzün en üst seviyesinde bir şarkınız varsa bu sorunu görüyorsunuz. Jellyfin yalnızca bir klasör içindeyse parçaları gruplandırır\",\n        \"badValue\": \"geçersiz seçenek \\\"{{value}}\\\". bu değer artık mevcut değil\",\n        \"remotePortError\": \"uzak sunucu bağlantı noktası ayarlanmaya çalışılırken bir hata oluştu\",\n        \"remotePortWarning\": \"yeni bağlantı noktasını uygulamak için sunucuyu yeniden başlatın\",\n        \"serverNotSelectedError\": \"sunucu seçili değil\",\n        \"serverRequired\": \"sunucu gerekli\",\n        \"sessionExpiredError\": \"oturumunuzun süresi doldu\",\n        \"systemFontError\": \"sistem fontlarını almaya çalışırken bir hata oluştu\",\n        \"endpointNotImplementedError\": \"{{endpoint}} uç noktası bu {{serverType}} için uygulanamaz\",\n        \"genericError\": \"bir hata oluştu\",\n        \"invalidServer\": \"geçersiz sunucu\",\n        \"localFontAccessDenied\": \"yerel fontlara erişim reddedildi\",\n        \"loginRateError\": \"çok fazla giriş denemesi, lütfen birkaç saniye içinde tekrar deneyin\",\n        \"mpvRequired\": \"MPV gerekli\",\n        \"networkError\": \"bir ağ hatası meydana geldi\",\n        \"notificationDenied\": \"bildirimler için izinler reddedildi. bu ayarın hiçbir etkisi yoktur\",\n        \"openError\": \"dosya açılamadı\",\n        \"playbackError\": \"medya oynatmayı çalışırken bir hata meydana geldi\",\n        \"credentialsRequired\": \"ki̇mli̇k bi̇lgi̇leri̇ gerekli\",\n        \"remoteDisableError\": \"uzak sunucuyu $t(common.disable) yapmaya çalışırken bir hata oluştu\",\n        \"remoteEnableError\": \"uzak sunucuyu $t(common.enable) yapmaya çalışırken bir hata oluştu\"\n    },\n    \"filter\": {\n        \"albumCount\": \"$t(entity.album, {\\\"count\\\": 2}) sayısı\",\n        \"biography\": \"biyografi\",\n        \"bitrate\": \"bit hızı\",\n        \"bpm\": \"bpm\",\n        \"comment\": \"yorum\",\n        \"communityRating\": \"topluluk derecelendirmesi\",\n        \"criticRating\": \"eleştirmen derecelendirmesi\",\n        \"dateAdded\": \"tarih eklendi\",\n        \"disc\": \"disk\",\n        \"duration\": \"süre\",\n        \"favorited\": \"favorilendi\",\n        \"fromYear\": \"yılından itibaren\",\n        \"id\": \"kimlik\",\n        \"isCompilation\": \"derleme\",\n        \"isFavorited\": \"favorilendi\",\n        \"isPublic\": \"halka açıktır\",\n        \"isRated\": \"oylandı\",\n        \"isRecentlyPlayed\": \"yakın zamanda çalındı\",\n        \"lastPlayed\": \"son çalınan\",\n        \"mostPlayed\": \"en çok çalınan\",\n        \"name\": \"isim\",\n        \"note\": \"not\",\n        \"owner\": \"$t(common.owner)\",\n        \"path\": \"yol\",\n        \"playCount\": \"çalma sayısı\",\n        \"random\": \"rastgele\",\n        \"rating\": \"oylama\",\n        \"recentlyAdded\": \"yakın zamanda eklendi\",\n        \"recentlyPlayed\": \"yakın zamanda oynadı\",\n        \"recentlyUpdated\": \"yakın zamanda güncellendi\",\n        \"releaseDate\": \"çıkış tarihi\",\n        \"releaseYear\": \"çıkış yılı\",\n        \"search\": \"arama\",\n        \"songCount\": \"şarkı sayısı\",\n        \"title\": \"başlık\",\n        \"toYear\": \"yılına kadar\",\n        \"trackNumber\": \"parça\",\n        \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n        \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n        \"albumArtist\": \"$t(entity.albumArtist, {\\\"count\\\": 1})\",\n        \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n        \"channels\": \"$t(common.channel_other)\"\n    },\n    \"form\": {\n        \"addServer\": {\n            \"error_savePassword\": \"şifreyi kaydetmeye çalışırken bir hata oluştu\",\n            \"ignoreCors\": \"cors'u $t(common.restartRequired) görmezden gel\",\n            \"ignoreSsl\": \"ssl bağlantısını görmezden gel $t(common.restartRequired)\",\n            \"input_legacyAuthentication\": \"eski kimlik doğrulamayı etkinleştir\",\n            \"input_name\": \"sunucu ismi\",\n            \"input_password\": \"şifre\",\n            \"input_savePassword\": \"şifreyi kaydet\",\n            \"input_url\": \"URL\",\n            \"input_username\": \"kullanıcı ismi\",\n            \"success\": \"sunucu başarıyla eklendi\",\n            \"title\": \"sunucu ekle\",\n            \"input_preferInstantMix\": \"anında mix tercih et\",\n            \"input_preferInstantMixDescription\": \"sadece benzer şarkılari bulmak icin anında mix kullan. Bu davranışı değiştiren eklentilere sahipseniz faydalı\"\n        },\n        \"addToPlaylist\": {\n            \"input_playlists\": \"$t(entity.playlist, {\\\"count\\\": 2})\",\n            \"input_skipDuplicates\": \"kopyaları atla\",\n            \"title\": \"$t(entity.playlist, {\\\"count\\\": 1}) listesine ekle\",\n            \"success\": \"$t(entity.playlistWithCount, {\\\"count\\\": {{numOfPlaylists}} }) $t(entity.trackWithCount, {\\\"count\\\": {{message}} }) eklendi\"\n        },\n        \"createPlaylist\": {\n            \"input_description\": \"$t(common.description)\",\n            \"input_name\": \"$t(common.name)\",\n            \"input_owner\": \"$t(common.owner)\",\n            \"input_public\": \"herkese açık\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) listesi başarıyla oluşturuldu\",\n            \"title\": \"$t(entity.playlist, {\\\"count\\\": 1}) listesini oluştur\"\n        },\n        \"deletePlaylist\": {\n            \"input_confirm\": \"onaylamak için $t(entity.playlist, {\\\"count\\\": 1}) listesinin adını yazın\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) listesi başarıyla silindi\",\n            \"title\": \"$t(entity.playlist, {\\\"count\\\": 1}) listesini sil\"\n        },\n        \"editPlaylist\": {\n            \"publicJellyfinNote\": \"Jellyfin bazı nedenlerden dolayı bir çalma listesinin herkese açık olup olmadığını göstermez. Bunun herkese açık kalmasını istiyorsanız, lütfen aşağıdaki girdiyi seçin\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) listesi başarıyla güncellendi\",\n            \"title\": \"$t(entity.playlist, {\\\"count\\\": 1}) listesini düzenle\"\n        },\n        \"lyricSearch\": {\n            \"input_artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n            \"input_name\": \"$t(common.name)\",\n            \"title\": \"şarkı sözü arama\"\n        },\n        \"queryEditor\": {\n            \"title\": \"sorgu düzenleyici\",\n            \"input_optionMatchAll\": \"hepsini eşleştir\",\n            \"input_optionMatchAny\": \"herhangi biriyle eşleştir\"\n        },\n        \"shareItem\": {\n            \"allowDownloading\": \"indirmeye izin ver\",\n            \"description\": \"açıklama\",\n            \"setExpiration\": \"sona erme tarihi ayarla\",\n            \"success\": \"paylaşma bağlantısı panoya kopyalandı (veya açmak için buraya tıklayın)\",\n            \"expireInvalid\": \"son kullanma tarihi gelecekte olmalı\",\n            \"createFailed\": \"paylaşım oluşturulamadı (paylaşım etkin mi?)\"\n        },\n        \"updateServer\": {\n            \"success\": \"sunucu başarıyla güncellendi\",\n            \"title\": \"sunucuyu güncelle\"\n        },\n        \"privateMode\": {\n            \"enabled\": \"gizli mod etkinleştirildi, oynatma durumu artık harici eklentilerden gizlendi\",\n            \"disabled\": \"gizli mod devre dışı bırakıldı, oynatma durumu artık etkinleştirilmiş harici eklentiler tarafından görülebilir\",\n            \"title\": \"gizli mod\"\n        }\n    },\n    \"page\": {\n        \"albumArtistDetail\": {\n            \"about\": \"{{artist}} hakkında\",\n            \"appearsOn\": \"üzerinde görünür\",\n            \"recentReleases\": \"son sürümler\",\n            \"viewDiscography\": \"diskografiyi görüntüle\",\n            \"relatedArtists\": \"$t(entity.artist, {\\\"count\\\": 2}) ile benzer\",\n            \"topSongs\": \"en iyi şarkılar\",\n            \"viewAll\": \"tümünü görüntüle\",\n            \"viewAllTracks\": \"tüm $t(entity.track, {\\\"count\\\": 2}) görüntüle\",\n            \"topSongsFrom\": \"{{title}} tarafından en iyi şarkılar\"\n        },\n        \"contextMenu\": {\n            \"addLast\": \"$t(player.addLast)\",\n            \"addNext\": \"$t(player.addNext)\",\n            \"addToFavorites\": \"$t(action.addToFavorites)\",\n            \"addToPlaylist\": \"$t(action.addToPlaylist)\",\n            \"createPlaylist\": \"$t(action.createPlaylist)\",\n            \"deletePlaylist\": \"$t(action.deletePlaylist)\",\n            \"deselectAll\": \"$t(action.deselectAll)\",\n            \"download\": \"indir\",\n            \"moveToNext\": \"$t(action.moveToNext)\",\n            \"moveToBottom\": \"$t(action.moveToBottom)\",\n            \"moveToTop\": \"$t(action.moveToTop)\",\n            \"numberSelected\": \"{{count}} seçildi\",\n            \"play\": \"$t(player.play)\",\n            \"playSimilarSongs\": \"$t(player.playSimilarSongs)\",\n            \"removeFromFavorites\": \"$t(action.removeFromFavorites)\",\n            \"removeFromPlaylist\": \"$t(action.removeFromPlaylist)\",\n            \"removeFromQueue\": \"$t(action.removeFromQueue)\",\n            \"setRating\": \"$t(action.setRating)\",\n            \"addFavorite\": \"$t(action.addToFavorites)\",\n            \"playShuffled\": \"$t(player.shuffle)\",\n            \"shareItem\": \"öğeyi paylaş\",\n            \"showDetails\": \"bilgi al\",\n            \"goToAlbum\": \"$t(entity.album, {\\\"count\\\": 1}) sayfasına git\",\n            \"goToAlbumArtist\": \"$t(entity.albumArtist, {\\\"count\\\": 1}) sayfasına git\"\n        },\n        \"manageServers\": {\n            \"url\": \"URL\",\n            \"username\": \"kullanıcıadı\",\n            \"editServerDetailsTooltip\": \"sunucu ayrıntılarını düzenle\",\n            \"removeServer\": \"sunucuyu kaldır\",\n            \"title\": \"sunucuları yönet\",\n            \"serverDetails\": \"sunucu detayları\"\n        },\n        \"fullscreenPlayer\": {\n            \"config\": {\n                \"dynamicBackground\": \"dinamik arka plan\",\n                \"dynamicImageBlur\": \"görüntü bulanıklık boyutu\",\n                \"dynamicIsImage\": \"arka plan resmini etkinleştir\",\n                \"followCurrentLyric\": \"şu anki şarkı sözlerini takip et\",\n                \"lyricAlignment\": \"şarkı sözü hizalama\",\n                \"lyricOffset\": \"şarkı sözü ofseti (ms)\",\n                \"lyricGap\": \"şarkı sözü boşluğu\",\n                \"lyricSize\": \"şarkı sözü boyutu\",\n                \"opacity\": \"opaklık\",\n                \"showLyricMatch\": \"şarkı sözü eşleşmesini göster\",\n                \"showLyricProvider\": \"şarkı sözü sağlayıcısını göster\",\n                \"synchronized\": \"eşitlenmiş\",\n                \"unsynchronized\": \"eşitlenmemiş\",\n                \"useImageAspectRatio\": \"görüntü en boy oranını kullanın\"\n            },\n            \"lyrics\": \"şarkı sözleri\",\n            \"related\": \"i̇lgili\",\n            \"upNext\": \"sıradaki\",\n            \"visualizer\": \"görselleştirici\",\n            \"noLyrics\": \"şarkı sözü bulunamadı\"\n        },\n        \"genreList\": {\n            \"showAlbums\": \"$t(entity.genre, {\\\"count\\\": 1}) $t(entity.album, {\\\"count\\\": 2}) göster\",\n            \"showTracks\": \"$t(entity.genre, {\\\"count\\\": 1})$t(entity.track, {\\\"count\\\": 2}) göster\",\n            \"title\": \"$t(entity.genre, {\\\"count\\\": 2})\"\n        },\n        \"globalSearch\": {\n            \"commands\": {\n                \"goToPage\": \"sayfaya git\",\n                \"searchFor\": \"{{query}} için ara\",\n                \"serverCommands\": \"sunucu komutları\"\n            },\n            \"title\": \"komutlar\"\n        },\n        \"home\": {\n            \"explore\": \"kütüphanenizden keşfedin\",\n            \"mostPlayed\": \"en çok çalınan\",\n            \"newlyAdded\": \"yeni eklenenler\",\n            \"recentlyPlayed\": \"yakınlarda çalınanlar\",\n            \"title\": \"$t(common.home)\"\n        },\n        \"itemDetail\": {\n            \"copyPath\": \"yolu panoya kopyala\",\n            \"copiedPath\": \"yol başarıyla kopyalandı\",\n            \"openFile\": \"dosya yöneticisinde parçayı göster\"\n        },\n        \"playlist\": {\n            \"reorder\": \"yeniden sıralama yalnızca kimliğe göre sıralama yapıldığında etkinleştirilir\"\n        },\n        \"playlistList\": {\n            \"title\": \"$t(entity.playlist, {\\\"count\\\": 2})\"\n        },\n        \"setting\": {\n            \"advanced\": \"gelişmiş\",\n            \"generalTab\": \"genel\",\n            \"hotkeysTab\": \"kısayol tuşları\",\n            \"playbackTab\": \"oynatma\",\n            \"windowTab\": \"pencere\"\n        },\n        \"sidebar\": {\n            \"albumArtists\": \"$t(entity.albumArtist, {\\\"count\\\": 2})\",\n            \"albums\": \"$t(entity.album, {\\\"count\\\": 2})\",\n            \"artists\": \"$t(entity.artist, {\\\"count\\\": 2})\",\n            \"folders\": \"$t(entity.folder, {\\\"count\\\": 2})\",\n            \"genres\": \"$t(entity.genre, {\\\"count\\\": 2})\",\n            \"home\": \"$t(common.home)\",\n            \"myLibrary\": \"kütüphanem\",\n            \"nowPlaying\": \"şimdi oynatılıyor\",\n            \"playlists\": \"$t(entity.playlist, {\\\"count\\\": 2})\",\n            \"search\": \"$t(common.search)\",\n            \"settings\": \"$t(common.setting, {\\\"count\\\": 2})\",\n            \"shared\": \"paylaşılan $t(entity.playlist, {\\\"count\\\": 2})\",\n            \"tracks\": \"$t(entity.track, {\\\"count\\\": 2})\"\n        },\n        \"trackList\": {\n            \"artistTracks\": \"{{artist}} parçaları\",\n            \"genreTracks\": \"\\\"{{genre}}\\\" $t(entity.track, {\\\"count\\\": 2})\",\n            \"title\": \"$t(entity.track, {\\\"count\\\": 2})\"\n        },\n        \"albumArtistList\": {\n            \"title\": \"$t(entity.albumArtist, {\\\"count\\\": 2})\"\n        },\n        \"albumDetail\": {\n            \"moreFromArtist\": \"$t(entity.artist, {\\\"count\\\": 1}) sanatçısından daha fazla\",\n            \"moreFromGeneric\": \"{{item}} tarafından daha fazla\",\n            \"released\": \"yayınlandı\"\n        },\n        \"albumList\": {\n            \"title\": \"$t(entity.album, {\\\"count\\\": 2})\",\n            \"genreAlbums\": \"\\\"{{genre}}\\\" $t(entity.album, {\\\"count\\\": 2})\",\n            \"artistAlbums\": \"{{artist}} albümleri\"\n        },\n        \"appMenu\": {\n            \"collapseSidebar\": \"kenar çubuğunu daralt\",\n            \"expandSidebar\": \"kenar çubuğunu genişlet\",\n            \"goBack\": \"geri dön\",\n            \"goForward\": \"i̇leriye git\",\n            \"manageServers\": \"sunucuları yönet\",\n            \"openBrowserDevtools\": \"tarayıcı geliştirici araçlarını aç\",\n            \"quit\": \"$t(common.quit)\",\n            \"selectServer\": \"sunucu seç\",\n            \"settings\": \"$t(common.setting, {\\\"count\\\": 2})\",\n            \"version\": \"{{version}} sürümü\",\n            \"privateModeOff\": \"gizli modu kapat\",\n            \"privateModeOn\": \"gizli modu aç\"\n        }\n    },\n    \"player\": {\n        \"addLast\": \"sona ekle\",\n        \"addNext\": \"sonrakine ekle\",\n        \"favorite\": \"favori\",\n        \"mute\": \"sessiz\",\n        \"muted\": \"sessiz\",\n        \"next\": \"sonraki\",\n        \"play\": \"çal\",\n        \"playbackFetchCancel\": \"bu biraz zaman alıyor... iptal etmek için bildirimi kapatın\",\n        \"playbackFetchInProgress\": \"şarkılar yükleniyor…\",\n        \"playbackFetchNoResults\": \"hiçbir şarkı bulunamadı\",\n        \"playbackSpeed\": \"oynatma hızı\",\n        \"playRandom\": \"rastgele çal\",\n        \"playSimilarSongs\": \"benzer şarkılar çal\",\n        \"previous\": \"önceki\",\n        \"queue_clear\": \"sırayı temizle\",\n        \"queue_moveToBottom\": \"seçileni üste taşı\",\n        \"queue_moveToTop\": \"seçileni alta taşı\",\n        \"queue_remove\": \"seçileni kaldır\",\n        \"repeat\": \"birini tekrarla\",\n        \"repeat_all\": \"tümünü tekrarla\",\n        \"repeat_off\": \"tekrarlama devre dışı\",\n        \"shuffle\": \"karışık çal\",\n        \"shuffle_off\": \"karışık çalmayı devre dışı bırak\",\n        \"skip\": \"atla\",\n        \"skip_back\": \"geriye atla\",\n        \"skip_forward\": \"ileri atla\",\n        \"stop\": \"durdur\",\n        \"toggleFullscreenPlayer\": \"tam ekran oynatıcıya geç\",\n        \"unfavorite\": \"favoriden kaldır\",\n        \"pause\": \"durdur\",\n        \"viewQueue\": \"kuyruğu görüntüle\"\n    },\n    \"setting\": {\n        \"accentColor\": \"vurgu rengi\",\n        \"accentColor_description\": \"uygulama için vurgu rengini ayarlar\",\n        \"albumBackground\": \"albüm arka plan resmi\",\n        \"albumBackground_description\": \"albüm resmini içeren albüm sayfaları için bir arka plan resmi ekler\",\n        \"albumBackgroundBlur\": \"albüm arka plan resmi bulanıklaştırma boyutu\",\n        \"albumBackgroundBlur_description\": \"albüm arka plan görüntüsüne uygulanan bulanıklık miktarını ayarlar\",\n        \"applicationHotkeys\": \"uygulama kısayol tuşları\",\n        \"applicationHotkeys_description\": \"uygulama kısayol tuşlarını yapılandırın. genel kısayol tuşu olarak ayarlamak için onay kutusunu değiştirin (yalnızca masaüstü)\",\n        \"artistConfiguration\": \"albüm sanatçı sayfası yapılandırması\",\n        \"artistConfiguration_description\": \"albüm sanatçısı sayfasında hangi öğelerin ve hangi sırayla gösterileceğini yapılandır\",\n        \"audioDevice\": \"ses aygıtı\",\n        \"audioDevice_description\": \"oynatma için kullanılacak ses cihazını seçin (yalnızca web oynatıcı)\",\n        \"audioExclusiveMode\": \"ses özel modu\",\n        \"audioExclusiveMode_description\": \"özel çıkış modunu etkinleştirin. Bu modda, sistem genellikle kilitlenir ve yalnızca mpv ses çıkışı yapabilir\",\n        \"audioPlayer\": \"ses oynatıcı\",\n        \"audioPlayer_description\": \"oynatma için kullanılacak ses oynatıcısını seçin\",\n        \"buttonSize\": \"oynatma çubuğu düğme boyutu\",\n        \"buttonSize_description\": \"oynatma çubuğu düğmelerinin boyutu\",\n        \"clearCache\": \"tarayıcı önbelleğini temizle\",\n        \"clearCache_description\": \"feishin'in 'zor temizliği'. feishin'in önbelleğini temizlemeye ek olarak, tarayıcı önbelleğini de boşaltın (kayıtlı resimler ve diğer varlıklar). sunucu kimlik bilgileri ve ayarları korunur\",\n        \"clearQueryCache\": \"feishin önbelleğini temizle\",\n        \"clearQueryCache_description\": \"feishin'in 'yumuşak temizliği'. bu işlem çalma listelerini, parça meta verilerini yeniler ve kayıtlı şarkı sözlerini sıfırlar. ayarlar, sunucu kimlik bilgileri ve önbelleğe alınmış görüntüler korunur\",\n        \"clearCacheSuccess\": \"önbellek başarıyla temizlendi\",\n        \"contextMenu\": \"içerik menüsü (sağ tıklama) yapılandırması\",\n        \"contextMenu_description\": \"bir öğeye sağ tıkladığınızda menüde gösterilen öğeleri gizlemenizi sağlar. işaretli olmayan öğeler gizlenecektir\",\n        \"crossfadeDuration\": \"çapraz geçiş süresi\",\n        \"crossfadeDuration_description\": \"çapraz geçiş efektinin süresini ayarlar\",\n        \"crossfadeStyle_description\": \"ses oynatıcı için kullanılacak çapraz geçiş stilini seçin\",\n        \"customCssEnable\": \"özel css etkinleştir\",\n        \"customCssEnable_description\": \"özel css yazmaya izin verir\",\n        \"customCssNotice\": \"Uyarı: bazı sterillemeler (url() ve içeriğe izin verilmemesi) olsa da, özel css kullanmak arayüzü değiştirmede hala risk oluşturabilir\",\n        \"customCss\": \"özel css\",\n        \"customCss_description\": \"özel css içeriği. Not: içerik ve uzaktan URL'ler izin verilmeyen özelliklerdir. İçeriğinizin önizlemesi aşağıda gösterilmektedir. Ayarlamadığınız ek alanlar sterilleme nedeniyle mevcuttur\",\n        \"customFontPath\": \"özel yazı tipi yolu\",\n        \"customFontPath_description\": \"uygulama için kullanılacak özel yazı tipinin yolunu ayarlar\",\n        \"disableLibraryUpdateOnStartup\": \"başlangıçta yeni sürümler için denetimi devre dışı bırak\",\n        \"discordApplicationId\": \"{{discord}} uygulama kimliği\",\n        \"discordApplicationId_description\": \"{{discord}} \\\"Rich Presence\\\" için uygulama kimliği (varsayılan olarak {{defaultId}})\",\n        \"discordPausedStatus\": \"duraklatıldığında \\\"Rich Presence\\\"da göster\",\n        \"discordPausedStatus_description\": \"etkinleştirildiğinde, oynatıcı duraklatıldığında durum gösterilir\",\n        \"discordIdleStatus\": \"\\\"Rich presence\\\" boşta durumunu göster\",\n        \"discordIdleStatus_description\": \"etkinleştirildiğinde, oynatıcı boştayken durumu günceller\",\n        \"discordListening\": \"durumu dinleme olarak göster\",\n        \"discordListening_description\": \"durumu çalma yerine dinleme olarak göster\",\n        \"discordRichPresence_description\": \"{{discord}} \\\"Rich Presence\\\" oynatma durumunu etkinleştirin. Görüntü tuşları şunlardır: {{icon}}, {{playing}} ve {{paused}}\",\n        \"discordServeImage\": \"sunucudan {{discord}} resimleri servis et\",\n        \"discordServeImage_description\": \"sunucudan {{discord}} Rich Presence için kapak resmi paylaşın, yalnızca Jellyfin ve Navidrome için kullanılabilir\",\n        \"discordUpdateInterval\": \"{{discord}} Rich Presence güncelleme aralığı\",\n        \"discordUpdateInterval_description\": \"her güncelleme arasındaki saniye cinsinden süre (minimum 15 saniye)\",\n        \"gaplessAudio\": \"aralıksız ses\",\n        \"gaplessAudio_description\": \"mpv için aralıksız ses ayarını belirler\",\n        \"gaplessAudio_optionWeak\": \"zayıf (tavsiye edilen)\",\n        \"globalMediaHotkeys\": \"evrensel medya kısayol tuşları\",\n        \"globalMediaHotkeys_description\": \"oynatmayı kontrol etmek için sistem medya kısayol tuşlarınızın kullanımını etkinleştirin veya devre dışı bırakın\",\n        \"homeConfiguration\": \"ana sayfa yapılandırma\",\n        \"homeConfiguration_description\": \"ana sayfada hangi öğelerin ve hangi sırayla gösterileceğini yapılandır\",\n        \"homeFeature\": \"ana sayfa öne çıkan görselleri\",\n        \"homeFeature_description\": \"ana sayfada büyük özellikli görsellerin gösterilip gösterilmeyeceğini kontrol eder\",\n        \"hotkey_rate0\": \"derecelendirme temizle\",\n        \"hotkey_rate1\": \"derecelendirme 1 yıldız\",\n        \"hotkey_rate2\": \"derecelendirme 2 yıldız\",\n        \"hotkey_rate3\": \"derecelendirme 3 yıldız\",\n        \"hotkey_rate4\": \"derecelendirme 4 yıldız\",\n        \"hotkey_rate5\": \"derecelendirme 5 yıldız\",\n        \"hotkey_skipBackward\": \"geri atla\",\n        \"hotkey_skipForward\": \"ileri atla\",\n        \"hotkey_toggleCurrentSongFavorite\": \"$t(common.currentSong) beğenilenlere ekle\",\n        \"hotkey_toggleFullScreenPlayer\": \"tam ekran oynatıcı tuşu\",\n        \"hotkey_togglePreviousSongFavorite\": \"$t(common.previousSong) beğenilenlere ekle\",\n        \"hotkey_toggleQueue\": \"kuyruğu aç\",\n        \"hotkey_toggleRepeat\": \"tekrarlamayı aç\",\n        \"hotkey_toggleShuffle\": \"karıştırmayı değiştir\",\n        \"hotkey_unfavoriteCurrentSong\": \"$t(common.currentSong) beğenilerden kaldır\",\n        \"hotkey_unfavoritePreviousSong\": \"$t(common.previousSong) beğenilerden kaldır\",\n        \"hotkey_volumeDown\": \"ses kısma\",\n        \"hotkey_volumeMute\": \"sessize alma\",\n        \"hotkey_volumeUp\": \"sesi yükselt\",\n        \"hotkey_zoomIn\": \"yakınlaştır\",\n        \"hotkey_zoomOut\": \"uzaklaştır\",\n        \"imageAspectRatio\": \"doğal kapak resmi en boy oranını kullanın\",\n        \"imageAspectRatio_description\": \"etkinleştirilirse, kapak resmi kendi doğal en boy oranı kullanılarak gösterilecektir. 1:1 olmayan resimler için kalan alan boş olacaktır\",\n        \"language_description\": \"uygulama için dili ayarlar ($t(common.restartRequired))\",\n        \"lastfm\": \"last.fm bağlantılarını göster\",\n        \"lastfm_description\": \"sanatçı/albüm sayfalarında Last.fm bağlantılarını göster\",\n        \"lastfmApiKey\": \"{{lastfm}} API anahtarı\",\n        \"lastfmApiKey_description\": \"{{lastfm}} için API anahtarı. kapak resmi için gereklidir\",\n        \"lyricFetch\": \"internetten şarkı sözü getirme\",\n        \"lyricFetch_description\": \"çeşitli internet kaynaklarından şarkı sözleri getirme\",\n        \"lyricFetchProvider\": \"şarkı sözlerini almak için sağlayıcılar\",\n        \"lyricFetchProvider_description\": \"şarkı sözlerinin getirileceği sağlayıcıları seçin. sağlayıcıların sırası, sorgulanacakları sıradır\",\n        \"lyricOffset\": \"şarkı sözü kaydırma (ms)\",\n        \"lyricOffset_description\": \"şarkı sözünü belirtilen milisaniye miktarı kadar kaydırır\",\n        \"minimizeToTray\": \"tepsiye yerleştir\",\n        \"minimizeToTray_description\": \"uygulamayı sistem tepsisine küçültme\",\n        \"minimumScrobblePercentage\": \"minimum \\\"scrobble\\\" (dinleme sayımı) süresi (yüzde)\",\n        \"minimumScrobblePercentage_description\": \"'scrobble' yapılmadan önce çalınması gereken minimum şarkı yüzdesi\",\n        \"minimumScrobbleSeconds\": \"minimum 'scrobble' (saniye)\",\n        \"minimumScrobbleSeconds_description\": \"'scrobble' yapılmadan önce çalınması gereken şarkının saniye cinsinden minimum süresi\",\n        \"mpvExecutablePath\": \"mpv çalıştırma yolu\",\n        \"mpvExecutablePath_description\": \"mpv çalıştırma yolunu ayarlar. boş bırakılırsa, varsayılan yol kullanılır\",\n        \"mpvExtraParameters_help\": \"her satır için tek\",\n        \"musicbrainz\": \"MusicBrainz bağlantılarını göster\",\n        \"musicbrainz_description\": \"MusicBrainz ID'in bulunduğu sanatçı/albüm sayfalarında MusicBrainz bağlantılarını göster\",\n        \"neteaseTranslation\": \"NetEase çevirilerini etkinleştirin\",\n        \"neteaseTranslation_description\": \"etkinleştirildiğinde, varsa NetEase platformunda çevrilmiş şarkı sözlerini alır ve görüntüler\",\n        \"passwordStore\": \"passwords/secret store\",\n        \"passwordStore_description\": \"hangi parola/gizli deponun kullanılacağıdır. parolaları saklama konusunda sorun yaşıyorsanız bunu değiştirin\",\n        \"playbackStyle\": \"oynatma stili\",\n        \"playbackStyle_description\": \"ses oynatıcı için kullanılacak oynatma stilini seçin\",\n        \"playbackStyle_optionCrossFade\": \"çapraz geçiş\",\n        \"playbackStyle_optionNormal\": \"normal\",\n        \"playButtonBehavior\": \"oynat düğmesi davranışı\",\n        \"playButtonBehavior_description\": \"kuyruğa şarkı eklerken oynat düğmesinin varsayılan davranışını ayarlar\",\n        \"playButtonBehavior_optionAddLast\": \"$t(player.addLast)\",\n        \"playButtonBehavior_optionAddNext\": \"$t(player.addNext)\",\n        \"playButtonBehavior_optionPlay\": \"$t(player.play)\",\n        \"playButtonBehavior_optionPlayShuffled\": \"$t(player.shuffle)\",\n        \"playerbarOpenDrawer\": \"oynatma çubuğu tam ekran geçişi\",\n        \"playerbarOpenDrawer_description\": \"tam ekran oynatıcıyı açmak için oynatma çubuğuna tıklamaya izin verir\",\n        \"remotePassword\": \"uzaktan kontrol sunucusu şifresi\",\n        \"remotePassword_description\": \"uzaktan kumanda sunucusu için parolayı ayarlar. Bu kimlik bilgileri varsayılan olarak güvensiz bir şekilde aktarılır, bu nedenle önemsemediğiniz benzersiz bir parola kullanmalısınız\",\n        \"remotePort\": \"uzaktan kontrol sunucusu bağlantı noktası\",\n        \"remotePort_description\": \"uzaktan kumanda sunucusu için bağlantı noktasını ayarlar\",\n        \"remoteUsername\": \"uzaktan kontrol sunucusu kullanıcı adı\",\n        \"remoteUsername_description\": \"uzaktan kontrol sunucusu için kullanıcı adını ayarlar. hem kullanıcı adı hem de parola boşsa, kimlik doğrulama devre dışı bırakılır\",\n        \"replayGainClipping\": \"{{ReplayGain}} kırpma\",\n        \"replayGainClipping_description\": \"Kazancı otomatik olarak düşürerek {{ReplayGain}}'in neden olduğu kırpılmayı önleyin\",\n        \"replayGainFallback\": \"{{ReplayGain}} geri dönüş\",\n        \"replayGainFallback_description\": \"dosyada {{ReplayGain}} etiketi yoksa db'e uygulanacak kazanç\",\n        \"replayGainMode\": \"{{ReplayGain}} modu\",\n        \"replayGainMode_description\": \"ses seviyesi kazancını dosya meta verilerinde saklanan {{ReplayGain}} değerlerine göre ayarlayın\",\n        \"replayGainMode_optionAlbum\": \"$t(entity.album, {\\\"count\\\": 1})\",\n        \"replayGainMode_optionNone\": \"$t(common.none)\",\n        \"replayGainMode_optionTrack\": \"$t(entity.track, {\\\"count\\\": 1})\",\n        \"replayGainPreamp\": \"{{ReplayGain}} preamp (dB)\",\n        \"replayGainPreamp_description\": \"{{ReplayGain}} değerlerine uygulanan preamp kazancını ayarlar\",\n        \"sampleRate\": \"örnekleme hızı\",\n        \"sampleRate_description\": \"seçilen örnekleme frekansı mevcut ortamınkinden farklıysa kullanılacak çıkış örnekleme oranını seçin. 8000'den küçük değerler için varsayılan frekans kullanacaktır\",\n        \"savePlayQueue\": \"oynatma kuyruğunu kaydet\",\n        \"savePlayQueue_description\": \"uygulama kapatıldığında oynatma kuyruğunu kaydedin ve uygulama açıldığında geri yükleyin\",\n        \"scrobble\": \"scrobble\",\n        \"scrobble_description\": \"scrobble medya sunucunuzda oynatılır\",\n        \"showSkipButton\": \"atlama düğmelerini göster\",\n        \"showSkipButton_description\": \"oynatıcı çubuğundaki atlama düğmelerini göster veya gizle\",\n        \"showSkipButtons\": \"atlama düğmelerini göster\",\n        \"showSkipButtons_description\": \"oynatıcı çubuğundaki atlama düğmelerini göster veya gizle\",\n        \"sidebarCollapsedNavigation\": \"kenar çubuğu (daraltılmış) navigasyon\",\n        \"sidebarCollapsedNavigation_description\": \"daraltılmış kenar çubuğunda gezinmeyi göster veya gizle\",\n        \"sidebarConfiguration\": \"kenar çubuğu yapılandırması\",\n        \"sidebarConfiguration_description\": \"öğeleri ve bunların kenar çubuğunda görünme sırasını seçme\",\n        \"sidebarPlaylistList\": \"kenar çubuğu çalma listesi\",\n        \"sidebarPlaylistList_description\": \"kenar çubuğunda çalma listesi listesini gösterme veya gizleme\",\n        \"sidePlayQueueStyle\": \"yan oynatma kuyruğu stili\",\n        \"sidePlayQueueStyle_description\": \"yan oynatma kuyruğunun stilini ayarlar\",\n        \"sidePlayQueueStyle_optionAttached\": \"ekli\",\n        \"sidePlayQueueStyle_optionDetached\": \"ayrılmış\",\n        \"skipDuration\": \"atlama süresi\",\n        \"skipDuration_description\": \"oynatıcı çubuğundaki atlama düğmeleri kullanılırken atlanacak süreyi ayarlar\",\n        \"translationApiKey_description\": \"çeviri için api anahtarı (Yalnızca global hizmet uç noktasını destekler)\",\n        \"translationTargetLanguage\": \"çeviri hedef dili\",\n        \"translationTargetLanguage_description\": \"çeviri için hedef dil\",\n        \"trayEnabled\": \"tepsiyi göster\",\n        \"trayEnabled_description\": \"tepsi simgesini/menüsünü göster/gizle. devre dışı bırakılırsa, tepsiye küçültme/çıkış da devre dışı bırakır\",\n        \"useSystemTheme\": \"sistem temasını kullan\",\n        \"useSystemTheme_description\": \"sistem tarafından tanımlanan açık veya koyu mod tercihini takip et\",\n        \"volumeWheelStep\": \"ses tekerleği adımı\",\n        \"volumeWheelStep_description\": \"ses seviyesi kaydırıcısı üzerinde fare tekerleğini kaydırırken değiştirilecek ses seviyesi miktarı\",\n        \"volumeWidth\": \"ses kaydırıcı genişliği\",\n        \"volumeWidth_description\": \"ses seviyesi kaydırıcısının genişliği\",\n        \"webAudio\": \"ağ sesini kullanın\",\n        \"webAudio_description\": \"ağ sesi kullanın. bu, replaygain gibi gelişmiş özellikleri etkinleştirir. aksi bir durumla karşılaşırsanız devre dışı bırakın\",\n        \"preservePitch\": \"perdeyi koru\",\n        \"preservePitch_description\": \"oynatma hızını değiştirirken perdeyi korur\",\n        \"windowBarStyle\": \"pencere çubuğu stili\",\n        \"windowBarStyle_description\": \"pencere çubuğunun stilini seçin\",\n        \"zoom\": \"yakınlaştırma yüzdesi\",\n        \"zoom_description\": \"uygulama için yakınlaştırma yüzdesini ayarlar\",\n        \"enableRemote\": \"uzaktan kontrol sunucusunu etkinleştir\",\n        \"enableRemote_description\": \"uzaktan kumanda sunucusunun diğer cihazların uygulamayı kontrol etmesine izin vermesini sağlar\",\n        \"externalLinks\": \"harici bağlantıları göster\",\n        \"externalLinks_description\": \"sanatçı/albüm sayfalarında dış bağlantıların (Last.fm, MusicBrainz) gösterilmesini sağlar\",\n        \"exitToTray\": \"tepsiye çıkış\",\n        \"exitToTray_description\": \"uygulamadan sistem tepsisine çıkma\",\n        \"followLyric\": \"güncel şarkı sözlerini takip et\",\n        \"followLyric_description\": \"şarkı sözünü geçerli çalma konumuna kaydırma\",\n        \"preferLocalLyrics\": \"yerel sözleri tercih edin\",\n        \"preferLocalLyrics_description\": \"mümkün olduğunda uzak (remote) şarkı sözleri yerine yerel olarak depolanan şarkı sözlerini tercih edin\",\n        \"font\": \"font\",\n        \"font_description\": \"uygulama için kullanılacak yazı tipini ayarlar\",\n        \"fontType\": \"yazı tipi\",\n        \"fontType_description\": \"yerleşik yazı tipi feishin tarafından sağlanan yazı tiplerinden birini seçer. sistem yazı tipi işletim sisteminiz tarafından sağlanan herhangi bir yazı tipini seçmenize izin verir. özel kendi yazı tipinizi sağlamanıza izin verir\",\n        \"fontType_optionBuiltIn\": \"yerleşik yazı tipi\",\n        \"fontType_optionCustom\": \"özel yazı tipi\",\n        \"fontType_optionSystem\": \"sistem yazı tipi\",\n        \"hotkey_browserBack\": \"tarayıcı geri\",\n        \"hotkey_browserForward\": \"tarayıcı ileri\",\n        \"hotkey_favoriteCurrentSong\": \"$t(common.currentSong) favorilere ekle\",\n        \"hotkey_favoritePreviousSong\": \"$t(common.previousSong) favorilere ekle\",\n        \"hotkey_globalSearch\": \"genel arama\",\n        \"hotkey_localSearch\": \"sayfa içi arama\",\n        \"hotkey_playbackNext\": \"sonraki parça\",\n        \"hotkey_playbackPause\": \"durdur\",\n        \"hotkey_playbackPlay\": \"çal\",\n        \"hotkey_playbackPlayPause\": \"çal / duraklat\",\n        \"translationApiKey\": \"çeviri api anahtarı\",\n        \"translationApiProvider_description\": \"çeviri için api sağlayıcısı\",\n        \"translationApiProvider\": \"çeviri api sağlayıcısı\",\n        \"transcodeFormat_description\": \"dönüştürülecek formatı seçer. sunucunun karar vermesi için boş bırakın\",\n        \"transcodeFormat\": \"dönüştürülecek format\",\n        \"transcodeBitrate_description\": \"kod dönüştürmek için bit hızını seçer. 0, sunucunun seçmesine izin ver anlamına gelir\",\n        \"transcodeBitrate\": \"kod dönüştürmek için bit hızı\",\n        \"transcode_description\": \"farklı formatlara kod dönüştürme sağlar\",\n        \"hotkey_playbackStop\": \"dur\",\n        \"hotkey_playbackPrevious\": \"önceki parça\",\n        \"skipPlaylistPage\": \"çalma listesi sayfasını atla\",\n        \"skipPlaylistPage_description\": \"bir çalma listesine giderken, varsayılan sayfa yerine çalma listesi şarkı listesi sayfasına gitme\",\n        \"startMinimized\": \"küçültülmüş olarak başlat\",\n        \"startMinimized_description\": \"uygulamayı sistem tepsisinde başlat\",\n        \"theme\": \"tema\",\n        \"theme_description\": \"uygulama için kullanılacak temayı ayarlar\",\n        \"themeDark\": \"tema (koyu)\",\n        \"themeDark_description\": \"uygulama için kullanılacak koyu temayı ayarlar\",\n        \"themeLight\": \"tema (açık)\",\n        \"themeLight_description\": \"uygulama için kullanılacak açık temayı ayarlar\",\n        \"discordDisplayType\": \"{{discord}} varlık gösterge türü\",\n        \"discordDisplayType_description\": \"durumunuzda dinlediğiniz şarkı olarak değiştirir\",\n        \"discordDisplayType_songname\": \"şarkı ismi\",\n        \"discordDisplayType_artistname\": \"Sanatçı adı(ları)\",\n        \"hotkey_navigateHome\": \"ana sayfaya git\",\n        \"preventSleepOnPlayback\": \"oynatma sırasında uykuyu önle\",\n        \"preventSleepOnPlayback_description\": \"müzik çalarken ekranın uyku moduna geçmesini önle\",\n        \"releaseChannel_optionBeta\": \"beta\",\n        \"releaseChannel_optionLatest\": \"en son\",\n        \"language\": \"dil\",\n        \"notify\": \"müzik bildirimi aktivleştir\"\n    },\n    \"table\": {\n        \"column\": {\n            \"album\": \"albüm\",\n            \"albumArtist\": \"albüm sanatçısı\",\n            \"albumCount\": \"$t(entity.album, {\\\"count\\\": 2})\",\n            \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n            \"biography\": \"biyografi\",\n            \"bitrate\": \"bit hızı\",\n            \"bpm\": \"bpm (dakika başına vuruş)\",\n            \"channels\": \"$t(common.channel_other)\",\n            \"codec\": \"$t(common.codec)\",\n            \"comment\": \"yorum\",\n            \"dateAdded\": \"tarih eklendi\",\n            \"discNumber\": \"disk\",\n            \"favorite\": \"favori\",\n            \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n            \"lastPlayed\": \"son çalınan\",\n            \"path\": \"yol\",\n            \"playCount\": \"oynatılıyor\",\n            \"rating\": \"derecelendirme\",\n            \"releaseDate\": \"çıkış tarihi\",\n            \"releaseYear\": \"yıl\",\n            \"size\": \"$t(common.size)\",\n            \"songCount\": \"$t(entity.track, {\\\"count\\\": 2})\",\n            \"title\": \"başlık\",\n            \"trackNumber\": \"parça\"\n        },\n        \"config\": {\n            \"general\": {\n                \"autoFitColumns\": \"sütunları otomatik sığdır\",\n                \"followCurrentSong\": \"güncel şarkıyı takip et\",\n                \"displayType\": \"görüntüleme türü\",\n                \"gap\": \"$t(common.gap)\",\n                \"itemGap\": \"öğe boşluğu (px)\",\n                \"itemSize\": \"öğe boyutu (px)\",\n                \"size\": \"$t(common.size)\",\n                \"tableColumns\": \"tablo sütunları\"\n            },\n            \"label\": {\n                \"actions\": \"$t(common.action_other)\",\n                \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n                \"albumArtist\": \"$t(entity.albumArtist, {\\\"count\\\": 1})\",\n                \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n                \"biography\": \"$t(common.biography)\",\n                \"bitrate\": \"$t(common.bitrate)\",\n                \"bpm\": \"$t(common.bpm)\",\n                \"channels\": \"$t(common.channel_other)\",\n                \"codec\": \"$t(common.codec)\",\n                \"dateAdded\": \"tarih eklendi\",\n                \"discNumber\": \"disk numarası\",\n                \"duration\": \"$t(common.duration)\",\n                \"favorite\": \"$t(common.favorite)\",\n                \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n                \"lastPlayed\": \"son çalınan\",\n                \"note\": \"$t(common.note)\",\n                \"owner\": \"$t(common.owner)\",\n                \"path\": \"$t(common.path)\",\n                \"playCount\": \"çalma sayısı\",\n                \"rating\": \"$t(common.rating)\",\n                \"releaseDate\": \"çıkış tarihi\",\n                \"rowIndex\": \"satır indeksi\",\n                \"size\": \"$t(common.size)\",\n                \"songCount\": \"$t(entity.track, {\\\"count\\\": 2})\",\n                \"title\": \"$t(common.title)\",\n                \"titleCombined\": \"$t(common.title) (birleşik)\",\n                \"trackNumber\": \"parça numarası\",\n                \"year\": \"$t(common.year)\"\n            },\n            \"view\": {\n                \"grid\": \"ızgara\",\n                \"list\": \"liste\",\n                \"table\": \"tablo\"\n            }\n        }\n    },\n    \"releaseType\": {\n        \"secondary\": {\n            \"demo\": \"demo\",\n            \"live\": \"canlı\",\n            \"remix\": \"remix\"\n        }\n    },\n    \"dragDropZone\": {\n        \"error_oneFileOnly\": \"lütfen sadece 1 dosya seç\",\n        \"error_readingFile\": \"bu dosyayi okurken bir sorun oluştu :{{errorMessage}}\"\n    }\n}\n"
  },
  {
    "path": "src/i18n/locales/uk.json",
    "content": "{\n    \"action\": {\n        \"addToFavorites\": \"додати до $t(entity.favorite, {\\\"count\\\": 2})\",\n        \"addOrRemoveFromSelection\": \"додати або видалити з вибору\",\n        \"selectRangeOfItems\": \"вибрати діапазон елементів\",\n        \"addToPlaylist\": \"додати до $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"clearQueue\": \"очистити чергу\",\n        \"createPlaylist\": \"створити $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"createRadioStation\": \"створити $t(entity.radioStation, {\\\"count\\\": 1})\",\n        \"deletePlaylist\": \"видалити $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"deleteRadioStation\": \"видалити $t(entity.radioStation, {\\\"count\\\": 1})\",\n        \"selectAll\": \"вибрати все\",\n        \"deselectAll\": \"скасувати вибір усього\",\n        \"downloadStarted\": \"почато завантаження {{count}} елементів\",\n        \"editPlaylist\": \"редагувати $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"goToPage\": \"перейти на сторінку\",\n        \"moveToNext\": \"перейти до наступного\",\n        \"moveToBottom\": \"перемістити вниз\",\n        \"moveToTop\": \"перемістити вгору\",\n        \"moveUp\": \"перемістити вище\",\n        \"moveDown\": \"перемістити нижче\",\n        \"holdToMoveToTop\": \"утримуйте, щоб перемістити вгору\",\n        \"holdToMoveToBottom\": \"утримувати, щоб перемістити вниз\",\n        \"moveItems\": \"перемістити елементи\",\n        \"shuffle\": \"перемішати\",\n        \"shuffleAll\": \"все випадково\",\n        \"shuffleSelected\": \"вибране випадково\",\n        \"refresh\": \"$t(common.refresh)\",\n        \"removeFromFavorites\": \"видалити з $t(entity.favorite, {\\\"count\\\": 2})\",\n        \"removeFromPlaylist\": \"видалити з $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"removeFromQueue\": \"видалити з черги\",\n        \"setRating\": \"встановити рейтинг\",\n        \"toggleSmartPlaylistEditor\": \"перемикати редактор $t(entity.smartPlaylist)\",\n        \"viewPlaylists\": \"показати $t(entity.playlist, {\\\"count\\\": 2})\",\n        \"viewMore\": \"переглянути більше\",\n        \"openApplicationDirectory\": \"відкрити каталог додатків\",\n        \"openIn\": {\n            \"lastfm\": \"Відкрити в Last.fm\",\n            \"musicbrainz\": \"Відкрити в MusicBrainz\"\n        }\n    },\n    \"common\": {\n        \"countSelected\": \"вибрано {{count}}\",\n        \"explicitStatus\": \"явний статус\",\n        \"action_one\": \"дія\",\n        \"action_few\": \"дії\",\n        \"action_many\": \"дій\",\n        \"add\": \"додати\",\n        \"additionalParticipants\": \"додаткові учасники\",\n        \"newVersion\": \"встановлено нову версію ({{version}})\",\n        \"viewReleaseNotes\": \"переглянути список змін\",\n        \"albumGain\": \"підсилення альбому\",\n        \"albumPeak\": \"піковий рівень альбому\",\n        \"areYouSure\": \"ви впевнені?\",\n        \"ascending\": \"зростаючи\",\n        \"backward\": \"назад\",\n        \"biography\": \"біографія\",\n        \"bitDepth\": \"розрядність\",\n        \"bitrate\": \"бітрейт\",\n        \"bpm\": \"уд/хв\",\n        \"cancel\": \"скасувати\",\n        \"center\": \"посередині\",\n        \"channel_one\": \"канал\",\n        \"channel_few\": \"канали\",\n        \"channel_many\": \"каналів\",\n        \"clear\": \"очистити\",\n        \"close\": \"закрити\",\n        \"codec\": \"кодек\",\n        \"collapse\": \"згорнути\",\n        \"comingSoon\": \"скоро…\",\n        \"configure\": \"налаштувати\",\n        \"confirm\": \"підтвердити\",\n        \"create\": \"створити\",\n        \"currentSong\": \"поточний $t(entity.track, {\\\"count\\\": 1})\",\n        \"decrease\": \"знизити\",\n        \"delete\": \"видалити\",\n        \"descending\": \"за спаданням\",\n        \"description\": \"опис\",\n        \"disable\": \"вимкнути\",\n        \"disc\": \"диск\",\n        \"dismiss\": \"відхилити\",\n        \"doNotShowAgain\": \"не показувати це знову\",\n        \"duration\": \"тривалість\",\n        \"view\": \"показати\",\n        \"edit\": \"змінити\",\n        \"enable\": \"увімкнути\",\n        \"expand\": \"розширити\",\n        \"example\": \"приклад\",\n        \"externalLinks\": \"зовнішні посилання\",\n        \"faster\": \"швидше\",\n        \"favorite\": \"улюблений\",\n        \"filter_one\": \"фільтр\",\n        \"filter_few\": \"фільтри\",\n        \"filter_many\": \"фільтрів\",\n        \"filters\": \"фільтри\",\n        \"filter_single\": \"одиночний\",\n        \"filter_multiple\": \"кілька\",\n        \"forceRestartRequired\": \"перезапустіть, щоб застосувати зміни… закрийте повідомлення, щоб перезапустити\",\n        \"forward\": \"уперед\",\n        \"gap\": \"прогалина\",\n        \"home\": \"додому\",\n        \"increase\": \"збільшити\",\n        \"left\": \"ліво\",\n        \"limit\": \"ліміт\",\n        \"manage\": \"управління\",\n        \"maximize\": \"максимізувати\",\n        \"menu\": \"меню\",\n        \"minimize\": \"мінімізувати\",\n        \"modified\": \"відредаговано\",\n        \"mbid\": \"MusicBrainz ID\",\n        \"mood\": \"настрій\",\n        \"name\": \"назва\",\n        \"no\": \"ні\",\n        \"none\": \"жоден\",\n        \"noResultsFromQuery\": \"запит не дав результатів\",\n        \"noFilters\": \"фільтри не налаштовані\",\n        \"note\": \"примітка\",\n        \"ok\": \"ок\",\n        \"owner\": \"власник\",\n        \"path\": \"шлях\",\n        \"playerMustBePaused\": \"плеєр повинен бути призупинений\",\n        \"preview\": \"перегляд\",\n        \"previousSong\": \"минулий $t(entity.track, {\\\"count\\\": 1})\",\n        \"private\": \"приватний\",\n        \"public\": \"публічний\",\n        \"quit\": \"вийти\",\n        \"random\": \"випадково\",\n        \"rating\": \"рейтинг\",\n        \"retry\": \"повторити спробу\",\n        \"recordLabel\": \"лейбл звукозапису\",\n        \"releaseType\": \"тип випуску\",\n        \"refresh\": \"оновити\",\n        \"reload\": \"перезавантажити\",\n        \"rename\": \"перейменувати\",\n        \"reset\": \"скинути\",\n        \"resetToDefault\": \"скинути до заводських налаштувань\",\n        \"restartRequired\": \"необхідний перезапуск\",\n        \"right\": \"право\",\n        \"clean\": \"чистo\",\n        \"sampleRate\": \"частота дискретизації\",\n        \"save\": \"зберегти\",\n        \"saveAndReplace\": \"зберегти та замінити\",\n        \"saveAs\": \"зберегти як\",\n        \"search\": \"пошук\",\n        \"setting_one\": \"налаштування\",\n        \"setting_few\": \"налаштування\",\n        \"setting_many\": \"налаштувань\",\n        \"slower\": \"повільніше\",\n        \"share\": \"поділитися\",\n        \"size\": \"розмір\",\n        \"sort\": \"впорядкувати\",\n        \"sortOrder\": \"порядок\",\n        \"tags\": \"теги\",\n        \"title\": \"назва\",\n        \"trackNumber\": \"трек\",\n        \"trackGain\": \"підсилення треку\",\n        \"trackPeak\": \"піковий рівень треку\",\n        \"translation\": \"переклад\",\n        \"unknown\": \"невідомий\",\n        \"version\": \"версія\",\n        \"year\": \"рік\",\n        \"yes\": \"так\",\n        \"explicit\": \"Експліцитний зміст\",\n        \"gridRows\": \"рядки сітки\",\n        \"tableColumns\": \"стовпці таблиці\",\n        \"itemsMore\": \"{{count}} більше\"\n    },\n    \"entity\": {\n        \"album_one\": \"альбом\",\n        \"album_few\": \"альбоми\",\n        \"album_many\": \"альбомів\",\n        \"albumArtist_one\": \"виконавець альбому\",\n        \"albumArtist_few\": \"виконавці альбому\",\n        \"albumArtist_many\": \"виконавців альбому\",\n        \"albumArtistCount_one\": \"{{count}} виконавець альбому\",\n        \"albumArtistCount_few\": \"{{count}} виконавці альбому\",\n        \"albumArtistCount_many\": \"{{count}} виконавців альбому\",\n        \"albumWithCount_one\": \"{{count}} альбом\",\n        \"albumWithCount_few\": \"{{count}} альбоми\",\n        \"albumWithCount_many\": \"{{count}} альбомів\",\n        \"radioStation_one\": \"радіостанція\",\n        \"radioStation_few\": \"радіостанції\",\n        \"radioStation_many\": \"радіостанцій\",\n        \"radioStationWithCount_one\": \"{{count}} радіостанція\",\n        \"radioStationWithCount_few\": \"{{count}} радіостанції\",\n        \"radioStationWithCount_many\": \"{{count}} радіостанцій\",\n        \"artist_one\": \"виконавець\",\n        \"artist_few\": \"виконавці\",\n        \"artist_many\": \"виконавців\",\n        \"artistWithCount_one\": \"{{count}} виконавець\",\n        \"artistWithCount_few\": \"{{count}} виконавці\",\n        \"artistWithCount_many\": \"{{count}} виконавців\",\n        \"favorite_one\": \"улюблений\",\n        \"favorite_few\": \"улюблені\",\n        \"favorite_many\": \"улюблених\",\n        \"folder_one\": \"папка\",\n        \"folder_few\": \"папки\",\n        \"folder_many\": \"папок\",\n        \"folderWithCount_one\": \"{{count}} папка\",\n        \"folderWithCount_few\": \"{{count}} папки\",\n        \"folderWithCount_many\": \"{{count}} папок\",\n        \"genre_one\": \"жанр\",\n        \"genre_few\": \"жанри\",\n        \"genre_many\": \"жанрів\",\n        \"genreWithCount_one\": \"{{count}} жанр\",\n        \"genreWithCount_few\": \"{{count}} жанри\",\n        \"genreWithCount_many\": \"{{count}} жанрів\",\n        \"playlist_one\": \"плейлист\",\n        \"playlist_few\": \"плейлисти\",\n        \"playlist_many\": \"плейлистів\",\n        \"play_one\": \"{{count}} відтворення\",\n        \"play_few\": \"{{count}} відтворення\",\n        \"play_many\": \"{{count}} відтворень\",\n        \"playlistWithCount_one\": \"{{count}} плейлист\",\n        \"playlistWithCount_few\": \"{{count}} плейлисти\",\n        \"playlistWithCount_many\": \"{{count}} плейлистів\",\n        \"smartPlaylist\": \"розумний $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"track_one\": \"трек\",\n        \"track_few\": \"треки\",\n        \"track_many\": \"треків\",\n        \"song_one\": \"пісня\",\n        \"song_few\": \"пісні\",\n        \"song_many\": \"пісень\",\n        \"trackWithCount_one\": \"{{count}} трек\",\n        \"trackWithCount_few\": \"{{count}} треки\",\n        \"trackWithCount_many\": \"{{count}} треків\"\n    },\n    \"error\": {\n        \"apiRouteError\": \"неможливо виконати запит\",\n        \"audioDeviceFetchError\": \"сталася помилка під час спроби отримати аудіопристрої\",\n        \"authenticationFailed\": \"аутентифікація не вдалася\",\n        \"badAlbum\": \"ви бачите цю сторінку, тому що ця пісня не входить до альбому. найімовірніше, ця проблема виникає, якщо у верхньому рівні вашої музичної папки знаходиться пісня. Jellyfin групує треки тільки в тому випадку, якщо вони знаходяться в папці\",\n        \"badValue\": \"недійсний параметр \\\"{{value}}\\\". це значення більше не існує\",\n        \"credentialsRequired\": \"необхідні дані для входу\",\n        \"endpointNotImplementedError\": \"кінцева точка {{endpoint}} не реалізована для {{serverType}}\",\n        \"genericError\": \"сталася помилка\",\n        \"invalidServer\": \"недійсний сервер\",\n        \"localFontAccessDenied\": \"відмова в доступі до локальних шрифтів\",\n        \"loginRateError\": \"занадто багато спроб входу, спробуйте ще раз через кілька секунд\",\n        \"mpvRequired\": \"необхідний MPV\",\n        \"multipleServerSaveQueueError\": \"у черзі відтворення є одна або кілька пісень, які не належать до поточного сервера. це не підтримується\",\n        \"networkError\": \"сталася мережева помилка\",\n        \"noNetwork\": \"сервер недоступний\",\n        \"noNetworkDescription\": \"не вдалося підключитися до цього сервера\",\n        \"notificationDenied\": \"дозвіл на сповіщення було відхилено. це налаштування не має впливу\",\n        \"openError\": \"не вдалося відкрити файл\",\n        \"playbackError\": \"сталася помилка під час спроби відтворити медіафайл\",\n        \"remoteDisableError\": \"сталася помилка під час спроби $t(common.disable) віддаленого сервера\",\n        \"remoteEnableError\": \"сталася помилка під час спроби $t(common.enable) віддаленого сервера\",\n        \"remotePortError\": \"сталася помилка під час спроби налаштувати порт віддаленого сервера\",\n        \"remotePortWarning\": \"перезапустіть сервер щоб застосувати новий порт\",\n        \"saveQueueFailed\": \"не вдалося зберегти чергу\",\n        \"serverNotSelectedError\": \"не вибрано жодного сервера\",\n        \"serverRequired\": \"потрібен сервер\",\n        \"sessionExpiredError\": \"ваша сесія закінчилася\",\n        \"systemFontError\": \"сталася помилка під час спроби отримати системні шрифти\",\n        \"settingsSyncError\": \"виявлено розбіжності між налаштуваннями в рендерері та основним процесом. перезапустіть програму, щоб застосувати зміни\"\n    },\n    \"filter\": {\n        \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n        \"albumArtist\": \"$t(entity.albumArtist, {\\\"count\\\": 1})\",\n        \"albumCount\": \"кількість $t(entity.album, {\\\"count\\\": 2})\",\n        \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n        \"biography\": \"біографія\",\n        \"bitrate\": \"бітрейт\",\n        \"bpm\": \"уд/хв\",\n        \"channels\": \"$t(common.channel, {\\\"count\\\": 2})\",\n        \"comment\": \"коментар\",\n        \"communityRating\": \"рейтинг спільноти\",\n        \"criticRating\": \"рейтинг критиків\",\n        \"dateAdded\": \"дата додавання\",\n        \"disc\": \"диск\",\n        \"duration\": \"тривалість\",\n        \"favorited\": \"улюблене\",\n        \"fromYear\": \"з року\",\n        \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n        \"id\": \"id\",\n        \"isCompilation\": \"є компіляцією\",\n        \"isFavorited\": \"є улюбленим\",\n        \"isPublic\": \"є публічним\",\n        \"isRated\": \"є оціненим\",\n        \"isRecentlyPlayed\": \"нещодавно відтворено\",\n        \"lastPlayed\": \"нещодавно відтворені\",\n        \"mostPlayed\": \"найбільш відтворювані\",\n        \"name\": \"назва\",\n        \"note\": \"примітка\",\n        \"owner\": \"$t(common.owner)\",\n        \"path\": \"шлях\",\n        \"playCount\": \"кількість відтворень\",\n        \"random\": \"випадково\",\n        \"rating\": \"рейтинг\",\n        \"recentlyAdded\": \"нещодавно додано\",\n        \"recentlyPlayed\": \"нещодавно відтворено\",\n        \"recentlyUpdated\": \"нещодавно оновлено\",\n        \"releaseDate\": \"дата випуску\",\n        \"releaseYear\": \"рік випуску\",\n        \"search\": \"шукати\",\n        \"songCount\": \"кількість пісень\",\n        \"sortName\": \"сортування за назвою\",\n        \"title\": \"назва\",\n        \"toYear\": \"до року\",\n        \"trackNumber\": \"трек\",\n        \"explicitStatus\": \"$t(common.explicitStatus)\"\n    },\n    \"datetime\": {\n        \"minuteShort\": \"хв.\",\n        \"secondShort\": \"сек.\",\n        \"hourShort\": \"год\",\n        \"dayShort\": \"дн.\"\n    },\n    \"filterOperator\": {\n        \"after\": \"є після\",\n        \"afterDate\": \"після (дата)\",\n        \"before\": \"є перед\",\n        \"beforeDate\": \"є перед (дата)\",\n        \"contains\": \"містить\",\n        \"endsWith\": \"закінчується на\",\n        \"inPlaylist\": \"є в\",\n        \"inTheLast\": \"є в останньому\",\n        \"inTheRange\": \"є в межах\",\n        \"inTheRangeDate\": \"є в межах (дата)\",\n        \"is\": \"є\",\n        \"isNot\": \"не є\",\n        \"isGreaterThan\": \"більше ніж\",\n        \"isLessThan\": \"менше ніж\",\n        \"matchesRegex\": \"відповідає регулярному виразу\",\n        \"notContains\": \"не містить\",\n        \"notInPlaylist\": \"немає в\",\n        \"notInTheLast\": \"не є в останньому\",\n        \"startsWith\": \"починається з\"\n    },\n    \"form\": {\n        \"addServer\": {\n            \"error_savePassword\": \"сталася помилка під час спроби зберегти пароль\",\n            \"ignoreCors\": \"ігнорувати cors ($t(common.restartRequired))\",\n            \"ignoreSsl\": \"ігнорувати ssl ($t(common.restartRequired)}\",\n            \"input_legacyAuthentication\": \"увімкнути застарілу автентифікацію\",\n            \"input_name\": \"назва сервера\",\n            \"input_password\": \"пароль\",\n            \"input_preferInstantMix\": \"віддавати перевагу миттєвому міксу\",\n            \"input_preferInstantMixDescription\": \"використовувати тільки миттєвий мікс щоб отримати подібні пісні. корисно, коли у вас є плагіни, які змінюють цю поведінку\",\n            \"input_preferRemoteUrl\": \"віддавати перевагу публічній URL-адресі\",\n            \"input_remoteUrl\": \"публічна URL-адреса\",\n            \"input_remoteUrlPlaceholder\": \"опціонально: публічна URL-адреса для зовнішніх функцій\",\n            \"input_savePassword\": \"зберегти пароль\",\n            \"input_url\": \"URL-адреса\",\n            \"input_username\": \"Ім'я користувача\",\n            \"success\": \"сервер додано успішно\",\n            \"title\": \"додати сервер\"\n        },\n        \"largeFetchConfirmation\": {\n            \"title\": \"додати елементи до черги\",\n            \"description\": \"Ця дія додасть усі елементи в поточний відфільтрований перегляд\"\n        },\n        \"addToPlaylist\": {\n            \"create\": \"створити $t(entity.playlist, {\\\"count\\\": 1}) {{playlist}}\",\n            \"input_playlists\": \"$t(entity.playlist, {\\\"count\\\": 2})\",\n            \"input_skipDuplicates\": \"пропустити дублікати\",\n            \"searchOrCreate\": \"шукайте $t(entity.playlist, {\\\"count\\\": 2}) або пишіть, щоб створити новий\",\n            \"success\": \"додано $t(entity.trackWithCount, {\\\"count\\\": {{message}} }) до $t(entity.playlistWithCount, {\\\"count\\\": {{numOfPlaylists}} })\",\n            \"title\": \"додати до $t(entity.playlist, {\\\"count\\\": 1})\"\n        },\n        \"createPlaylist\": {\n            \"input_description\": \"$t(common.description)\",\n            \"input_name\": \"$t(common.name)\",\n            \"input_owner\": \"$t(common.owner)\",\n            \"input_public\": \"публічний\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) стрворено успішно\",\n            \"title\": \"створити $t(entity.playlist, {\\\"count\\\": 1})\"\n        },\n        \"createRadioStation\": {\n            \"success\": \"радіостанція створена успішно\",\n            \"title\": \"створити радіостанцію\",\n            \"input_homepageUrl\": \"адреса домашньої сторінки\",\n            \"input_name\": \"назва\",\n            \"input_streamUrl\": \"URL-адреса потоку\"\n        },\n        \"deletePlaylist\": {\n            \"input_confirm\": \"введіть ім'я $t(entity.playlist, {\\\"count\\\": 1}) для підтвердження\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) успішно видалено\",\n            \"title\": \"видалити $t(entity.playlist, {\\\"count\\\": 1})\"\n        },\n        \"editPlaylist\": {\n            \"publicJellyfinNote\": \"Jellyfin з якоїсь причини не показує, чи є плейлист публічним чи ні. Якщо ви хочете, щоб він залишався публічним, виберіть варіант нижче\",\n            \"editNote\": \"ручне редагування не рекомендується для великих плейлистів. ви впевнені, що готові прийняти ризик втрати даних, який виникає при перезапису існуючого плейлисту?\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) успішно оновлено\",\n            \"title\": \"змінити $t(entity.playlist, {\\\"count\\\": 1})\"\n        },\n        \"lyricsExport\": {\n            \"export\": \"експортувати тексти пісень\",\n            \"input_synced\": \"експортувати синхронізовані тексти пісень\",\n            \"input_offset\": \"$t(setting.lyricOffset)\"\n        },\n        \"lyricSearch\": {\n            \"input_artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n            \"input_name\": \"$t(common.name)\",\n            \"title\": \"шукати тексти пісень\"\n        },\n        \"queryEditor\": {\n            \"title\": \"редактор запитів\",\n            \"input_optionMatchAll\": \"збіг за всіма\",\n            \"input_optionMatchAny\": \"збіг за будь-яким\",\n            \"addRuleGroup\": \"додати групу правил\",\n            \"removeRuleGroup\": \"видалити групу правил\",\n            \"resetToDefault\": \"скинути до заводських налаштувань\",\n            \"clearFilters\": \"очистити фільтри\"\n        },\n        \"saveQueue\": {\n            \"success\": \"черга відтворення збережена на сервері\"\n        },\n        \"shareItem\": {\n            \"allowDownloading\": \"дозволити завантаження\",\n            \"description\": \"опис\",\n            \"setExpiration\": \"встановити термін дії\",\n            \"success\": \"посилання для спільного використання скопійовано в буфер обміну (натисніть тут, щоб відкрити)\",\n            \"expireInvalid\": \"термін дії повинен бути в майбутньому\",\n            \"createFailed\": \"не вдалося створити спільний доступ (чи ввімкнено спільний доступ?)\"\n        },\n        \"shuffleAll\": {\n            \"title\": \"відтворити випадково\",\n            \"input_genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n            \"input_limit\": \"скільки пісень?\",\n            \"input_minYear\": \"від року\",\n            \"input_maxYear\": \"до року\",\n            \"input_played\": \"відтворити фільтр\",\n            \"input_played_optionAll\": \"всі треки\",\n            \"input_played_optionUnplayed\": \"тільки не відтворені треки\",\n            \"input_played_optionPlayed\": \"тільки відтворені треки\"\n        },\n        \"updateServer\": {\n            \"success\": \"сервер успішно оновлено\",\n            \"title\": \"оновити сервер\"\n        },\n        \"privateMode\": {\n            \"enabled\": \"приватний режим увімкнено, стан відтворення тепер приховано від зовнішніх інтеграцій\",\n            \"disabled\": \"приватний режим вимкнено, стан відтворення тепер видно для увімкнених зовнішніх інтеграцій\",\n            \"title\": \"приватний режим\"\n        }\n    },\n    \"player\": {\n        \"skip\": \"пропустити\"\n    },\n    \"page\": {\n        \"albumArtistDetail\": {\n            \"about\": \"Про {{artist}}\",\n            \"appearsOn\": \"з'являється на\",\n            \"favoriteSongs\": \"улюблені пісні\",\n            \"groupingTypeAll\": \"всі типи випуску\",\n            \"groupingTypePrimary\": \"основні типи випуску\",\n            \"recentReleases\": \"останні випуски\",\n            \"viewDiscography\": \"переглянути дискографію\",\n            \"relatedArtists\": \"подібні $t(entity.artist, {\\\"count\\\": 2})\",\n            \"topSongs\": \"найкращі пісні\",\n            \"topSongsCommunity\": \"спільнота\",\n            \"topSongsFrom\": \"найкращі пісні від {{title}}\",\n            \"topSongsPersonal\": \"особисте\",\n            \"favoriteSongsFrom\": \"улюблені пісні від {{title}}\",\n            \"viewAll\": \"показати все\",\n            \"viewAllTracks\": \"показати усі $t(entity.track, {\\\"count\\\": 2})\"\n        },\n        \"albumArtistList\": {\n            \"title\": \"$t(entity.albumArtist, {\\\"count\\\": 2})\"\n        },\n        \"albumDetail\": {\n            \"moreFromArtist\": \"більше від цього $t(entity.artist, {\\\"count\\\": 1})\",\n            \"moreFromGeneric\": \"більше від {{item}}\",\n            \"released\": \"видано\"\n        },\n        \"albumList\": {\n            \"artistAlbums\": \"альбоми виконавця {{artist}}\",\n            \"genreAlbums\": \"\\\"{{genre}}\\\" $t(entity.album, {\\\"count\\\": 2})\",\n            \"title\": \"$t(entity.album, {\\\"count\\\": 2})\"\n        },\n        \"radioList\": {\n            \"title\": \"радіостанції\"\n        },\n        \"releasenotes\": {\n            \"commitsSinceStable\": \"комміти від {{stable}}\",\n            \"noNewCommits\": \"немає нових коммітів у цьому періоді\",\n            \"noStableReleaseToCompare\": \"немає доступної стабільної версії для порівняння\"\n        },\n        \"favorites\": {\n            \"title\": \"$t(entity.favorite, {\\\"count\\\": 2})\"\n        },\n        \"windowBar\": {\n            \"paused\": \"(Призупинено) \",\n            \"privateMode\": \"(Приватний режим)\"\n        },\n        \"appMenu\": {\n            \"collapseSidebar\": \"згорнути бічну панель\",\n            \"commandPalette\": \"відкрити палітру команд\",\n            \"expandSidebar\": \"розгорнути бічну панель\",\n            \"goBack\": \"повернутися назад\",\n            \"goForward\": \"перейти вперед\",\n            \"manageServers\": \"управління серверами\",\n            \"privateModeOff\": \"вимкнути приватний режим\",\n            \"privateModeOn\": \"увімкнути приватний режим\",\n            \"openBrowserDevtools\": \"відкрити інструменти розробника\",\n            \"quit\": \"$t(common.quit)\",\n            \"selectServer\": \"вибрати сервер\",\n            \"selectMusicFolder\": \"вибрати папку з музикою\",\n            \"noMusicFolder\": \"не вибрано папку з музикою\",\n            \"multipleMusicFolders\": \"Вибрано {{count}} папок з музикою\",\n            \"settings\": \"$t(common.setting, {\\\"count\\\": 2})\",\n            \"version\": \"версія {{version}}\"\n        },\n        \"manageServers\": {\n            \"title\": \"управління серверами\",\n            \"serverDetails\": \"інформація про сервер\",\n            \"url\": \"URL-адреса\",\n            \"username\": \"Ім'я користувача\",\n            \"editServerDetailsTooltip\": \"редагувати дані сервера\",\n            \"removeServer\": \"видалити сервер\"\n        },\n        \"contextMenu\": {\n            \"addFavorite\": \"$t(action.addToFavorites)\",\n            \"addLast\": \"$t(player.addLast)\",\n            \"addNext\": \"$t(player.addNext)\",\n            \"addToFavorites\": \"$t(action.addToFavorites)\",\n            \"addToPlaylist\": \"$t(action.addToPlaylist)\",\n            \"createPlaylist\": \"$t(action.createPlaylist)\",\n            \"deletePlaylist\": \"$t(action.deletePlaylist)\",\n            \"deselectAll\": \"$t(action.deselectAll)\",\n            \"download\": \"завантажити\",\n            \"moveItems\": \"$t(action.moveItems)\",\n            \"moveToNext\": \"$t(action.moveToNext)\",\n            \"moveToBottom\": \"$t(action.moveToBottom)\",\n            \"moveToTop\": \"$t(action.moveToTop)\",\n            \"numberSelected\": \"{{count}} вибрано\",\n            \"play\": \"$t(player.play)\",\n            \"playSimilarSongs\": \"$t(player.playSimilarSongs)\",\n            \"removeFromFavorites\": \"$t(action.removeFromFavorites)\",\n            \"removeFromPlaylist\": \"$t(action.removeFromPlaylist)\",\n            \"removeFromQueue\": \"$t(action.removeFromQueue)\",\n            \"setRating\": \"$t(action.setRating)\",\n            \"playShuffled\": \"$t(player.shuffle)\",\n            \"shareItem\": \"поділитися елементом\",\n            \"goTo\": \"перейти до\",\n            \"goToAlbum\": \"перейти до $t(entity.album, {\\\"count\\\": 1})\",\n            \"goToAlbumArtist\": \"перейти до $t(entity.albumArtist, {\\\"count\\\": 1})\",\n            \"showDetails\": \"отримати інформацію\"\n        }\n    }\n}\n"
  },
  {
    "path": "src/i18n/locales/zh-Hans.json",
    "content": "{\n    \"action\": {\n        \"editPlaylist\": \"编辑 $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"moveToTop\": \"移至顶部\",\n        \"clearQueue\": \"清空播放队列\",\n        \"addToFavorites\": \"添加到 $t(entity.favorite, {\\\"count\\\": 2})\",\n        \"addToPlaylist\": \"添加到 $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"createPlaylist\": \"创建 $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"removeFromPlaylist\": \"从 $t(entity.playlist, {\\\"count\\\": 1}) 移除\",\n        \"viewPlaylists\": \"查看 $t(entity.playlist, {\\\"count\\\": 2})\",\n        \"refresh\": \"$t(common.refresh)\",\n        \"deletePlaylist\": \"删除 $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"removeFromQueue\": \"从播放队列中移除\",\n        \"deselectAll\": \"取消全选\",\n        \"moveToBottom\": \"移至底部\",\n        \"setRating\": \"评分\",\n        \"toggleSmartPlaylistEditor\": \"切换 $t(entity.smartPlaylist) 编辑器\",\n        \"removeFromFavorites\": \"从 $t(entity.favorite, {\\\"count\\\": 2}) 移除\",\n        \"goToPage\": \"前往页面\",\n        \"openIn\": {\n            \"lastfm\": \"在 Last.fm 中打开\",\n            \"musicbrainz\": \"在 MusicBrainz 中打开\"\n        },\n        \"moveToNext\": \"移至下一首\",\n        \"downloadStarted\": \"开始下载 {{count}} 个项目\",\n        \"moveUp\": \"向上移动\",\n        \"moveDown\": \"向下移动\",\n        \"holdToMoveToTop\": \"按住即可移至到顶部\",\n        \"holdToMoveToBottom\": \"按住即可移动到底部\",\n        \"moveItems\": \"移动项目\",\n        \"shuffle\": \"随机播放\",\n        \"shuffleAll\": \"随机播放全部\",\n        \"shuffleSelected\": \"随机播放选定的内容\",\n        \"viewMore\": \"查看更多\",\n        \"addOrRemoveFromSelection\": \"在所选内容中添加或移除\",\n        \"selectRangeOfItems\": \"批量选择\",\n        \"selectAll\": \"全选\",\n        \"createRadioStation\": \"创建$t(entity.radioStation, {\\\"count\\\": 1})\",\n        \"deleteRadioStation\": \"删除$t(entity.radioStation, {\\\"count\\\": 1})\",\n        \"openApplicationDirectory\": \"打开应用程序目录\",\n        \"goToCurrent\": \"转到当前项目\"\n    },\n    \"common\": {\n        \"increase\": \"增高\",\n        \"rating\": \"评分\",\n        \"bpm\": \"bpm\",\n        \"refresh\": \"刷新\",\n        \"unknown\": \"未知\",\n        \"edit\": \"编辑\",\n        \"favorite\": \"收藏\",\n        \"left\": \"左\",\n        \"save\": \"保存\",\n        \"right\": \"右\",\n        \"currentSong\": \"当前$t(entity.track, {\\\"count\\\": 1})\",\n        \"collapse\": \"折叠\",\n        \"trackNumber\": \"音轨编号\",\n        \"descending\": \"降序\",\n        \"add\": \"添加\",\n        \"ascending\": \"升序\",\n        \"dismiss\": \"忽略\",\n        \"year\": \"年份\",\n        \"manage\": \"管理\",\n        \"minimize\": \"最小化\",\n        \"modified\": \"已修改\",\n        \"name\": \"名称\",\n        \"maximize\": \"最大化\",\n        \"decrease\": \"降低\",\n        \"description\": \"描述\",\n        \"configure\": \"配置\",\n        \"path\": \"路径\",\n        \"center\": \"中央\",\n        \"owner\": \"所有者\",\n        \"enable\": \"启用\",\n        \"clear\": \"清空\",\n        \"forward\": \"前进\",\n        \"delete\": \"删除\",\n        \"cancel\": \"取消\",\n        \"forceRestartRequired\": \"重启应用以使更改生效…关闭通知即可重启\",\n        \"setting_other\": \"设置\",\n        \"version\": \"版本\",\n        \"title\": \"标题\",\n        \"filter_other\": \"筛选\",\n        \"filters\": \"筛选\",\n        \"create\": \"创建\",\n        \"bitrate\": \"比特率\",\n        \"saveAndReplace\": \"保存并替换\",\n        \"action_other\": \"操作\",\n        \"confirm\": \"确定\",\n        \"resetToDefault\": \"重置为默认状态\",\n        \"home\": \"主页\",\n        \"comingSoon\": \"即将上线…\",\n        \"reset\": \"重置\",\n        \"disable\": \"禁用\",\n        \"menu\": \"菜单\",\n        \"restartRequired\": \"需要重启应用\",\n        \"previousSong\": \"上一首$t(entity.track, {\\\"count\\\": 1})\",\n        \"noResultsFromQuery\": \"未查询到匹配结果\",\n        \"quit\": \"退出\",\n        \"expand\": \"展开\",\n        \"search\": \"搜索\",\n        \"saveAs\": \"另存为\",\n        \"random\": \"随机\",\n        \"biography\": \"简介\",\n        \"sortOrder\": \"顺序\",\n        \"backward\": \"后退\",\n        \"gap\": \"空隙\",\n        \"limit\": \"限制\",\n        \"duration\": \"时长\",\n        \"ok\": \"好\",\n        \"no\": \"否\",\n        \"playerMustBePaused\": \"播放器必须先暂停\",\n        \"channel_other\": \"频道\",\n        \"none\": \"无\",\n        \"disc\": \"碟片\",\n        \"yes\": \"是\",\n        \"size\": \"大小\",\n        \"areYouSure\": \"是否确定？\",\n        \"note\": \"注释\",\n        \"close\": \"关闭\",\n        \"albumPeak\": \"专辑峰值\",\n        \"mbid\": \"MusicBrainz ID\",\n        \"reload\": \"重载\",\n        \"trackGain\": \"音轨增益\",\n        \"trackPeak\": \"音轨峰值\",\n        \"albumGain\": \"专辑增益\",\n        \"codec\": \"编解码器\",\n        \"share\": \"分享\",\n        \"preview\": \"预览\",\n        \"translation\": \"翻译\",\n        \"additionalParticipants\": \"其他参与者\",\n        \"tags\": \"标签\",\n        \"viewReleaseNotes\": \"查看发行说明\",\n        \"newVersion\": \"已安装新版本 ({{version}})\",\n        \"bitDepth\": \"位深度\",\n        \"sampleRate\": \"采样率\",\n        \"explicitStatus\": \"显式状态\",\n        \"clean\": \"清除\",\n        \"explicit\": \"显式\",\n        \"private\": \"私人\",\n        \"public\": \"公开\",\n        \"recordLabel\": \"唱片公司\",\n        \"releaseType\": \"发布类型\",\n        \"doNotShowAgain\": \"不要再显示此内容\",\n        \"view\": \"查看\",\n        \"externalLinks\": \"外部链接\",\n        \"faster\": \"更快\",\n        \"noFilters\": \"未配置任何筛选器\",\n        \"slower\": \"更慢\",\n        \"sort\": \"排序\",\n        \"gridRows\": \"网格行\",\n        \"tableColumns\": \"表格列\",\n        \"itemsMore\": \"{{count}} 更多\",\n        \"countSelected\": \"已选择{{count}}项\",\n        \"retry\": \"重试\",\n        \"example\": \"示例\",\n        \"filter_single\": \"单项\",\n        \"mood\": \"氛围\",\n        \"rename\": \"重命名\",\n        \"filter_multiple\": \"多项\",\n        \"newVersionAvailable\": \"新版本现已可用\"\n    },\n    \"entity\": {\n        \"albumArtist_other\": \"专辑艺术家\",\n        \"albumArtistCount_other\": \"{{count}} 位专辑艺术家\",\n        \"albumWithCount_other\": \"{{count}} 张专辑\",\n        \"album_other\": \"专辑\",\n        \"genre_other\": \"流派\",\n        \"playlistWithCount_other\": \"{{count}} 个播放列表\",\n        \"playlist_other\": \"播放列表\",\n        \"artist_other\": \"艺术家\",\n        \"folderWithCount_other\": \"{{count}} 个文件夹\",\n        \"track_other\": \"曲目\",\n        \"favorite_other\": \"收藏\",\n        \"artistWithCount_other\": \"{{count}} 位艺术家\",\n        \"folder_other\": \"文件夹\",\n        \"smartPlaylist\": \"智能$t(entity.playlist, {\\\"count\\\": 1})\",\n        \"genreWithCount_other\": \"{{count}} 种流派\",\n        \"trackWithCount_other\": \"{{count}} 首曲目\",\n        \"play_other\": \"{{count}} 次播放\",\n        \"song_other\": \"歌曲\",\n        \"radioStation_other\": \"广播电台\",\n        \"radioStationWithCount_other\": \"{{count}} 个广播电台\"\n    },\n    \"player\": {\n        \"repeat_all\": \"循环全部\",\n        \"stop\": \"停止\",\n        \"repeat\": \"循环\",\n        \"queue_remove\": \"移除所选项\",\n        \"playRandom\": \"随机播放\",\n        \"skip\": \"跳过\",\n        \"previous\": \"上一首\",\n        \"toggleFullscreenPlayer\": \"全屏\",\n        \"skip_back\": \"向后跳过\",\n        \"favorite\": \"收藏\",\n        \"next\": \"下一首\",\n        \"shuffle\": \"播放（随机）\",\n        \"playbackFetchNoResults\": \"未找到歌曲\",\n        \"playbackFetchInProgress\": \"正在加载歌曲…\",\n        \"addNext\": \"下一个\",\n        \"playbackFetchCancel\": \"请稍等…关闭通知以取消操作\",\n        \"play\": \"播放\",\n        \"repeat_off\": \"循环关闭\",\n        \"queue_clear\": \"清空播放队列\",\n        \"muted\": \"已静音\",\n        \"unfavorite\": \"取消收藏\",\n        \"queue_moveToTop\": \"将所选项移至底部\",\n        \"queue_moveToBottom\": \"将所选项移至顶部\",\n        \"shuffle_off\": \"禁用随机播放\",\n        \"addLast\": \"最后\",\n        \"mute\": \"静音\",\n        \"skip_forward\": \"向前跳过\",\n        \"playbackSpeed\": \"播放速度\",\n        \"pause\": \"暂停\",\n        \"playSimilarSongs\": \"播放类似的歌曲\",\n        \"viewQueue\": \"查看播放队列\",\n        \"saveQueueToServer\": \"将播放队列保存到服务器\",\n        \"restoreQueueFromServer\": \"从服务器恢复播放队列\",\n        \"lyrics\": \"歌词\",\n        \"addLastShuffled\": \"最后（随机）\",\n        \"addNextShuffled\": \"下一个（随机）\",\n        \"artistRadio\": \"艺术家电台\",\n        \"holdToShuffle\": \"按住即可随机\",\n        \"trackRadio\": \"追踪广播\",\n        \"sleepTimer\": \"睡眠定时器\",\n        \"sleepTimer_endOfSong\": \"当前歌曲结束时\",\n        \"sleepTimer_minutes\": \"{{count}} 分钟\",\n        \"sleepTimer_hours\": \"{{count}} 小时\",\n        \"sleepTimer_custom\": \"自定义\",\n        \"sleepTimer_off\": \"关闭\",\n        \"sleepTimer_timeRemaining\": \"剩余时间 {{time}}\",\n        \"sleepTimer_setCustom\": \"设置定时器\",\n        \"sleepTimer_cancel\": \"取消定时器\",\n        \"albumRadio\": \"专辑电台\"\n    },\n    \"setting\": {\n        \"crossfadeStyle_description\": \"选择用于音频播放器的淡入淡出风格\",\n        \"hotkey_favoriteCurrentSong\": \"收藏$t(common.currentSong)\",\n        \"audioExclusiveMode_description\": \"启用独占输出模式。在此模式下，系统通常被锁定为只有 mpv 能够输出音频\",\n        \"disableLibraryUpdateOnStartup\": \"禁用启动时查询新版本\",\n        \"gaplessAudio\": \"无缝音频\",\n        \"audioPlayer_description\": \"选择用于播放的音频播放器\",\n        \"globalMediaHotkeys\": \"全局媒体快捷键\",\n        \"gaplessAudio_description\": \"调整 mpv 无缝音频设置\",\n        \"followLyric_description\": \"滚动歌词到当前播放位置\",\n        \"audioExclusiveMode\": \"音频独占模式\",\n        \"font\": \"字体\",\n        \"neteaseTranslation\": \"启用网易云歌词翻译\",\n        \"neteaseTranslation_description\": \"启用后，在获取歌词时将包含并显示网易云音乐提供的翻译（如果存在）\",\n        \"crossfadeDuration_description\": \"设置淡入淡出持续时间\",\n        \"audioDevice\": \"音频设备\",\n        \"enableRemote\": \"启用远程控制服务器\",\n        \"fontType\": \"字体类型\",\n        \"applicationHotkeys\": \"应用快捷键\",\n        \"globalMediaHotkeys_description\": \"启用或禁用系统媒体快捷键以控制播放\",\n        \"customFontPath\": \"自定义字体路径\",\n        \"followLyric\": \"跟随当前歌词\",\n        \"crossfadeDuration\": \"淡入淡出持续时间\",\n        \"audioPlayer\": \"音频播放器\",\n        \"discordApplicationId\": \"{{discord}} 应用 id\",\n        \"applicationHotkeys_description\": \"配置应用快捷键。勾选设为全局快捷键（仅桌面端）\",\n        \"customFontPath_description\": \"设置应用使用的自定义字体路径\",\n        \"gaplessAudio_optionWeak\": \"弱（推荐）\",\n        \"font_description\": \"设置应用使用的字体\",\n        \"audioDevice_description\": \"选择用于播放的音频设备\",\n        \"enableRemote_description\": \"启用远程控制服务器，以允许其他设备控制此应用\",\n        \"remotePort_description\": \"设置远程服务器端口\",\n        \"hotkey_skipBackward\": \"向后跳过\",\n        \"replayGainMode_description\": \"根据文件元数据中存储的 {{ReplayGain}} 值调整音量增益\",\n        \"volumeWheelStep_description\": \"在音量滑块上滚动鼠标滚轮时要更改的音量大小\",\n        \"theme_description\": \"设置应用的主题\",\n        \"hotkey_playbackPause\": \"暂停\",\n        \"replayGainFallback\": \"{{ReplayGain}}后备方案\",\n        \"sidebarCollapsedNavigation_description\": \"在折叠的侧边栏中显示或隐藏导航\",\n        \"hotkey_volumeUp\": \"音量增高\",\n        \"skipDuration\": \"跳过时长\",\n        \"showSkipButtons\": \"显示跳过按钮\",\n        \"playButtonBehavior_optionPlay\": \"$t(player.play)\",\n        \"minimumScrobblePercentage\": \"最小记录时长（百分比）\",\n        \"lyricFetch\": \"从互联网获取歌词\",\n        \"scrobble\": \"记录播放信息\",\n        \"skipDuration_description\": \"设置每次按下跳过按钮将会跳过的时长\",\n        \"fontType_optionSystem\": \"系统字体\",\n        \"mpvExecutablePath_description\": \"设置 mpv 可执行文件的路径。如果留空，则使用默认路径\",\n        \"sampleRate\": \"采样率\",\n        \"sidePlayQueueStyle_optionAttached\": \"吸附\",\n        \"sidebarConfiguration\": \"侧边栏设定\",\n        \"sampleRate_description\": \"如果选择的采样频率与当前媒体的采样频率不同，请选择要使用的输出采样率。小于 8000 的值将使用默认频率\",\n        \"replayGainMode_optionNone\": \"$t(common.none)\",\n        \"hotkey_zoomIn\": \"放大\",\n        \"scrobble_description\": \"在你的媒体服务器中记录播放信息\",\n        \"hotkey_browserForward\": \"浏览器前进\",\n        \"themeLight\": \"主题（浅色）\",\n        \"fontType_optionBuiltIn\": \"内置字体\",\n        \"hotkey_playbackPlayPause\": \"播放/暂停\",\n        \"hotkey_rate1\": \"评为 1 星\",\n        \"hotkey_skipForward\": \"向前跳过\",\n        \"sidePlayQueueStyle\": \"侧边播放列表样式\",\n        \"playButtonBehavior_optionAddLast\": \"$t(player.addLast)\",\n        \"zoom\": \"缩放率\",\n        \"minimizeToTray_description\": \"将应用程序最小化到系统托盘\",\n        \"hotkey_playbackPlay\": \"播放\",\n        \"hotkey_togglePreviousSongFavorite\": \"切换收藏$t(common.previousSong)\",\n        \"hotkey_volumeDown\": \"音量降低\",\n        \"hotkey_unfavoritePreviousSong\": \"取消收藏$t(common.previousSong)\",\n        \"hotkey_globalSearch\": \"全局搜索\",\n        \"remoteUsername_description\": \"设置远程控制服务器的用户名。如果用户名和密码都为空，则身份验证将被禁用\",\n        \"exitToTray_description\": \"退出应用时最小化到系统托盘\",\n        \"hotkey_favoritePreviousSong\": \"收藏$t(common.previousSong)\",\n        \"replayGainMode_optionAlbum\": \"$t(entity.album, {\\\"count\\\": 1})\",\n        \"lyricOffset\": \"歌词偏移（毫秒）\",\n        \"fontType_optionCustom\": \"自定义字体\",\n        \"themeDark_description\": \"应用将使用深色主题\",\n        \"remotePassword\": \"远程控制服务器密码\",\n        \"lyricFetchProvider\": \"歌词源\",\n        \"language_description\": \"设置应用的语言（$t(common.restartRequired)）\",\n        \"playbackStyle_optionCrossFade\": \"淡入淡出\",\n        \"hotkey_rate3\": \"评为 3 星\",\n        \"replayGainMode_optionTrack\": \"$t(entity.track, {\\\"count\\\": 1})\",\n        \"themeLight_description\": \"应用将使用浅色主题\",\n        \"hotkey_toggleFullScreenPlayer\": \"全屏播放\",\n        \"hotkey_localSearch\": \"页面内搜索\",\n        \"hotkey_toggleQueue\": \"切换播放队列\",\n        \"zoom_description\": \"设置应用程序的缩放率\",\n        \"remotePassword_description\": \"设置远程控制服务器的密码。这些凭据默认以不安全的方式传输，因此您应该使用一个您不在意的唯一密码\",\n        \"hotkey_rate5\": \"评为 5 星\",\n        \"hotkey_playbackPrevious\": \"上一首\",\n        \"showSkipButtons_description\": \"在播放条显示或隐藏播放按钮\",\n        \"playbackStyle\": \"播放风格\",\n        \"hotkey_toggleShuffle\": \"切换随机\",\n        \"theme\": \"主题\",\n        \"playbackStyle_description\": \"选择音频播放器的播放风格\",\n        \"mpvExecutablePath\": \"mpv 可执行文件路径\",\n        \"hotkey_rate2\": \"评为 2 星\",\n        \"playButtonBehavior_description\": \"设置将歌曲添加到播放队列时播放按钮的默认行为\",\n        \"minimumScrobblePercentage_description\": \"歌曲被记录为已播放所需的最小播放百分比\",\n        \"exitToTray\": \"退出时最小化到托盘\",\n        \"hotkey_rate4\": \"评为 4 星\",\n        \"showSkipButton_description\": \"在播放条上显示或隐藏跳过按钮\",\n        \"savePlayQueue\": \"保存播放队列\",\n        \"minimumScrobbleSeconds_description\": \"歌曲被记录为已播放所需的最小播放时间\",\n        \"skipPlaylistPage_description\": \"打开歌单时，直接查看歌曲列表而非查看默认页面\",\n        \"fontType_description\": \"内置字体可以选择 feishin 提供的字体之一。系统字体允许您选择操作系统提供的任何字体。自定义选项允许您使用自己的字体\",\n        \"playButtonBehavior\": \"播放按钮行为\",\n        \"volumeWheelStep\": \"音量滚轮分度\",\n        \"sidebarPlaylistList_description\": \"显示或隐藏侧边栏歌单列表\",\n        \"sidePlayQueueStyle_description\": \"设置侧边播放列表样式\",\n        \"replayGainMode\": \"{{ReplayGain}}模式\",\n        \"playbackStyle_optionNormal\": \"正常\",\n        \"windowBarStyle\": \"窗口顶栏风格\",\n        \"replayGainFallback_description\": \"如果文件没有 {{ReplayGain}} 标签，则在数据库中应用增益\",\n        \"hotkey_toggleRepeat\": \"切换循环\",\n        \"lyricOffset_description\": \"将歌词偏移指定的毫秒数\",\n        \"sidebarConfiguration_description\": \"选择侧边栏包含的项目与顺序\",\n        \"remotePort\": \"远程服务器端口\",\n        \"hotkey_playbackNext\": \"下一首\",\n        \"useSystemTheme_description\": \"使用系统定义的浅色或深色主题\",\n        \"playButtonBehavior_optionAddNext\": \"$t(player.addNext)\",\n        \"lyricFetch_description\": \"从多个互联网源获取歌词\",\n        \"lyricFetchProvider_description\": \"选择要从中获取歌词的提供商\",\n        \"sidePlayQueueStyle_optionDetached\": \"不吸附\",\n        \"hotkey_zoomOut\": \"缩小\",\n        \"hotkey_unfavoriteCurrentSong\": \"取消收藏$t(common.currentSong)\",\n        \"hotkey_rate0\": \"清除评分\",\n        \"hotkey_volumeMute\": \"静音\",\n        \"hotkey_toggleCurrentSongFavorite\": \"切换收藏$t(common.currentSong)\",\n        \"remoteUsername\": \"远程控制服务器用户名\",\n        \"hotkey_browserBack\": \"浏览器后退\",\n        \"showSkipButton\": \"显示跳过按钮\",\n        \"sidebarPlaylistList\": \"侧边栏歌单列表\",\n        \"minimizeToTray\": \"最小化到托盘\",\n        \"skipPlaylistPage\": \"跳过播放列表页面\",\n        \"themeDark\": \"主题（深色）\",\n        \"sidebarCollapsedNavigation\": \"侧边栏（已折叠）导航\",\n        \"minimumScrobbleSeconds\": \"最小记录时间（秒）\",\n        \"hotkey_playbackStop\": \"停止\",\n        \"windowBarStyle_description\": \"选择窗口顶栏的风格\",\n        \"savePlayQueue_description\": \"当应用程序关闭时保存播放队列，并在应用程序打开时恢复它\",\n        \"useSystemTheme\": \"跟随系统\",\n        \"discordIdleStatus_description\": \"启用后将会在播放器闲置时更新状态\",\n        \"replayGainClipping_description\": \"自动降低增益以防止{{ReplayGain}}造成削波\",\n        \"replayGainPreamp\": \"{{ReplayGain}}前置放大（分贝）\",\n        \"replayGainClipping\": \"{{ReplayGain}}削波\",\n        \"discordUpdateInterval\": \"{{discord}} rich presence 更新间隔\",\n        \"discordApplicationId_description\": \"{{discord}} rich presence 应用 id（默认为 {{defaultId}}）\",\n        \"discordUpdateInterval_description\": \"更新间隔秒数（至少 15 秒）\",\n        \"discordRichPresence_description\": \"在 {{discord}} rich presence 中显示播放状态。图片键为：{{icon}}、{{playing}} 和 {{paused}}\",\n        \"accentColor\": \"强调色\",\n        \"accentColor_description\": \"设置应用的强调色\",\n        \"replayGainPreamp_description\": \"调整应用在{{ReplayGain}}值上的前置放大增益\",\n        \"discordIdleStatus\": \"显示 rich presence 闲置状态\",\n        \"clearCache\": \"清除浏览器缓存\",\n        \"buttonSize\": \"播放器栏按钮大小\",\n        \"buttonSize_description\": \"播放器栏按钮大小\",\n        \"clearCache_description\": \"feishin的“硬清除”。除了清除feishin的缓存，清空浏览器缓存（保存的图像和其他资源）。服务器凭据和设置会被保留\",\n        \"clearQueryCache_description\": \"feishin的“软清除”。这将会刷新播放列表、元数据并重置保存的歌词。设置、服务器凭据和缓存图像会被保留\",\n        \"clearQueryCache\": \"清除feishin缓存\",\n        \"externalLinks\": \"显示外部链接\",\n        \"externalLinks_description\": \"允许在艺术家/专辑页面上显示外部链接（Last.fm、MusicBrainz）\",\n        \"mpvExtraParameters_help\": \"每行一个\",\n        \"startMinimized\": \"启动最小化\",\n        \"startMinimized_description\": \"在系统托盘中启动应用程序\",\n        \"passwordStore_description\": \"使用什么密码/密钥存储。如果您在存储密码时遇到问题，请更改此设置\",\n        \"clearCacheSuccess\": \"缓存清除成功\",\n        \"homeConfiguration\": \"主页配置\",\n        \"homeConfiguration_description\": \"配置主页上显示的项目以及显示顺序\",\n        \"passwordStore\": \"密码/密钥存储\",\n        \"homeFeature_description\": \"控制是否在主页上显示大型特色轮播\",\n        \"homeFeature\": \"首页 精选 轮播\",\n        \"imageAspectRatio\": \"保留封面图像纵横比\",\n        \"imageAspectRatio_description\": \"如果启用，封面图像将保留纵横比显示。对于不是1:1的图像，剩余的空间将是空的\",\n        \"volumeWidth\": \"音量滑块宽度\",\n        \"volumeWidth_description\": \"音量滑块的宽度\",\n        \"discordListening\": \"显示状态为正在监听\",\n        \"discordListening_description\": \"将状态显示为正在监听，而不是正在播放\",\n        \"contextMenu_description\": \"允许您隐藏右键单击项目时显示在菜单中的项目。未选中的项目将被隐藏\",\n        \"customCssEnable_description\": \"允许编写自定义 css\",\n        \"customCss\": \"自定义css\",\n        \"customCss_description\": \"自定义css内容。注意：内容和远程url是不允许的属性。内容预览展示如下。出于安全考虑，您未设置的其它字段也会显示\",\n        \"contextMenu\": \"上下文菜单（右键单击）配置\",\n        \"customCssEnable\": \"启用自定义 css\",\n        \"customCssNotice\": \"警告：虽然预设了一些安全限制（不允许 url() 和 content:），但使用自定义 css 仍然会因更改界面而带来风险\",\n        \"transcode_description\": \"可以转码为不同的格式\",\n        \"transcodeBitrate\": \"转码比特率\",\n        \"albumBackground\": \"专辑背景图片\",\n        \"albumBackground_description\": \"为包含专辑封面的专辑页面添加背景图像\",\n        \"albumBackgroundBlur\": \"专辑背景图像模糊大小\",\n        \"albumBackgroundBlur_description\": \"调整相册背景图片的模糊程度\",\n        \"playerbarOpenDrawer\": \"播放器栏全屏切换\",\n        \"playerbarOpenDrawer_description\": \"允许点击播放器栏打开全屏播放器\",\n        \"transcodeBitrate_description\": \"选择要转码的比特率。0 表示让服务器选择\",\n        \"transcodeFormat\": \"转码格式\",\n        \"transcodeFormat_description\": \"选择要转码的格式。留空让服务器决定\",\n        \"webAudio_description\": \"使用 web 音频。这将启用重播增益等高级功能。如果您遇到其他情况，请禁用\",\n        \"artistConfiguration_description\": \"配置专辑艺术家页面上显示的项目及其显示顺序\",\n        \"webAudio\": \"使用 web 音频\",\n        \"artistConfiguration\": \"专辑艺术家页面配置\",\n        \"playButtonBehavior_optionPlayShuffled\": \"$t(player.shuffle)\",\n        \"trayEnabled_description\": \"显示/隐藏托盘图标/菜单。如果禁用，也会禁用最小化/退出到托盘\",\n        \"trayEnabled\": \"显示托盘\",\n        \"translationApiProvider\": \"翻译api提供商\",\n        \"translationApiProvider_description\": \"翻译api提供商\",\n        \"translationApiKey\": \"翻译api密钥\",\n        \"translationApiKey_description\": \"翻译api密钥（仅限全球服务节点）\",\n        \"translationTargetLanguage\": \"目标翻译语言\",\n        \"translationTargetLanguage_description\": \"目标翻译语言\",\n        \"lastfmApiKey\": \"{{lastfm}} API 密钥\",\n        \"lastfmApiKey_description\": \"{{lastfm}} 的 API 密钥。封面艺术图所需\",\n        \"discordServeImage\": \"从服务器提供 {{discord}} 图像\",\n        \"discordServeImage_description\": \"从服务器本身分享 {{discord}} rich presence 的封面艺术，仅适用于 Jellyfin 和 Navidrome。 {{discord}} 使用机器人来获取图像，因此您的服务器必须可通过公共互联网访问\",\n        \"musicbrainz\": \"显示 MusicBrainz 链接\",\n        \"musicbrainz_description\": \"在存在 MusicBrainz ID 的艺术家/专辑页面上显示 MusicBrainz 的链接\",\n        \"lastfm\": \"显示 last.fm 链接\",\n        \"lastfm_description\": \"在艺术家/专辑页面上显示 Last.fm 的链接\",\n        \"preferLocalLyrics_description\": \"优先选择本地歌词（如有），而不是远程歌词\",\n        \"preferLocalLyrics\": \"首选本地歌词\",\n        \"discordPausedStatus\": \"暂停时显示rich presence\",\n        \"discordPausedStatus_description\": \"启用后将在播放器暂停时显示状态\",\n        \"preservePitch\": \"保持音高\",\n        \"preservePitch_description\": \"在调整播放速度时保持音高\",\n        \"discordDisplayType\": \"{{discord}} 存在显示类型\",\n        \"discordDisplayType_description\": \"改变您在状态中收听的内容\",\n        \"discordDisplayType_songname\": \"歌曲名称\",\n        \"discordDisplayType_artistname\": \"艺术家名称\",\n        \"hotkey_navigateHome\": \"导航到主页\",\n        \"preventSleepOnPlayback\": \"防止播放时进入睡眠状态\",\n        \"preventSleepOnPlayback_description\": \"播放音乐时防止显示器进入睡眠状态\",\n        \"discordLinkType\": \"{{discord}} 状态链接\",\n        \"discordLinkType_description\": \"在 {{discord}} 的歌曲和艺术家字段中添加 {{lastfm}} 或 {{musicbrainz}} 的外部链接。{{musicbrainz}} 最准确，但需要标签，且不提供艺术家链接，而 {{lastfm}} 则始终提供链接。无需额外的网络请求\",\n        \"discordLinkType_none\": \"$t(common.none)\",\n        \"discordLinkType_mbz_lastfm\": \"{{musicbrainz}} 和 {{lastfm}} 后备\",\n        \"artistBackground\": \"艺术家背景图片\",\n        \"artistBackground_description\": \"为包含艺术家作品的艺术家页面添加背景图片\",\n        \"artistBackgroundBlur\": \"艺术家背景图像模糊尺寸\",\n        \"artistBackgroundBlur_description\": \"调整应用于艺术家背景图像的模糊程度\",\n        \"releaseChannel_optionLatest\": \"最新的\",\n        \"releaseChannel_optionBeta\": \"测试版\",\n        \"releaseChannel\": \"发布通道\",\n        \"releaseChannel_description\": \"选择稳定版、测试版或 Alpha（夜间构建版）以启用自动更新\",\n        \"mediaSession\": \"启用媒体会话\",\n        \"mediaSession_description\": \"启用媒体会话集成，在系统音量叠加层和锁屏界面显示媒体控件和元数据\",\n        \"exportImportSettings_control_description\": \"通过 JSON 导出和导入设置\",\n        \"exportImportSettings_control_exportText\": \"导出设置\",\n        \"exportImportSettings_control_importText\": \"导入设置\",\n        \"exportImportSettings_control_title\": \"导入/导出设置\",\n        \"exportImportSettings_destructiveWarning\": \"导入设置会破坏现有设置，请在点击下方“导入”按钮前仔细阅读以上内容！\",\n        \"exportImportSettings_importBtn\": \"导入设置\",\n        \"exportImportSettings_importModalTitle\": \"导入 feishin 设置\",\n        \"exportImportSettings_importSuccess\": \"设置已成功导入！\",\n        \"exportImportSettings_notValidJSON\": \"传递的文件不是有效的 JSON 文件\",\n        \"exportImportSettings_offendingKeyError\": \"\\\"{{offendingKey}}\\\" 不正确 - {{reason}}\",\n        \"enableAutoTranslation_description\": \"歌词加载时自动启用翻译\",\n        \"enableAutoTranslation\": \"启用自动翻译\",\n        \"imageResolution_description\": \"程序中使用的图片分辨率，设置为0时使用原始图片\",\n        \"artistReleaseTypeConfiguration_description\": \"配置专辑艺术家页面上显示的发行类型及顺序\",\n        \"logLevel_description\": \"设置显示的最低日志级别。debug显示所有日志，error仅显示错误日志\",\n        \"showLyricsInSidebar_description\": \"在播放列表的附加面板中增加歌词显示页面\",\n        \"playerbarSlider_description\": \"不建议在网络速度较慢或按流量计费情况下使用波形图\",\n        \"showVisualizerInSidebar_description\": \"在播放侧边栏中增加可视化效果\",\n        \"analyticsDisable_description\": \"发送匿名使用数据帮助开发者改进应用程序\",\n        \"showRatings_description\": \"控制是否在界面上显示星级评分\",\n        \"followCurrentSong_description\": \"自动滚动播放列表至当前播放的歌曲\",\n        \"audioFadeOnStatusChange_description\": \"启用音乐淡入和淡出效果\",\n        \"combinedLyricsAndVisualizer_description\": \"将歌词和可视化界面合并到同一面板中\",\n        \"queryBuilderCustomFields_description\": \"在查询构建器添加自定义字段\",\n        \"combinedLyricsAndVisualizer\": \"在播放器侧边栏合并歌词和可视化界面\",\n        \"autoDJ_description\": \"自动添加相似歌曲到队列中\",\n        \"notify_description\": \"歌曲变更时显示通知\",\n        \"mpvExtraParameters_description\": \"向mpv传递额外参数\",\n        \"audioFadeOnStatusChange\": \"音频改变时淡入淡出\",\n        \"showVisualizerInSidebar\": \"在播放器侧边栏显示可视化效果\",\n        \"showLyricsInSidebar\": \"在播放器侧边栏显示歌词\",\n        \"analyticsDisable\": \"退出使用情况的分析\",\n        \"artistReleaseTypeConfiguration\": \"艺术家发行类型设置\",\n        \"useThemeAccentColor\": \"使用主题强调色\",\n        \"mpvExtraParameters\": \"mpv额外参数\",\n        \"showRatings\": \"显示星级评分\",\n        \"followCurrentSong\": \"跟随当前歌曲\",\n        \"logLevel\": \"日志等级\",\n        \"playerbarWaveformAlign_optionTop\": \"顶部对齐\",\n        \"playerbarWaveformAlign_optionCenter\": \"居中对齐\",\n        \"playerbarWaveformAlign_optionBottom\": \"底部对齐\",\n        \"queryBuilderCustomFields_inputLabel\": \"厂牌\",\n        \"queryBuilderCustomFields_inputTag\": \"标签\",\n        \"logLevel_optionDebug\": \"Debug\",\n        \"logLevel_optionError\": \"Error\",\n        \"logLevel_optionInfo\": \"Info\",\n        \"logLevel_optionWarn\": \"Warn\",\n        \"imageResolution_optionSidebar\": \"侧边栏\",\n        \"imageResolution_optionHeader\": \"页首\",\n        \"language\": \"语言\",\n        \"notify\": \"启用歌曲通知\",\n        \"imageResolution\": \"图像分辨率\",\n        \"imageResolution_optionTable\": \"表格\",\n        \"imageResolution_optionFullScreenPlayer\": \"全屏播放器\",\n        \"playerbarSlider\": \"播放进度条\",\n        \"playerbarSliderType_optionSlider\": \"滑块\",\n        \"playerbarSliderType_optionWaveform\": \"波形\",\n        \"playerbarWaveformAlign\": \"波形对齐方式\",\n        \"playerbarWaveformBarWidth\": \"波形宽度\",\n        \"playerbarWaveformGap\": \"波形间距\",\n        \"transcode\": \"启用转码功能\",\n        \"useThemeAccentColor_description\": \"使用所选主题中定义的主色，而不是自定义强调色\",\n        \"homeFeatureStyle_optionSingle\": \"单项\",\n        \"autoDJ\": \"自动DJ\",\n        \"autoDJ_itemCount\": \"项目数量\",\n        \"autoDJ_itemCount_description\": \"启用自动 DJ 功能后，尝试添加到队列中的项目数\",\n        \"autoDJ_timing\": \"定时\",\n        \"autoDJ_timing_description\": \"自动 DJ 触发前队列中剩余的歌曲数量\",\n        \"crossfadeStyle\": \"交叉渐变风格\",\n        \"discordRichPresence\": \"{{discord}} rich presence\",\n        \"homeFeatureStyle_description\": \"控制首页特色轮播图的样式\",\n        \"homeFeatureStyle\": \"首页特色旋转样式\",\n        \"homeFeatureStyle_optionMultiple\": \"多样\",\n        \"hotkey_listNavigateToPage\": \"列表导航至项目页面\",\n        \"hotkey_listPlayDefault\": \"播放列表\",\n        \"hotkey_listPlayLast\": \"播放列表最后\",\n        \"hotkey_listPlayNext\": \"播放列表下一个\",\n        \"hotkey_listPlayNow\": \"播放列表现在\",\n        \"pathReplace\": \"文件路径替换\",\n        \"pathReplace_description\": \"替换服务器的默认文件路径\",\n        \"pathReplace_optionRemovePrefix\": \"移除前缀\",\n        \"pathReplace_optionAddPrefix\": \"添加前缀\",\n        \"playerFilters\": \"从队列中筛选歌曲\",\n        \"playerFilters_description\": \"根据以下条件，忽略添加到队列中的歌曲\",\n        \"artistRadioCount_description\": \"设置艺术家电台和曲目电台要获取的歌曲数量\",\n        \"artistRadioCount\": \"艺术家/曲目电台数量\",\n        \"imageResolution_optionItemCard\": \"项目卡\",\n        \"playerbarWaveformRadius\": \"波形半径\",\n        \"enableGridMultiSelect\": \"启用网格多选\",\n        \"enableGridMultiSelect_description\": \"启用后，允许在网格视图中选择多个项目。禁用后，点击网格项目图像将跳转到项目页面\",\n        \"sidebarPlaylistSorting_description\": \"允许在侧边栏中使用拖放操作手动对播放列表进行排序，而不是使用默认的服务器顺序\",\n        \"sidebarPlaylistSorting\": \"侧边栏播放列表排序\",\n        \"sidebarPlaylistListFilterRegex_description\": \"隐藏侧边栏中与此正则表达式匹配的播放列表\",\n        \"sidebarPlaylistListFilterRegex_placeholder\": \"例如：^每日精选*\",\n        \"sidebarPlaylistListFilterRegex\": \"播放列表筛选正则表达式\",\n        \"queryBuilder\": \"查询构建器\",\n        \"queryBuilderCustomFields\": \"自定义字段\",\n        \"analyticsEnable\": \"发送基于使用情况的分析\",\n        \"analyticsEnable_description\": \"发送匿名使用数据帮助开发者改进应用程序\",\n        \"automaticUpdates\": \"自动更新\",\n        \"automaticUpdates_description\": \"自动检查并安装更新\",\n        \"releaseChannel_optionAlpha\": \"alpha（每日构建版）\",\n        \"discordStateIcon\": \"显示播放图标\",\n        \"discordStateIcon_description\": \"在 rich presence 状态中显示一个小的播放图标。启用“暂停时显示 rich presence 在线状态”后，暂停图标始终显示\",\n        \"blurExplicitImages\": \"模糊显式图片\",\n        \"blurExplicitImages_description\": \"专辑和歌曲封面若被标记为不雅内容，将会进行模糊处理\",\n        \"autosave\": \"自动保存播放队列\",\n        \"autosave_description\": \"启用自动将播放队列保存到服务器的功能。此功能仅在使用 Navidrome/Subsonic 时可用，且不能使用混合播放队列。\",\n        \"autosaveCount\": \"自动播放队列保存频率\",\n        \"autosaveCount_description\": \"队列保存前需要更改多少首歌曲？1（最少）表示每次歌曲更改\",\n        \"useThemePrimaryShade\": \"使用主题主色调\",\n        \"useThemePrimaryShade_description\": \"对于主要颜色变体，请使用所选主题中定义的主色调\",\n        \"primaryShade\": \"主色调\",\n        \"primaryShade_description\": \"覆盖按钮、链接和其他主色元素使用的主色调（0-9）\",\n        \"playerItemConfiguration_description\": \"配置全屏播放器上显示的项目及其显示顺序\",\n        \"playerItemConfiguration\": \"播放器项目配置\"\n    },\n    \"error\": {\n        \"remotePortWarning\": \"重启服务器使新端口生效\",\n        \"systemFontError\": \"获取系统字体时出现错误\",\n        \"playbackError\": \"无法播放媒体\",\n        \"endpointNotImplementedError\": \"{{serverType}} 尚未实现端点 {{endpoint}}\",\n        \"remotePortError\": \"设置远程服务器端口时发生错误\",\n        \"serverRequired\": \"需要服务器\",\n        \"authenticationFailed\": \"认证失败\",\n        \"apiRouteError\": \"无法路由请求\",\n        \"genericError\": \"发生了错误\",\n        \"credentialsRequired\": \"需要凭证\",\n        \"sessionExpiredError\": \"会话已过期\",\n        \"remoteEnableError\": \"$t(common.enable)远程服务器时出现错误\",\n        \"localFontAccessDenied\": \"无法获取本地字体\",\n        \"serverNotSelectedError\": \"未选择服务器\",\n        \"remoteDisableError\": \"$t(common.disable)远程服务器时出现错误\",\n        \"mpvRequired\": \"需要 MPV\",\n        \"audioDeviceFetchError\": \"无法获取音频设备\",\n        \"invalidServer\": \"无效的服务器\",\n        \"loginRateError\": \"登录请求尝试次数过多，请稍后再试\",\n        \"badAlbum\": \"您看到此页面是因为这首歌不是专辑的一部分。如果您的音乐文件夹顶层有一首歌曲，您很可能会遇到此问题。Jellyfin 仅对位于文件夹中的曲目进行分组\",\n        \"networkError\": \"发生网络错误\",\n        \"openError\": \"无法打开文件\",\n        \"badValue\": \"无效的选项 \\\"{{value}}\\\". 此值不再存在\",\n        \"notificationDenied\": \"通知权限被拒绝。此设置无效\",\n        \"multipleServerSaveQueueError\": \"不支持此操作（播放列表中包含来自其他服务器的歌曲）\",\n        \"noNetwork\": \"服务器不可用\",\n        \"noNetworkDescription\": \"无法连接到该服务器\",\n        \"saveQueueFailed\": \"播放列表保存失败\",\n        \"settingsSyncError\": \"渲染器设置与主进程中存在差异，请重启程序以应用更改\",\n        \"invalidJson\": \"无效的 JSON\",\n        \"serverLockSingleServer\": \"服务器锁定时，只允许一台服务器运行\",\n        \"playbackPausedDueToError\": \"发生错误，播放已暂停\"\n    },\n    \"filter\": {\n        \"mostPlayed\": \"最多播放过\",\n        \"playCount\": \"播放次数\",\n        \"recentlyPlayed\": \"最近播放\",\n        \"title\": \"标题\",\n        \"rating\": \"评分\",\n        \"search\": \"搜索\",\n        \"bitrate\": \"比特率\",\n        \"recentlyAdded\": \"最近添加\",\n        \"name\": \"名称\",\n        \"dateAdded\": \"已添加日期\",\n        \"releaseDate\": \"发布日期\",\n        \"communityRating\": \"社区评分\",\n        \"path\": \"路径\",\n        \"favorited\": \"已收藏\",\n        \"albumArtist\": \"$t(entity.albumArtist, {\\\"count\\\": 1})\",\n        \"releaseYear\": \"发布年份\",\n        \"biography\": \"个人简介\",\n        \"songCount\": \"歌曲数量\",\n        \"random\": \"随机\",\n        \"lastPlayed\": \"最后播放\",\n        \"toYear\": \"截止年份\",\n        \"fromYear\": \"起始年份\",\n        \"criticRating\": \"评论家评分\",\n        \"trackNumber\": \"曲目\",\n        \"bpm\": \"bpm\",\n        \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n        \"comment\": \"评论\",\n        \"isCompilation\": \"为合辑\",\n        \"isFavorited\": \"已收藏\",\n        \"isPublic\": \"已公开\",\n        \"recentlyUpdated\": \"最近更新\",\n        \"isRated\": \"已评分\",\n        \"isRecentlyPlayed\": \"最近播放过\",\n        \"channels\": \"$t(common.channel, {\\\"count\\\": 2})\",\n        \"owner\": \"$t(common.owner)\",\n        \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n        \"note\": \"注释\",\n        \"albumCount\": \"$t(entity.album, {\\\"count\\\": 2})数\",\n        \"id\": \"id\",\n        \"disc\": \"碟片\",\n        \"duration\": \"时长\",\n        \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n        \"explicitStatus\": \"$t(common.explicitStatus)\",\n        \"sortName\": \"排序名称\",\n        \"matchAnd\": \"和\",\n        \"matchOr\": \"或\"\n    },\n    \"page\": {\n        \"sidebar\": {\n            \"nowPlaying\": \"正在播放\",\n            \"playlists\": \"$t(entity.playlist, {\\\"count\\\": 2})\",\n            \"search\": \"$t(common.search)\",\n            \"tracks\": \"$t(entity.track, {\\\"count\\\": 2})\",\n            \"albums\": \"$t(entity.album, {\\\"count\\\": 2})\",\n            \"genres\": \"$t(entity.genre, {\\\"count\\\": 2})\",\n            \"folders\": \"$t(entity.folder, {\\\"count\\\": 2})\",\n            \"settings\": \"$t(common.setting, {\\\"count\\\": 2})\",\n            \"home\": \"$t(common.home)\",\n            \"artists\": \"$t(entity.artist, {\\\"count\\\": 2})\",\n            \"albumArtists\": \"$t(entity.albumArtist, {\\\"count\\\": 2})\",\n            \"shared\": \"共享$t(entity.playlist, {\\\"count\\\": 2})\",\n            \"myLibrary\": \"我的媒体库\",\n            \"favorites\": \"$t(entity.favorite, {\\\"count\\\": 2})\",\n            \"radio\": \"$t(entity.radioStation, {\\\"count\\\": 2})\",\n            \"collections\": \"合集\"\n        },\n        \"fullscreenPlayer\": {\n            \"config\": {\n                \"showLyricMatch\": \"显示匹配的歌词\",\n                \"dynamicBackground\": \"动态背景\",\n                \"synchronized\": \"已同步\",\n                \"opacity\": \"透明度\",\n                \"lyricSize\": \"歌词字体大小\",\n                \"showLyricProvider\": \"显示歌词提供者\",\n                \"unsynchronized\": \"未同步\",\n                \"lyricAlignment\": \"歌词对齐\",\n                \"useImageAspectRatio\": \"使用图片纵横比\",\n                \"lyricGap\": \"歌词间距\",\n                \"followCurrentLyric\": \"跟随当前歌词\",\n                \"dynamicImageBlur\": \"图像模糊大小\",\n                \"dynamicIsImage\": \"启用背景图像\",\n                \"lyricOffset\": \"歌词延迟补偿（毫秒）\"\n            },\n            \"lyrics\": \"歌词\",\n            \"related\": \"相关\",\n            \"upNext\": \"即将播放\",\n            \"visualizer\": \"可视化\",\n            \"noLyrics\": \"未找到歌词\"\n        },\n        \"appMenu\": {\n            \"selectServer\": \"选择服务器\",\n            \"version\": \"版本 {{version}}\",\n            \"manageServers\": \"管理服务器\",\n            \"expandSidebar\": \"展开侧边栏\",\n            \"collapseSidebar\": \"折叠侧边栏\",\n            \"openBrowserDevtools\": \"打开浏览器开发者工具\",\n            \"goBack\": \"返回\",\n            \"goForward\": \"前进\",\n            \"settings\": \"$t(common.setting, {\\\"count\\\": 2})\",\n            \"quit\": \"$t(common.quit)\",\n            \"privateModeOff\": \"关闭私人模式\",\n            \"privateModeOn\": \"开启私人模式\",\n            \"multipleMusicFolders\": \"已选择{{count}}个媒体库\",\n            \"noMusicFolder\": \"未选择任何音乐库\",\n            \"selectMusicFolder\": \"选择媒体库\",\n            \"commandPalette\": \"打开命令面板\"\n        },\n        \"home\": {\n            \"mostPlayed\": \"最多播放\",\n            \"newlyAdded\": \"最近添加的发布\",\n            \"explore\": \"从库中搜索\",\n            \"recentlyPlayed\": \"最近播放\",\n            \"title\": \"$t(common.home)\",\n            \"recentlyReleased\": \"最近发布\",\n            \"genres\": \"$t(entity.genre, {\\\"count\\\": 2})\"\n        },\n        \"albumDetail\": {\n            \"moreFromArtist\": \"更多该$t(entity.artist, {\\\"count\\\": 1})作品\",\n            \"moreFromGeneric\": \"更多{{item}}作品\",\n            \"released\": \"已发布\"\n        },\n        \"setting\": {\n            \"playbackTab\": \"播放\",\n            \"generalTab\": \"通用\",\n            \"hotkeysTab\": \"快捷键\",\n            \"windowTab\": \"窗口\",\n            \"advanced\": \"高级\",\n            \"updates\": \"更新\",\n            \"cache\": \"缓存\",\n            \"analytics\": \"分析\",\n            \"application\": \"应用\",\n            \"theme\": \"主题\",\n            \"controls\": \"控制\",\n            \"sidebar\": \"侧边栏\",\n            \"remote\": \"远程服务\",\n            \"exportImport\": \"导入/导出\",\n            \"scrobble\": \"播放记录\",\n            \"audio\": \"音频\",\n            \"lyrics\": \"歌词\",\n            \"transcoding\": \"转码\",\n            \"discord\": \"Discord\",\n            \"logger\": \"日志记录器\",\n            \"queryBuilder\": \"查询构建器\",\n            \"lyricsDisplay\": \"歌词显示\",\n            \"playerFilters\": \"播放筛选器\"\n        },\n        \"globalSearch\": {\n            \"commands\": {\n                \"serverCommands\": \"服务器命令\",\n                \"goToPage\": \"跳至页面\",\n                \"searchFor\": \"搜索{{query}}\"\n            },\n            \"title\": \"命令\"\n        },\n        \"contextMenu\": {\n            \"setRating\": \"$t(action.setRating)\",\n            \"removeFromPlaylist\": \"$t(action.removeFromPlaylist)\",\n            \"removeFromFavorites\": \"$t(action.removeFromFavorites)\",\n            \"play\": \"$t(player.play)\",\n            \"numberSelected\": \"{{count}} 已选择\",\n            \"removeFromQueue\": \"$t(action.removeFromQueue)\",\n            \"addToPlaylist\": \"$t(action.addToPlaylist)\",\n            \"addToFavorites\": \"$t(action.addToFavorites)\",\n            \"moveToTop\": \"$t(action.moveToTop)\",\n            \"deletePlaylist\": \"$t(action.deletePlaylist)\",\n            \"moveToBottom\": \"$t(action.moveToBottom)\",\n            \"createPlaylist\": \"$t(action.createPlaylist)\",\n            \"addNext\": \"$t(player.addNext)\",\n            \"deselectAll\": \"$t(action.deselectAll)\",\n            \"addLast\": \"$t(player.addLast)\",\n            \"addFavorite\": \"$t(action.addToFavorites)\",\n            \"showDetails\": \"获取信息\",\n            \"shareItem\": \"分享项目\",\n            \"playSimilarSongs\": \"$t(player.playSimilarSongs)\",\n            \"download\": \"下载\",\n            \"playShuffled\": \"$t(player.shuffle)\",\n            \"moveToNext\": \"$t(action.moveToNext)\",\n            \"goToAlbum\": \"转到 $t(entity.album, {\\\"count\\\": 1})\",\n            \"goToAlbumArtist\": \"转到 $t(entity.albumArtist, {\\\"count\\\": 1})\",\n            \"moveItems\": \"$t(action.moveItems)\",\n            \"goTo\": \"前往\"\n        },\n        \"trackList\": {\n            \"title\": \"$t(entity.track, {\\\"count\\\": 2})\",\n            \"genreTracks\": \"\\\"{{genre}}\\\" $t(entity.track, {\\\"count\\\": 2})\",\n            \"artistTracks\": \"{{artist}} 的曲目\"\n        },\n        \"albumArtistList\": {\n            \"title\": \"$t(entity.albumArtist, {\\\"count\\\": 2})\"\n        },\n        \"albumList\": {\n            \"title\": \"$t(entity.album, {\\\"count\\\": 2})\",\n            \"artistAlbums\": \"{{artist}}的专辑\",\n            \"genreAlbums\": \"\\\"{{genre}}\\\" $t(entity.album, {\\\"count\\\": 2})\"\n        },\n        \"genreList\": {\n            \"title\": \"$t(entity.genre, {\\\"count\\\": 2})\",\n            \"showAlbums\": \"显示$t(entity.genre, {\\\"count\\\": 1}) $t(entity.album, {\\\"count\\\": 2})\",\n            \"showTracks\": \"显示$t(entity.genre, {\\\"count\\\": 1}) $t(entity.track, {\\\"count\\\": 2})\"\n        },\n        \"playlistList\": {\n            \"title\": \"$t(entity.playlist, {\\\"count\\\": 2})\"\n        },\n        \"albumArtistDetail\": {\n            \"recentReleases\": \"最近发布\",\n            \"viewDiscography\": \"查看唱片目录\",\n            \"relatedArtists\": \"相关$t(entity.artist, {\\\"count\\\": 2})\",\n            \"topSongs\": \"热门歌曲\",\n            \"topSongsFrom\": \"{{title}}的热门歌曲\",\n            \"viewAllTracks\": \"查看所有$t(entity.track, {\\\"count\\\": 2})\",\n            \"about\": \"关于{{artist}}\",\n            \"appearsOn\": \"出现在\",\n            \"viewAll\": \"查看全部\",\n            \"groupingTypeAll\": \"所有发行类型\",\n            \"groupingTypePrimary\": \"首选发布类型\",\n            \"favoriteSongs\": \"收藏的歌曲\",\n            \"favoriteSongsFrom\": \"来自 {{title}} 收藏的歌曲\",\n            \"topSongsCommunity\": \"社区\",\n            \"topSongsPersonal\": \"个人\"\n        },\n        \"itemDetail\": {\n            \"copyPath\": \"将路径复制到剪贴板\",\n            \"copiedPath\": \"路径复制成功\",\n            \"openFile\": \"在文件管理器中显示曲目\"\n        },\n        \"playlist\": {\n            \"reorder\": \"仅在按 ID 排序时启用重排序\"\n        },\n        \"manageServers\": {\n            \"url\": \"URL\",\n            \"title\": \"管理服务器\",\n            \"serverDetails\": \"服务器详细信息\",\n            \"username\": \"用户名\",\n            \"editServerDetailsTooltip\": \"编辑服务器详细信息\",\n            \"removeServer\": \"移除服务器\"\n        },\n        \"favorites\": {\n            \"title\": \"$t(entity.favorite, {\\\"count\\\": 2})\"\n        },\n        \"folderList\": {\n            \"title\": \"$t(entity.folder, {\\\"count\\\": 2})\"\n        },\n        \"radioList\": {\n            \"title\": \"广播电台\"\n        },\n        \"windowBar\": {\n            \"paused\": \"(暂停) \",\n            \"privateMode\": \"(私人模式)\"\n        },\n        \"collections\": {\n            \"overrideExisting\": \"覆盖现有\",\n            \"saveAsCollection\": \"保存为集合\"\n        },\n        \"releasenotes\": {\n            \"commitsSinceStable\": \"自 {{stable}} 以来的提交\",\n            \"noNewCommits\": \"此范围内没有新的提交\",\n            \"noStableReleaseToCompare\": \"目前没有稳定版本可供比较\"\n        }\n    },\n    \"form\": {\n        \"deletePlaylist\": {\n            \"title\": \"删除$t(entity.playlist, {\\\"count\\\": 1})\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1})已成功删除\",\n            \"input_confirm\": \"输入$t(entity.playlist, {\\\"count\\\": 1})的名称进行确认\"\n        },\n        \"addServer\": {\n            \"title\": \"添加服务器\",\n            \"input_username\": \"用户名\",\n            \"input_password\": \"密码\",\n            \"input_legacyAuthentication\": \"启用旧版认证方式\",\n            \"input_name\": \"服务器名称\",\n            \"success\": \"服务器添加成功\",\n            \"input_savePassword\": \"保存密码\",\n            \"ignoreSsl\": \"忽略 ssl $t(common.restartRequired)\",\n            \"ignoreCors\": \"忽略 cors $t(common.restartRequired)\",\n            \"error_savePassword\": \"保存密码时出现错误\",\n            \"input_url\": \"url\",\n            \"input_preferInstantMixDescription\": \"仅使用即时混音来获取类似的歌曲。如果您有修改此行为的插件，则很有用\",\n            \"input_preferInstantMix\": \"首选即时混音\",\n            \"input_preferRemoteUrl\": \"首选公共 url\",\n            \"input_remoteUrl\": \"公共 url\",\n            \"input_remoteUrlPlaceholder\": \"可选：对外功能的公共 url\"\n        },\n        \"addToPlaylist\": {\n            \"success\": \"添加$t(entity.trackWithCount, {\\\"count\\\": {{message}} })到$t(entity.playlistWithCount, {\\\"count\\\": {{numOfPlaylists}} })\",\n            \"title\": \"添加到$t(entity.playlist, {\\\"count\\\": 1})\",\n            \"input_skipDuplicates\": \"跳过重复\",\n            \"input_playlists\": \"$t(entity.playlist, {\\\"count\\\": 2})\",\n            \"create\": \"创建 $t(entity.playlist, {\\\"count\\\": 1}) {{playlist}}\",\n            \"searchOrCreate\": \"搜索 $t(entity.playlist, {\\\"count\\\": 2}) 或键入以创建一个新的\"\n        },\n        \"createPlaylist\": {\n            \"title\": \"创建$t(entity.playlist, {\\\"count\\\": 1})\",\n            \"input_public\": \"公开\",\n            \"success\": \"已成功创建 $t(entity.playlist, {\\\"count\\\": 1})\",\n            \"input_description\": \"$t(common.description)\",\n            \"input_name\": \"$t(common.name)\",\n            \"input_owner\": \"$t(common.owner)\"\n        },\n        \"updateServer\": {\n            \"title\": \"更新服务器\",\n            \"success\": \"服务器已更新成功\"\n        },\n        \"queryEditor\": {\n            \"input_optionMatchAll\": \"匹配全部\",\n            \"input_optionMatchAny\": \"匹配任何\",\n            \"title\": \"查询编辑器\",\n            \"resetToDefault\": \"恢复默认值\",\n            \"clearFilters\": \"清除筛选\",\n            \"addRuleGroup\": \"添加规则组\",\n            \"removeRuleGroup\": \"移除规则组\"\n        },\n        \"editPlaylist\": {\n            \"title\": \"编辑$t(entity.playlist, {\\\"count\\\": 1})\",\n            \"publicJellyfinNote\": \"Jellyfin 出于某种原因不会显示播放列表是否公开。如果您希望保持公开，请选择以下输入\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1})更新成功\",\n            \"editNote\": \"不建议对大型播放列表进行手动编辑，你确定接受新播放列表覆盖已有播放列表可能导致的数据丢失风险吗？\"\n        },\n        \"lyricSearch\": {\n            \"title\": \"搜索歌词\",\n            \"input_name\": \"$t(common.name)\",\n            \"input_artist\": \"$t(entity.artist, {\\\"count\\\": 1})\"\n        },\n        \"shareItem\": {\n            \"expireInvalid\": \"过期时间必须是将来的时间\",\n            \"createFailed\": \"创建共享失败（是否已启用共享？）\",\n            \"allowDownloading\": \"允许下载\",\n            \"description\": \"描述\",\n            \"setExpiration\": \"设置过期时间\",\n            \"success\": \"共享链接已复制到剪贴板（或单击此处打开）\",\n            \"copyToClipboard\": \"复制到剪贴板：Ctrl+C，Enter\",\n            \"successMustClick\": \"分享创建成功。点击此处打开\"\n        },\n        \"privateMode\": {\n            \"enabled\": \"启用私人模式，播放状态现在对外部集成隐藏\",\n            \"disabled\": \"私人模式已禁用，播放状态现在对启用的外部集成可见\",\n            \"title\": \"私人模式\"\n        },\n        \"largeFetchConfirmation\": {\n            \"title\": \"将项目加入到播放列表\",\n            \"description\": \"此操作将添加当前筛选视图中的所有项目\"\n        },\n        \"createRadioStation\": {\n            \"input_homepageUrl\": \"首页地址\",\n            \"input_name\": \"名称\",\n            \"input_streamUrl\": \"串流地址\",\n            \"success\": \"电台创建成功\",\n            \"title\": \"创建广播电台\"\n        },\n        \"lyricsExport\": {\n            \"export\": \"导出歌词\",\n            \"input_synced\": \"导出同步歌词\",\n            \"input_offset\": \"$t(setting.lyricOffset)\"\n        },\n        \"saveQueue\": {\n            \"success\": \"播放列表已保存至服务器\"\n        },\n        \"shuffleAll\": {\n            \"title\": \"随机播放\",\n            \"input_genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n            \"input_played_optionAll\": \"所有曲目\",\n            \"input_maxYear\": \"截止年份\",\n            \"input_minYear\": \"起始年份\",\n            \"input_played_optionUnplayed\": \"仅未播放的曲目\",\n            \"input_played_optionPlayed\": \"仅已播放的曲目\",\n            \"input_limit\": \"有多少首歌？\",\n            \"input_played\": \"播放筛选器\"\n        }\n    },\n    \"table\": {\n        \"config\": {\n            \"general\": {\n                \"displayType\": \"显示类型\",\n                \"gap\": \"$t(common.gap)\",\n                \"tableColumns\": \"列\",\n                \"autoFitColumns\": \"列宽自适应\",\n                \"size\": \"$t(common.size)\",\n                \"itemGap\": \"项目间隙（px）\",\n                \"itemSize\": \"项目大小 (px)\",\n                \"followCurrentSong\": \"关注当前播放的歌曲\",\n                \"rowHoverHighlight\": \"鼠标悬停时高亮\",\n                \"pagination_itemsPerPage\": \"每页项目条数\",\n                \"itemsPerRow\": \"每行项目条数\",\n                \"pinToRight\": \"固定到右侧\",\n                \"size_default\": \"默认\",\n                \"size_compact\": \"紧凑\",\n                \"size_large\": \"松散\",\n                \"pagination\": \"分页\",\n                \"pagination_infinite\": \"无限滚动\",\n                \"pagination_paginate\": \"分页式\",\n                \"moveUp\": \"上移\",\n                \"moveDown\": \"下移\",\n                \"pinToLeft\": \"固定在左侧\",\n                \"alignLeft\": \"左对齐\",\n                \"alignCenter\": \"居中对齐\",\n                \"alignRight\": \"右对齐\",\n                \"alternateRowColors\": \"隔行填色\",\n                \"advancedSettings\": \"高级设置\",\n                \"autosize\": \"自动调整大小\",\n                \"horizontalBorders\": \"行边框\",\n                \"verticalBorders\": \"列边框\",\n                \"showHeader\": \"显示标题\"\n            },\n            \"view\": {\n                \"table\": \"表格\",\n                \"grid\": \"网格\",\n                \"list\": \"列表\",\n                \"detail\": \"详情\"\n            },\n            \"label\": {\n                \"releaseDate\": \"发布日期\",\n                \"title\": \"$t(common.title)\",\n                \"duration\": \"$t(common.duration)\",\n                \"dateAdded\": \"添加日期\",\n                \"size\": \"$t(common.size)\",\n                \"bpm\": \"$t(common.bpm)\",\n                \"lastPlayed\": \"最后播放\",\n                \"trackNumber\": \"音轨编号\",\n                \"rowIndex\": \"行索引\",\n                \"rating\": \"$t(common.rating)\",\n                \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n                \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n                \"note\": \"$t(common.note)\",\n                \"biography\": \"$t(common.biography)\",\n                \"owner\": \"$t(common.owner)\",\n                \"path\": \"$t(common.path)\",\n                \"channels\": \"$t(common.channel, {\\\"count\\\": 2})\",\n                \"playCount\": \"播放次数\",\n                \"bitrate\": \"$t(common.bitrate)\",\n                \"actions\": \"$t(common.action, {\\\"count\\\": 2})\",\n                \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n                \"discNumber\": \"碟片编号\",\n                \"favorite\": \"$t(common.favorite)\",\n                \"year\": \"$t(common.year)\",\n                \"albumArtist\": \"$t(entity.albumArtist, {\\\"count\\\": 1})\",\n                \"titleCombined\": \"$t(common.title)（合并）\",\n                \"codec\": \"$t(common.codec)\",\n                \"songCount\": \"$t(entity.track, {\\\"count\\\": 2})\",\n                \"albumCount\": \"$t(entity.album, {\\\"count\\\": 2})\",\n                \"image\": \"图片\",\n                \"bitDepth\": \"$t(common.bitDepth)\",\n                \"sampleRate\": \"$t(common.sampleRate)\",\n                \"genreBadge\": \"$t(entity.genre, {\\\"count\\\": 1})（徽章）\",\n                \"composer\": \"作曲家\",\n                \"titleArtist\": \"$t(common.title) (艺术家)\",\n                \"albumGroup\": \"专辑分组\"\n            }\n        },\n        \"column\": {\n            \"comment\": \"评论\",\n            \"album\": \"专辑\",\n            \"rating\": \"评分\",\n            \"favorite\": \"收藏\",\n            \"playCount\": \"播放次数\",\n            \"albumCount\": \"$t(entity.album, {\\\"count\\\": 2})\",\n            \"releaseYear\": \"年份\",\n            \"lastPlayed\": \"最后播放\",\n            \"biography\": \"简介\",\n            \"releaseDate\": \"发布日期\",\n            \"bitrate\": \"比特率\",\n            \"title\": \"标题\",\n            \"bpm\": \"bpm\",\n            \"dateAdded\": \"添加日期\",\n            \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n            \"songCount\": \"$t(entity.track, {\\\"count\\\": 2})\",\n            \"trackNumber\": \"音轨编号\",\n            \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n            \"albumArtist\": \"专辑艺术家\",\n            \"path\": \"路径\",\n            \"channels\": \"$t(common.channel, {\\\"count\\\": 2})\",\n            \"discNumber\": \"碟片\",\n            \"size\": \"$t(common.size)\",\n            \"codec\": \"$t(common.codec)\",\n            \"owner\": \"所有者\",\n            \"bitDepth\": \"$t(common.bitDepth)\",\n            \"sampleRate\": \"$t(common.sampleRate)\"\n        }\n    },\n    \"dragDropZone\": {\n        \"error_oneFileOnly\": \"请仅选择 1 个文件\",\n        \"error_readingFile\": \"读取文件时出现问题：{{errorMessage}}\",\n        \"mainText\": \"将文件拖放到这里\"\n    },\n    \"releaseType\": {\n        \"primary\": {\n            \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n            \"broadcast\": \"播送\",\n            \"ep\": \"迷你专辑（EP）\",\n            \"single\": \"单曲\",\n            \"other\": \"其他\"\n        },\n        \"secondary\": {\n            \"audiobook\": \"有声读物\",\n            \"compilation\": \"合辑\",\n            \"demo\": \"样本唱片（Demo）\",\n            \"interview\": \"访谈\",\n            \"live\": \"现场表演（Live）\",\n            \"mixtape\": \"混音专辑\",\n            \"remix\": \"再混音（Remix）\",\n            \"soundtrack\": \"原声带\",\n            \"audioDrama\": \"广播剧\",\n            \"djMix\": \"DJ混音\",\n            \"fieldRecording\": \"现场录制\",\n            \"spokenWord\": \"访谈\"\n        }\n    },\n    \"filterOperator\": {\n        \"after\": \"之后\",\n        \"afterDate\": \"晚于（日期）\",\n        \"before\": \"之前\",\n        \"beforeDate\": \"早于（日期）\",\n        \"contains\": \"包含\",\n        \"endsWith\": \"以…结尾\",\n        \"inPlaylist\": \"在…中\",\n        \"inTheRange\": \"在范围内\",\n        \"inTheLast\": \"在最后\",\n        \"is\": \"是\",\n        \"isNot\": \"不是\",\n        \"isGreaterThan\": \"大于\",\n        \"isLessThan\": \"小于\",\n        \"matchesRegex\": \"匹配正则表达式\",\n        \"notContains\": \"不包含\",\n        \"startsWith\": \"以…开头\",\n        \"inTheRangeDate\": \"在（日期）范围内\",\n        \"notInPlaylist\": \"不在…中\",\n        \"notInTheLast\": \"不在最后\"\n    },\n    \"datetime\": {\n        \"minuteShort\": \"分\",\n        \"secondShort\": \"秒\",\n        \"hourShort\": \"小时\",\n        \"dayShort\": \"天\"\n    },\n    \"visualizer\": {\n        \"configPasteFailed\": \"应用配置失败，请检查配置格式。\",\n        \"configPasteReadFailed\": \"读取剪贴板失败\",\n        \"configCopyFailed\": \"复制设置失败\",\n        \"configCopied\": \"已复制设置到剪贴板\",\n        \"pasteConfigurationPlaceholder\": \"将JSON配置粘贴到此处…\",\n        \"addCustomGradient\": \"添加自定义渐变\",\n        \"presetNamePlaceholder\": \"输入预设名称\",\n        \"configPasted\": \"成功应用配置\",\n        \"pasteFromClipboard\": \"从剪贴板粘贴\",\n        \"saveAsPreset\": \"保存为预设\",\n        \"customGradients\": \"自定义渐变\",\n        \"showFPS\": \"显示帧率（FPS）\",\n        \"presets\": \"预设\",\n        \"general\": \"普通\",\n        \"mode\": \"模式\",\n        \"visualizerType\": \"可视化器效果类型\",\n        \"selectPreset\": \"选择预设\",\n        \"applyPreset\": \"应用预设\",\n        \"updatePreset\": \"更新预设\",\n        \"copyConfiguration\": \"复制配置\",\n        \"pasteConfiguration\": \"粘贴配置\",\n        \"applyConfiguration\": \"应用配置\",\n        \"presetName\": \"预设名称\",\n        \"mode1To8\": \"模式 1 - 8\",\n        \"mode10\": \"模式 10\",\n        \"fillAlpha\": \"填充透明度\",\n        \"lineWidth\": \"线宽\",\n        \"maxFPS\": \"最大帧率（FPS）\",\n        \"opacity\": \"不透明度\",\n        \"gradientName\": \"渐变名称\",\n        \"gradientNamePlaceholder\": \"渐变名称\",\n        \"vertical\": \"垂直\",\n        \"horizontal\": \"水平\",\n        \"addColor\": \"添加颜色\",\n        \"position\": \"位置\",\n        \"cycleTime\": \"循环时间（秒）\",\n        \"channelLayout\": \"声道布局\",\n        \"remove\": \"移除\",\n        \"pasteGradientPlaceholder\": \"在此处粘贴颜色渐变的配置JSON…\",\n        \"pasteGradient\": \"粘贴颜色渐变配置\",\n        \"custom\": \"自定义\",\n        \"builtIn\": \"内置\",\n        \"colors\": \"颜色\",\n        \"gradient\": \"渐变\",\n        \"miscellaneousSettings\": \"杂项设置\",\n        \"options\": {\n            \"channelLayout\": {\n                \"single\": \"单项\",\n                \"dualCombined\": \"双重组合\",\n                \"dualHorizontal\": \"双水平\",\n                \"dualVertical\": \"双垂直\"\n            },\n            \"mode\": {\n                \"0\": \"[0] 离散频率\",\n                \"1\": \"[1] 1/24倍频程 / 240频段\",\n                \"2\": \"[2] 1/12 倍频程 / 120 频段\",\n                \"3\": \"[3] 1/8倍频程 / 80频段\",\n                \"4\": \"[4] 1/6倍频程 / 60频段\",\n                \"5\": \"[5] 1/4倍频程 / 40频段\",\n                \"6\": \"[6] 1/3倍频程 / 30频段\",\n                \"7\": \"[7] 半倍频程 / 20 频段\",\n                \"8\": \"[8] 全倍频程 / 10 频段\",\n                \"10\": \"[10] 折线图 / 面积图\"\n            },\n            \"colorMode\": {\n                \"gradient\": \"渐变\",\n                \"barIndex\": \"Bar-Index\",\n                \"barLevel\": \"Bar-Level\"\n            },\n            \"gradient\": {\n                \"classic\": \"经典\",\n                \"prism\": \"棱镜\",\n                \"rainbow\": \"彩虹\",\n                \"steelblue\": \"钢蓝色\",\n                \"orangered\": \"橙红色\"\n            },\n            \"frequencyScale\": {\n                \"none\": \"无\",\n                \"bark\": \"树皮鳞片\",\n                \"linear\": \"线性刻度\",\n                \"log\": \"对数刻度\",\n                \"mel\": \"梅尔刻度\"\n            },\n            \"weightingFilter\": {\n                \"none\": \"无\",\n                \"a\": \"A\",\n                \"b\": \"B\",\n                \"c\": \"C\",\n                \"d\": \"D\",\n                \"z\": \"Z\"\n            }\n        },\n        \"cyclePresets\": \"循环预设\",\n        \"includeAllPresets\": \"包含所有预设\",\n        \"ignoredPresets\": \"忽略预设\",\n        \"selectedPresets\": \"已选预设\",\n        \"randomizeNextPreset\": \"随机化下一个预设\",\n        \"blendTime\": \"混合时间\",\n        \"barSpace\": \"住间距\",\n        \"colorStops\": \"颜色停止\",\n        \"level\": \"等级\",\n        \"colorMode\": \"颜色模式\",\n        \"gradientLeft\": \"左侧渐变\",\n        \"gradientRight\": \"右侧渐变\",\n        \"fft\": \"FFT\",\n        \"fftSize\": \"FFT 大小\",\n        \"smoothing\": \"平滑\",\n        \"frequencyRangeAndScaling\": \"频率范围和缩放\",\n        \"minimumFrequency\": \"最低频率\",\n        \"maximumFrequency\": \"最大频率\",\n        \"frequencyScale\": \"频率尺度\",\n        \"sensitivity\": \"灵敏度\",\n        \"weightingFilter\": \"加权滤波器\",\n        \"minimumDecibels\": \"最低分贝\",\n        \"maximumDecibels\": \"最大分贝\",\n        \"linearAmplitude\": \"线性振幅\",\n        \"linearBoost\": \"线性增强\",\n        \"peakBehavior\": \"峰值行为\",\n        \"showPeaks\": \"显示峰值\",\n        \"fadePeaks\": \"峰值淡出\",\n        \"peakLine\": \"峰值线条\",\n        \"gravity\": \"重力\",\n        \"peakFadeTime\": \"峰值淡出时间（毫秒）\",\n        \"peakHoldTime\": \"峰值保持时间（毫秒）\",\n        \"radialSpectrum\": \"圆形频谱\",\n        \"radial\": \"径向\",\n        \"radialInvert\": \"径向反转\",\n        \"spinSpeed\": \"旋转速度\",\n        \"radius\": \"半径\",\n        \"reflexMirror\": \"反射镜\",\n        \"reflexFit\": \"反射贴合\",\n        \"reflexRatio\": \"反射比率\",\n        \"reflexAlpha\": \"反射Alpha\",\n        \"reflexBrightness\": \"反射亮度\",\n        \"mirror\": \"镜像\",\n        \"lowResolution\": \"低分辨率\",\n        \"splitGradient\": \"渐变分割\",\n        \"showScaleX\": \"显示比例尺 X\",\n        \"noteLabels\": \"笔记标签\",\n        \"showScaleY\": \"显示比例尺 Y\",\n        \"alphaBars\": \"Alpha 条\",\n        \"ansiBands\": \"ANSI 频段\",\n        \"ledBars\": \"LED 灯条\",\n        \"trueLeds\": \"真正的LED\",\n        \"lumiBars\": \"Lumi 条\",\n        \"outlineBars\": \"轮廓栏\",\n        \"roundBars\": \"圆条\"\n    },\n    \"queryBuilder\": {\n        \"standardTags\": \"标准标签\",\n        \"customTags\": \"自定义标签\"\n    }\n}\n"
  },
  {
    "path": "src/i18n/locales/zh-Hant.json",
    "content": "{\n    \"common\": {\n        \"backward\": \"返回\",\n        \"biography\": \"簡介\",\n        \"bitrate\": \"位元率\",\n        \"bpm\": \"bpm\",\n        \"clear\": \"清空\",\n        \"collapse\": \"折疊\",\n        \"comingSoon\": \"即將推出…\",\n        \"confirm\": \"確認\",\n        \"decrease\": \"降低\",\n        \"delete\": \"刪除\",\n        \"descending\": \"降冪\",\n        \"description\": \"描述\",\n        \"forceRestartRequired\": \"重新啟動應用程式以使更改生效…關閉通知後即可重啟\",\n        \"menu\": \"選單\",\n        \"action_other\": \"操作\",\n        \"add\": \"新增\",\n        \"areYouSure\": \"你確定嗎？\",\n        \"ascending\": \"升冪\",\n        \"disable\": \"禁用\",\n        \"disc\": \"光碟\",\n        \"dismiss\": \"不再顯示\",\n        \"duration\": \"時長\",\n        \"edit\": \"編輯\",\n        \"enable\": \"啟用\",\n        \"expand\": \"展開\",\n        \"favorite\": \"收藏\",\n        \"filter_other\": \"篩選\",\n        \"filters\": \"篩選\",\n        \"forward\": \"前進\",\n        \"gap\": \"空隙\",\n        \"home\": \"首頁\",\n        \"increase\": \"增高\",\n        \"left\": \"左\",\n        \"limit\": \"限制\",\n        \"manage\": \"管理\",\n        \"maximize\": \"最大化\",\n        \"ok\": \"好\",\n        \"owner\": \"所有者\",\n        \"path\": \"路徑\",\n        \"playerMustBePaused\": \"播放器必須先暫停\",\n        \"previousSong\": \"上一首$t(entity.track, {\\\"count\\\": 1})\",\n        \"quit\": \"退出\",\n        \"random\": \"隨機\",\n        \"rating\": \"評分\",\n        \"refresh\": \"重新整理\",\n        \"reset\": \"重置\",\n        \"resetToDefault\": \"恢復為預設值\",\n        \"restartRequired\": \"需要重新啟動應用程式\",\n        \"right\": \"右\",\n        \"save\": \"儲存\",\n        \"saveAndReplace\": \"儲存並取代\",\n        \"saveAs\": \"儲存為\",\n        \"search\": \"搜尋\",\n        \"sortOrder\": \"順序\",\n        \"title\": \"標題\",\n        \"trackNumber\": \"音軌編號\",\n        \"unknown\": \"未知\",\n        \"size\": \"大小\",\n        \"version\": \"版本\",\n        \"year\": \"年份\",\n        \"yes\": \"是\",\n        \"cancel\": \"取消\",\n        \"center\": \"中央\",\n        \"channel_other\": \"聲道\",\n        \"configure\": \"設定\",\n        \"create\": \"建立\",\n        \"currentSong\": \"目前$t(entity.track, {\\\"count\\\": 1})\",\n        \"minimize\": \"最小化\",\n        \"modified\": \"已修改\",\n        \"name\": \"名稱\",\n        \"no\": \"否\",\n        \"none\": \"無\",\n        \"noResultsFromQuery\": \"未查詢到匹配結果\",\n        \"note\": \"注釋\",\n        \"additionalParticipants\": \"額外參與者\",\n        \"newVersion\": \"已安裝新版本 ({{version}})\",\n        \"viewReleaseNotes\": \"查看發行註記\",\n        \"albumGain\": \"專輯增益\",\n        \"albumPeak\": \"專輯峰值\",\n        \"bitDepth\": \"位元深度\",\n        \"close\": \"關閉\",\n        \"codec\": \"編碼\",\n        \"mbid\": \"MusicBrainz ID\",\n        \"preview\": \"預覽\",\n        \"reload\": \"重新載入\",\n        \"sampleRate\": \"取樣率\",\n        \"setting_other\": \"設定\",\n        \"share\": \"分享\",\n        \"tags\": \"標籤\",\n        \"trackGain\": \"曲目增益\",\n        \"trackPeak\": \"歌曲峰值\",\n        \"translation\": \"翻譯\",\n        \"doNotShowAgain\": \"不再顯示\",\n        \"externalLinks\": \"外部連結\",\n        \"faster\": \"更快\",\n        \"private\": \"私人\",\n        \"public\": \"公開\",\n        \"recordLabel\": \"唱片公司\",\n        \"releaseType\": \"發行類型\",\n        \"slower\": \"更慢\",\n        \"sort\": \"排序\",\n        \"tableColumns\": \"表格欄位\",\n        \"clean\": \"清除\",\n        \"explicitStatus\": \"Explicit狀態\",\n        \"explicit\": \"Explicit\",\n        \"gridRows\": \"網格行\",\n        \"noFilters\": \"未設定任何過濾器\",\n        \"countSelected\": \"{{count}}個已選取\",\n        \"retry\": \"重試\",\n        \"example\": \"範例\",\n        \"mood\": \"情緒\",\n        \"view\": \"查看\",\n        \"rename\": \"重新命名\",\n        \"itemsMore\": \"{{count}} 更多\",\n        \"filter_single\": \"單選\",\n        \"filter_multiple\": \"複選\",\n        \"newVersionAvailable\": \"有新的版本可供使用\",\n        \"numberOfResults\": \"{{numberOfResults}} 項結果\"\n    },\n    \"error\": {\n        \"endpointNotImplementedError\": \"{{serverType}} 尚未實現端點 {{endpoint}}\",\n        \"apiRouteError\": \"請求失敗：無法路由\",\n        \"audioDeviceFetchError\": \"無法取得音訊設備\",\n        \"authenticationFailed\": \"驗證失敗\",\n        \"credentialsRequired\": \"需要憑證\",\n        \"genericError\": \"發生了錯誤\",\n        \"invalidServer\": \"無效的伺服器\",\n        \"localFontAccessDenied\": \"無法取得本地字體\",\n        \"loginRateError\": \"登入請求嘗試次數過多，請稍後再試\",\n        \"remoteDisableError\": \"$t(common.disable)遠端伺服器時出現錯誤\",\n        \"remoteEnableError\": \"$t(common.enable)遠端伺服器時出現錯誤\",\n        \"remotePortError\": \"設定遠端伺服器連接埠時發生錯誤\",\n        \"remotePortWarning\": \"重啟伺服器使新連接埠生效\",\n        \"serverRequired\": \"需要伺服器\",\n        \"sessionExpiredError\": \"工作階段已過期\",\n        \"systemFontError\": \"嘗試取得系統字體時出現錯誤\",\n        \"serverNotSelectedError\": \"未選擇伺服器\",\n        \"mpvRequired\": \"需要 MPV\",\n        \"playbackError\": \"無法播放媒體\",\n        \"badAlbum\": \"您看到此頁面是因為這首歌不是專輯的一部分。如果您的音樂資料夾頂層有一首歌，則很可能會看到此問題。 Jellyfin 僅將資料夾中的曲目分組\",\n        \"badValue\": \"無效選項「{{value}}」。該值不再存在\",\n        \"networkError\": \"發生網路錯誤\",\n        \"notificationDenied\": \"通知權限被拒絕。此設定無效\",\n        \"openError\": \"無法開啟檔案\",\n        \"multipleServerSaveQueueError\": \"播放佇列中包含不是來自目前伺服器的歌曲，此操作不受支援\",\n        \"saveQueueFailed\": \"儲存播放佇列失敗\",\n        \"settingsSyncError\": \"偵測到渲染器與主程式之間的設定不一致，請重新啟動應用程式以套用變更\",\n        \"noNetwork\": \"伺服器無法連線\",\n        \"noNetworkDescription\": \"無法連接到此伺服器\",\n        \"invalidJson\": \"無效的 JSON\",\n        \"serverLockSingleServer\": \"當伺服器鎖定時只允許一個伺服器\",\n        \"playbackPausedDueToError\": \"發生錯誤，已停止播放\"\n    },\n    \"page\": {\n        \"contextMenu\": {\n            \"removeFromFavorites\": \"$t(action.removeFromFavorites)\",\n            \"addToFavorites\": \"$t(action.addToFavorites)\",\n            \"addToPlaylist\": \"$t(action.addToPlaylist)\",\n            \"removeFromPlaylist\": \"$t(action.removeFromPlaylist)\",\n            \"removeFromQueue\": \"$t(action.removeFromQueue)\",\n            \"addFavorite\": \"$t(action.addToFavorites)\",\n            \"addLast\": \"$t(player.addLast)\",\n            \"addNext\": \"$t(player.addNext)\",\n            \"createPlaylist\": \"$t(action.createPlaylist)\",\n            \"deletePlaylist\": \"$t(action.deletePlaylist)\",\n            \"deselectAll\": \"$t(action.deselectAll)\",\n            \"moveToBottom\": \"$t(action.moveToBottom)\",\n            \"setRating\": \"$t(action.setRating)\",\n            \"moveToTop\": \"$t(action.moveToTop)\",\n            \"numberSelected\": \"已選取 {{count}}\",\n            \"play\": \"$t(player.play)\",\n            \"download\": \"下載\",\n            \"moveToNext\": \"$t(action.moveToNext)\",\n            \"playSimilarSongs\": \"$t(player.playSimilarSongs)\",\n            \"playShuffled\": \"$t(player.shuffle)\",\n            \"shareItem\": \"分享項目\",\n            \"showDetails\": \"取得資訊\",\n            \"goToAlbum\": \"前往 $t(entity.album, {\\\"count\\\": 1})\",\n            \"goToAlbumArtist\": \"前往 $t(entity.albumArtist, {\\\"count\\\": 1})\",\n            \"moveItems\": \"$t(action.moveItems)\",\n            \"goTo\": \"前往\"\n        },\n        \"globalSearch\": {\n            \"title\": \"指令\",\n            \"commands\": {\n                \"goToPage\": \"跳至頁面\",\n                \"searchFor\": \"搜尋 {{query}}\",\n                \"serverCommands\": \"伺服器指令\"\n            }\n        },\n        \"home\": {\n            \"explore\": \"從媒體庫中搜尋\",\n            \"recentlyPlayed\": \"最近播放\",\n            \"title\": \"$t(common.home)\",\n            \"mostPlayed\": \"最多播放\",\n            \"newlyAdded\": \"最近新增的發行\",\n            \"recentlyReleased\": \"最近發佈\",\n            \"genres\": \"$t(entity.genre, {\\\"count\\\": 2})\"\n        },\n        \"appMenu\": {\n            \"openBrowserDevtools\": \"開啟瀏覽器開發者工具\",\n            \"collapseSidebar\": \"折疊側邊欄\",\n            \"expandSidebar\": \"展開側邊欄\",\n            \"goBack\": \"返回\",\n            \"goForward\": \"前進\",\n            \"quit\": \"$t(common.quit)\",\n            \"selectServer\": \"選擇伺服器\",\n            \"settings\": \"$t(common.setting, {\\\"count\\\": 2})\",\n            \"version\": \"版本 {{version}}\",\n            \"manageServers\": \"管理伺服器\",\n            \"privateModeOff\": \"關閉私人模式\",\n            \"privateModeOn\": \"開啟私人模式\",\n            \"selectMusicFolder\": \"選擇媒體庫\",\n            \"noMusicFolder\": \"未選取任何媒體庫\",\n            \"multipleMusicFolders\": \"已選取 {{count}} 個媒體庫\",\n            \"commandPalette\": \"開啟命令面板\"\n        },\n        \"fullscreenPlayer\": {\n            \"config\": {\n                \"showLyricProvider\": \"顯示歌詞提供者\",\n                \"useImageAspectRatio\": \"使用圖片縱橫比\",\n                \"dynamicBackground\": \"動態背景\",\n                \"followCurrentLyric\": \"跟隨目前歌詞\",\n                \"lyricAlignment\": \"歌詞對齊\",\n                \"lyricGap\": \"歌詞間距\",\n                \"lyricSize\": \"歌詞字體大小\",\n                \"synchronized\": \"已同步\",\n                \"unsynchronized\": \"未同步\",\n                \"opacity\": \"透明度\",\n                \"showLyricMatch\": \"顯示匹配的歌詞\",\n                \"dynamicImageBlur\": \"圖片模糊大小\",\n                \"dynamicIsImage\": \"啟用背景圖片\",\n                \"lyricOffset\": \"歌詞偏移時間 (ms)\"\n            },\n            \"lyrics\": \"歌詞\",\n            \"related\": \"相關\",\n            \"upNext\": \"即將播放\",\n            \"visualizer\": \"視覺化\",\n            \"noLyrics\": \"未找到歌詞\"\n        },\n        \"playlistList\": {\n            \"title\": \"$t(entity.playlist, {\\\"count\\\": 2})\"\n        },\n        \"setting\": {\n            \"hotkeysTab\": \"快捷鍵\",\n            \"playbackTab\": \"播放\",\n            \"windowTab\": \"視窗\",\n            \"generalTab\": \"一般\",\n            \"advanced\": \"進階\",\n            \"analytics\": \"分析\",\n            \"updates\": \"更新\",\n            \"cache\": \"快取\",\n            \"application\": \"應用程式\",\n            \"theme\": \"主題\",\n            \"controls\": \"控制面板\",\n            \"sidebar\": \"側邊攔\",\n            \"remote\": \"遠端控制\",\n            \"exportImport\": \"匯入/匯出\",\n            \"scrobble\": \"記錄播放資訊\",\n            \"audio\": \"音訊\",\n            \"lyrics\": \"歌詞\",\n            \"transcoding\": \"轉碼\",\n            \"discord\": \"Discord\",\n            \"queryBuilder\": \"查詢建構器\",\n            \"playerFilters\": \"播放過濾器\",\n            \"logger\": \"日誌記錄器\",\n            \"lyricsDisplay\": \"歌詞顯示\"\n        },\n        \"albumArtistList\": {\n            \"title\": \"$t(entity.albumArtist, {\\\"count\\\": 2})\"\n        },\n        \"albumDetail\": {\n            \"moreFromArtist\": \"更多來自 $t(entity.artist, {\\\"count\\\": 1}) 的作品\",\n            \"moreFromGeneric\": \"更多{{item}}作品\",\n            \"released\": \"發行\"\n        },\n        \"albumList\": {\n            \"title\": \"$t(entity.album, {\\\"count\\\": 2})\",\n            \"artistAlbums\": \"{{artist}} 的專輯\",\n            \"genreAlbums\": \"\\\"{{genre}}\\\" $t(entity.album, {\\\"count\\\": 2})\"\n        },\n        \"genreList\": {\n            \"title\": \"$t(entity.genre, {\\\"count\\\": 2})\",\n            \"showAlbums\": \"顯示 $t(entity.genre, {\\\"count\\\": 1}) $t(entity.album, {\\\"count\\\": 2})\",\n            \"showTracks\": \"顯示 $t(entity.genre, {\\\"count\\\": 1}) $t(entity.track, {\\\"count\\\": 2})\"\n        },\n        \"sidebar\": {\n            \"albumArtists\": \"$t(entity.albumArtist, {\\\"count\\\": 2})\",\n            \"albums\": \"$t(entity.album, {\\\"count\\\": 2})\",\n            \"artists\": \"$t(entity.artist, {\\\"count\\\": 2})\",\n            \"folders\": \"$t(entity.folder, {\\\"count\\\": 2})\",\n            \"search\": \"$t(common.search)\",\n            \"settings\": \"$t(common.setting, {\\\"count\\\": 2})\",\n            \"tracks\": \"$t(entity.track, {\\\"count\\\": 2})\",\n            \"genres\": \"$t(entity.genre, {\\\"count\\\": 2})\",\n            \"home\": \"$t(common.home)\",\n            \"nowPlaying\": \"正在播放\",\n            \"playlists\": \"$t(entity.playlist, {\\\"count\\\": 2})\",\n            \"myLibrary\": \"我的媒體庫\",\n            \"shared\": \"已分享 $t(entity.playlist, {\\\"count\\\": 2})\",\n            \"favorites\": \"$t(entity.favorite, {\\\"count\\\": 2})\",\n            \"radio\": \"$t(entity.radioStation, {\\\"count\\\": 2})\",\n            \"collections\": \"收藏\"\n        },\n        \"trackList\": {\n            \"title\": \"$t(entity.track, {\\\"count\\\": 2})\",\n            \"artistTracks\": \"{{artist}} 的歌曲\",\n            \"genreTracks\": \"\\\"{{genre}}\\\" $t(entity.track, {\\\"count\\\": 2})\"\n        },\n        \"albumArtistDetail\": {\n            \"about\": \"關於{{artist}}\",\n            \"appearsOn\": \"出現在\",\n            \"recentReleases\": \"最近發行\",\n            \"viewDiscography\": \"查看音樂作品\",\n            \"relatedArtists\": \"關聯$t(entity.artist, {\\\"count\\\": 2})\",\n            \"topSongs\": \"熱門歌曲\",\n            \"topSongsFrom\": \"{{title}} 的熱門歌曲\",\n            \"viewAll\": \"檢視所有\",\n            \"viewAllTracks\": \"檢視所有$t(entity.track, {\\\"count\\\": 2})\",\n            \"groupingTypeAll\": \"所有發佈類型\",\n            \"groupingTypePrimary\": \"主要發佈類型\",\n            \"favoriteSongs\": \"最愛歌曲\",\n            \"favoriteSongsFrom\": \"{{title}} 的最愛歌曲\",\n            \"topSongsCommunity\": \"社群\",\n            \"topSongsPersonal\": \"個人\"\n        },\n        \"manageServers\": {\n            \"title\": \"管理伺服器\",\n            \"serverDetails\": \"伺服器詳細資訊\",\n            \"url\": \"URL\",\n            \"username\": \"使用者名稱\",\n            \"editServerDetailsTooltip\": \"編輯伺服器詳細資訊\",\n            \"removeServer\": \"移除伺服器\"\n        },\n        \"itemDetail\": {\n            \"copyPath\": \"複製路徑至剪貼簿\",\n            \"copiedPath\": \"成功複製路徑\",\n            \"openFile\": \"在檔案管理器中顯示曲目\"\n        },\n        \"playlist\": {\n            \"reorder\": \"僅當按 ID 排序時才啟用重新排序\"\n        },\n        \"favorites\": {\n            \"title\": \"$t(entity.favorite, {\\\"count\\\": 2})\"\n        },\n        \"folderList\": {\n            \"title\": \"$t(entity.folder, {\\\"count\\\": 2})\"\n        },\n        \"radioList\": {\n            \"title\": \"電台\"\n        },\n        \"windowBar\": {\n            \"paused\": \"(暫停) \",\n            \"privateMode\": \"(私人模式)\"\n        },\n        \"collections\": {\n            \"overrideExisting\": \"複寫現有的\",\n            \"saveAsCollection\": \"儲存為收藏\"\n        },\n        \"releasenotes\": {\n            \"commitsSinceStable\": \"提交自 {{stable}}\",\n            \"noNewCommits\": \"在此區間內沒有新的提交\",\n            \"noStableReleaseToCompare\": \"沒有穩定的發行可供比較\"\n        }\n    },\n    \"player\": {\n        \"playbackFetchInProgress\": \"正在載入歌曲…\",\n        \"addLast\": \"新增至尾端\",\n        \"addNext\": \"新增至下一首\",\n        \"favorite\": \"收藏\",\n        \"mute\": \"靜音\",\n        \"muted\": \"已靜音\",\n        \"playbackFetchNoResults\": \"未找到歌曲\",\n        \"playbackSpeed\": \"播放速度\",\n        \"playRandom\": \"隨機播放\",\n        \"previous\": \"上一首\",\n        \"queue_clear\": \"清空播放佇列\",\n        \"queue_remove\": \"移除所選\",\n        \"repeat\": \"循環\",\n        \"repeat_all\": \"全部循環\",\n        \"repeat_off\": \"不循環\",\n        \"shuffle\": \"播放 (隨機)\",\n        \"shuffle_off\": \"未啟用隨機播放\",\n        \"skip\": \"跳過\",\n        \"skip_back\": \"向後跳過\",\n        \"skip_forward\": \"向前跳過\",\n        \"stop\": \"停止\",\n        \"toggleFullscreenPlayer\": \"切換全螢幕播放器\",\n        \"unfavorite\": \"取消收藏\",\n        \"pause\": \"暫停\",\n        \"next\": \"下一首\",\n        \"play\": \"播放\",\n        \"playbackFetchCancel\": \"請稍等…關閉通知以取消\",\n        \"queue_moveToBottom\": \"使所選置頂\",\n        \"queue_moveToTop\": \"使所選置底\",\n        \"playSimilarSongs\": \"播放相似歌曲\",\n        \"viewQueue\": \"檢視佇列\",\n        \"addLastShuffled\": \"新增至尾端 (隨機)\",\n        \"addNextShuffled\": \"新增至下一首 (隨機)\",\n        \"holdToShuffle\": \"按住以隨機\",\n        \"lyrics\": \"歌詞\",\n        \"restoreQueueFromServer\": \"從伺服器還原播放佇列\",\n        \"saveQueueToServer\": \"將播放佇列儲存至伺服器\",\n        \"artistRadio\": \"藝人電台\",\n        \"trackRadio\": \"曲目電台\",\n        \"sleepTimer\": \"睡眠定時器\",\n        \"sleepTimer_endOfSong\": \"歌曲播完時\",\n        \"sleepTimer_minutes\": \"{{count}} 分鐘\",\n        \"sleepTimer_hours\": \"{{count}} 小時\",\n        \"sleepTimer_custom\": \"自訂\",\n        \"sleepTimer_off\": \"關閉\",\n        \"sleepTimer_timeRemaining\": \"剩餘 {{time}}\",\n        \"sleepTimer_setCustom\": \"設定定時器\",\n        \"sleepTimer_cancel\": \"取消定時器\",\n        \"albumRadio\": \"專輯電台\"\n    },\n    \"setting\": {\n        \"audioPlayer_description\": \"選擇用於播放的音訊播放器\",\n        \"themeLight\": \"主題（淺色）\",\n        \"themeLight_description\": \"應用程式將使用淺色主題\",\n        \"hotkey_volumeDown\": \"音量降低\",\n        \"hotkey_volumeMute\": \"靜音\",\n        \"minimumScrobblePercentage\": \"最小紀錄時長（百分比）\",\n        \"minimumScrobblePercentage_description\": \"歌曲被記錄為已播放（scrobble）所需的最小播放百分比\",\n        \"theme_description\": \"設定應用程式的主題\",\n        \"accentColor\": \"強調色\",\n        \"accentColor_description\": \"設定應用程式的強調色\",\n        \"applicationHotkeys\": \"應用程式快捷鍵\",\n        \"applicationHotkeys_description\": \"設定應用程式快捷鍵。切換勾選框來設為全域快捷鍵（僅桌面端）\",\n        \"audioDevice\": \"音訊設備\",\n        \"audioDevice_description\": \"選擇用於播放的音訊設備\",\n        \"audioExclusiveMode\": \"音訊獨佔模式\",\n        \"audioExclusiveMode_description\": \"啟用獨佔輸出模式。在此模式下，系統通常被鎖定，只有 mpv 能夠輸出音訊\",\n        \"audioPlayer\": \"音訊播放器\",\n        \"crossfadeDuration\": \"淡入淡出持續時間\",\n        \"crossfadeDuration_description\": \"設定淡入淡出持續時間\",\n        \"crossfadeStyle_description\": \"選擇用於音訊播放器的淡入淡出風格\",\n        \"customFontPath\": \"自定字體路徑\",\n        \"customFontPath_description\": \"設定應用程式使用的自定字體路徑\",\n        \"disableLibraryUpdateOnStartup\": \"禁用啟動時檢查新版本\",\n        \"discordApplicationId\": \"{{discord}} 應用程式 id\",\n        \"discordApplicationId_description\": \"{{discord}} rich presence 應用程式 id（預設為 {{defaultId}}）\",\n        \"discordIdleStatus\": \"顯示 rich presence 閒置狀態\",\n        \"discordIdleStatus_description\": \"啟用後將會在播放器閒置時更新狀態\",\n        \"discordRichPresence_description\": \"在 {{discord}} rich presence 中顯示播放狀態。圖片鍵為：{{icon}}、{{playing}} 和 {{paused}}\",\n        \"discordUpdateInterval\": \"{{discord}} rich presence 更新間隔\",\n        \"discordUpdateInterval_description\": \"更新間隔秒數（至少 15 秒）\",\n        \"enableRemote\": \"啟用遠端控制伺服器\",\n        \"enableRemote_description\": \"啟用遠端控制伺服器，以允許其他設備控制此應用程式\",\n        \"exitToTray\": \"關閉時到將視窗最小化\",\n        \"followLyric\": \"跟隨目前歌詞\",\n        \"font_description\": \"設定應用程式使用的字體\",\n        \"fontType\": \"字體類型\",\n        \"fontType_description\": \"內建字體可以選擇 feishin 提供的字體之一。系統字體允許您選擇作業系統提供的任何字體。自定選項允許您使用自己的字體\",\n        \"fontType_optionBuiltIn\": \"內建字體\",\n        \"fontType_optionCustom\": \"自定字體\",\n        \"fontType_optionSystem\": \"系統字體\",\n        \"gaplessAudio\": \"無間隔音訊\",\n        \"gaplessAudio_description\": \"調整 mpv 無間隔音訊設定\",\n        \"gaplessAudio_optionWeak\": \"弱（建議）\",\n        \"globalMediaHotkeys\": \"全域媒體快捷鍵\",\n        \"hotkey_browserForward\": \"瀏覽器往前\",\n        \"hotkey_favoritePreviousSong\": \"收藏 $t(common.previousSong)\",\n        \"hotkey_globalSearch\": \"全域搜尋\",\n        \"hotkey_localSearch\": \"頁面內搜尋\",\n        \"hotkey_playbackNext\": \"下一首\",\n        \"hotkey_playbackPause\": \"暫停\",\n        \"hotkey_playbackPlay\": \"播放\",\n        \"hotkey_playbackPlayPause\": \"播放/暫停\",\n        \"hotkey_playbackPrevious\": \"上一首\",\n        \"hotkey_rate2\": \"評為 2 星\",\n        \"hotkey_rate1\": \"評為 1 星\",\n        \"hotkey_rate3\": \"評為 3 星\",\n        \"hotkey_rate4\": \"評為 4 星\",\n        \"hotkey_rate5\": \"評為 5 星\",\n        \"hotkey_skipBackward\": \"退進\",\n        \"hotkey_skipForward\": \"快進\",\n        \"hotkey_toggleCurrentSongFavorite\": \"收藏 / 取消收藏$t(common.currentSong)\",\n        \"hotkey_toggleFullScreenPlayer\": \"切換全螢幕播放器\",\n        \"hotkey_togglePreviousSongFavorite\": \"收藏 / 取消收藏$t(common.previousSong)\",\n        \"hotkey_toggleQueue\": \"顯示 / 隱藏佇列\",\n        \"hotkey_toggleRepeat\": \"切換循環播放設定\",\n        \"hotkey_toggleShuffle\": \"切換隨機播放設定\",\n        \"hotkey_unfavoriteCurrentSong\": \"取消收藏$t(common.currentSong)\",\n        \"hotkey_unfavoritePreviousSong\": \"取消收藏$t(common.previousSong)\",\n        \"hotkey_zoomIn\": \"放大\",\n        \"hotkey_zoomOut\": \"縮小\",\n        \"language_description\": \"設定應用程式的語言（$t(common.restartRequired)）\",\n        \"lyricFetch\": \"從網路取得歌詞\",\n        \"lyricFetch_description\": \"從多個網路來源取得歌詞\",\n        \"lyricFetchProvider\": \"歌詞來源\",\n        \"lyricOffset\": \"歌詞偏移（毫秒）\",\n        \"lyricOffset_description\": \"將歌詞偏移指定的毫秒數\",\n        \"lyricFetchProvider_description\": \"選擇歌詞來源\",\n        \"minimizeToTray\": \"最小化到系統匣\",\n        \"minimizeToTray_description\": \"將應用程式最小化到系統匣\",\n        \"minimumScrobbleSeconds\": \"最小紀錄時間（秒）\",\n        \"minimumScrobbleSeconds_description\": \"歌曲被記錄為已播放（scrobble）所需的最小播放時間\",\n        \"mpvExecutablePath\": \"mpv 執行檔路徑\",\n        \"playbackStyle_optionCrossFade\": \"淡入淡出\",\n        \"playbackStyle_optionNormal\": \"一般\",\n        \"playButtonBehavior\": \"播放按鈕動作\",\n        \"playButtonBehavior_description\": \"設定歌曲新增到佇列時播放按鈕的預設動作\",\n        \"playButtonBehavior_optionAddLast\": \"$t(player.addLast)\",\n        \"playButtonBehavior_optionAddNext\": \"$t(player.addNext)\",\n        \"remotePort\": \"遠端控制伺服器連接埠\",\n        \"remoteUsername\": \"遠端控制伺服器使用者名稱\",\n        \"replayGainClipping\": \"{{ReplayGain}}削波\",\n        \"replayGainFallback\": \"{{ReplayGain}}後備替代\",\n        \"replayGainFallback_description\": \"歌曲沒有{{ReplayGain}}標簽時使用的增益（以分貝為單位）\",\n        \"replayGainMode\": \"{{ReplayGain}}模式\",\n        \"replayGainMode_description\": \"根據歌曲標籤中儲存的{{ReplayGain}}值調整音量增益\",\n        \"replayGainMode_optionAlbum\": \"$t(entity.album, {\\\"count\\\": 1})\",\n        \"replayGainMode_optionNone\": \"$t(common.none)\",\n        \"replayGainMode_optionTrack\": \"$t(entity.track, {\\\"count\\\": 1})\",\n        \"replayGainPreamp\": \"{{ReplayGain}}前置放大（分貝）\",\n        \"replayGainPreamp_description\": \"調整使用在{{ReplayGain}}值上的前置放大增益\",\n        \"savePlayQueue\": \"儲存播放佇列\",\n        \"sampleRate_description\": \"如果選擇的取樣率與目前媒體的取樣率不同，請選擇要使用的輸出取樣率。小於 8000 的值將使用預設頻率\",\n        \"savePlayQueue_description\": \"當應用程式關閉時儲存播放佇列，並在應用程式開啟時恢複它\",\n        \"scrobble\": \"記錄播放資訊（Scrobble）\",\n        \"scrobble_description\": \"在你的媒體伺服器中記錄播放資訊\",\n        \"showSkipButton\": \"顯示跳過按鈕\",\n        \"showSkipButton_description\": \"在播放條上顯示/隱藏跳過按鈕\",\n        \"sidebarPlaylistList\": \"側邊欄播放清單列表\",\n        \"sidebarCollapsedNavigation\": \"側邊欄（已折疊）導航\",\n        \"sidebarCollapsedNavigation_description\": \"在折疊的側邊欄中顯示或隱藏導航\",\n        \"sidebarConfiguration\": \"側邊欄設定\",\n        \"sidebarConfiguration_description\": \"選擇側邊欄包含的項目與順序\",\n        \"sidebarPlaylistList_description\": \"顯示或隱藏側邊欄歌單清單\",\n        \"sidePlayQueueStyle\": \"側邊播放佇列樣式\",\n        \"sidePlayQueueStyle_description\": \"設定側邊播放佇列樣式\",\n        \"sidePlayQueueStyle_optionAttached\": \"吸附\",\n        \"sidePlayQueueStyle_optionDetached\": \"分離\",\n        \"skipDuration\": \"跳過時長\",\n        \"skipDuration_description\": \"設定每次按下跳過按鈕將會跳過的時長\",\n        \"skipPlaylistPage\": \"跳過播放清單頁面\",\n        \"skipPlaylistPage_description\": \"開啟播放清單時，直接查看歌曲列表而非查看預設頁面\",\n        \"theme\": \"主題\",\n        \"themeDark\": \"主題（深色）\",\n        \"useSystemTheme_description\": \"使用系統定義的淺色或深色主題\",\n        \"useSystemTheme\": \"跟隨系統\",\n        \"volumeWheelStep\": \"音量滾輪步進\",\n        \"volumeWheelStep_description\": \"在音量條上滾動滑鼠滾輪時要更改的音量大小\",\n        \"windowBarStyle\": \"視窗頂欄風格\",\n        \"windowBarStyle_description\": \"選擇視窗頂欄的風格\",\n        \"zoom\": \"縮放比例\",\n        \"zoom_description\": \"設定應用程式的縮放比例\",\n        \"hotkey_volumeUp\": \"音量增高\",\n        \"sampleRate\": \"取樣率\",\n        \"showSkipButtons_description\": \"在播放條顯示/隱藏播放按鈕\",\n        \"playbackStyle\": \"播放風格\",\n        \"exitToTray_description\": \"退出應用程式時最小化到系統匣而非關閉\",\n        \"followLyric_description\": \"滾動歌詞到目前播放位置\",\n        \"font\": \"字體\",\n        \"globalMediaHotkeys_description\": \"啟用或禁用系統媒體快捷鍵以控制播放\",\n        \"hotkey_browserBack\": \"瀏覽器返回\",\n        \"hotkey_favoriteCurrentSong\": \"收藏 $t(common.currentSong)\",\n        \"hotkey_playbackStop\": \"停止\",\n        \"hotkey_rate0\": \"清除評分\",\n        \"mpvExecutablePath_description\": \"設定 mpv 執行檔的路徑。如果留空，則使用預設路徑\",\n        \"playbackStyle_description\": \"選擇播放器的播放風格\",\n        \"playButtonBehavior_optionPlay\": \"$t(player.play)\",\n        \"remotePassword\": \"遠端控制伺服器密碼\",\n        \"remotePassword_description\": \"設定遠端控制伺服器的密碼。這些憑證預設以不安全的方式傳輸，因此您應該使用一個您不在意的唯一密碼\",\n        \"remotePort_description\": \"設定遠端控制伺服器的連接埠\",\n        \"remoteUsername_description\": \"設定遠端控制伺服器的使用者名稱。如果使用者名稱和密碼都為空，則身分驗證將被禁用\",\n        \"replayGainClipping_description\": \"自動降低增益以防止{{ReplayGain}}造成削波\",\n        \"showSkipButtons\": \"顯示跳過按鈕\",\n        \"themeDark_description\": \"應用程式將使用深色主題\",\n        \"clearQueryCache_description\": \"Feishin的「軟清除」。這將會刷新播放清單、曲目標籤並重置儲存的歌詞。會保留設定、伺服器憑證和暫存圖片\",\n        \"clearCache\": \"清除瀏覽器快取\",\n        \"clearCache_description\": \"Feishin的「硬清除」。除了清除Feishin的快取、清除瀏覽器快取（儲存的圖片和其他資源）。會保留伺服器憑證和設定\",\n        \"clearQueryCache\": \"清除Feishin快取\",\n        \"buttonSize\": \"播放器欄按鈕大小\",\n        \"buttonSize_description\": \"播放器欄按鈕大小\",\n        \"albumBackground\": \"專輯背景圖片\",\n        \"albumBackground_description\": \"為包含專輯封面的專輯頁面新增背景圖片\",\n        \"albumBackgroundBlur\": \"專輯背景圖片模糊大小\",\n        \"albumBackgroundBlur_description\": \"調整應用程式於專輯背景圖片的模糊量\",\n        \"artistConfiguration\": \"專輯藝人頁面設定\",\n        \"artistConfiguration_description\": \"設定專輯藝人頁面中顯示的項目及排序\",\n        \"clearCacheSuccess\": \"成功清除快取\",\n        \"contextMenu\": \"右鍵選單設定\",\n        \"contextMenu_description\": \"允許您隱藏在右鍵選單項目時顯示的項目。未選取的項目將被隱藏\",\n        \"customCssEnable\": \"啟用自訂CSS\",\n        \"customCssEnable_description\": \"允許撰寫自訂CSS\",\n        \"customCssNotice\": \"警告：即使已限制某些用法（不允許 url() 和 content:），但使用自訂 CSS 仍然會透過更改介面帶來風險\",\n        \"customCss\": \"自訂CSS\",\n        \"customCss_description\": \"自訂 CSS 內容。注意：內容和遠端 URL 是不允許使用的屬性。您的內容預覽如下所示。由於需要進行清理，因此存在一些您未設定的其他欄位\",\n        \"discordPausedStatus\": \"暫停時顯示 rich presence\",\n        \"discordPausedStatus_description\": \"啟用後，播放器暫停時將顯示狀態\",\n        \"discordListening\": \"將狀態設為\\\"正在聽\\\"\",\n        \"discordListening_description\": \"將狀態顯示為\\\"正在聽\\\"而不是\\\"正在玩\\\"\",\n        \"discordServeImage\": \"從伺服器提供{{discord}}圖片\",\n        \"discordServeImage_description\": \"從伺服器本身分享 {{discord}} Rich Presence的封面圖片，僅支援 Jellyfin 與 Navidrome。{{discord}} 會透過機器人擷取圖片，因此您的伺服器必須能從公開網路連線\",\n        \"externalLinks\": \"顯示外部連結\",\n        \"externalLinks_description\": \"在藝人/專輯頁面顯示外部連結(Last.fm, MusicBrainz)\",\n        \"preferLocalLyrics\": \"偏好本地歌詞\",\n        \"preferLocalLyrics_description\": \"優先選擇本地歌詞，而不是遠端歌詞（如果可用）\",\n        \"homeConfiguration\": \"首頁設定\",\n        \"homeConfiguration_description\": \"設定在首頁上顯示哪些項目以及顯示順序\",\n        \"homeFeature\": \"首頁特色輪播\",\n        \"homeFeature_description\": \"控制是否在首頁上顯示大型特色輪播\",\n        \"imageAspectRatio\": \"使用原生封面照長寬比\",\n        \"imageAspectRatio_description\": \"如果啟用，封面照將使用其原始長寬比顯示。對於非 1:1 的封面，剩餘空間將為空\",\n        \"lastfm\": \"顯示 last.fm 連結\",\n        \"lastfm_description\": \"在藝人/專輯頁面顯示 Last.fm 連結\",\n        \"lastfmApiKey\": \"{{lastfm}} API金鑰\",\n        \"lastfmApiKey_description\": \"{{lastfm}}的API金鑰。用於封面照\",\n        \"mpvExtraParameters_help\": \"一行一個\",\n        \"musicbrainz\": \"顯示 MusicBrainz 連結\",\n        \"musicbrainz_description\": \"在存在 MusicBrainz ID 的藝人/專輯頁面上顯示 MusicBrainz 的連結\",\n        \"neteaseTranslation\": \"啟用網易翻譯\",\n        \"neteaseTranslation_description\": \"啟用後，將從網易取得並顯示翻譯的歌詞（如果有）\",\n        \"passwordStore\": \"密碼/secret儲存\",\n        \"passwordStore_description\": \"使用什麼密碼/secret儲存。如果您在儲存密碼時遇到問題，請變更此項目\",\n        \"playButtonBehavior_optionPlayShuffled\": \"$t(player.shuffle)\",\n        \"playerbarOpenDrawer\": \"播放器列全螢幕切換\",\n        \"playerbarOpenDrawer_description\": \"允許點擊播放器列以開啟全螢幕播放器\",\n        \"startMinimized\": \"啟動時最小化\",\n        \"startMinimized_description\": \"在系統匣中啟動應用程式\",\n        \"transcode_description\": \"啟用轉碼到不同格式\",\n        \"transcodeBitrate\": \"要轉碼的位元率\",\n        \"transcodeBitrate_description\": \"選擇要轉碼的位元率。 0 表示讓伺服器選擇\",\n        \"transcodeFormat\": \"轉碼的格式\",\n        \"transcodeFormat_description\": \"選擇要轉碼的格式。留空來讓伺服器決定\",\n        \"translationApiProvider\": \"翻譯API提供者\",\n        \"translationApiProvider_description\": \"翻譯API的提供者\",\n        \"translationApiKey\": \"翻譯API金鑰\",\n        \"translationApiKey_description\": \"翻譯的API金鑰（僅限全域伺服端點）\",\n        \"translationTargetLanguage\": \"目標翻譯語言\",\n        \"translationTargetLanguage_description\": \"翻譯的目標語言\",\n        \"trayEnabled\": \"顯示系統匣\",\n        \"trayEnabled_description\": \"顯示/隱藏系統匣圖示/選單。如果停用，將同時停用最小化/退出到系統匣\",\n        \"volumeWidth\": \"音量條寬度\",\n        \"volumeWidth_description\": \"音量條的寬度\",\n        \"webAudio\": \"使用網頁音訊\",\n        \"webAudio_description\": \"使用網頁音訊。這將啟用重播增益等進階功能。如果您遇到其他問題，請停用\",\n        \"preservePitch\": \"保持音高\",\n        \"preservePitch_description\": \"修改播放速度時保留音調\",\n        \"artistBackground\": \"藝人背景圖片\",\n        \"artistBackground_description\": \"為藝人頁面新增含藝人圖片的背景圖像\",\n        \"artistBackgroundBlur\": \"藝人背景圖片模糊程度\",\n        \"artistBackgroundBlur_description\": \"調整套用至藝人背景圖片的模糊程度\",\n        \"releaseChannel_optionLatest\": \"最新版本\",\n        \"releaseChannel_optionBeta\": \"測試版\",\n        \"releaseChannel_description\": \"選擇自動更新時要使用穩定、測試或是 alpha (每日建構版) 版本\",\n        \"discordDisplayType\": \"{{discord}} presence 顯示類型\",\n        \"discordDisplayType_description\": \"變更您在狀態中正在聆聽的內容\",\n        \"discordDisplayType_songname\": \"歌曲名稱\",\n        \"discordDisplayType_artistname\": \"藝人名稱\",\n        \"discordLinkType\": \"{{discord}} presence 連結\",\n        \"discordLinkType_description\": \"在 {{discord}} Rich Presence中，為歌曲和藝人欄位新增 {{lastfm}} 或 {{musicbrainz}} 的外部連結。{{musicbrainz}} 的準確度最高，但需要標籤且不提供藝人連結；而 {{lastfm}} 通常都能提供連結。此功能不會產生額外的網路請求\",\n        \"discordLinkType_none\": \"$t(common.none)\",\n        \"discordLinkType_mbz_lastfm\": \"{{musicbrainz}} 並以 {{lastfm}} 備用\",\n        \"hotkey_navigateHome\": \"導航至首頁\",\n        \"preventSleepOnPlayback\": \"防止播放時進入睡眠狀態\",\n        \"preventSleepOnPlayback_description\": \"在音樂播放時防止螢幕進入睡眠狀態\",\n        \"mediaSession\": \"啟用 Media Session\",\n        \"mediaSession_description\": \"啟用 Media Session 整合功能，於系統音量 Overlay 和鎖定畫面中顯示媒體資料與控制面板\",\n        \"releaseChannel\": \"發佈通道\",\n        \"analyticsDisable\": \"選擇退出使用情況分析\",\n        \"analyticsDisable_description\": \"經過匿名處理的使用情況資料將傳送給開發者，以協助改進應用程式\",\n        \"crossfadeStyle\": \"交叉淡入淡出風格\",\n        \"discordRichPresence\": \"{{discord}} Rich Presence\",\n        \"enableAutoTranslation_description\": \"歌詞載入時自動啟用翻譯功能\",\n        \"enableAutoTranslation\": \"啟用自動翻譯\",\n        \"exportImportSettings_control_description\": \"使用 JSON 匯出與匯入設定\",\n        \"exportImportSettings_control_exportText\": \"匯出設定\",\n        \"exportImportSettings_control_importText\": \"匯入設定\",\n        \"exportImportSettings_control_title\": \"匯入/匯出設定\",\n        \"exportImportSettings_destructiveWarning\": \"匯入設定會覆蓋現有資料，請在點擊下方「匯入」按鈕前詳閱上述內容！\",\n        \"exportImportSettings_importBtn\": \"匯入設定\",\n        \"exportImportSettings_importModalTitle\": \"匯入Feishin設定\",\n        \"exportImportSettings_importSuccess\": \"設定已成功匯入！\",\n        \"exportImportSettings_notValidJSON\": \"傳遞的檔案不是有效的 JSON\",\n        \"exportImportSettings_offendingKeyError\": \"\\\"{{offendingKey}}\\\" 不正確 - {{reason}}\",\n        \"language\": \"語言\",\n        \"notify\": \"啟用歌曲通知\",\n        \"notify_description\": \"當歌曲變更時顯示通知\",\n        \"playerbarSlider\": \"播放進度條\",\n        \"playerbarSliderType_optionSlider\": \"滑桿\",\n        \"playerbarSliderType_optionWaveform\": \"波形\",\n        \"playerbarWaveformAlign\": \"波形對齊\",\n        \"playerbarWaveformAlign_optionTop\": \"靠上對齊\",\n        \"playerbarWaveformAlign_optionCenter\": \"置中對齊\",\n        \"playerbarWaveformAlign_optionBottom\": \"靠下對齊\",\n        \"playerbarWaveformBarWidth\": \"波形寬度\",\n        \"playerbarWaveformGap\": \"波形間距\",\n        \"playerbarWaveformRadius\": \"波形圓角\",\n        \"showLyricsInSidebar_description\": \"在播放佇列增加一個面板來顯示歌詞\",\n        \"showLyricsInSidebar\": \"在播放器側邊欄顯示歌詞\",\n        \"showVisualizerInSidebar_description\": \"在播放佇列增加一個面板來顯示視覺化效果\",\n        \"showVisualizerInSidebar\": \"在播放器側邊欄顯示視覺化效果\",\n        \"transcode\": \"啟用轉碼功能\",\n        \"queryBuilderCustomFields_inputLabel\": \"唱片公司\",\n        \"queryBuilderCustomFields_inputTag\": \"標籤\",\n        \"queryBuilderCustomFields\": \"自訂欄位\",\n        \"audioFadeOnStatusChange\": \"狀態變更時音訊淡入淡出\",\n        \"audioFadeOnStatusChange_description\": \"當播放/暫停狀態變更時，啟用淡入淡出效果\",\n        \"queryBuilder\": \"查詢建構器\",\n        \"queryBuilderCustomFields_description\": \"在查詢建構器中新增自訂欄位\",\n        \"followCurrentSong_description\": \"自動將播放佇列捲動至當前播放的歌曲\",\n        \"followCurrentSong\": \"跟隨當前歌曲\",\n        \"playerbarSlider_description\": \"不建議在網路速度緩慢或計費的網路下使用波形\",\n        \"playerFilters\": \"從佇列中過濾歌曲\",\n        \"playerFilters_description\": \"根據以下條件，排除要新增至佇列中的歌曲\",\n        \"autoDJ\": \"Auto DJ\",\n        \"autoDJ_description\": \"自動將相似的歌曲加入到播放佇列\",\n        \"autoDJ_itemCount\": \"歌曲數量\",\n        \"autoDJ_itemCount_description\": \"在啟用Auto DJ時嘗試加入佇列的歌曲數量\",\n        \"autoDJ_timing_description\": \"佇列中剩餘多少歌曲時啟動 Auto DJ\",\n        \"autoDJ_timing\": \"觸發時機\",\n        \"logLevel\": \"log等級\",\n        \"logLevel_description\": \"設定要顯示的最低日誌等級。Debug 會顯示所有日誌，Error 僅會顯示錯誤訊息\",\n        \"logLevel_optionDebug\": \"Debug\",\n        \"logLevel_optionError\": \"Error\",\n        \"logLevel_optionInfo\": \"Info\",\n        \"logLevel_optionWarn\": \"Warn\",\n        \"useThemeAccentColor\": \"使用主題強調色\",\n        \"useThemeAccentColor_description\": \"使用所選主題中定義的主要顏色，而非自訂的強調色\",\n        \"artistRadioCount_description\": \"設定為藝人電台與曲目電台擷取的歌曲數量\",\n        \"imageResolution\": \"圖片解析度\",\n        \"imageResolution_description\": \"應用程式中所使用圖片的解析度。設定為 0 時，將使用圖片的原始解析度\",\n        \"imageResolution_optionTable\": \"表格\",\n        \"imageResolution_optionItemCard\": \"項目卡片\",\n        \"imageResolution_optionSidebar\": \"側邊欄\",\n        \"imageResolution_optionHeader\": \"頁首\",\n        \"imageResolution_optionFullScreenPlayer\": \"全螢幕播放器\",\n        \"combinedLyricsAndVisualizer_description\": \"將歌詞與視覺化效果整合至同一個面板\",\n        \"combinedLyricsAndVisualizer\": \"在播放器側邊欄整合歌詞與視覺化效果\",\n        \"artistRadioCount\": \"藝人/歌曲電台數量\",\n        \"showRatings_description\": \"控制星級評分功能是否顯示於介面中\",\n        \"showRatings\": \"顯示星級評分\",\n        \"artistReleaseTypeConfiguration\": \"藝人發行類型設定\",\n        \"artistReleaseTypeConfiguration_description\": \"設定專輯藝人頁面中顯示的發行類型及排序\",\n        \"hotkey_listNavigateToPage\": \"從清單導覽至項目頁面\",\n        \"mpvExtraParameters\": \"MPV額外參數\",\n        \"mpvExtraParameters_description\": \"傳遞給MPV的額外參數\",\n        \"pathReplace\": \"檔案路徑替換\",\n        \"pathReplace_description\": \"替換您伺服器的預設檔案路徑\",\n        \"pathReplace_optionRemovePrefix\": \"移除前綴\",\n        \"pathReplace_optionAddPrefix\": \"增加前綴\",\n        \"sidebarPlaylistSorting\": \"側邊欄播放清單排序\",\n        \"homeFeatureStyle_description\": \"控制首頁輪播的樣式\",\n        \"homeFeatureStyle\": \"首頁特色輪播樣式\",\n        \"homeFeatureStyle_optionMultiple\": \"多重\",\n        \"homeFeatureStyle_optionSingle\": \"單一\",\n        \"hotkey_listPlayDefault\": \"清單播放\",\n        \"hotkey_listPlayLast\": \"清單尾端播放\",\n        \"hotkey_listPlayNext\": \"清單下一項播放\",\n        \"hotkey_listPlayNow\": \"清單立即播放\",\n        \"enableGridMultiSelect\": \"啟用網格多選\",\n        \"enableGridMultiSelect_description\": \"啟用時，允許在網格檢視中選擇多項。停用時，單擊網格項目圖片將導航到項目頁面\",\n        \"sidebarPlaylistSorting_description\": \"允許在側邊欄中使用拖放手動對播放清單進行排序，而不是預設的伺服器排序\",\n        \"sidebarPlaylistListFilterRegex_description\": \"在側邊欄中隱藏與此正規表達式匹配的播放清單\",\n        \"sidebarPlaylistListFilterRegex_placeholder\": \"範例: ^Daily Mix.*\",\n        \"sidebarPlaylistListFilterRegex\": \"播放清單過濾器正規表達式\",\n        \"blurExplicitImages\": \"模糊露骨圖片\",\n        \"blurExplicitImages_description\": \"標記為露骨的專輯和歌曲封面將被模糊\",\n        \"releaseChannel_optionAlpha\": \"alpha (每日建構版)\",\n        \"analyticsEnable\": \"傳送基於使用情況的分析報告\",\n        \"analyticsEnable_description\": \"匿名化的使用情況資料會傳送給開發者，以協助改進應用程式\",\n        \"automaticUpdates\": \"自動更新\",\n        \"automaticUpdates_description\": \"自動檢查並安裝更新\",\n        \"discordStateIcon\": \"顯示播放中圖示\",\n        \"discordStateIcon_description\": \"在 rich presence 狀態中顯示一個小的播放圖示。啟用「暫停時顯示 rich presence」時，會始終顯示暫停的圖示\",\n        \"useThemePrimaryShade\": \"套用主題主色調\",\n        \"useThemePrimaryShade_description\": \"使用所選主題中定義的主色調作為主色變體\",\n        \"primaryShade\": \"主要色調\",\n        \"primaryShade_description\": \"覆蓋按鈕、連結及其他主色調元素所使用的主色調(0–9)\",\n        \"playerItemConfiguration_description\": \"設定全螢幕播放器顯示的項目及排列順序\",\n        \"playerItemConfiguration\": \"播放器項目設定\",\n        \"autosave\": \"自動儲存播放佇列\",\n        \"autosave_description\": \"啟用自動將播放佇列儲存到您的伺服器。這只有在使用Navidrome/Subsonic時才可使用，並且您不能有混合播放佇列。\",\n        \"autosaveCount\": \"自動播放佇列儲存頻率\",\n        \"autosaveCount_description\": \"在儲存佇列之前，有多少曲目更改。1（最小）表示每次歌曲更改\",\n        \"spotify_description\": \"在藝人與專輯頁面顯示 Spotify 的連結\",\n        \"spotify\": \"顯示 Spotify 的連結\",\n        \"nativeSpotify_description\": \"在 Spotify 應用程式中開啟，而非在瀏覽器中開啟\",\n        \"nativeSpotify\": \"使用 Spotify 應用程式\",\n        \"sidePlayQueueLayout\": \"側邊播放佇列佈局\",\n        \"sidePlayQueueLayout_description\": \"設定吸附側邊播放佇列的佈局\",\n        \"sidePlayQueueLayout_optionHorizontal\": \"水平\",\n        \"sidePlayQueueLayout_optionVertical\": \"垂直\",\n        \"listenbrainz_description\": \"在藝術家/專輯頁面上顯示 ListenBrainz 的連結\",\n        \"listenbrainz\": \"顯示 ListenBrainz 連結\",\n        \"qobuz_description\": \"在藝術家/專輯頁面上顯示 Qobuz 的連結\",\n        \"qobuz\": \"顯示 Qobuz 連結\"\n    },\n    \"table\": {\n        \"config\": {\n            \"general\": {\n                \"displayType\": \"顯示風格\",\n                \"gap\": \"$t(common.gap)\",\n                \"size\": \"$t(common.size)\",\n                \"tableColumns\": \"列\",\n                \"autoFitColumns\": \"自動調整列寬\",\n                \"followCurrentSong\": \"跟隨目前歌曲\",\n                \"itemGap\": \"項目間隔 (px)\",\n                \"itemSize\": \"項目大小 (px)\",\n                \"advancedSettings\": \"進階設定\",\n                \"autosize\": \"自動調整大小\",\n                \"moveUp\": \"往上\",\n                \"moveDown\": \"往下\",\n                \"pinToLeft\": \"固定在左側\",\n                \"pinToRight\": \"固定在右側\",\n                \"alignLeft\": \"靠左對齊\",\n                \"alignCenter\": \"置中對齊\",\n                \"alignRight\": \"靠右對齊\",\n                \"itemsPerRow\": \"每行項目數\",\n                \"size_default\": \"預設\",\n                \"size_compact\": \"緊湊\",\n                \"size_large\": \"大型\",\n                \"pagination\": \"分頁模式\",\n                \"pagination_itemsPerPage\": \"每頁項目數\",\n                \"pagination_infinite\": \"無限滾動\",\n                \"pagination_paginate\": \"分頁式\",\n                \"alternateRowColors\": \"隔行上色\",\n                \"horizontalBorders\": \"行邊框線\",\n                \"rowHoverHighlight\": \"滑鼠懸停Highlight\",\n                \"verticalBorders\": \"列邊框線\",\n                \"showHeader\": \"顯示標題\"\n            },\n            \"label\": {\n                \"actions\": \"$t(common.action, {\\\"count\\\": 2})\",\n                \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n                \"albumArtist\": \"$t(entity.albumArtist, {\\\"count\\\": 1})\",\n                \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n                \"bpm\": \"$t(common.bpm)\",\n                \"biography\": \"$t(common.biography)\",\n                \"bitrate\": \"$t(common.bitrate)\",\n                \"channels\": \"$t(common.channel, {\\\"count\\\": 2})\",\n                \"dateAdded\": \"新增日期\",\n                \"discNumber\": \"光碟編號\",\n                \"duration\": \"$t(common.duration)\",\n                \"favorite\": \"$t(common.favorite)\",\n                \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n                \"lastPlayed\": \"最後播放\",\n                \"note\": \"$t(common.note)\",\n                \"owner\": \"$t(common.owner)\",\n                \"path\": \"$t(common.path)\",\n                \"playCount\": \"播放次數\",\n                \"releaseDate\": \"發行日期\",\n                \"rowIndex\": \"行號\",\n                \"size\": \"$t(common.size)\",\n                \"title\": \"$t(common.title)\",\n                \"titleCombined\": \"$t(common.title)（合併）\",\n                \"trackNumber\": \"曲目編號\",\n                \"year\": \"$t(common.year)\",\n                \"rating\": \"$t(common.rating)\",\n                \"codec\": \"$t(common.codec)\",\n                \"songCount\": \"$t(entity.track, {\\\"count\\\": 2})\",\n                \"albumCount\": \"$t(entity.album, {\\\"count\\\": 2})\",\n                \"genreBadge\": \"$t(entity.genre, {\\\"count\\\": 1}) (徽章)\",\n                \"image\": \"圖片\",\n                \"bitDepth\": \"$t(common.bitDepth)\",\n                \"sampleRate\": \"$t(common.sampleRate)\",\n                \"composer\": \"作曲者\",\n                \"titleArtist\": \"$t(common.title) (藝人)\",\n                \"albumGroup\": \"專輯分組\"\n            },\n            \"view\": {\n                \"table\": \"表格\",\n                \"grid\": \"網格\",\n                \"list\": \"列表\",\n                \"detail\": \"詳情\"\n            }\n        },\n        \"column\": {\n            \"album\": \"專輯\",\n            \"albumArtist\": \"專輯藝人\",\n            \"albumCount\": \"$t(entity.album, {\\\"count\\\": 2})\",\n            \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n            \"biography\": \"簡介\",\n            \"bitrate\": \"位元率\",\n            \"channels\": \"$t(common.channel, {\\\"count\\\": 2})\",\n            \"comment\": \"評論\",\n            \"dateAdded\": \"新增日期\",\n            \"discNumber\": \"光碟\",\n            \"favorite\": \"收藏\",\n            \"lastPlayed\": \"最後播放\",\n            \"path\": \"路徑\",\n            \"playCount\": \"播放次數\",\n            \"rating\": \"評價\",\n            \"releaseDate\": \"發布日期\",\n            \"releaseYear\": \"年份\",\n            \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n            \"bpm\": \"bpm\",\n            \"songCount\": \"$t(entity.track, {\\\"count\\\": 2})\",\n            \"title\": \"標題\",\n            \"trackNumber\": \"曲目編號\",\n            \"size\": \"$t(common.size)\",\n            \"codec\": \"$t(common.codec)\",\n            \"owner\": \"擁有者\",\n            \"bitDepth\": \"$t(common.bitDepth)\",\n            \"sampleRate\": \"$t(common.sampleRate)\"\n        }\n    },\n    \"action\": {\n        \"addToFavorites\": \"新增到$t(entity.favorite, {\\\"count\\\": 2})\",\n        \"clearQueue\": \"清空播放佇列\",\n        \"createPlaylist\": \"建立$t(entity.playlist, {\\\"count\\\": 1})\",\n        \"deletePlaylist\": \"刪除$t(entity.playlist, {\\\"count\\\": 1})\",\n        \"addToPlaylist\": \"新增到$t(entity.playlist, {\\\"count\\\": 1})\",\n        \"deselectAll\": \"取消全選\",\n        \"editPlaylist\": \"編輯 $t(entity.playlist, {\\\"count\\\": 1})\",\n        \"goToPage\": \"前往頁面\",\n        \"moveToBottom\": \"移至底部\",\n        \"moveToTop\": \"移至頂部\",\n        \"refresh\": \"$t(common.refresh)\",\n        \"removeFromFavorites\": \"從$t(entity.favorite, {\\\"count\\\": 2})移除\",\n        \"removeFromPlaylist\": \"從$t(entity.playlist, {\\\"count\\\": 1})移除\",\n        \"removeFromQueue\": \"從播放佇列中移除\",\n        \"setRating\": \"評分\",\n        \"toggleSmartPlaylistEditor\": \"切換$t(entity.smartPlaylist)編輯器\",\n        \"viewPlaylists\": \"查看$t(entity.playlist, {\\\"count\\\": 2})\",\n        \"moveToNext\": \"移至下一項\",\n        \"openIn\": {\n            \"lastfm\": \"在Last.fm開啟\",\n            \"musicbrainz\": \"在MusicBrainz開啟\",\n            \"spotify\": \"在 Spotify 中開啟\",\n            \"listenbrainz\": \"在 ListenBrainz 中開啟\",\n            \"qobuz\": \"在 Qobuz 中開啟\"\n        },\n        \"downloadStarted\": \"已開始下載 {{count}} 項內容\",\n        \"moveItems\": \"移動項目\",\n        \"shuffle\": \"隨機播放\",\n        \"shuffleAll\": \"全部隨機播放\",\n        \"shuffleSelected\": \"隨機播放選取項目\",\n        \"viewMore\": \"查看更多\",\n        \"moveUp\": \"向上移動\",\n        \"moveDown\": \"向下移動\",\n        \"holdToMoveToTop\": \"按住以移動至頂部\",\n        \"holdToMoveToBottom\": \"按住以移動至底部\",\n        \"createRadioStation\": \"建立 $t(entity.radioStation, {\\\"count\\\": 1})\",\n        \"deleteRadioStation\": \"刪除 $t(entity.radioStation, {\\\"count\\\": 1})\",\n        \"openApplicationDirectory\": \"開啟應用程式目錄\",\n        \"addOrRemoveFromSelection\": \"新增或移除選取項目\",\n        \"selectAll\": \"全選\",\n        \"selectRangeOfItems\": \"批量選取\",\n        \"goToCurrent\": \"前往當前項目\"\n    },\n    \"entity\": {\n        \"album_other\": \"專輯\",\n        \"albumArtist_other\": \"專輯藝人\",\n        \"albumArtistCount_other\": \"{{count}} 位專輯藝人\",\n        \"artist_other\": \"藝人\",\n        \"artistWithCount_other\": \"{{count}} 位藝人\",\n        \"favorite_other\": \"收藏\",\n        \"folder_other\": \"資料夾\",\n        \"folderWithCount_other\": \"{{count}} 個資料夾\",\n        \"genre_other\": \"曲風\",\n        \"genreWithCount_other\": \"{{count}} 種曲風\",\n        \"playlist_other\": \"播放清單\",\n        \"playlistWithCount_other\": \"{{count}} 個播放清單\",\n        \"smartPlaylist\": \"智慧$t(entity.playlist, {\\\"count\\\": 1})\",\n        \"track_other\": \"曲目\",\n        \"trackWithCount_other\": \"{{count}} 首曲目\",\n        \"albumWithCount_other\": \"{{count}} 張專輯\",\n        \"play_other\": \"{{count}}次播放\",\n        \"song_other\": \"歌曲\",\n        \"radioStation_other\": \"電台\",\n        \"radioStationWithCount_other\": \"{{count}} 個電台\"\n    },\n    \"filter\": {\n        \"albumCount\": \"$t(entity.album, {\\\"count\\\": 2})數\",\n        \"artist\": \"$t(entity.artist, {\\\"count\\\": 1})\",\n        \"biography\": \"個人簡介\",\n        \"bitrate\": \"位元率\",\n        \"bpm\": \"bpm\",\n        \"channels\": \"$t(common.channel, {\\\"count\\\": 2})\",\n        \"comment\": \"評論\",\n        \"communityRating\": \"社群評分\",\n        \"criticRating\": \"評論家評分\",\n        \"dateAdded\": \"已新增日期\",\n        \"disc\": \"光碟\",\n        \"duration\": \"時長\",\n        \"id\": \"id\",\n        \"fromYear\": \"從年份\",\n        \"genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n        \"isCompilation\": \"為合輯\",\n        \"isFavorited\": \"已收藏\",\n        \"isPublic\": \"已公開\",\n        \"isRated\": \"已評分\",\n        \"name\": \"名稱\",\n        \"note\": \"注釋\",\n        \"isRecentlyPlayed\": \"最近播放過\",\n        \"lastPlayed\": \"上次播放過\",\n        \"mostPlayed\": \"播放最多\",\n        \"owner\": \"$t(common.owner)\",\n        \"path\": \"路徑\",\n        \"playCount\": \"播放次數\",\n        \"random\": \"隨機\",\n        \"rating\": \"評分\",\n        \"recentlyPlayed\": \"最近播放\",\n        \"recentlyUpdated\": \"最近更新\",\n        \"releaseDate\": \"發布日期\",\n        \"songCount\": \"曲目數\",\n        \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n        \"albumArtist\": \"$t(entity.albumArtist, {\\\"count\\\": 1})\",\n        \"favorited\": \"已收藏\",\n        \"recentlyAdded\": \"最近新增\",\n        \"releaseYear\": \"發布年份\",\n        \"search\": \"搜尋\",\n        \"title\": \"標題\",\n        \"toYear\": \"從年份\",\n        \"trackNumber\": \"曲目\",\n        \"explicitStatus\": \"$t(common.explicitStatus)\",\n        \"sortName\": \"排序名稱\",\n        \"matchAnd\": \"和\",\n        \"matchOr\": \"或\"\n    },\n    \"form\": {\n        \"addServer\": {\n            \"input_legacyAuthentication\": \"啟用舊版認證方式\",\n            \"input_name\": \"伺服器名稱\",\n            \"input_password\": \"密碼\",\n            \"input_savePassword\": \"儲存密碼\",\n            \"input_url\": \"url\",\n            \"input_username\": \"使用者名稱\",\n            \"success\": \"伺服器新增成功\",\n            \"title\": \"新增伺服器\",\n            \"error_savePassword\": \"儲存密碼時出現錯誤\",\n            \"ignoreCors\": \"忽略 cors $t(common.restartRequired)\",\n            \"ignoreSsl\": \"忽略 ssl $t(common.restartRequired)\",\n            \"input_preferInstantMix\": \"偏好即時混音\",\n            \"input_preferInstantMixDescription\": \"僅使用即時混音功能來取得相似歌曲。若您擁有能修改此行為的外掛，此功能將相當實用\",\n            \"input_preferRemoteUrl\": \"優先使用公開網址\",\n            \"input_remoteUrl\": \"公開網址\",\n            \"input_remoteUrlPlaceholder\": \"選用：對外功能的公開網址\"\n        },\n        \"addToPlaylist\": {\n            \"input_playlists\": \"$t(entity.playlist, {\\\"count\\\": 2})\",\n            \"input_skipDuplicates\": \"跳過重複\",\n            \"success\": \"新增 $t(entity.trackWithCount, {\\\"count\\\": {{message}} }) 到 $t(entity.playlistWithCount, {\\\"count\\\": {{numOfPlaylists}} })\",\n            \"title\": \"新增到$t(entity.playlist, {\\\"count\\\": 1})\",\n            \"create\": \"建立 $t(entity.playlist, {\\\"count\\\": 1}) {{playlist}}\",\n            \"searchOrCreate\": \"搜尋$t(entity.playlist, {\\\"count\\\": 2}) 或輸入內容以建立新項目\"\n        },\n        \"createPlaylist\": {\n            \"input_description\": \"$t(common.description)\",\n            \"input_name\": \"$t(common.name)\",\n            \"input_owner\": \"$t(common.owner)\",\n            \"input_public\": \"公開\",\n            \"success\": \"已成功建立 $t(entity.playlist, {\\\"count\\\": 1})\",\n            \"title\": \"建立$t(entity.playlist, {\\\"count\\\": 1})\"\n        },\n        \"lyricSearch\": {\n            \"input_name\": \"$t(common.name)\",\n            \"title\": \"搜尋歌詞\",\n            \"input_artist\": \"$t(entity.artist, {\\\"count\\\": 1})\"\n        },\n        \"queryEditor\": {\n            \"input_optionMatchAll\": \"匹配全部\",\n            \"input_optionMatchAny\": \"匹配任何\",\n            \"title\": \"查詢編輯器\",\n            \"addRuleGroup\": \"新增規則群組\",\n            \"removeRuleGroup\": \"移除規則群組\",\n            \"resetToDefault\": \"恢復為預設值\",\n            \"clearFilters\": \"清除篩選\"\n        },\n        \"updateServer\": {\n            \"success\": \"伺服器已更新成功\",\n            \"title\": \"更新伺服器\"\n        },\n        \"deletePlaylist\": {\n            \"input_confirm\": \"輸入$t(entity.playlist, {\\\"count\\\": 1})的名稱進行確認\",\n            \"title\": \"刪除$t(entity.playlist, {\\\"count\\\": 1})\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1})已成功刪除\"\n        },\n        \"editPlaylist\": {\n            \"title\": \"編輯$t(entity.playlist, {\\\"count\\\": 1})\",\n            \"publicJellyfinNote\": \"Jellyfin 出於某種原因，不會顯示播放清單是否公開。如果您希望保持公開狀態，請選擇以下輸入\",\n            \"success\": \"$t(entity.playlist, {\\\"count\\\": 1}) 更新成功\",\n            \"editNote\": \"不建議手動編輯大型播放清單，你確定要承擔覆寫現有播放清單可能造成的資料遺失風險嗎？\"\n        },\n        \"shareItem\": {\n            \"allowDownloading\": \"允許下載\",\n            \"description\": \"描述\",\n            \"setExpiration\": \"設定過期時間\",\n            \"success\": \"分享連結已複製到剪貼簿（或點擊此處開啟）\",\n            \"expireInvalid\": \"到期日必須是未來\",\n            \"createFailed\": \"無法建立分享（分享是否啟用？）\",\n            \"copyToClipboard\": \"複製到剪貼簿：Ctrl+C, Enter\",\n            \"successMustClick\": \"分享建立成功，點擊此處開啟\"\n        },\n        \"privateMode\": {\n            \"enabled\": \"已啟用私人模式，播放狀態將對外部整合隱藏\",\n            \"disabled\": \"已停用私人模式，播放狀態現對已啟用的外部整合可見\",\n            \"title\": \"私人模式\"\n        },\n        \"largeFetchConfirmation\": {\n            \"title\": \"將項目加入播放佇列\",\n            \"description\": \"此操作將新增目前篩選檢視中的所有項目\"\n        },\n        \"shuffleAll\": {\n            \"title\": \"隨機播放\",\n            \"input_genre\": \"$t(entity.genre, {\\\"count\\\": 1})\",\n            \"input_limit\": \"多少曲目？\",\n            \"input_minYear\": \"起始年份\",\n            \"input_maxYear\": \"結束年份\",\n            \"input_played\": \"播放過濾器\",\n            \"input_played_optionAll\": \"所有曲目\",\n            \"input_played_optionUnplayed\": \"僅未播放的曲目\",\n            \"input_played_optionPlayed\": \"僅播放過的曲目\"\n        },\n        \"createRadioStation\": {\n            \"success\": \"電台建立成功\",\n            \"title\": \"建立電台\",\n            \"input_homepageUrl\": \"首頁連結\",\n            \"input_name\": \"名稱\",\n            \"input_streamUrl\": \"串流網址\"\n        },\n        \"saveQueue\": {\n            \"success\": \"已將播放佇列儲存至伺服器\"\n        },\n        \"lyricsExport\": {\n            \"export\": \"匯出歌詞\",\n            \"input_synced\": \"匯出同步歌詞\",\n            \"input_offset\": \"$t(setting.lyricOffset)\"\n        }\n    },\n    \"releaseType\": {\n        \"primary\": {\n            \"album\": \"$t(entity.album, {\\\"count\\\": 1})\",\n            \"ep\": \"EP\",\n            \"other\": \"其他\",\n            \"broadcast\": \"廣播\",\n            \"single\": \"單曲\"\n        },\n        \"secondary\": {\n            \"audiobook\": \"有聲書\",\n            \"audioDrama\": \"廣播劇\",\n            \"compilation\": \"合輯\",\n            \"djMix\": \"DJ Mix\",\n            \"fieldRecording\": \"現場錄音\",\n            \"demo\": \"Demo\",\n            \"interview\": \"訪談\",\n            \"live\": \"Live\",\n            \"mixtape\": \"混音帶\",\n            \"remix\": \"Remix\",\n            \"soundtrack\": \"原聲帶\",\n            \"spokenWord\": \"訪談\"\n        }\n    },\n    \"dragDropZone\": {\n        \"error_oneFileOnly\": \"請僅選擇一個檔案\",\n        \"error_readingFile\": \"讀取檔案時發生問題：{{errorMessage}}\",\n        \"mainText\": \"將檔案拖放到此處\"\n    },\n    \"queryBuilder\": {\n        \"standardTags\": \"標準標籤\",\n        \"customTags\": \"自訂標籤\"\n    },\n    \"filterOperator\": {\n        \"after\": \"在…之後\",\n        \"afterDate\": \"晚於 (日期)\",\n        \"before\": \"在…之前\",\n        \"beforeDate\": \"早於 (日期)\",\n        \"contains\": \"包含\",\n        \"endsWith\": \"以…結尾\",\n        \"inPlaylist\": \"在…之中\",\n        \"inTheRange\": \"在範圍內\",\n        \"inTheRangeDate\": \"在（日期）範圍內\",\n        \"is\": \"是\",\n        \"isNot\": \"不是\",\n        \"isGreaterThan\": \"大於\",\n        \"isLessThan\": \"小於\",\n        \"matchesRegex\": \"符合正規表達式\",\n        \"notContains\": \"不包含\",\n        \"notInPlaylist\": \"不在…之中\",\n        \"startsWith\": \"以…開頭\",\n        \"inTheLast\": \"在最後\",\n        \"notInTheLast\": \"不在最後\"\n    },\n    \"datetime\": {\n        \"minuteShort\": \"分\",\n        \"secondShort\": \"秒\",\n        \"hourShort\": \"小時\",\n        \"dayShort\": \"天\"\n    },\n    \"visualizer\": {\n        \"visualizerType\": \"視覺化效果類型\",\n        \"cyclePresets\": \"循環切換預設\",\n        \"cycleTime\": \"循環時間 (秒)\",\n        \"includeAllPresets\": \"包含所有預設\",\n        \"ignoredPresets\": \"忽略的預設\",\n        \"selectedPresets\": \"已選取的預設\",\n        \"randomizeNextPreset\": \"隨機切換下一個預設\",\n        \"blendTime\": \"過渡時間\",\n        \"presets\": \"預設\",\n        \"selectPreset\": \"選擇預設\",\n        \"applyPreset\": \"套用預設\",\n        \"saveAsPreset\": \"儲存為預設\",\n        \"updatePreset\": \"更新預設\",\n        \"copyConfiguration\": \"複製設定\",\n        \"pasteConfiguration\": \"貼上設定\",\n        \"pasteConfigurationPlaceholder\": \"在此處貼上JSON設定...\",\n        \"pasteFromClipboard\": \"從剪貼簿貼上\",\n        \"applyConfiguration\": \"套用設定\",\n        \"configCopied\": \"設定已複製至剪貼簿\",\n        \"configCopyFailed\": \"無法複製設定\",\n        \"configPasted\": \"設定套用成功\",\n        \"configPasteFailed\": \"無法套用設定，請檢查格式。\",\n        \"configPasteReadFailed\": \"無法從剪貼簿讀取內容\",\n        \"presetName\": \"預設名稱\",\n        \"presetNamePlaceholder\": \"輸入預設名稱\",\n        \"general\": \"一般\",\n        \"mode\": \"模式\",\n        \"mode1To8\": \"模式 1 - 8\",\n        \"mode10\": \"模式 10\",\n        \"barSpace\": \"柱間距\",\n        \"lineWidth\": \"線條寬度\",\n        \"fillAlpha\": \"填充透明度\",\n        \"channelLayout\": \"聲道佈局\",\n        \"maxFPS\": \"最大幀率\",\n        \"opacity\": \"不透明度\",\n        \"customGradients\": \"自訂漸層\",\n        \"addCustomGradient\": \"新增自訂漸層\",\n        \"gradientName\": \"漸層名稱\",\n        \"gradientNamePlaceholder\": \"漸層名稱\",\n        \"vertical\": \"垂直\",\n        \"horizontal\": \"水平\",\n        \"colorStops\": \"顏色分界點\",\n        \"addColor\": \"新增顏色\",\n        \"position\": \"位置\",\n        \"remove\": \"移除\",\n        \"custom\": \"自訂\",\n        \"builtIn\": \"內建\",\n        \"colors\": \"顏色\",\n        \"colorMode\": \"顏色模式\",\n        \"gradient\": \"漸層\",\n        \"gradientLeft\": \"左側漸層\",\n        \"gradientRight\": \"右側漸層\",\n        \"fft\": \"FFT\",\n        \"fftSize\": \"FFT 取樣大小\",\n        \"smoothing\": \"平滑度\",\n        \"frequencyRangeAndScaling\": \"頻率範圍與縮放\",\n        \"minimumFrequency\": \"最低頻率\",\n        \"maximumFrequency\": \"最高頻率\",\n        \"frequencyScale\": \"頻率量表\",\n        \"sensitivity\": \"靈敏度\",\n        \"weightingFilter\": \"權重濾波器\",\n        \"minimumDecibels\": \"最小分貝\",\n        \"maximumDecibels\": \"最大分貝\",\n        \"linearAmplitude\": \"線性振幅\",\n        \"linearBoost\": \"線性增益\",\n        \"peakBehavior\": \"峰值行為\",\n        \"showPeaks\": \"顯示峰值\",\n        \"fadePeaks\": \"峰值淡出\",\n        \"peakLine\": \"峰值線條\",\n        \"gravity\": \"重力\",\n        \"peakFadeTime\": \"峰值淡出時間 (毫秒)\",\n        \"peakHoldTime\": \"峰值停留時間 (毫秒)\",\n        \"radialSpectrum\": \"圓形頻譜\",\n        \"level\": \"層級\",\n        \"pasteGradient\": \"貼上漸層\",\n        \"pasteGradientPlaceholder\": \"在這裡貼上漸層JSON...\",\n        \"radial\": \"放射\",\n        \"radialInvert\": \"反轉放射\",\n        \"spinSpeed\": \"旋轉速度\",\n        \"radius\": \"半徑\",\n        \"reflexMirror\": \"反射鏡像\",\n        \"reflexFit\": \"反射貼齊\",\n        \"reflexRatio\": \"反射比例\",\n        \"reflexAlpha\": \"反射 Alpha\",\n        \"reflexBrightness\": \"反射亮度\",\n        \"mirror\": \"鏡像\",\n        \"miscellaneousSettings\": \"雜項設定\",\n        \"alphaBars\": \"Alpha 條\",\n        \"ansiBands\": \"ASNI 波段\",\n        \"ledBars\": \"LED 條\",\n        \"trueLeds\": \"真實 LED\",\n        \"lumiBars\": \"輝光條\",\n        \"outlineBars\": \"外框條\",\n        \"roundBars\": \"圓角條\",\n        \"lowResolution\": \"低解析\",\n        \"splitGradient\": \"分割漸層\",\n        \"showFPS\": \"顯示 FPS\",\n        \"showScaleX\": \"顯示 X 軸比例\",\n        \"noteLabels\": \"音符標籤\",\n        \"showScaleY\": \"顯示Y軸比例\",\n        \"options\": {\n            \"mode\": {\n                \"0\": \"[0] 離散頻率\",\n                \"1\": \"[1] 1/24th 八度音 / 240 頻段\",\n                \"2\": \"[2] 1/12th 八度音 / 120 頻段\",\n                \"3\": \"[3] 1/8th 八度音 / 80 頻段\",\n                \"4\": \"[4] 1/6th 八度音 / 60 頻段\",\n                \"5\": \"[5] 1/4th 八度音 / 40 頻段\",\n                \"6\": \"[6] 1/3rd 八度音 / 30 頻段\",\n                \"7\": \"[7] 一半八度音 / 20 頻段\",\n                \"8\": \"[8] 完整八度音 / 10 頻段\",\n                \"10\": \"[10] 線 / 區域圖表\"\n            },\n            \"colorMode\": {\n                \"gradient\": \"梯度\",\n                \"barIndex\": \"條-指數\",\n                \"barLevel\": \"條-高度\"\n            },\n            \"gradient\": {\n                \"classic\": \"經典\",\n                \"prism\": \"菱鏡\",\n                \"rainbow\": \"彩虹\",\n                \"steelblue\": \"鋼藍\",\n                \"orangered\": \"橙紅色\"\n            },\n            \"channelLayout\": {\n                \"single\": \"單一\",\n                \"dualCombined\": \"雙重-合併\",\n                \"dualHorizontal\": \"雙重-水平\",\n                \"dualVertical\": \"雙重-垂直\"\n            },\n            \"frequencyScale\": {\n                \"none\": \"無\",\n                \"bark\": \"比例刻度\",\n                \"linear\": \"線性比例\",\n                \"log\": \"Log 比例\",\n                \"mel\": \"Mel 比例\"\n            },\n            \"weightingFilter\": {\n                \"none\": \"無\",\n                \"a\": \"A\",\n                \"b\": \"B\",\n                \"c\": \"C\",\n                \"d\": \"D\",\n                \"z\": \"Z\"\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "src/main/features/core/autodiscover/index.ts",
    "content": "import { createSocket } from 'dgram';\nimport { ipcMain } from 'electron';\n\nimport { DiscoveredServerItem, ServerType } from '/@/shared/types/types';\n\ntype JellyfinResponse = {\n    Address: string;\n    Id: string;\n    Name: string;\n};\n\nfunction discoverAll(reply: (server: DiscoveredServerItem) => void) {\n    return Promise.all([discoverJellyfin(reply)]);\n}\n\nfunction discoverJellyfin(reply: (server: DiscoveredServerItem) => void) {\n    const sock = createSocket('udp4');\n    sock.on('message', (msg) => {\n        try {\n            const response: JellyfinResponse = JSON.parse(msg.toString('utf-8'));\n\n            reply({\n                name: response.Name,\n                type: ServerType.JELLYFIN,\n                url: response.Address,\n            });\n        } catch (e) {\n            // Got a spurious response, ignore?\n            console.error(e);\n        }\n    });\n\n    sock.bind(() => {\n        sock.setBroadcast(true);\n        // Send a broadcast packet to both loopback and default route, allowing discovery of same-machine instances\n        sock.send('who is JellyfinServer?', 7359, '127.255.255.255');\n        sock.send('who is JellyfinServer?', 7359, '255.255.255.255');\n    });\n\n    return new Promise<void>((resolve) => {\n        setTimeout(() => {\n            sock.close();\n            resolve();\n        }, 3000);\n    });\n}\n\nipcMain.on('autodiscover-ping', (ev) => {\n    if (ev.ports.length === 0) throw new Error('Expected a port to stream autodiscovery results');\n    const port = ev.ports[0];\n\n    discoverAll((result) => port.postMessage(result))\n        .then(() => port.close())\n        .catch((err) => console.error(err));\n});\n"
  },
  {
    "path": "src/main/features/core/discord-rpc/index.ts",
    "content": "import { Client, SetActivity } from '@xhayper/discord-rpc';\nimport { ipcMain } from 'electron';\n\nconst FEISHIN_DISCORD_APPLICATION_ID = '1165957668758900787';\n\nlet client: Client | null = null;\n\nconst createClient = async (clientId?: string) => {\n    client = new Client({\n        clientId: clientId || FEISHIN_DISCORD_APPLICATION_ID,\n    });\n\n    await client.login();\n\n    return client;\n};\n\nconst isConnected = () => {\n    return client?.isConnected;\n};\n\nconst setActivity = (activity: SetActivity) => {\n    if (client) {\n        client.user?.setActivity({\n            ...activity,\n        });\n    }\n};\n\nconst clearActivity = () => {\n    if (client) {\n        client.user?.clearActivity();\n    }\n};\n\nconst quit = () => {\n    if (client) {\n        client?.destroy();\n    }\n};\n\nipcMain.handle('discord-rpc-initialize', async (_event, clientId?: string) => {\n    await createClient(clientId);\n});\n\nipcMain.handle('discord-rpc-is-connected', () => {\n    return isConnected();\n});\n\nipcMain.handle('discord-rpc-set-activity', (_event, activity: SetActivity) => {\n    if (client) {\n        setActivity(activity);\n    }\n});\n\nipcMain.handle('discord-rpc-clear-activity', () => {\n    if (client) {\n        clearActivity();\n    }\n});\n\nipcMain.handle('discord-rpc-quit', () => {\n    quit();\n});\n\nexport const discordRpc = {\n    clearActivity,\n    createClient,\n    isConnected,\n    quit,\n    setActivity,\n};\n"
  },
  {
    "path": "src/main/features/core/index.ts",
    "content": "import './autodiscover';\nimport './lyrics';\nimport './player';\nimport './remote';\nimport './settings';\nimport './discord-rpc';\n"
  },
  {
    "path": "src/main/features/core/lyrics/genius.ts",
    "content": "import axios, { AxiosResponse } from 'axios';\nimport { load } from 'cheerio';\n\nimport {\n    InternetProviderLyricResponse,\n    InternetProviderLyricSearchResponse,\n    LyricSearchQuery,\n    LyricSource,\n} from '.';\nimport { orderSearchResults } from './shared';\n\nconst SEARCH_URL = 'https://genius.com/api/search/song';\n\n// Adapted from https://github.com/NyaomiDEV/Sunamu/blob/master/src/main/lyricproviders/genius.ts\n\nexport interface GeniusResponse {\n    meta: Meta;\n    response: Response;\n}\n\nexport interface Hit {\n    highlights: any[];\n    index: string;\n    result: Result;\n    type: string;\n}\n\nexport interface Meta {\n    status: number;\n}\n\nexport interface PrimaryArtist {\n    _type: string;\n    api_path: string;\n    header_image_url: string;\n    id: number;\n    image_url: string;\n    index_character: string;\n    is_meme_verified: boolean;\n    is_verified: boolean;\n    name: string;\n    slug: string;\n    url: string;\n}\n\nexport interface ReleaseDateComponents {\n    day: number;\n    month: number;\n    year: number;\n}\n\nexport interface Response {\n    next_page: number;\n    sections: Section[];\n}\n\nexport interface Result {\n    _type: string;\n    annotation_count: number;\n    api_path: string;\n    artist_names: string;\n    featured_artists: any[];\n    full_title: string;\n    header_image_thumbnail_url: string;\n    header_image_url: string;\n    id: number;\n    instrumental: boolean;\n    language: string;\n    lyrics_owner_id: number;\n    lyrics_state: string;\n    lyrics_updated_at: number;\n    path: string;\n    primary_artist: PrimaryArtist;\n    pyongs_count: null;\n    relationships_index_url: string;\n    release_date_components: ReleaseDateComponents;\n    release_date_for_display: string;\n    release_date_with_abbreviated_month_for_display: string;\n    song_art_image_thumbnail_url: string;\n    song_art_image_url: string;\n    stats: Stats;\n    title: string;\n    title_with_featured: string;\n    updated_by_human_at: number;\n    url: string;\n}\n\nexport interface Section {\n    hits: Hit[];\n    type: string;\n}\n\nexport interface Stats {\n    hot: boolean;\n    unreviewed_annotations: number;\n}\n\nexport async function getLyricsBySongId(url: string): Promise<null | string> {\n    let result: AxiosResponse<string, any>;\n    try {\n        result = await axios.get<string>(url, { responseType: 'text' });\n    } catch (e) {\n        console.error('Genius lyrics request got an error!', (e as Error)?.message);\n        return null;\n    }\n\n    const $ = load(result.data.split('<br/>').join('\\n'));\n    const lyricsDiv = $('div.lyrics');\n\n    if (lyricsDiv.length > 0) return lyricsDiv.text().trim();\n\n    const lyricSections = $('div[data-lyrics-container=\"true\"]')\n        .map((_, e) => {\n            $(e).find('[data-exclude-from-selection=\"true\"]').remove();\n            return $(e).text();\n        })\n        .toArray()\n        .join('\\n');\n    return lyricSections;\n}\n\nexport async function getSearchResults(\n    params: LyricSearchQuery,\n): Promise<InternetProviderLyricSearchResponse[] | null> {\n    let result: AxiosResponse<GeniusResponse>;\n\n    const searchQuery = [params.artist, params.name].join(' ');\n\n    if (!searchQuery) {\n        return null;\n    }\n\n    try {\n        result = await axios.get(SEARCH_URL, {\n            params: {\n                per_page: '5',\n                q: searchQuery,\n            },\n        });\n    } catch (e) {\n        console.error('Genius search request got an error!', (e as Error)?.message);\n        return null;\n    }\n\n    const rawSongsResult = result.data.response?.sections?.[0]?.hits?.map((hit) => hit.result);\n\n    if (!rawSongsResult) return null;\n\n    const songResults: InternetProviderLyricSearchResponse[] = rawSongsResult.map((song) => {\n        return {\n            artist: song.artist_names,\n            id: song.url,\n            isSync: null,\n            name: song.full_title,\n            source: LyricSource.GENIUS,\n        };\n    });\n\n    return orderSearchResults({ params, results: songResults });\n}\n\nexport async function query(\n    params: LyricSearchQuery,\n): Promise<InternetProviderLyricResponse | null> {\n    const response = await getSongId(params);\n    if (!response) {\n        return null;\n    }\n\n    const lyrics = await getLyricsBySongId(response.id);\n    if (!lyrics) {\n        return null;\n    }\n\n    return {\n        artist: response.artist,\n        id: response.id,\n        lyrics,\n        name: response.name,\n        source: LyricSource.GENIUS,\n    };\n}\n\nasync function getSongId(\n    params: LyricSearchQuery,\n): Promise<null | Omit<InternetProviderLyricResponse, 'lyrics'>> {\n    let result: AxiosResponse<GeniusResponse>;\n    try {\n        result = await axios.get(SEARCH_URL, {\n            params: {\n                per_page: '1',\n                q: `${params.artist} ${params.name}`,\n            },\n        });\n    } catch (e) {\n        console.error('Genius search request got an error!', (e as Error)?.message);\n        return null;\n    }\n\n    const hit = result.data.response?.sections?.[0]?.hits?.[0]?.result;\n\n    if (!hit) {\n        return null;\n    }\n\n    return {\n        artist: hit.artist_names,\n        id: hit.url,\n        name: hit.title,\n        source: LyricSource.GENIUS,\n    };\n}\n"
  },
  {
    "path": "src/main/features/core/lyrics/index.ts",
    "content": "import { ipcMain } from 'electron';\n\nimport { store } from '../settings';\nimport { getLyricsBySongId as getGenius, getSearchResults as searchGenius } from './genius';\nimport { getLyricsBySongId as getLrcLib, getSearchResults as searchLrcLib } from './lrclib';\nimport { getLyricsBySongId as getNetease, getSearchResults as searchNetease } from './netease';\nimport { orderSearchResults } from './shared';\nimport {\n    getLyricsBySongId as getSimpMusic,\n    getSearchResults as searchSimpMusic,\n} from './simpmusic';\n\nimport { Song } from '/@/shared/types/domain-types';\n\nexport enum LyricSource {\n    GENIUS = 'Genius',\n    LRCLIB = 'lrclib.net',\n    NETEASE = 'NetEase',\n    SIMPMUSIC = 'SimpMusic',\n}\n\nexport type FullLyricsMetadata = Omit<InternetProviderLyricResponse, 'id' | 'lyrics' | 'source'> & {\n    lyrics: LyricsResponse;\n    remote: boolean;\n    source: string;\n};\n\nexport type InternetProviderLyricResponse = {\n    artist: string;\n    id: string;\n    lyrics: string;\n    name: string;\n    source: LyricSource;\n};\n\nexport type InternetProviderLyricSearchResponse = {\n    artist: string;\n    id: string;\n    isSync: boolean | null;\n    name: string;\n    score?: number;\n    source: LyricSource;\n};\n\nexport type LyricGetQuery = {\n    remoteSongId: string;\n    remoteSource: LyricSource;\n    song: Song;\n};\n\nexport type LyricOverride = Omit<InternetProviderLyricResponse, 'lyrics'>;\n\nexport type LyricSearchQuery = {\n    album?: string;\n    artist?: string;\n    duration?: number;\n    name?: string;\n};\n\nexport type LyricsResponse = string | SynchronizedLyricsArray;\n\nexport type SynchronizedLyricsArray = Array<[number, string]>;\n\ntype CachedLyrics = Record<LyricSource, InternetProviderLyricResponse>;\ntype GetFetcher = (id: string) => Promise<null | string>;\ntype SearchFetcher = (\n    params: LyricSearchQuery,\n) => Promise<InternetProviderLyricSearchResponse[] | null>;\n\nconst SEARCH_FETCHERS: Record<LyricSource, SearchFetcher> = {\n    [LyricSource.GENIUS]: searchGenius,\n    [LyricSource.LRCLIB]: searchLrcLib,\n    [LyricSource.NETEASE]: searchNetease,\n    [LyricSource.SIMPMUSIC]: searchSimpMusic,\n};\n\nconst GET_FETCHERS: Record<LyricSource, GetFetcher> = {\n    [LyricSource.GENIUS]: getGenius,\n    [LyricSource.LRCLIB]: getLrcLib,\n    [LyricSource.NETEASE]: getNetease,\n    [LyricSource.SIMPMUSIC]: getSimpMusic,\n};\n\nconst MAX_CACHED_ITEMS = 10;\n\nconst lyricCache = new Map<string, CachedLyrics>();\n\nconst searchAllSources = async (\n    params: LyricSearchQuery,\n): Promise<InternetProviderLyricSearchResponse[]> => {\n    const sources = store.get('lyrics', []) as LyricSource[];\n\n    const searchPromises = sources.map((source) =>\n        SEARCH_FETCHERS[source](params).then((searchResults) => ({ searchResults, source })),\n    );\n\n    const settled = await Promise.allSettled(searchPromises);\n\n    const allSearchResults: InternetProviderLyricSearchResponse[] = [];\n\n    for (const result of settled) {\n        if (result.status === 'fulfilled' && result.value.searchResults) {\n            allSearchResults.push(...result.value.searchResults);\n        } else if (result.status === 'rejected') {\n            const index = settled.indexOf(result);\n            console.error(`Error searching ${sources[index]} for lyrics:`, result.reason);\n        }\n    }\n    return allSearchResults;\n};\n\nconst getRemoteLyrics = async (song: Song) => {\n    const sources = store.get('lyrics', []) as LyricSource[];\n\n    const cached = lyricCache.get(song.id.toString());\n\n    if (cached) {\n        for (const source of sources) {\n            const data = cached[source];\n            if (data) return data;\n        }\n    }\n\n    const params: LyricSearchQuery = {\n        album: song.album || song.name,\n        artist: song.artists[0].name,\n        duration: song.duration / 1000.0,\n        name: song.name,\n    };\n\n    const allSearchResults = await searchAllSources(params);\n\n    if (allSearchResults.length === 0) {\n        return null;\n    }\n\n    const rankedResults = orderSearchResults({\n        params,\n        results: allSearchResults,\n    });\n\n    const bestMatch = rankedResults[0];\n\n    if (!bestMatch) {\n        return null;\n    }\n\n    // Score is 0-1 where 0 = perfect match, 1 = worst match\n    const matchThreshold = 0.55;\n    const matchScore = bestMatch.score ?? 1;\n\n    if (matchScore > matchThreshold) {\n        return null;\n    }\n\n    let lyricsFromSource: InternetProviderLyricResponse | null = null;\n\n    try {\n        const lyrics = await GET_FETCHERS[bestMatch.source](bestMatch.id);\n        if (lyrics) {\n            lyricsFromSource = {\n                artist: bestMatch.artist,\n                id: bestMatch.id,\n                lyrics,\n                name: bestMatch.name,\n                source: bestMatch.source,\n            };\n        }\n    } catch (error) {\n        console.error(`Error fetching lyrics from ${bestMatch.source}:`, error);\n    }\n\n    if (lyricsFromSource) {\n        const newResult = cached\n            ? {\n                  ...cached,\n                  [lyricsFromSource.source]: lyricsFromSource,\n              }\n            : ({ [lyricsFromSource.source]: lyricsFromSource } as CachedLyrics);\n\n        if (lyricCache.size === MAX_CACHED_ITEMS && cached === undefined) {\n            const toRemove = lyricCache.keys().next().value;\n            if (toRemove) {\n                lyricCache.delete(toRemove);\n            }\n        }\n\n        lyricCache.set(song.id.toString(), newResult);\n    }\n\n    return lyricsFromSource;\n};\n\nconst searchRemoteLyrics = async (params: LyricSearchQuery) => {\n    const allSearchResults = await searchAllSources(params);\n\n    const results: Record<LyricSource, InternetProviderLyricSearchResponse[]> = {\n        [LyricSource.GENIUS]: [],\n        [LyricSource.LRCLIB]: [],\n        [LyricSource.NETEASE]: [],\n        [LyricSource.SIMPMUSIC]: [],\n    };\n    for (const item of allSearchResults) {\n        results[item.source].push(item);\n    }\n    return results;\n};\n\nconst getRemoteLyricsById = async (params: LyricGetQuery): Promise<null | string> => {\n    const { remoteSongId, remoteSource } = params;\n    const response = await GET_FETCHERS[remoteSource](remoteSongId);\n\n    if (!response) {\n        return null;\n    }\n\n    return response;\n};\n\nipcMain.handle('lyric-by-song', async (_event, song: any) => {\n    const lyric = await getRemoteLyrics(song);\n    return lyric;\n});\n\nipcMain.handle('lyric-search', async (_event, params: LyricSearchQuery) => {\n    const lyricResults = await searchRemoteLyrics(params);\n    return lyricResults;\n});\n\nipcMain.handle('lyric-by-remote-id', async (_event, params: LyricGetQuery) => {\n    const lyricResults = await getRemoteLyricsById(params);\n    return lyricResults;\n});\n"
  },
  {
    "path": "src/main/features/core/lyrics/lrclib.ts",
    "content": "// Credits to https://github.com/tranxuanthang/lrcget for API implementation\nimport axios, { AxiosResponse } from 'axios';\n\nimport {\n    InternetProviderLyricResponse,\n    InternetProviderLyricSearchResponse,\n    LyricSearchQuery,\n    LyricSource,\n} from '.';\nimport { orderSearchResults } from './shared';\n\nconst FETCH_URL = 'https://lrclib.net/api/get';\nconst SEEARCH_URL = 'https://lrclib.net/api/search';\n\nconst TIMEOUT_MS = 5000;\n\nexport interface LrcLibSearchResponse {\n    albumName: string;\n    artistName: string;\n    duration?: number;\n    id: number;\n    instrumental?: boolean;\n    name: string;\n    plainLyrics: null | string;\n    syncedLyrics: null | string;\n}\n\nexport interface LrcLibTrackResponse {\n    albumName: string;\n    artistName: string;\n    duration: number;\n    id: number;\n    instrumental: boolean;\n    isrc: string;\n    lang: string;\n    name: string;\n    plainLyrics: null | string;\n    releaseDate: string;\n    spotifyId: string;\n    syncedLyrics: null | string;\n}\n\nexport async function getLyricsBySongId(songId: string): Promise<null | string> {\n    let result: AxiosResponse<LrcLibTrackResponse, any>;\n\n    try {\n        result = await axios.get<LrcLibTrackResponse>(`${FETCH_URL}/${songId}`);\n    } catch (e) {\n        console.error('LrcLib lyrics request got an error!', (e as Error)?.message);\n        return null;\n    }\n\n    return result.data.syncedLyrics || result.data.plainLyrics || null;\n}\n\nexport async function getSearchResults(\n    params: LyricSearchQuery,\n): Promise<InternetProviderLyricSearchResponse[] | null> {\n    let result: AxiosResponse<LrcLibSearchResponse[]>;\n\n    if (!params.name) {\n        return null;\n    }\n\n    try {\n        result = await axios.get<LrcLibSearchResponse[]>(SEEARCH_URL, {\n            params: {\n                q: params.name,\n            },\n        });\n    } catch (e) {\n        console.error('LrcLib search request got an error!', (e as Error)?.message);\n        return null;\n    }\n\n    if (!result.data) return null;\n\n    const songResults: InternetProviderLyricSearchResponse[] = result.data.map((song) => {\n        return {\n            artist: song.artistName,\n            id: String(song.id),\n            isSync: song.syncedLyrics ? true : false,\n            name: song.name,\n            source: LyricSource.LRCLIB,\n        };\n    });\n\n    return orderSearchResults({ params, results: songResults });\n}\n\nexport async function query(\n    params: LyricSearchQuery,\n): Promise<InternetProviderLyricResponse | null> {\n    let result: AxiosResponse<LrcLibTrackResponse, any>;\n\n    try {\n        result = await axios.get<LrcLibTrackResponse>(FETCH_URL, {\n            headers: {\n                'User-Agent': 'LRCGET v0.2.0 (https://github.com/jeffvli/feishin)',\n            },\n            params: {\n                album_name: params.album,\n                artist_name: params.artist,\n                duration: params.duration,\n                track_name: params.name,\n            },\n            timeout: TIMEOUT_MS,\n        });\n    } catch (e) {\n        console.error('LrcLib search request got an error!', (e as Error).message);\n        return null;\n    }\n\n    const lyrics = result.data.syncedLyrics || result.data.plainLyrics || null;\n\n    if (!lyrics) {\n        return null;\n    }\n\n    return {\n        artist: result.data.artistName,\n        id: String(result.data.id),\n        lyrics,\n        name: result.data.name,\n        source: LyricSource.LRCLIB,\n    };\n}\n"
  },
  {
    "path": "src/main/features/core/lyrics/netease.ts",
    "content": "import axios, { AxiosResponse } from 'axios';\n\nimport {\n    InternetProviderLyricResponse,\n    InternetProviderLyricSearchResponse,\n    LyricSearchQuery,\n    LyricSource,\n} from '.';\nimport { store } from '../settings';\nimport { orderSearchResults } from './shared';\n\nconst SEARCH_URL = 'https://music.163.com/api/search/get';\nconst LYRICS_URL = 'https://music.163.com/api/song/lyric';\n\n// Adapted from https://github.com/NyaomiDEV/Sunamu/blob/master/src/main/lyricproviders/netease.ts\n\nexport interface Result {\n    hasMore: boolean;\n    songCount: number;\n    songs: Song[];\n}\n\ninterface Album {\n    artist: Artist;\n    copyrightId: number;\n    id: number;\n    mark: number;\n    name: string;\n    picId: number;\n    publishTime: number;\n    size: number;\n    status: number;\n    transNames?: string[];\n}\n\ninterface Artist {\n    albumSize: number;\n    alias: any[];\n    fansGroup: null;\n    id: number;\n    img1v1: number;\n    img1v1Url: string;\n    name: string;\n    picId: number;\n    picUrl: null;\n    trans: null;\n}\n\ninterface NetEaseResponse {\n    code: number;\n    result: Result;\n}\n\ninterface Song {\n    album: Album;\n    alias: string[];\n    artists: Artist[];\n    copyrightId: number;\n    duration: number;\n    fee: number;\n    ftype: number;\n    id: number;\n    mark: number;\n    mvid: number;\n    name: string;\n    rtype: number;\n    rUrl: null;\n    status: number;\n    transNames?: string[];\n}\n\nexport async function getLyricsBySongId(songId: string): Promise<null | string> {\n    let result: AxiosResponse<any, any>;\n    try {\n        result = await axios.get(LYRICS_URL, {\n            params: {\n                id: songId,\n                kv: '-1',\n                lv: '-1',\n                tv: '-1',\n            },\n        });\n    } catch (e) {\n        console.error('NetEase lyrics request got an error!', e);\n        return null;\n    }\n    const enableTranslation = store.get('enableNeteaseTranslation', false) as boolean;\n    const originalLrc = result.data.lrc?.lyric;\n    if (!enableTranslation) {\n        return originalLrc || null;\n    }\n    const translatedLrc = result.data.tlyric?.lyric;\n    return mergeLyrics(originalLrc, translatedLrc);\n}\n\nexport async function getSearchResults(\n    params: LyricSearchQuery,\n): Promise<InternetProviderLyricSearchResponse[] | null> {\n    let result: AxiosResponse<NetEaseResponse>;\n\n    const searchQuery = [params.artist, params.name].join(' ');\n\n    if (!searchQuery) {\n        return null;\n    }\n\n    try {\n        result = await axios.get(SEARCH_URL, {\n            params: {\n                limit: 5,\n                offset: 0,\n                s: searchQuery,\n                type: '1',\n            },\n        });\n    } catch (e) {\n        console.error('NetEase search request got an error!', e);\n        return null;\n    }\n\n    const rawSongsResult = result?.data.result?.songs;\n\n    if (!rawSongsResult) return null;\n\n    const songResults: InternetProviderLyricSearchResponse[] = rawSongsResult.map((song) => {\n        const artist = song.artists ? song.artists.map((artist) => artist.name).join(', ') : '';\n\n        return {\n            artist,\n            id: String(song.id),\n            isSync: null,\n            name: song.name,\n            source: LyricSource.NETEASE,\n        };\n    });\n\n    return orderSearchResults({ params, results: songResults });\n}\n\nexport async function query(\n    params: LyricSearchQuery,\n): Promise<InternetProviderLyricResponse | null> {\n    const lyricsMatch = await getMatchedLyrics(params);\n    if (!lyricsMatch) {\n        return null;\n    }\n\n    const lyrics = await getLyricsBySongId(lyricsMatch.id);\n    if (!lyrics) {\n        return null;\n    }\n\n    return {\n        artist: lyricsMatch.artist,\n        id: lyricsMatch.id,\n        lyrics,\n        name: lyricsMatch.name,\n        source: LyricSource.NETEASE,\n    };\n}\n\nasync function getMatchedLyrics(\n    params: LyricSearchQuery,\n): Promise<null | Omit<InternetProviderLyricResponse, 'lyrics'>> {\n    const results = await getSearchResults(params);\n\n    const firstMatch = results?.[0];\n\n    if (!firstMatch || (firstMatch?.score && firstMatch.score > 0.5)) {\n        return null;\n    }\n\n    return firstMatch;\n}\n\nfunction mergeLyrics(original: string | undefined, translated: string | undefined): null | string {\n    if (!original) {\n        return null;\n    }\n    if (!translated) {\n        return original;\n    }\n\n    const lrcLineRegex = /\\[(\\d{2}:\\d{2}\\.\\d{2,3})\\](.*)/;\n    const translatedMap = new Map<string, string>();\n\n    // Parse the translated LRC and store it in a Map for efficient timestamp-based lookups.\n    translated.split('\\n').forEach((line) => {\n        const match = line.match(lrcLineRegex);\n        if (match) {\n            const timestamp = match[1];\n            const text = match[2].trim();\n            if (text) {\n                translatedMap.set(timestamp, text);\n            }\n        }\n    });\n\n    if (translatedMap.size === 0) {\n        return original;\n    }\n\n    // Iterate through each line of the original LRC. If a translation exists for the same timestamp, append the translated text after the original text.\n    const finalLines = original.split('\\n').map((line) => {\n        const match = line.match(lrcLineRegex);\n\n        if (match) {\n            const timestamp = match[1];\n            const originalText = match[2].trim();\n            const translatedText = translatedMap.get(timestamp);\n\n            if (translatedText && originalText) {\n                // Append and add a break delimiter to separate the original and translated text\n                return [`[${timestamp}]${originalText}`, translatedText].join('_BREAK_');\n            }\n        }\n\n        // If no match or no translation is found, return the original line unchanged.\n        return line;\n    });\n\n    return finalLines.join('\\n');\n}\n"
  },
  {
    "path": "src/main/features/core/lyrics/shared.ts",
    "content": "import Fuse, { FuseResult, IFuseOptions } from 'fuse.js';\n\nimport {\n    InternetProviderLyricSearchResponse,\n    LyricSearchQuery,\n} from '/@/shared/types/domain-types';\n\nexport const orderSearchResults = (args: {\n    params: LyricSearchQuery;\n    results: InternetProviderLyricSearchResponse[];\n}) => {\n    const { params, results } = args;\n\n    const options: IFuseOptions<InternetProviderLyricSearchResponse> = {\n        fieldNormWeight: 1,\n        includeScore: true,\n        keys: [\n            { getFn: (song) => song.name, name: 'name', weight: 2 },\n            { getFn: (song) => song.artist, name: 'artist', weight: 2 },\n        ],\n        threshold: 0.6,\n    };\n\n    const fuse = new Fuse(results, options);\n\n    let searchResults: Array<FuseResult<InternetProviderLyricSearchResponse>>;\n\n    if (params.artist && params.name) {\n        const artistFuse = new Fuse(results, {\n            includeScore: true,\n            keys: [{ getFn: (song) => song.artist, name: 'artist' }],\n            threshold: 0.6,\n        });\n\n        const nameFuse = new Fuse(results, {\n            includeScore: true,\n            keys: [{ getFn: (song) => song.name, name: 'name' }],\n            threshold: 0.6,\n        });\n\n        const artistResults = artistFuse.search(params.artist);\n        const nameResults = nameFuse.search(params.name);\n\n        const artistScores = new Map(artistResults.map((r) => [r.item.id, r.score ?? 1]));\n        const nameScores = new Map(nameResults.map((r) => [r.item.id, r.score ?? 1]));\n\n        const combinedResults = new Map<string, FuseResult<InternetProviderLyricSearchResponse>>();\n\n        artistResults.forEach((result) => {\n            const nameScore = nameScores.get(result.item.id);\n            if (nameScore !== undefined) {\n                const combinedScore = Math.max(result.score ?? 1, nameScore);\n                combinedResults.set(result.item.id, {\n                    ...result,\n                    score: combinedScore,\n                });\n            }\n        });\n\n        nameResults.forEach((result) => {\n            if (!combinedResults.has(result.item.id)) {\n                const artistScore = artistScores.get(result.item.id);\n                if (artistScore !== undefined) {\n                    const combinedScore = Math.max(result.score ?? 1, artistScore);\n                    combinedResults.set(result.item.id, {\n                        ...result,\n                        score: combinedScore,\n                    });\n                }\n            }\n        });\n\n        searchResults = Array.from(combinedResults.values());\n    } else {\n        searchResults = fuse.search<InternetProviderLyricSearchResponse>({\n            ...(params.artist && { artist: params.artist }),\n            ...(params.name && { name: params.name }),\n        });\n    }\n\n    const sortedResults = searchResults.sort((a, b) => {\n        const aIsSync = a.item.isSync === true ? 1 : 0;\n        const bIsSync = b.item.isSync === true ? 1 : 0;\n\n        if (aIsSync !== bIsSync) {\n            return bIsSync - aIsSync;\n        }\n\n        return (a.score || 0) - (b.score || 0);\n    });\n\n    return sortedResults.map((result) => ({\n        ...result.item,\n        score: result.score,\n    }));\n};\n"
  },
  {
    "path": "src/main/features/core/lyrics/simpmusic.ts",
    "content": "import axios, { AxiosResponse } from 'axios';\n\nimport {\n    InternetProviderLyricResponse,\n    InternetProviderLyricSearchResponse,\n    LyricSearchQuery,\n    LyricSource,\n} from '.';\nimport { orderSearchResults } from './shared';\n\nconst API_URL = 'https://api-lyrics.simpmusic.org/v1';\n\nconst TIMEOUT_MS = 5000;\n\nexport interface SimpMusicLyric {\n    albumName?: string;\n    artistName: string;\n    durationSeconds?: number;\n    id: string;\n    plainLyric?: string;\n    richSyncLyrics?: string;\n    songTitle: string;\n    syncedLyrics?: string;\n    videoId: string;\n    vote?: number;\n}\n\nexport interface SimpMusicSearchResponse {\n    data: SimpMusicLyric[];\n    success: boolean;\n}\n\nexport async function getLyricsBySongId(songId: string): Promise<null | string> {\n    let result: AxiosResponse;\n\n    try {\n        result = await axios.get(`${API_URL}/${songId}`, {\n            timeout: TIMEOUT_MS,\n        });\n    } catch (e) {\n        console.error('SimpMusic lyrics request errored:', (e as Error)?.message);\n        return null;\n    }\n\n    const firstLyric = (result.data.data?.[0] ?? null) as null | SimpMusicLyric;\n    if (!firstLyric) return null;\n\n    return firstLyric.syncedLyrics || firstLyric.plainLyric || null;\n}\n\nexport async function getSearchResults(\n    params: LyricSearchQuery,\n): Promise<InternetProviderLyricSearchResponse[] | null> {\n    let result: AxiosResponse<SimpMusicSearchResponse>;\n\n    if (!params.name) return null;\n\n    try {\n        result = await axios.get<SimpMusicSearchResponse>(`${API_URL}/search`, {\n            params: {\n                q: params.name,\n            },\n            timeout: TIMEOUT_MS,\n        });\n    } catch (e) {\n        console.error('SimpMusic search errored:', (e as Error)?.message);\n        return null;\n    }\n\n    if (!result.data?.data) return null;\n\n    const songResults: InternetProviderLyricSearchResponse[] = result.data.data.map((song) => ({\n        artist: song.artistName,\n        id: song.videoId,\n        isSync: song.syncedLyrics ? true : false,\n        name: song.songTitle,\n        source: LyricSource.SIMPMUSIC,\n    }));\n\n    return orderSearchResults({ params, results: songResults });\n}\n\nexport async function query(\n    params: LyricSearchQuery,\n): Promise<InternetProviderLyricResponse | null> {\n    if (!params.name) return null;\n\n    let search: AxiosResponse<SimpMusicSearchResponse>;\n\n    try {\n        search = await axios.get<SimpMusicSearchResponse>(`${API_URL}/search`, {\n            params: {\n                q: params.name,\n            },\n            timeout: TIMEOUT_MS,\n        });\n    } catch (e) {\n        console.error('SimpMusic search errored:', (e as Error).message);\n        return null;\n    }\n\n    const first = search.data?.data?.[0];\n    if (!first) return null;\n\n    let lyric: AxiosResponse<SimpMusicLyric>;\n\n    try {\n        lyric = await axios.get<SimpMusicLyric>(`${API_URL}/${first.videoId}`, {\n            timeout: TIMEOUT_MS,\n        });\n    } catch (e) {\n        console.error('SimpMusic lyrics fetch errored:', (e as Error).message);\n        return null;\n    }\n\n    const lyrics = lyric.data.syncedLyrics || lyric.data.plainLyric || null;\n    if (!lyrics) return null;\n\n    return {\n        artist: lyric.data.artistName,\n        id: lyric.data.videoId,\n        lyrics,\n        name: lyric.data.songTitle,\n        source: LyricSource.SIMPMUSIC,\n    };\n}\n"
  },
  {
    "path": "src/main/features/core/player/index.ts",
    "content": "import console from 'console';\nimport { app, ipcMain } from 'electron';\nimport { rm } from 'fs/promises';\nimport uniq from 'lodash/uniq';\nimport MpvAPI from 'node-mpv';\nimport { pid } from 'node:process';\nimport process from 'process';\n\nimport { getMainWindow, sendToastToRenderer } from '../../../index';\nimport { createLog, isWindows } from '../../../utils';\nimport { store } from '../settings';\n\nimport { PlayerData } from '/@/shared/types/domain-types';\n\ndeclare module 'node-mpv';\n\n// function wait(timeout: number) {\n//     return new Promise((resolve) => {\n//         setTimeout(() => {\n//             resolve('resolved');\n//         }, timeout);\n//     });\n// }\n\nlet mpvInstance: MpvAPI | null = null;\nlet currentPlayerData: null | PlayerData = null;\nconst socketPath = isWindows() ? `\\\\\\\\.\\\\pipe\\\\mpvserver-${pid}` : `/tmp/node-mpv-${pid}.sock`;\n\nconst NodeMpvErrorCode = {\n    0: 'Unable to load file or stream',\n    1: 'Invalid argument',\n    2: 'Binary not found',\n    3: 'IPC command invalid',\n    4: 'Unable to bind IPC socket',\n    5: 'Connection timeout',\n    6: 'MPV is already running',\n    7: 'Could not send IPC message',\n    8: 'MPV is not running',\n    9: 'Unsupported protocol',\n};\n\ntype NodeMpvError = {\n    errcode: number;\n    method: string;\n    stackTrace: string;\n    verbose: string;\n};\n\nconst mpvLog = (\n    data: { action: string; toast?: 'info' | 'success' | 'warning' },\n    err?: NodeMpvError,\n) => {\n    const { action, toast } = data;\n\n    if (err) {\n        const message = `[AUDIO PLAYER] ${action} - mpv errorcode ${err.errcode} - ${\n            NodeMpvErrorCode[err.errcode as keyof typeof NodeMpvErrorCode]\n        }`;\n\n        sendToastToRenderer({ message, type: 'error' });\n        createLog({ message, type: 'error' });\n    }\n\n    const message = `[AUDIO PLAYER] ${action}`;\n    createLog({ message, type: 'error' });\n    if (toast) {\n        sendToastToRenderer({ message, type: toast });\n    }\n};\n\nconst MPV_BINARY_PATH = store.get('mpv_path') as string | undefined;\n\nconst prefetchPlaylistParams = [\n    '--prefetch-playlist=no',\n    '--prefetch-playlist=yes',\n    '--prefetch-playlist',\n];\n\nconst DEFAULT_MPV_PARAMETERS = (extraParameters?: string[]) => {\n    const parameters = ['--idle=yes', '--no-config', '--load-scripts=no'];\n\n    if (!extraParameters?.some((param) => prefetchPlaylistParams.includes(param))) {\n        parameters.push('--prefetch-playlist=yes');\n    }\n\n    return parameters;\n};\n\nconst createMpv = async (data: {\n    binaryPath?: string;\n    extraParameters?: string[];\n    properties?: Record<string, any>;\n}): Promise<MpvAPI> => {\n    const { binaryPath, extraParameters, properties } = data;\n\n    const params = uniq([...DEFAULT_MPV_PARAMETERS(extraParameters), ...(extraParameters || [])]);\n\n    const mpv = new MpvAPI(\n        {\n            audio_only: true,\n            auto_restart: false,\n            binary: binaryPath || MPV_BINARY_PATH || undefined,\n            socket: socketPath,\n            time_update: 1,\n        },\n        params,\n    );\n\n    try {\n        await mpv.start();\n    } catch (error: any) {\n        console.error('mpv failed to start', error);\n    } finally {\n        await mpv.setMultipleProperties(properties || {});\n    }\n\n    mpv.on('status', (status) => {\n        if (status.property === 'playlist-pos') {\n            // mpv uses playlist-pos = -1 when nothing is playing (ended, cleared, load failure, etc).\n            if (status.value === -1) {\n                mpv?.pause();\n                return;\n            }\n\n            // In our 2-item queue model, playlist-pos should normally be 0.\n            // When mpv auto-advances to the next track it becomes > 0 (typically 1).\n            if (typeof status.value === 'number' && status.value > 0) {\n                getMainWindow()?.webContents.send('renderer-player-auto-next');\n            }\n        }\n    });\n\n    // Automatically updates the play button when the player is playing\n    mpv.on('resumed', () => {\n        getMainWindow()?.webContents.send('renderer-player-play');\n    });\n\n    // Automatically updates the play button when the player is stopped\n    mpv.on('stopped', () => {\n        getMainWindow()?.webContents.send('renderer-player-stop');\n    });\n\n    // Automatically updates the play button when the player is paused\n    mpv.on('paused', () => {\n        getMainWindow()?.webContents.send('renderer-player-pause');\n    });\n\n    // Event output every interval set by time_update, used to update the current time\n    mpv.on('timeposition', (time: number) => {\n        getMainWindow()?.webContents.send('renderer-player-current-time', time);\n    });\n\n    return mpv;\n};\n\nexport const getMpvInstance = () => {\n    return mpvInstance;\n};\n\nconst quit = async (instance?: MpvAPI | null) => {\n    const mpv = instance || getMpvInstance();\n    if (mpv) {\n        try {\n            await mpv.quit();\n        } catch {\n            // If quit() fails, try to kill the process directly\n            const mpvProcess = (mpv as any).process || (mpv as any).mpvProcess;\n            if (mpvProcess && typeof mpvProcess.kill === 'function') {\n                try {\n                    mpvProcess.kill('SIGTERM');\n                } catch (killErr) {\n                    mpvLog({ action: 'Failed to kill mpv process' }, killErr as NodeMpvError);\n                }\n            }\n        }\n        if (!isWindows()) {\n            try {\n                await rm(socketPath);\n            } catch {\n                // Ignore errors when removing socket file\n            }\n        }\n    }\n};\n\nconst setAudioPlayerFallback = (isError: boolean) => {\n    getMainWindow()?.webContents.send('renderer-player-fallback', isError);\n};\n\nipcMain.on('player-set-properties', async (_event, data: Record<string, any>) => {\n    mpvLog({ action: `Setting properties: ${JSON.stringify(data)}` });\n    if (data.length === 0) {\n        return;\n    }\n\n    try {\n        if (data.length === 1) {\n            getMpvInstance()?.setProperty(Object.keys(data)[0], Object.values(data)[0]);\n        } else {\n            getMpvInstance()?.setMultipleProperties(data);\n        }\n    } catch (err: any | NodeMpvError) {\n        mpvLog({ action: `Failed to set properties: ${JSON.stringify(data)}` }, err);\n    }\n});\n\nipcMain.handle(\n    'player-restart',\n    async (_event, data: { extraParameters?: string[]; properties?: Record<string, any> }) => {\n        try {\n            mpvLog({\n                action: `Attempting to initialize mpv with parameters: ${JSON.stringify(data)}`,\n            });\n\n            // Clean up previous mpv instance\n            getMpvInstance()?.stop();\n            getMpvInstance()\n                ?.quit()\n                .catch((error) => {\n                    mpvLog({ action: 'Failed to quit existing MPV' }, error);\n                });\n            mpvInstance = null;\n\n            mpvInstance = await createMpv(data);\n            mpvLog({ action: 'Restarted mpv', toast: 'success' });\n            setAudioPlayerFallback(false);\n        } catch (err: any | NodeMpvError) {\n            mpvLog({ action: 'Failed to restart mpv, falling back to web player' }, err);\n            setAudioPlayerFallback(true);\n        }\n    },\n);\n\nipcMain.handle(\n    'player-initialize',\n    async (_event, data: { extraParameters?: string[]; properties?: Record<string, any> }) => {\n        try {\n            mpvLog({\n                action: `Attempting to initialize mpv with parameters: ${JSON.stringify(data)}`,\n            });\n            mpvInstance = await createMpv(data);\n            setAudioPlayerFallback(false);\n        } catch (err: any | NodeMpvError) {\n            mpvLog({ action: 'Failed to initialize mpv, falling back to web player' }, err);\n            setAudioPlayerFallback(true);\n        }\n    },\n);\n\nipcMain.on('player-quit', async () => {\n    try {\n        await getMpvInstance()?.stop();\n        await quit();\n    } catch (err: any | NodeMpvError) {\n        mpvLog({ action: 'Failed to quit mpv' }, err);\n    } finally {\n        mpvInstance = null;\n    }\n});\n\nipcMain.handle('player-is-running', async () => {\n    return getMpvInstance()?.isRunning();\n});\n\nipcMain.handle('player-clean-up', async () => {\n    getMpvInstance()?.stop();\n    getMpvInstance()?.clearPlaylist();\n});\n\nipcMain.on('player-start', async () => {\n    try {\n        await getMpvInstance()?.play();\n    } catch (err: any | NodeMpvError) {\n        mpvLog({ action: 'Failed to start mpv playback' }, err);\n    }\n});\n\n// Starts the player\nipcMain.on('player-play', async () => {\n    try {\n        await getMpvInstance()?.play();\n    } catch (err: any | NodeMpvError) {\n        mpvLog({ action: 'Failed to start mpv playback' }, err);\n    }\n});\n\n// Pauses the player\nipcMain.on('player-pause', async () => {\n    try {\n        await getMpvInstance()?.pause();\n    } catch (err: any | NodeMpvError) {\n        mpvLog({ action: 'Failed to pause mpv playback' }, err);\n    }\n});\n\n// Stops the player\nipcMain.on('player-stop', async () => {\n    try {\n        await getMpvInstance()?.stop();\n    } catch (err: any | NodeMpvError) {\n        mpvLog({ action: 'Failed to stop mpv playback' }, err);\n    }\n});\n\n// Goes to the next track in the playlist\nipcMain.on('player-next', async () => {\n    try {\n        await getMpvInstance()?.next();\n    } catch (err: any | NodeMpvError) {\n        mpvLog({ action: 'Failed to go to next track' }, err);\n    }\n});\n\n// Goes to the previous track in the playlist\nipcMain.on('player-previous', async () => {\n    try {\n        await getMpvInstance()?.prev();\n    } catch (err: any | NodeMpvError) {\n        mpvLog({ action: 'Failed to go to previous track' }, err);\n    }\n});\n\n// Seeks forward or backward by the given amount of seconds\nipcMain.on('player-seek', async (_event, time: number) => {\n    try {\n        await getMpvInstance()?.seek(time);\n    } catch (err: any | NodeMpvError) {\n        mpvLog({ action: `Failed to seek by ${time} seconds` }, err);\n    }\n});\n\n// Seeks to the given time in seconds\nipcMain.on('player-seek-to', async (_event, time: number) => {\n    try {\n        await getMpvInstance()?.goToPosition(time);\n    } catch (err: any | NodeMpvError) {\n        mpvLog({ action: `Failed to seek to ${time} seconds` }, err);\n    }\n});\n\n// Sets the queue in position 0 and 1 to the given data. Used when manually starting a song or using the next/prev buttons\nipcMain.on('player-set-queue', async (_event, current?: string, next?: string, pause?: boolean) => {\n    if (!current && !next) {\n        try {\n            await getMpvInstance()?.clearPlaylist();\n            await getMpvInstance()?.pause();\n            return;\n        } catch (err: any | NodeMpvError) {\n            mpvLog({ action: `Failed to clear play queue` }, err);\n        }\n    }\n\n    try {\n        if (current) {\n            try {\n                await getMpvInstance()?.load(current, 'replace');\n            } catch (error: any | NodeMpvError) {\n                mpvLog({ action: `Failed to load current song` }, error);\n                await getMpvInstance()?.play();\n            }\n\n            if (next) {\n                await getMpvInstance()?.load(next, 'append');\n            }\n        }\n\n        if (pause) {\n            await getMpvInstance()?.pause();\n        } else if (pause === false) {\n            // Only force play if pause is explicitly false\n            await getMpvInstance()?.play();\n        }\n    } catch (err: any | NodeMpvError) {\n        mpvLog({ action: `Failed to set play queue` }, err);\n    }\n});\n\n// Replaces the queue in position 1 to the given data\nipcMain.on('player-set-queue-next', async (_event, url?: string) => {\n    try {\n        const size = await getMpvInstance()?.getPlaylistSize();\n\n        if (size && size > 1) {\n            await getMpvInstance()?.playlistRemove(1);\n        }\n\n        if (url) {\n            getMpvInstance()?.load(url, 'append');\n        }\n    } catch (err: any | NodeMpvError) {\n        mpvLog({ action: `Failed to set play queue` }, err);\n    }\n});\n\n// Sets the next song in the queue when reaching the end of the queue\nipcMain.on('player-auto-next', async (_event, url?: string) => {\n    // Always keep the current song as position 0 in the mpv queue\n    // This allows us to easily set update the next song in the queue without\n    // disturbing the currently playing song\n\n    try {\n        await getMpvInstance()\n            ?.playlistRemove(0)\n            .catch(() => {\n                getMpvInstance()?.pause();\n            });\n\n        if (url) {\n            await getMpvInstance()?.load(url, 'append');\n        }\n    } catch (err: any | NodeMpvError) {\n        mpvLog({ action: `Failed to load next song` }, err);\n    }\n});\n\n// Sets the volume to the given value (0-100)\nipcMain.on('player-volume', async (_event, value: number) => {\n    try {\n        if (!value || value < 0 || value > 100) {\n            return;\n        }\n\n        await getMpvInstance()?.volume(value);\n    } catch (err: any | NodeMpvError) {\n        mpvLog({ action: `Failed to set volume to ${value}` }, err);\n    }\n});\n\n// Toggles the mute status\nipcMain.on('player-mute', async (_event, mute: boolean) => {\n    try {\n        await getMpvInstance()?.mute(mute);\n    } catch (err: any | NodeMpvError) {\n        mpvLog({ action: `Failed to set mute status` }, err);\n    }\n});\n\nipcMain.handle('player-get-time', async (): Promise<number | undefined> => {\n    try {\n        return getMpvInstance()?.getTimePosition();\n    } catch (err: any | NodeMpvError) {\n        mpvLog({ action: `Failed to get current time` }, err);\n        return 0;\n    }\n});\n\n// Updates the current player metadata (song data)\nipcMain.on('player-update-metadata', (_event, data: PlayerData) => {\n    currentPlayerData = data;\n});\n\n// Returns the current player metadata (song data)\nipcMain.handle('player-metadata', async (): Promise<null | PlayerData> => {\n    return currentPlayerData;\n});\n\n// Returns the stream metadata from mpv (for radio streams)\nipcMain.handle(\n    'player-stream-metadata',\n    async (): Promise<null | { artist: null | string; title: null | string }> => {\n        try {\n            const metadata = await getMpvInstance()?.getProperty('metadata');\n            if (metadata && typeof metadata === 'object') {\n                // Try to get separate title and artist fields first\n                let artist: null | string =\n                    (metadata['artist'] as string) ||\n                    (metadata['ARTIST'] as string) ||\n                    (metadata['icy-artist'] as string) ||\n                    null;\n                let title: null | string =\n                    (metadata['title'] as string) || (metadata['TITLE'] as string) || null;\n\n                // If we don't have separate fields, try to parse from combined formats\n                if (!title && !artist) {\n                    const combinedTitle =\n                        (metadata['icy-title'] as string) ||\n                        (metadata['StreamTitle'] as string) ||\n                        (metadata['stream-title'] as string) ||\n                        null;\n\n                    if (combinedTitle && typeof combinedTitle === 'string') {\n                        // Try to parse \"Artist - Title\" format\n                        const match = combinedTitle.match(/^(.*?)\\s*[-–—]\\s*(.+)$/);\n                        if (match) {\n                            artist = match[1].trim() || null;\n                            title = match[2].trim() || null;\n                        } else {\n                            // If no separator found, treat the whole thing as title\n                            title = combinedTitle;\n                        }\n                    }\n                } else if (!title) {\n                    // If we have artist but no title, try to get from combined format\n                    const combinedTitle =\n                        (metadata['icy-title'] as string) ||\n                        (metadata['StreamTitle'] as string) ||\n                        (metadata['stream-title'] as string) ||\n                        null;\n                    if (combinedTitle && typeof combinedTitle === 'string') {\n                        title = combinedTitle;\n                    }\n                } else if (!artist) {\n                    // If we have title but no artist, try to get from combined format\n                    const combinedTitle =\n                        (metadata['icy-title'] as string) ||\n                        (metadata['StreamTitle'] as string) ||\n                        (metadata['stream-title'] as string) ||\n                        null;\n                    if (\n                        combinedTitle &&\n                        typeof combinedTitle === 'string' &&\n                        combinedTitle !== title\n                    ) {\n                        // Try to parse artist from combined format\n                        const match = combinedTitle.match(/^(.*?)\\s*[-–—]\\s*(.+)$/);\n                        if (match && match[2].trim() === title) {\n                            artist = match[1].trim() || null;\n                        }\n                    }\n                }\n\n                return { artist, title };\n            }\n            return null;\n        } catch (err: any | NodeMpvError) {\n            mpvLog({ action: `Failed to get stream metadata` }, err);\n            return null;\n        }\n    },\n);\n\nipcMain.handle(\n    'player-get-audio-devices',\n    async (): Promise<{ label: string; value: string }[]> => {\n        try {\n            const instance = getMpvInstance();\n            let tempInstance: MpvAPI | null = null;\n            let mpvToUse: MpvAPI | null = null;\n\n            if (instance && instance.isRunning()) {\n                mpvToUse = instance;\n            } else {\n                try {\n                    tempInstance = await createMpv({});\n                    mpvToUse = tempInstance;\n                } catch (err: any | NodeMpvError) {\n                    mpvLog(\n                        { action: 'Failed to create temporary MPV instance for audio device list' },\n                        err,\n                    );\n                    return [];\n                }\n            }\n\n            try {\n                const deviceList = await mpvToUse.getProperty('audio-device-list');\n\n                if (!deviceList || !Array.isArray(deviceList)) {\n                    return [];\n                }\n\n                const devices = deviceList.map((device: any) => {\n                    const name = device.name || device.description || 'Unknown Device';\n                    const description = device.description || '';\n                    const label = description ? `${name} (${description})` : name;\n                    return {\n                        label,\n                        value: name,\n                    };\n                });\n\n                return devices;\n            } finally {\n                if (tempInstance && tempInstance !== instance) {\n                    try {\n                        await quit(tempInstance);\n                    } catch {\n                        // Ignore\n                    }\n                }\n            }\n        } catch (err: any | NodeMpvError) {\n            mpvLog({ action: 'Failed to get audio devices' }, err);\n            return [];\n        }\n    },\n);\n\nenum MpvState {\n    STARTED,\n    IN_PROGRESS,\n    DONE,\n}\n\nlet mpvState = MpvState.STARTED;\n\n// Cleanup function that can be called from multiple places\nconst cleanupMpv = async (force = false) => {\n    if (mpvState === MpvState.DONE && !force) {\n        return;\n    }\n\n    const instance = getMpvInstance();\n    if (instance) {\n        try {\n            if (!force) {\n                await instance.stop();\n            }\n            await quit(instance);\n        } catch (err: any | NodeMpvError) {\n            mpvLog({ action: `Failed to cleanup mpv` }, err);\n            // Force kill as fallback\n            const mpvProcess = (instance as any).process || (instance as any).mpvProcess;\n            if (mpvProcess && typeof mpvProcess.kill === 'function') {\n                try {\n                    mpvProcess.kill('SIGKILL');\n                } catch {\n                    // Ignore kill errors\n                }\n            }\n        } finally {\n            mpvInstance = null;\n        }\n    }\n};\n\napp.on('before-quit', async (event) => {\n    switch (mpvState) {\n        case MpvState.DONE:\n            return;\n        case MpvState.IN_PROGRESS:\n            event.preventDefault();\n            break;\n        case MpvState.STARTED: {\n            try {\n                mpvState = MpvState.IN_PROGRESS;\n                event.preventDefault();\n                await cleanupMpv();\n            } catch (err: any | NodeMpvError) {\n                mpvLog({ action: `Failed to cleanly before-quit` }, err);\n            } finally {\n                mpvState = MpvState.DONE;\n                app.quit();\n            }\n            break;\n        }\n    }\n});\n\n// Handle process exit events to ensure mpv is killed even if app crashes\nprocess.on('exit', () => {\n    const instance = getMpvInstance();\n    if (instance) {\n        // Try to access and kill the process directly\n        const mpvProcess = (instance as any).process || (instance as any).mpvProcess;\n        if (mpvProcess && typeof mpvProcess.kill === 'function') {\n            try {\n                mpvProcess.kill('SIGKILL');\n            } catch {\n                // Ignore errors during exit\n            }\n        }\n    }\n});\n\n// Handle signals that can terminate the process\nprocess.on('SIGINT', async () => {\n    await cleanupMpv(true);\n    process.exit(0);\n});\n\nprocess.on('SIGTERM', async () => {\n    await cleanupMpv(true);\n    process.exit(0);\n});\n\n// Handle uncaught exceptions - cleanup mpv before crashing\nprocess.on('uncaughtException', async (error) => {\n    console.error('Uncaught exception:', error);\n    await cleanupMpv(true).catch(() => {\n        // Ignore cleanup errors during crash\n    });\n});\n\n// Handle unhandled rejections - cleanup mpv\nprocess.on('unhandledRejection', async (reason) => {\n    console.error('Unhandled rejection:', reason);\n    await cleanupMpv(true).catch(() => {\n        // Ignore cleanup errors\n    });\n});\n"
  },
  {
    "path": "src/main/features/core/player/media-keys.ts",
    "content": "import { BrowserWindow, globalShortcut, systemPreferences } from 'electron';\n\nimport { isLinux, isMacOS } from '../../../utils';\nimport { store } from '../settings';\n\nimport { PlayerType } from '/@/shared/types/types';\n\nexport const enableMediaKeys = (window: BrowserWindow | null) => {\n    if (isMacOS()) {\n        const shouldPrompt = store.get('should_prompt_accessibility', true) as boolean;\n        const shownWarning = store.get('shown_accessibility_warning', false) as boolean;\n        const trusted = systemPreferences.isTrustedAccessibilityClient(shouldPrompt);\n\n        if (shouldPrompt) {\n            store.set('should_prompt_accessibility', false);\n        }\n\n        if (!trusted && !shownWarning) {\n            window?.webContents.send('toast-from-main', {\n                message:\n                    'Feishin is not a trusted accessibility client. Media keys will not work until this setting is changed',\n                type: 'warning',\n            });\n            store.set('shown_accessibility_warning', true);\n        }\n    }\n\n    const enableMediaSession = store.get('mediaSession', false) as boolean;\n    const playbackType = store.get('playbackType', PlayerType.WEB) as PlayerType;\n\n    if (!enableMediaSession || isLinux() || playbackType !== PlayerType.WEB) {\n        globalShortcut.register('MediaStop', () => {\n            window?.webContents.send('renderer-player-stop');\n        });\n\n        globalShortcut.register('MediaPlayPause', () => {\n            window?.webContents.send('renderer-player-play-pause');\n        });\n\n        globalShortcut.register('MediaNextTrack', () => {\n            window?.webContents.send('renderer-player-next');\n        });\n\n        globalShortcut.register('MediaPreviousTrack', () => {\n            window?.webContents.send('renderer-player-previous');\n        });\n    }\n};\n\nexport const disableMediaKeys = () => {\n    globalShortcut.unregister('MediaStop');\n    globalShortcut.unregister('MediaPlayPause');\n    globalShortcut.unregister('MediaNextTrack');\n    globalShortcut.unregister('MediaPreviousTrack');\n};\n"
  },
  {
    "path": "src/main/features/core/remote/index.ts",
    "content": "import axios from 'axios';\nimport { app, ipcMain } from 'electron';\nimport { promises, Stats } from 'fs';\nimport { readFile } from 'fs/promises';\nimport { createServer, IncomingMessage, Server, ServerResponse } from 'http';\nimport { join } from 'path';\nimport { WebSocket, WebSocketServer, Server as WsServer } from 'ws';\nimport { deflate, gzip } from 'zlib';\n\nimport manifest from './manifest.json';\n\nimport { getMainWindow } from '/@/main/index';\nimport { isLinux } from '/@/main/utils';\nimport { QueueSong } from '/@/shared/types/domain-types';\nimport { ClientEvent, ServerEvent } from '/@/shared/types/remote-types';\nimport { PlayerRepeat, PlayerStatus, SongState } from '/@/shared/types/types';\n\nlet mprisPlayer: any | undefined;\n\nasync function initMpris() {\n    if (isLinux()) {\n        const mpris = await import('../../linux/mpris');\n        mprisPlayer = mpris.mprisPlayer;\n    }\n}\n\ninitMpris();\n\ninterface MimeType {\n    css: string;\n    html: string;\n    ico: string;\n    js: string;\n}\n\ninterface RemoteConfig {\n    enabled: boolean;\n    password: string;\n    port: number;\n    username: string;\n}\n\ndeclare class StatefulWebSocket extends WebSocket {\n    alive: boolean;\n    auth: boolean;\n}\n\nlet server: Server | undefined;\nlet wsServer: undefined | WsServer<typeof StatefulWebSocket>;\n\nconst settings: RemoteConfig = {\n    enabled: false,\n    password: '',\n    port: 4333,\n    username: '',\n};\n\ntype SendData = ServerEvent & {\n    client: StatefulWebSocket;\n};\n\nfunction broadcast(message: ServerEvent): void {\n    if (wsServer) {\n        for (const client of wsServer.clients) {\n            send({ client, ...message });\n        }\n    }\n}\n\nfunction send({ client, data, event }: SendData): void {\n    if (client.readyState === WebSocket.OPEN) {\n        if (client.alive && client.auth) {\n            client.send(JSON.stringify({ data, event }));\n        }\n    }\n}\n\nexport const shutdownServer = () => {\n    if (wsServer) {\n        wsServer.clients.forEach((client) => client.close(4000));\n        wsServer.close();\n        wsServer = undefined;\n    }\n\n    if (server) {\n        server.close();\n        server = undefined;\n    }\n};\n\nconst MIME_TYPES: MimeType = {\n    css: 'text/css',\n    html: 'text/html; charset=UTF-8',\n    ico: 'image/x-icon',\n    js: 'application/javascript',\n};\n\nconst PING_TIMEOUT_MS = 10000;\nconst UP_TIMEOUT_MS = 5000;\n\nenum Encoding {\n    GZIP = 'gzip',\n    NONE = 'none',\n    ZLIB = 'deflate',\n}\n\nconst GZIP_REGEX = /\\bgzip\\b/;\nconst ZLIB_REGEX = /bdeflate\\b/;\n\nconst currentState: SongState = {};\n\nconst getEncoding = (encoding: string | string[]): Encoding => {\n    const encodingArray = Array.isArray(encoding) ? encoding : [encoding];\n\n    for (const code of encodingArray) {\n        if (code.match(GZIP_REGEX)) {\n            return Encoding.GZIP;\n        }\n        if (code.match(ZLIB_REGEX)) {\n            return Encoding.ZLIB;\n        }\n    }\n\n    return Encoding.NONE;\n};\n\nconst cache = new Map<string, Map<Encoding, [number, Buffer]>>();\n\nfunction authorize(req: IncomingMessage): boolean {\n    if (settings.username || settings.password) {\n        // https://stackoverflow.com/questions/23616371/basic-http-authentication-with-node-and-express-4\n\n        const authorization = req.headers.authorization?.split(' ')[1] || '';\n        const [login, password] = Buffer.from(authorization, 'base64').toString().split(':');\n\n        return login === settings.username && password === settings.password;\n    }\n\n    return true;\n}\n\nasync function serveFile(\n    req: IncomingMessage,\n    file: string,\n    extension: keyof MimeType,\n    res: ServerResponse,\n): Promise<void> {\n    const fileName = `${file}.${extension}`;\n    const path = app.isPackaged\n        ? join(__dirname, '../remote', fileName)\n        : join(__dirname, '../../out/remote', fileName);\n\n    let stats: Stats;\n\n    try {\n        stats = await promises.stat(path);\n    } catch (error) {\n        res.statusCode = 404;\n        res.setHeader('Content-Type', 'text/plain');\n        res.end((error as Error).message);\n        // This is a resolve, even though it is an error, because we want specific (non 500) status\n        return Promise.resolve();\n    }\n\n    const encodings = req.headers['accept-encoding'] ?? '';\n    const selectedEncoding = getEncoding(encodings);\n\n    const ifMatch = req.headers['if-none-match'];\n\n    const fileInfo = cache.get(fileName);\n    let cached = fileInfo?.get(selectedEncoding);\n\n    if (cached && cached[0] !== stats.mtimeMs) {\n        cache.get(fileName)!.delete(selectedEncoding);\n        cached = undefined;\n    }\n\n    if (ifMatch && cached) {\n        const options = ifMatch.split(',');\n\n        for (const option of options) {\n            const mTime = Number(option.replaceAll('\"', '').trim());\n\n            if (cached[0] === mTime) {\n                setOk(res, cached[0], extension, selectedEncoding);\n                return Promise.resolve();\n            }\n        }\n    }\n\n    if (!cached || cached[0] !== stats.mtimeMs) {\n        const content = await readFile(path);\n\n        switch (selectedEncoding) {\n            case Encoding.GZIP:\n                return new Promise((resolve, reject) => {\n                    gzip(content, (error, result) => {\n                        if (error) {\n                            reject(error);\n                            return;\n                        }\n\n                        const newEntry: [number, Buffer] = [stats.mtimeMs, result];\n\n                        if (fileInfo) {\n                            fileInfo.set(selectedEncoding, newEntry);\n                        } else {\n                            cache.set(fileName, new Map([[selectedEncoding, newEntry]]));\n                        }\n\n                        setOk(res, stats.mtimeMs, extension, selectedEncoding, result);\n                        resolve();\n                    });\n                });\n\n            case Encoding.ZLIB:\n                return new Promise((resolve, reject) => {\n                    deflate(content, (error, result) => {\n                        if (error) {\n                            reject(error);\n                            return;\n                        }\n\n                        const newEntry: [number, Buffer] = [stats.mtimeMs, result];\n\n                        if (fileInfo) {\n                            fileInfo.set(selectedEncoding, newEntry);\n                        } else {\n                            cache.set(fileName, new Map([[selectedEncoding, newEntry]]));\n                        }\n\n                        setOk(res, stats.mtimeMs, extension, selectedEncoding, result);\n                        resolve();\n                    });\n                });\n            default: {\n                const newEntry: [number, Buffer] = [stats.mtimeMs, content];\n\n                if (fileInfo) {\n                    fileInfo.set(selectedEncoding, newEntry);\n                } else {\n                    cache.set(fileName, new Map([[selectedEncoding, newEntry]]));\n                }\n\n                setOk(res, stats.mtimeMs, extension, selectedEncoding, content);\n                return Promise.resolve();\n            }\n        }\n    }\n\n    setOk(res, cached[0], extension, selectedEncoding, cached[1]);\n\n    return Promise.resolve();\n}\n\nfunction setOk(\n    res: ServerResponse,\n    mtimeMs: number,\n    extension: keyof MimeType,\n    encoding: Encoding,\n    data?: Buffer,\n) {\n    res.statusCode = data ? 200 : 304;\n\n    res.setHeader('Content-Type', MIME_TYPES[extension]);\n    res.setHeader('ETag', `\"${mtimeMs}\"`);\n    res.setHeader('Cache-Control', 'public');\n\n    if (encoding !== 'none') res.setHeader('Content-Encoding', encoding);\n    res.end(data);\n}\n\nconst enableServer = (config: RemoteConfig): Promise<void> => {\n    return new Promise<void>((resolve, reject) => {\n        try {\n            if (server) {\n                server.close();\n            }\n\n            server = createServer({}, async (req, res) => {\n                if (!authorize(req)) {\n                    res.statusCode = 401;\n                    res.setHeader('WWW-Authenticate', 'Basic realm=\"401\"');\n                    res.end('Authorization required');\n                    return;\n                }\n\n                try {\n                    switch (req.url) {\n                        case '/': {\n                            await serveFile(req, 'index', 'html', res);\n                            break;\n                        }\n                        case '/credentials': {\n                            res.statusCode = 200;\n                            res.setHeader('Content-Type', 'text/plain');\n                            res.end(req.headers.authorization);\n                            break;\n                        }\n                        case '/favicon.ico': {\n                            await serveFile(req, 'favicon', 'ico', res);\n                            break;\n                        }\n                        case '/manifest.json': {\n                            res.statusCode = 200;\n                            res.setHeader('Content-Type', 'application/json');\n                            res.end(JSON.stringify(manifest));\n                            break;\n                        }\n                        case '/remote.css': {\n                            await serveFile(req, 'remote', 'css', res);\n                            break;\n                        }\n                        case '/remote.js': {\n                            await serveFile(req, 'remote', 'js', res);\n                            break;\n                        }\n                        default: {\n                            if (req.url?.startsWith('/worker.js')) {\n                                await serveFile(req, 'worker', 'js', res);\n                            } else {\n                                res.statusCode = 404;\n                                res.setHeader('Content-Type', 'text/plain');\n                                res.end('Not Found');\n                            }\n                        }\n                    }\n                } catch (error) {\n                    res.statusCode = 500;\n                    res.setHeader('Content-Type', 'text/plain');\n                    res.end((error as Error).message);\n                }\n            });\n\n            server.listen(config.port, resolve);\n            wsServer = new WebSocketServer<typeof StatefulWebSocket>({ server });\n\n            wsServer!.on('connection', (ws: StatefulWebSocket) => {\n                let authFail: number | undefined;\n                ws.alive = true;\n\n                if (!settings.username && !settings.password) {\n                    ws.auth = true;\n                } else {\n                    authFail = setTimeout(() => {\n                        if (!ws.auth) {\n                            ws.close();\n                        }\n                    }, 10000) as unknown as number;\n                }\n\n                ws.on('error', console.error);\n\n                ws.on('message', (data) => {\n                    try {\n                        const json = JSON.parse(data.toString()) as ClientEvent;\n                        const event = json.event;\n\n                        if (!ws.auth) {\n                            if (event === 'authenticate') {\n                                const auth = json.header.split(' ')[1];\n                                const [login, password] = Buffer.from(auth, 'base64')\n                                    .toString()\n                                    .split(':');\n\n                                if (login === settings.username && password === settings.password) {\n                                    ws.auth = true;\n                                } else {\n                                    ws.close();\n                                }\n\n                                clearTimeout(authFail);\n                            } else {\n                                return;\n                            }\n                        }\n\n                        switch (event) {\n                            case 'favorite': {\n                                const { favorite, id } = json;\n                                if (id && id === currentState.song?.id) {\n                                    getMainWindow()?.webContents.send('request-favorite', {\n                                        favorite,\n                                        id,\n                                        serverId: currentState.song._serverId,\n                                    });\n                                }\n                                break;\n                            }\n                            case 'next': {\n                                getMainWindow()?.webContents.send('renderer-player-next');\n                                break;\n                            }\n                            case 'pause': {\n                                getMainWindow()?.webContents.send('renderer-player-pause');\n                                break;\n                            }\n                            case 'play': {\n                                getMainWindow()?.webContents.send('renderer-player-play');\n                                break;\n                            }\n                            case 'previous': {\n                                getMainWindow()?.webContents.send('renderer-player-previous');\n                                break;\n                            }\n                            case 'proxy': {\n                                const toFetch = currentState.song?.imageUrl?.replaceAll(\n                                    /&(size|width|height)=\\d+/g,\n                                    '',\n                                );\n\n                                if (!toFetch) return;\n\n                                axios\n                                    .get(toFetch, { responseType: 'arraybuffer' })\n                                    .then((resp) => {\n                                        if (ws.readyState === WebSocket.OPEN) {\n                                            send({\n                                                client: ws,\n                                                data: Buffer.from(resp.data, 'binary').toString(\n                                                    'base64',\n                                                ),\n                                                event: 'proxy',\n                                            });\n                                        }\n                                        return null;\n                                    })\n                                    .catch((error) => {\n                                        if (ws.readyState === WebSocket.OPEN) {\n                                            send({\n                                                client: ws,\n                                                data: error.message,\n                                                event: 'error',\n                                            });\n                                        }\n                                    });\n\n                                break;\n                            }\n                            case 'rating': {\n                                const { id, rating } = json;\n                                if (id && id === currentState.song?.id) {\n                                    getMainWindow()?.webContents.send('request-rating', {\n                                        id,\n                                        rating,\n                                        serverId: currentState.song._serverId,\n                                    });\n                                }\n                                break;\n                            }\n                            case 'repeat': {\n                                getMainWindow()?.webContents.send('renderer-player-toggle-repeat');\n                                break;\n                            }\n                            case 'shuffle': {\n                                getMainWindow()?.webContents.send('renderer-player-toggle-shuffle');\n                                break;\n                            }\n                            case 'volume': {\n                                let volume = Number(json.volume);\n\n                                if (volume > 100) {\n                                    volume = 100;\n                                } else if (volume < 0) {\n                                    volume = 0;\n                                }\n\n                                currentState.volume = volume;\n\n                                broadcast({ data: volume, event: 'volume' });\n                                getMainWindow()?.webContents.send('request-volume', {\n                                    volume,\n                                });\n\n                                if (mprisPlayer) {\n                                    mprisPlayer.volume = volume / 100;\n                                }\n                                break;\n                            }\n                            case 'position': {\n                                const { position } = json;\n                                if (mprisPlayer) {\n                                    mprisPlayer.getPosition = () => position * 1e6;\n                                }\n                                getMainWindow()?.webContents.send('request-position', {\n                                    position,\n                                });\n                            }\n                        }\n                    } catch (error) {\n                        console.error(error);\n                    }\n                });\n\n                ws.on('pong', () => {\n                    ws.alive = true;\n                });\n\n                ws.send(JSON.stringify({ data: currentState, event: 'state' }));\n            });\n\n            const heartBeat = setInterval(() => {\n                wsServer?.clients.forEach((ws) => {\n                    if (!ws.alive) {\n                        ws.terminate();\n                        return;\n                    }\n\n                    ws.alive = false;\n                    ws.ping();\n                });\n            }, PING_TIMEOUT_MS);\n\n            wsServer!.on('close', () => {\n                clearInterval(heartBeat);\n            });\n\n            setTimeout(() => {\n                reject(new Error('Server did not come up'));\n            }, UP_TIMEOUT_MS);\n        } catch (error) {\n            reject(error);\n            shutdownServer();\n        }\n    });\n};\n\nipcMain.handle('remote-enable', async (_event, enabled: boolean) => {\n    settings.enabled = enabled;\n\n    if (enabled) {\n        try {\n            await enableServer(settings);\n        } catch (error) {\n            return (error as Error).message;\n        }\n    } else {\n        shutdownServer();\n    }\n\n    return null;\n});\n\nipcMain.handle('remote-port', async (_event, port: number) => {\n    settings.port = port;\n});\n\nipcMain.on('remote-password', (_event, password: string) => {\n    settings.password = password;\n    wsServer?.clients.forEach((client) => client.close(4002));\n});\n\nipcMain.handle(\n    'remote-settings',\n    async (_event, enabled: boolean, port: number, username: string, password: string) => {\n        settings.enabled = enabled;\n        settings.password = password;\n        settings.port = port;\n        settings.username = username;\n\n        if (enabled) {\n            try {\n                await enableServer(settings);\n            } catch (error) {\n                return (error as Error).message;\n            }\n        } else {\n            shutdownServer();\n        }\n\n        return null;\n    },\n);\n\nipcMain.on('remote-username', (_event, username: string) => {\n    settings.username = username;\n    wsServer?.clients.forEach((client) => client.close(4002));\n});\n\nipcMain.on('update-favorite', (_event, favorite: boolean, serverId: string, ids: string[]) => {\n    if (currentState.song?._serverId !== serverId) return;\n\n    const id = currentState.song.id;\n\n    for (const songId of ids) {\n        if (songId === id) {\n            currentState.song.userFavorite = favorite;\n            broadcast({ data: { favorite, id: songId }, event: 'favorite' });\n            return;\n        }\n    }\n});\n\nipcMain.on('update-rating', (_event, rating: number, serverId: string, ids: string[]) => {\n    if (currentState.song?._serverId !== serverId) return;\n\n    const id = currentState.song.id;\n\n    for (const songId of ids) {\n        if (songId === id) {\n            currentState.song.userRating = rating;\n            broadcast({ data: { id: songId, rating }, event: 'rating' });\n            return;\n        }\n    }\n});\n\nipcMain.on('update-repeat', (_event, repeat: PlayerRepeat) => {\n    currentState.repeat = repeat;\n    broadcast({ data: repeat, event: 'repeat' });\n});\n\nipcMain.on('update-shuffle', (_event, shuffle: boolean) => {\n    currentState.shuffle = shuffle;\n    broadcast({ data: shuffle, event: 'shuffle' });\n});\n\nipcMain.on('update-playback', (_event, status: PlayerStatus) => {\n    currentState.status = status;\n    broadcast({ data: status, event: 'playback' });\n});\n\nipcMain.on('update-song', (_event, song: QueueSong | undefined, imageUrl?: null | string) => {\n    const songChanged = song?.id !== currentState.song?.id;\n    if (song) {\n        song.imageUrl = imageUrl || null;\n    }\n    currentState.song = song;\n\n    if (songChanged) {\n        broadcast({ data: song || null, event: 'song' });\n    }\n});\n\nipcMain.on('update-volume', (_event, volume: number) => {\n    currentState.volume = volume;\n    broadcast({ data: volume, event: 'volume' });\n});\n\nif (mprisPlayer) {\n    mprisPlayer.on('loopStatus', (event: string) => {\n        const repeat = event === 'Playlist' ? 'all' : event === 'Track' ? 'one' : 'none';\n\n        currentState.repeat = repeat as PlayerRepeat;\n        broadcast({ data: repeat, event: 'repeat' } as ServerEvent);\n    });\n\n    mprisPlayer.on('shuffle', (shuffle: boolean) => {\n        currentState.shuffle = shuffle;\n        broadcast({ data: shuffle, event: 'shuffle' });\n    });\n\n    mprisPlayer.on('volume', (vol: number) => {\n        let volume = Math.round(vol * 100);\n\n        if (volume > 100) {\n            volume = 100;\n        } else if (volume < 0) {\n            volume = 0;\n        }\n        currentState.volume = volume;\n        broadcast({ data: volume, event: 'volume' });\n        getMainWindow()?.webContents.send('request-volume', {\n            volume,\n        });\n    });\n}\n\nipcMain.on('update-position', (_event, position: number) => {\n    currentState.position = position;\n    broadcast({ data: position, event: 'position' });\n});\n"
  },
  {
    "path": "src/main/features/core/remote/manifest.json",
    "content": "{\n    \"name\": \"Feishin Remote\",\n    \"short_name\": \"Feishin Remote\",\n    \"start_url\": \"/\",\n    \"background_color\": \"#000100\",\n    \"theme_color\": \"#E7E7E7\",\n    \"icons\": [\n        {\n            \"src\": \"favicon.ico\",\n            \"sizes\": \"32x32\",\n            \"type\": \"image/png\",\n            \"purpose\": \"maskable any\"\n        }\n    ],\n    \"display\": \"standalone\",\n    \"orientation\": \"portrait\"\n}\n"
  },
  {
    "path": "src/main/features/core/settings/index.ts",
    "content": "import type { TitleTheme } from '/@/shared/types/types';\n\nimport { app, dialog, ipcMain, nativeTheme, OpenDialogOptions, safeStorage } from 'electron';\nimport Store from 'electron-store';\nimport path from 'path';\n\nconst getFrame = () => {\n    const isWindows = process.platform === 'win32';\n    const isMacOS = process.platform === 'darwin';\n\n    if (isWindows) {\n        return 'windows';\n    }\n\n    if (isMacOS) {\n        return 'macOS';\n    }\n\n    return 'linux';\n};\n\nconst isDevelopment = process.env.NODE_ENV === 'development';\n\nconst defaultUserDataPath = app.getPath('userData');\nconst storePath = isDevelopment\n    ? path.normalize(`${defaultUserDataPath}-dev`)\n    : path.normalize(defaultUserDataPath);\n\nexport const store = new Store<any>({\n    beforeEachMigration: (_store, context) => {\n        console.log(`settings migrate from ${context.fromVersion} → ${context.toVersion}`);\n    },\n    cwd: storePath,\n    defaults: {\n        disable_auto_updates: false,\n        enableNeteaseTranslation: false,\n        global_media_hotkeys: true,\n        lyrics: ['NetEase', 'lrclib.net'],\n        mediaSession: false,\n        playbackType: 'web',\n        should_prompt_accessibility: true,\n        shown_accessibility_warning: false,\n        window_enable_tray: true,\n        window_exit_to_tray: false,\n        window_minimize_to_tray: false,\n        window_start_minimized: false,\n        window_window_bar_style: getFrame(),\n    },\n    migrations: {\n        '>=0.21.2': (store) => {\n            store.set('window_bar_style', 'linux');\n        },\n        '>=1.0.0': (store) => {\n            store.clear();\n        },\n    },\n});\n\nipcMain.handle('settings-get', (_event, data: { property: string }) => {\n    return store.get(`${data.property}`);\n});\n\nipcMain.on('settings-set', (__event, data: { property: string; value: any }) => {\n    if (data.value === undefined) {\n        store.delete(data.property);\n    } else {\n        store.set(data.property, data.value);\n    }\n});\n\nipcMain.handle('password-get', (_event, server: string): null | string => {\n    if (safeStorage.isEncryptionAvailable()) {\n        const servers = store.get('server') as Record<string, string> | undefined;\n\n        if (!servers) {\n            return null;\n        }\n\n        const encrypted = servers[server];\n        if (!encrypted) return null;\n\n        const decrypted = safeStorage.decryptString(Buffer.from(encrypted, 'hex'));\n        return decrypted;\n    }\n\n    return null;\n});\n\nipcMain.on('password-remove', (_event, server: string) => {\n    const passwords = store.get('server', {}) as Record<string, string>;\n    if (server in passwords) {\n        delete passwords[server];\n    }\n    store.set({ server: passwords });\n});\n\nipcMain.handle('password-set', (_event, password: string, server: string) => {\n    if (safeStorage.isEncryptionAvailable()) {\n        const encrypted = safeStorage.encryptString(password);\n        const passwords = store.get('server', {}) as Record<string, string>;\n        passwords[server] = encrypted.toString('hex');\n        store.set({ server: passwords });\n\n        return true;\n    }\n    return false;\n});\n\nipcMain.on('theme-set', (_event, theme: TitleTheme) => {\n    store.set('theme', theme);\n    nativeTheme.themeSource = theme;\n});\n\nipcMain.handle('open-file-selector', async (_event, options: OpenDialogOptions) => {\n    const result = await dialog.showOpenDialog({\n        ...options,\n        properties: ['openFile'],\n    });\n\n    return result.filePaths[0] || null;\n});\n"
  },
  {
    "path": "src/main/features/darwin/dock-menu.ts",
    "content": "import { app, ipcMain, Menu } from 'electron';\n\nimport { getMainWindow } from '/@/main/index';\nimport { PlayerStatus } from '/@/shared/types/types';\n\nlet currentStatus: PlayerStatus = PlayerStatus.PAUSED;\n\nconst updateDockMenu = () => {\n    if (!app.dock) return;\n\n    const isPlaying = currentStatus === PlayerStatus.PLAYING;\n\n    const dockMenu = Menu.buildFromTemplate([\n        {\n            click: () => {\n                getMainWindow()?.webContents.send('renderer-player-play-pause');\n            },\n            label: isPlaying ? 'Pause' : 'Play',\n        },\n        {\n            type: 'separator',\n        },\n        {\n            click: () => {\n                getMainWindow()?.webContents.send('renderer-player-next');\n            },\n            label: 'Next',\n        },\n        {\n            click: () => {\n                getMainWindow()?.webContents.send('renderer-player-previous');\n            },\n            label: 'Previous',\n        },\n        {\n            type: 'separator',\n        },\n        {\n            click: () => {\n                getMainWindow()?.webContents.send('renderer-player-stop');\n            },\n            label: 'Stop',\n        },\n    ]);\n\n    app.dock.setMenu(dockMenu);\n};\n\nipcMain.on('update-playback', (_event, status: PlayerStatus) => {\n    currentStatus = status;\n    updateDockMenu();\n});\n\n// Initialize dock menu after app is ready\napp.whenReady().then(() => {\n    updateDockMenu();\n});\n"
  },
  {
    "path": "src/main/features/darwin/index.ts",
    "content": "import './dock-menu';\n"
  },
  {
    "path": "src/main/features/index.ts",
    "content": "import './core';\nimport(`./${process.platform}`);\n"
  },
  {
    "path": "src/main/features/linux/index.ts",
    "content": "import './mpris';\n"
  },
  {
    "path": "src/main/features/linux/mpris.ts",
    "content": "import { ipcMain } from 'electron';\nimport Player from 'mpris-service';\n\nimport { getMainWindow } from '/@/main/index';\nimport { QueueSong } from '/@/shared/types/domain-types';\nimport { PlayerRepeat, PlayerStatus } from '/@/shared/types/types';\n\nconst mprisPlayer = Player({\n    identity: 'Feishin',\n    maximumRate: 1.0,\n    minimumRate: 1.0,\n    name: 'Feishin',\n    rate: 1.0,\n    supportedInterfaces: ['player'],\n    supportedMimeTypes: ['audio/mpeg', 'application/ogg'],\n    supportedUriSchemes: ['file'],\n});\n\nmprisPlayer.on('quit', () => {\n    process.exit();\n});\n\nconst hasData = (): boolean => {\n    return mprisPlayer.metadata && !!mprisPlayer.metadata['mpris:length'];\n};\n\nmprisPlayer.on('stop', () => {\n    getMainWindow()?.webContents.send('renderer-player-stop');\n    mprisPlayer.playbackStatus = 'Paused';\n});\n\nmprisPlayer.on('pause', () => {\n    if (!hasData()) return;\n    getMainWindow()?.webContents.send('renderer-player-pause');\n    mprisPlayer.playbackStatus = 'Paused';\n});\n\nmprisPlayer.on('play', () => {\n    if (!hasData()) return;\n    getMainWindow()?.webContents.send('renderer-player-play');\n    mprisPlayer.playbackStatus = 'Playing';\n});\n\nmprisPlayer.on('playpause', () => {\n    if (!hasData()) return;\n    getMainWindow()?.webContents.send('renderer-player-play-pause');\n    if (mprisPlayer.playbackStatus !== 'Playing') {\n        mprisPlayer.playbackStatus = 'Playing';\n    } else {\n        mprisPlayer.playbackStatus = 'Paused';\n    }\n});\n\nmprisPlayer.on('next', () => {\n    if (!hasData()) return;\n    getMainWindow()?.webContents.send('renderer-player-next');\n\n    if (mprisPlayer.playbackStatus !== 'Playing') {\n        mprisPlayer.playbackStatus = 'Playing';\n    }\n});\n\nmprisPlayer.on('previous', () => {\n    if (!hasData()) return;\n    getMainWindow()?.webContents.send('renderer-player-previous');\n\n    if (mprisPlayer.playbackStatus !== 'Playing') {\n        mprisPlayer.playbackStatus = Player.PLAYBACK_STATUS_PLAYING;\n    }\n});\n\nmprisPlayer.on('volume', (vol: number) => {\n    let volume = Math.round(vol * 100);\n\n    if (volume > 100) {\n        volume = 100;\n    } else if (volume < 0) {\n        volume = 0;\n    }\n\n    getMainWindow()?.webContents.send('request-volume', {\n        volume,\n    });\n\n    mprisPlayer.volume = volume / 100;\n});\n\nmprisPlayer.on('shuffle', (event: boolean) => {\n    getMainWindow()?.webContents.send('mpris-request-toggle-shuffle', { shuffle: event });\n    mprisPlayer.shuffle = event;\n});\n\nmprisPlayer.on('loopStatus', (event: string) => {\n    getMainWindow()?.webContents.send('mpris-request-toggle-repeat', { repeat: event });\n    mprisPlayer.loopStatus = event;\n});\n\nmprisPlayer.on('position', (event: any) => {\n    getMainWindow()?.webContents.send('request-position', {\n        position: event.position / 1e6,\n    });\n});\n\nmprisPlayer.on('seek', (event: number) => {\n    getMainWindow()?.webContents.send('request-seek', {\n        offset: event / 1e6,\n    });\n});\n\nmprisPlayer.on('raise', () => {\n    getMainWindow()?.show();\n});\n\nipcMain.on('update-position', (_event, arg: number) => {\n    mprisPlayer.getPosition = () => arg * 1e6;\n});\n\nipcMain.on('update-seek', (_event, arg) => {\n    mprisPlayer.seeked(arg * 1e6);\n});\n\nipcMain.on('update-volume', (_event, volume) => {\n    mprisPlayer.volume = Number(volume) / 100;\n});\n\nipcMain.on('update-playback', (_event, status: PlayerStatus) => {\n    mprisPlayer.playbackStatus = status === PlayerStatus.PLAYING ? 'Playing' : 'Paused';\n});\n\nconst REPEAT_TO_MPRIS: Record<PlayerRepeat, string> = {\n    [PlayerRepeat.ALL]: 'Playlist',\n    [PlayerRepeat.NONE]: 'None',\n    [PlayerRepeat.ONE]: 'Track',\n};\n\nipcMain.on('update-repeat', (_event, arg: PlayerRepeat) => {\n    mprisPlayer.loopStatus = REPEAT_TO_MPRIS[arg];\n});\n\nipcMain.on('update-shuffle', (_event, shuffle: boolean) => {\n    mprisPlayer.shuffle = shuffle;\n});\n\nipcMain.on(\n    'update-song',\n    (_event, song: QueueSong | undefined, imageUrl: null | string | undefined) => {\n        try {\n            if (!song?.id) {\n                mprisPlayer.metadata = {};\n                return;\n            }\n\n            mprisPlayer.metadata = {\n                'mpris:artUrl': imageUrl || null,\n                'mpris:length': song.duration ? Math.round((song.duration || 0) * 1e3) : null,\n                'mpris:trackid': song.id\n                    ? mprisPlayer.objectPath(`track/${song.id?.replace('-', '')}`)\n                    : '',\n                'xesam:album': song.album || null,\n                'xesam:albumArtist': song.albumArtists?.length\n                    ? song.albumArtists.map((artist) => artist.name)\n                    : null,\n                'xesam:artist': song.artists?.length\n                    ? song.artists.map((artist) => artist.name)\n                    : null,\n                'xesam:audioBpm': song.bpm,\n                // Comment is a `list of strings` type\n                'xesam:comment': song.comment ? [song.comment] : null,\n                'xesam:contentCreated': song.releaseDate,\n                'xesam:discNumber': song.discNumber ? song.discNumber : null,\n                'xesam:genre': song.genres?.length\n                    ? song.genres.map((genre: any) => genre.name)\n                    : null,\n                'xesam:lastUsed': song.lastPlayedAt,\n                'xesam:title': song.name || null,\n                'xesam:trackNumber': song.trackNumber ? song.trackNumber : null,\n                'xesam:useCount':\n                    song.playCount !== null && song.playCount !== undefined ? song.playCount : null,\n                // User ratings are only on Navidrome/Subsonic and are on a scale of 1-5\n                'xesam:userRating': song.userRating ? song.userRating / 5 : null,\n            };\n        } catch (err) {\n            console.error(err);\n        }\n    },\n);\n\nexport { mprisPlayer };\n"
  },
  {
    "path": "src/main/features/win32/index.ts",
    "content": ""
  },
  {
    "path": "src/main/index.ts",
    "content": "import type { UpdateCheckResult } from 'electron-updater';\n\nimport { is } from '@electron-toolkit/utils';\nimport {\n    app,\n    BrowserWindow,\n    BrowserWindowConstructorOptions,\n    globalShortcut,\n    ipcMain,\n    Menu,\n    nativeImage,\n    nativeTheme,\n    net,\n    powerSaveBlocker,\n    protocol,\n    Rectangle,\n    screen,\n    shell,\n    Tray,\n} from 'electron';\nimport electronLocalShortcut from 'electron-localshortcut';\nimport log from 'electron-log/main';\nimport { AppImageUpdater, autoUpdater, MacUpdater, NsisUpdater } from 'electron-updater';\nimport { access, constants } from 'fs';\nimport path, { join } from 'path';\nimport semver from 'semver';\n\nimport packageJson from '../../package.json';\nimport { disableMediaKeys, enableMediaKeys } from './features/core/player/media-keys';\nimport { shutdownServer } from './features/core/remote';\nimport { store } from './features/core/settings';\nimport MenuBuilder from './menu';\nimport {\n    autoUpdaterLogInterface,\n    createLog,\n    disableAutoUpdates,\n    hotkeyToElectronAccelerator,\n    isLinux,\n    isMacOS,\n    isWindows,\n} from './utils';\nimport './features';\n\nimport { PlayerType, TitleTheme } from '/@/shared/types/types';\n\nconst ALPHA_UPDATER_CONFIG: {\n    bucket: string;\n    channel: string;\n    endpoint: string;\n    provider: 's3';\n} = {\n    bucket: '',\n    channel: 'alpha',\n    endpoint: 'https://feishin-nightly-bucket.jeffvli.org',\n    provider: 's3',\n};\n\nconst GITHUB_UPDATER_CONFIG = {\n    owner: 'jeffvli',\n    provider: 'github' as const,\n    repo: 'feishin',\n};\n\ntype UpdaterInstance = AppImageUpdater | MacUpdater | NsisUpdater | typeof autoUpdater;\n\nclass AppUpdater {\n    constructor() {\n        const effectiveChannel = store.get('release_channel') as string;\n        console.log('Effective update channel:', effectiveChannel);\n        if (effectiveChannel === 'alpha') {\n            checkAllChannelsAndGetBest().then(({ result, updater: updaterInstance }) => {\n                updaterInstance.autoInstallOnAppQuit = true;\n                updaterInstance.autoRunAppAfterInstall = true;\n                if (isMacOS()) {\n                    if (result?.isUpdateAvailable) {\n                        getMainWindow()?.webContents.send(\n                            'update-available',\n                            result.updateInfo.version,\n                        );\n                    }\n                } else {\n                    updaterInstance.checkForUpdatesAndNotify();\n                }\n            });\n            return;\n        }\n\n        configureAndGetUpdater();\n        if (isMacOS()) {\n            autoUpdater.autoDownload = false;\n            autoUpdater\n                .checkForUpdates()\n                .then((result) => {\n                    if (result?.isUpdateAvailable) {\n                        getMainWindow()?.webContents.send(\n                            'update-available',\n                            result.updateInfo.version,\n                        );\n                    }\n                })\n                .catch((err) => console.error('Check for updates failed', err));\n        } else {\n            autoUpdater.checkForUpdatesAndNotify();\n        }\n    }\n}\n\n// When release channel is alpha, check alpha and latest for updates and return\n// the updater + result for the newest version found (so alpha users can receive\n// latest updates when they are newer than the current alpha).\nasync function checkAllChannelsAndGetBest(): Promise<{\n    result: null | UpdateCheckResult;\n    updater: UpdaterInstance;\n}> {\n    const currentVersion = packageJson.version;\n    const candidates: Array<{\n        channel: 'alpha' | 'beta' | 'latest';\n        result: UpdateCheckResult;\n        updater: UpdaterInstance;\n    }> = [];\n\n    const alphaUpdater = createAlphaUpdaterInstance();\n    alphaUpdater.logger = autoUpdaterLogInterface;\n    alphaUpdater.channel = ALPHA_UPDATER_CONFIG.channel;\n    alphaUpdater.allowPrerelease = true;\n    alphaUpdater.disableDifferentialDownload = true;\n    alphaUpdater.allowDowngrade = true;\n\n    try {\n        console.log('Checking for updates on alpha channel');\n        const alphaResult = await alphaUpdater.checkForUpdates();\n        if (\n            alphaResult?.updateInfo?.version &&\n            alphaResult.isUpdateAvailable &&\n            semver.valid(alphaResult.updateInfo.version) &&\n            semver.gt(alphaResult.updateInfo.version, currentVersion)\n        ) {\n            candidates.push({ channel: 'alpha', result: alphaResult, updater: alphaUpdater });\n        }\n    } catch (e) {\n        log.warn('Alpha channel check failed', e);\n    }\n\n    try {\n        autoUpdater.setFeedURL(GITHUB_UPDATER_CONFIG);\n        configureAutoUpdaterForChannel('latest');\n        console.log('Checking for updates on latest channel (GitHub)');\n        const latestResult = await autoUpdater.checkForUpdates();\n        if (\n            latestResult?.updateInfo?.version &&\n            latestResult.isUpdateAvailable &&\n            semver.valid(latestResult.updateInfo.version) &&\n            semver.gt(latestResult.updateInfo.version, currentVersion)\n        ) {\n            candidates.push({ channel: 'latest', result: latestResult, updater: autoUpdater });\n        }\n    } catch (e) {\n        log.warn('Latest channel check failed', e);\n    }\n\n    if (candidates.length === 0) {\n        return { result: null, updater: alphaUpdater };\n    }\n\n    const best = candidates.reduce((a, b) =>\n        semver.gt(a.result.updateInfo.version, b.result.updateInfo.version) ? a : b,\n    );\n\n    if (best.channel === 'latest') {\n        configureAutoUpdaterForChannel('latest');\n    }\n\n    return { result: best.result, updater: best.updater };\n}\n\nfunction configureAndGetUpdater(): UpdaterInstance {\n    const isBetaVersion = packageJson.version.includes('-beta');\n    const isAlphaVersion = packageJson.version.includes('-alpha');\n    let releaseChannel = store.get('release_channel');\n    const isNotConfigured = !releaseChannel;\n\n    console.log('Release channel:', releaseChannel);\n    console.log('Is beta version:', isBetaVersion);\n    console.log('Is alpha version:', isAlphaVersion);\n    console.log('Is not configured:', isNotConfigured);\n\n    if (isNotConfigured) {\n        console.log('Release channel not configured, setting default channel');\n        const defaultChannel = isAlphaVersion ? 'alpha' : isBetaVersion ? 'beta' : 'latest';\n        store.set('release_channel', defaultChannel);\n        releaseChannel = defaultChannel;\n    }\n\n    const effectiveChannel = store.get('release_channel') as string;\n\n    if (effectiveChannel === 'alpha') {\n        const updater = createAlphaUpdaterInstance();\n        log.transports.file.level = 'info';\n        updater.logger = autoUpdaterLogInterface;\n        updater.channel = ALPHA_UPDATER_CONFIG.channel;\n        updater.allowPrerelease = true;\n        updater.disableDifferentialDownload = true;\n        updater.allowDowngrade = true;\n        updater.autoInstallOnAppQuit = true;\n        updater.autoRunAppAfterInstall = true;\n        return updater;\n    }\n\n    log.transports.file.level = 'info';\n    autoUpdater.logger = autoUpdaterLogInterface;\n    autoUpdater.autoInstallOnAppQuit = true;\n    autoUpdater.autoRunAppAfterInstall = true;\n\n    if (effectiveChannel === 'beta') {\n        autoUpdater.channel = 'beta';\n        autoUpdater.allowDowngrade = true;\n        autoUpdater.allowPrerelease = true;\n        autoUpdater.disableDifferentialDownload = true;\n    } else {\n        autoUpdater.channel = 'latest';\n        autoUpdater.allowPrerelease = false;\n    }\n\n    return autoUpdater;\n}\n\n/**\n * Configures the global autoUpdater for a specific GitHub channel (beta or latest).\n * Used when checking multiple channels or when the winning channel is beta/latest.\n */\nfunction configureAutoUpdaterForChannel(channel: 'beta' | 'latest'): void {\n    log.transports.file.level = 'info';\n    autoUpdater.logger = autoUpdaterLogInterface;\n    autoUpdater.autoInstallOnAppQuit = true;\n    autoUpdater.autoRunAppAfterInstall = true;\n    if (channel === 'beta') {\n        autoUpdater.channel = 'beta';\n        autoUpdater.allowDowngrade = true;\n        autoUpdater.allowPrerelease = true;\n        autoUpdater.disableDifferentialDownload = true;\n    } else {\n        autoUpdater.channel = 'latest';\n        autoUpdater.allowPrerelease = false;\n    }\n}\n\nfunction createAlphaUpdaterInstance(): AppImageUpdater | MacUpdater | NsisUpdater {\n    if (isMacOS()) {\n        return new MacUpdater(ALPHA_UPDATER_CONFIG);\n    }\n\n    if (isLinux()) {\n        return new AppImageUpdater(ALPHA_UPDATER_CONFIG);\n    }\n\n    return new NsisUpdater(ALPHA_UPDATER_CONFIG);\n}\n\nprotocol.registerSchemesAsPrivileged([{ privileges: { bypassCSP: true }, scheme: 'feishin' }]);\n\nprocess.on('uncaughtException', (error: any) => {\n    console.error('Error in main process', error);\n});\n\nif (store.get('ignore_ssl')) {\n    app.commandLine.appendSwitch('ignore-certificate-errors');\n}\n\n// From https://github.com/tutao/tutanota/commit/92c6ed27625fcf367f0fbcc755d83d7ff8fde94b\nif (isLinux() && !process.argv.some((a) => a.startsWith('--password-store='))) {\n    const passwordStore = store.get('password_store', 'gnome-libsecret') as string;\n    app.commandLine.appendSwitch('password-store', passwordStore);\n}\n\n// Handle fractional scaling issue from Wayland https://github.com/jeffvli/feishin/issues/1271#issuecomment-4063326712\nif (isLinux()) {\n    app.commandLine.appendSwitch('disable-features', 'WaylandFractionalScaleV1');\n}\n\nlet mainWindow: BrowserWindow | null = null;\nlet tray: null | Tray = null;\nlet exitFromTray = false;\nlet forceQuit = false;\nlet powerSaveBlockerId: null | number = null;\n\nif (process.env.NODE_ENV === 'production') {\n    import('source-map-support').then((sourceMapSupport) => {\n        sourceMapSupport.install();\n    });\n}\n\nconst isDevelopment = process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true';\n\nif (isDevelopment) {\n    import('electron-debug').then((electronDebug) => {\n        electronDebug.default();\n    });\n}\n\nconst installExtensions = async () => {\n    import('electron-devtools-installer').then((installer) => {\n        const forceDownload = !!process.env.UPGRADE_EXTENSIONS;\n        const extensions = ['REACT_DEVELOPER_TOOLS', 'REDUX_DEVTOOLS'];\n\n        installer\n            .installExtension(\n                extensions.map((name) => installer[name]),\n                { forceDownload },\n            )\n            .then((installedExtensions) => {\n                createLog({\n                    message: `Installed extension: ${installedExtensions}`,\n                    type: 'info',\n                });\n            })\n            .catch(() => {\n                // Ignore\n            });\n    });\n};\n\nconst userDataPath = app.getPath('userData');\n\nif (isDevelopment) {\n    const devUserDataPath = `${userDataPath}-dev`;\n    app.setPath('userData', devUserDataPath);\n}\n\nconst RESOURCES_PATH = app.isPackaged\n    ? path.join(process.resourcesPath, 'assets')\n    : path.join(__dirname, '../../assets');\n\nconst getAssetPath = (...paths: string[]): string => {\n    return path.join(RESOURCES_PATH, ...paths);\n};\n\nexport const getMainWindow = () => {\n    return mainWindow;\n};\n\nexport const sendToastToRenderer = ({\n    message,\n    type,\n}: {\n    message: string;\n    type: 'error' | 'info' | 'success' | 'warning';\n}) => {\n    getMainWindow()?.webContents.send('toast-from-main', {\n        message,\n        type,\n    });\n};\n\nconst createWinThumbarButtons = () => {\n    if (isWindows()) {\n        getMainWindow()?.setThumbarButtons([\n            {\n                click: () => getMainWindow()?.webContents.send('renderer-player-previous'),\n                icon: nativeImage.createFromPath(getAssetPath('skip-previous.png')),\n                tooltip: 'Previous Track',\n            },\n            {\n                click: () => getMainWindow()?.webContents.send('renderer-player-play-pause'),\n                icon: nativeImage.createFromPath(getAssetPath('play-circle.png')),\n                tooltip: 'Play/Pause',\n            },\n            {\n                click: () => getMainWindow()?.webContents.send('renderer-player-next'),\n                icon: nativeImage.createFromPath(getAssetPath('skip-next.png')),\n                tooltip: 'Next Track',\n            },\n        ]);\n    }\n};\n\nconst createTray = () => {\n    let trayIcon: Electron.NativeImage | string;\n\n    if (isMacOS()) {\n        const iconPath = getAssetPath('icons/IconTemplate.png');\n        const icon = nativeImage.createFromPath(iconPath);\n        icon.setTemplateImage(true);\n        trayIcon = icon;\n    } else if (isLinux()) {\n        trayIcon = getAssetPath('icons/icon.png');\n    } else {\n        trayIcon = getAssetPath('icons/icon.ico');\n    }\n\n    tray = new Tray(trayIcon);\n\n    const contextMenu = Menu.buildFromTemplate([\n        {\n            click: () => {\n                getMainWindow()?.webContents.send('renderer-player-play-pause');\n            },\n            label: 'Play/Pause',\n        },\n        {\n            click: () => {\n                getMainWindow()?.webContents.send('renderer-player-next');\n            },\n            label: 'Next Track',\n        },\n        {\n            click: () => {\n                getMainWindow()?.webContents.send('renderer-player-previous');\n            },\n            label: 'Previous Track',\n        },\n        {\n            click: () => {\n                getMainWindow()?.webContents.send('renderer-player-stop');\n            },\n            label: 'Stop',\n        },\n        {\n            type: 'separator',\n        },\n        {\n            click: () => {\n                if (mainWindow === null) createWindow(false);\n                else {\n                    mainWindow.show();\n                    createWinThumbarButtons();\n                }\n            },\n            label: 'Open main window',\n        },\n        {\n            click: () => {\n                exitFromTray = true;\n                app.quit();\n            },\n            label: 'Quit',\n        },\n    ]);\n\n    tray.on('click', () => {\n        if (store.get('window_minimize_to_tray')) {\n            if (mainWindow?.isVisible()) {\n                mainWindow?.hide();\n            } else {\n                mainWindow?.show();\n                createWinThumbarButtons();\n            }\n        } else {\n            mainWindow?.show();\n            createWinThumbarButtons();\n        }\n    });\n\n    tray.setToolTip('Feishin');\n    tray.setContextMenu(contextMenu);\n};\n\nasync function createWindow(first = true): Promise<void> {\n    if (isDevelopment) {\n        await installExtensions().catch(console.log);\n    }\n\n    const nativeFrame = store.get('window_window_bar_style', 'linux') === 'linux';\n    store.set('window_has_frame', nativeFrame);\n\n    const nativeFrameConfig: Record<string, BrowserWindowConstructorOptions> = {\n        linux: {\n            autoHideMenuBar: true,\n            frame: true,\n        },\n        macOS: {\n            autoHideMenuBar: true,\n            frame: true,\n            titleBarStyle: 'default',\n            trafficLightPosition: { x: 10, y: 10 },\n        },\n        windows: {\n            autoHideMenuBar: true,\n            frame: true,\n        },\n    };\n\n    // Create the browser window.\n    mainWindow = new BrowserWindow({\n        autoHideMenuBar: true,\n        frame: false,\n        height: 900,\n        icon: isWindows() ? getAssetPath('icons/icon.ico') : getAssetPath('icons/icon.png'),\n        minHeight: 120,\n        minWidth: 480,\n        show: false,\n        webPreferences: {\n            allowRunningInsecureContent: !!store.get('ignore_ssl'),\n            backgroundThrottling: false,\n            contextIsolation: true,\n            devTools: true,\n            nodeIntegration: true,\n            preload: join(__dirname, '../preload/index.js'),\n            sandbox: false,\n            webSecurity: !store.get('ignore_cors'),\n        },\n        width: 1440,\n        ...(nativeFrame && isLinux() && nativeFrameConfig.linux),\n        ...(nativeFrame && isMacOS() && nativeFrameConfig.macOS),\n        ...(nativeFrame && isWindows() && nativeFrameConfig.windows),\n    });\n\n    // From https://github.com/electron/electron/issues/526#issuecomment-1663959513\n    const bounds = store.get('bounds') as Rectangle | undefined;\n    if (bounds) {\n        const screenArea = screen.getDisplayMatching(bounds).workArea;\n        if (\n            bounds.x > screenArea.x + screenArea.width ||\n            bounds.x < screenArea.x ||\n            bounds.y < screenArea.y ||\n            bounds.y > screenArea.y + screenArea.height\n        ) {\n            if (bounds.width < screenArea.width && bounds.height < screenArea.height) {\n                mainWindow.setBounds({ height: bounds.height, width: bounds.width });\n            } else {\n                mainWindow.setBounds({ height: 900, width: 1440 });\n            }\n        } else {\n            mainWindow.setBounds(bounds);\n        }\n    }\n\n    electronLocalShortcut.register(mainWindow, 'Ctrl+Shift+I', () => {\n        mainWindow?.webContents.openDevTools();\n    });\n\n    ipcMain.on('window-dev-tools', () => {\n        mainWindow?.webContents.openDevTools();\n    });\n\n    ipcMain.on('window-maximize', () => {\n        mainWindow?.maximize();\n    });\n\n    ipcMain.on('window-unmaximize', () => {\n        mainWindow?.unmaximize();\n    });\n\n    ipcMain.on('window-minimize', () => {\n        mainWindow?.minimize();\n    });\n\n    ipcMain.on('window-close', () => {\n        mainWindow?.close();\n    });\n\n    ipcMain.on('window-quit', () => {\n        shutdownServer();\n        mainWindow?.close();\n        app.exit();\n    });\n\n    ipcMain.handle('window-clear-cache', async () => {\n        return mainWindow?.webContents.session.clearCache();\n    });\n\n    ipcMain.handle(\n        'app-check-for-updates',\n        async (): Promise<{ updateAvailable: boolean; version?: string }> => {\n            if (disableAutoUpdates()) {\n                console.log('Auto updates are disabled');\n                return { updateAvailable: false };\n            }\n\n            try {\n                console.log('Checking for updates');\n                const effectiveChannel = store.get('release_channel') as string;\n                let result: null | UpdateCheckResult;\n                let updater: UpdaterInstance;\n\n                if (effectiveChannel === 'alpha') {\n                    const best = await checkAllChannelsAndGetBest();\n                    result = best.result;\n                    updater = best.updater;\n                } else {\n                    updater = configureAndGetUpdater();\n                    result = await updater.checkForUpdates();\n                }\n\n                const updateAvailable = result?.isUpdateAvailable ?? false;\n                console.log('Update available:', updateAvailable);\n                if (updateAvailable && store.get('disable_auto_updates') !== true) {\n                    if (isMacOS()) {\n                        getMainWindow()?.webContents.send(\n                            'update-available',\n                            result?.updateInfo?.version,\n                        );\n                    } else {\n                        console.log('Downloading update');\n                        updater.downloadUpdate();\n                    }\n                }\n\n                return {\n                    updateAvailable,\n                    version: result?.updateInfo?.version,\n                };\n            } catch {\n                console.log('Error checking for updates');\n                return { updateAvailable: false };\n            }\n        },\n    );\n\n    ipcMain.on('app-restart', () => {\n        // Fix for .AppImage\n        if (process.env.APPIMAGE) {\n            app.exit();\n            app.relaunch({\n                args: process.argv.slice(1).concat(['--appimage-extract-and-run']),\n                execPath: process.env.APPIMAGE,\n            });\n            app.exit(0);\n        } else {\n            app.relaunch();\n            app.exit(0);\n        }\n    });\n\n    ipcMain.on('global-media-keys-enable', () => {\n        enableMediaKeys(mainWindow);\n    });\n\n    ipcMain.on('global-media-keys-disable', () => {\n        disableMediaKeys();\n    });\n\n    ipcMain.on('download-url', (_event, url: string) => {\n        mainWindow?.webContents.downloadURL(url);\n    });\n\n    const globalMediaKeysEnabled = store.get('global_media_hotkeys', true) as boolean;\n\n    if (globalMediaKeysEnabled) {\n        enableMediaKeys(mainWindow);\n    }\n\n    const startWindowMinimized = store.get('window_start_minimized', false) as boolean;\n\n    mainWindow.on('ready-to-show', () => {\n        // mainWindow.show()\n\n        if (!mainWindow) {\n            throw new Error('\"mainWindow\" is not defined');\n        }\n\n        if (!first || !startWindowMinimized) {\n            const maximized = store.get('maximized');\n            const fullScreen = store.get('fullscreen');\n\n            if (maximized) {\n                mainWindow.maximize();\n            }\n            if (fullScreen) {\n                mainWindow.setFullScreen(true);\n            }\n\n            mainWindow.show();\n            createWinThumbarButtons();\n        }\n    });\n\n    mainWindow.on('closed', () => {\n        ipcMain.removeHandler('window-clear-cache');\n        ipcMain.removeHandler('app-check-for-updates');\n        mainWindow = null;\n    });\n\n    mainWindow.on('close', (event) => {\n        store.set('bounds', mainWindow?.getNormalBounds());\n        store.set('maximized', mainWindow?.isMaximized());\n        store.set('fullscreen', mainWindow?.isFullScreen());\n\n        if (!exitFromTray && store.get('window_exit_to_tray')) {\n            event.preventDefault();\n            mainWindow?.hide();\n        }\n\n        if (forceQuit) {\n            app.exit();\n        }\n    });\n\n    (mainWindow as any).on('minimize', (event: any) => {\n        if (store.get('window_minimize_to_tray') === true) {\n            event.preventDefault();\n            mainWindow?.hide();\n        }\n    });\n\n    if (isWindows()) {\n        app.setAppUserModelId('org.jeffvli.feishin');\n    }\n\n    if (isMacOS()) {\n        app.on('before-quit', () => {\n            forceQuit = true;\n        });\n    }\n\n    const menuBuilder = new MenuBuilder(mainWindow);\n    menuBuilder.buildMenu();\n\n    if (process.platform !== 'darwin') {\n        Menu.setApplicationMenu(null);\n    }\n\n    // Open URLs in the user's browser\n    mainWindow.webContents.setWindowOpenHandler((edata) => {\n        shell.openExternal(edata.url);\n        return { action: 'deny' };\n    });\n\n    if (!disableAutoUpdates() && store.get('disable_auto_updates') !== true) {\n        new AppUpdater();\n    }\n\n    const theme = store.get('theme') as TitleTheme | undefined;\n    nativeTheme.themeSource = theme || 'dark';\n\n    mainWindow.webContents.setWindowOpenHandler((details) => {\n        shell.openExternal(details.url);\n        return { action: 'deny' };\n    });\n\n    // HMR for renderer base on electron-vite cli.\n    // Load the remote URL for development or the local html file for production.\n    if (is.dev && process.env['ELECTRON_RENDERER_URL']) {\n        mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']);\n    } else {\n        mainWindow.loadFile(join(__dirname, '../renderer/index.html'));\n    }\n}\n\n// Only allow hardware media key handling if:\n// 1. The \"Enable Media Session\" setting is enabled\n// 2. The playback type is WEB (mpv not supported)\n// 3. The platform is not Linux (because we are using mpris instead)\nconst enableMediaSession = store.get('mediaSession', false) as boolean;\nconst playbackType = store.get('playbackType', PlayerType.WEB) as PlayerType;\nconst shouldDisableMediaFeatures =\n    isLinux() || !enableMediaSession || playbackType !== PlayerType.WEB;\n\nif (shouldDisableMediaFeatures) {\n    app.commandLine.appendSwitch(\n        'disable-features',\n        'HardwareMediaKeyHandling,MediaSessionService',\n    );\n}\n\n// https://github.com/electron/electron/issues/46538#issuecomment-2808806722\napp.commandLine.appendSwitch('gtk-version', '3');\n\n// Enable garbage collection API\napp.commandLine.appendSwitch('js-flags', '--expose-gc');\n\n// Must duplicate with the one in renderer process settings.store.ts\nenum BindingActions {\n    GLOBAL_SEARCH = 'globalSearch',\n    LOCAL_SEARCH = 'localSearch',\n    MUTE = 'volumeMute',\n    NEXT = 'next',\n    PAUSE = 'pause',\n    PLAY = 'play',\n    PLAY_PAUSE = 'playPause',\n    PREVIOUS = 'previous',\n    SHUFFLE = 'toggleShuffle',\n    SKIP_BACKWARD = 'skipBackward',\n    SKIP_FORWARD = 'skipForward',\n    STOP = 'stop',\n    TOGGLE_FULLSCREEN_PLAYER = 'toggleFullscreenPlayer',\n    TOGGLE_QUEUE = 'toggleQueue',\n    TOGGLE_REPEAT = 'toggleRepeat',\n    VOLUME_DOWN = 'volumeDown',\n    VOLUME_UP = 'volumeUp',\n}\n\nconst HOTKEY_ACTIONS: Record<BindingActions, () => void> = {\n    [BindingActions.GLOBAL_SEARCH]: () => {},\n    [BindingActions.LOCAL_SEARCH]: () => {},\n    [BindingActions.MUTE]: () => getMainWindow()?.webContents.send('renderer-player-volume-mute'),\n    [BindingActions.NEXT]: () => getMainWindow()?.webContents.send('renderer-player-next'),\n    [BindingActions.PAUSE]: () => getMainWindow()?.webContents.send('renderer-player-pause'),\n    [BindingActions.PLAY]: () => getMainWindow()?.webContents.send('renderer-player-play'),\n    [BindingActions.PLAY_PAUSE]: () =>\n        getMainWindow()?.webContents.send('renderer-player-play-pause'),\n    [BindingActions.PREVIOUS]: () => getMainWindow()?.webContents.send('renderer-player-previous'),\n    [BindingActions.SHUFFLE]: () =>\n        getMainWindow()?.webContents.send('renderer-player-toggle-shuffle'),\n    [BindingActions.SKIP_BACKWARD]: () =>\n        getMainWindow()?.webContents.send('renderer-player-skip-backward'),\n    [BindingActions.SKIP_FORWARD]: () =>\n        getMainWindow()?.webContents.send('renderer-player-skip-forward'),\n    [BindingActions.STOP]: () => getMainWindow()?.webContents.send('renderer-player-stop'),\n    [BindingActions.TOGGLE_FULLSCREEN_PLAYER]: () => {},\n    [BindingActions.TOGGLE_QUEUE]: () => {},\n    [BindingActions.TOGGLE_REPEAT]: () =>\n        getMainWindow()?.webContents.send('renderer-player-toggle-repeat'),\n    [BindingActions.VOLUME_DOWN]: () =>\n        getMainWindow()?.webContents.send('renderer-player-volume-down'),\n    [BindingActions.VOLUME_UP]: () =>\n        getMainWindow()?.webContents.send('renderer-player-volume-up'),\n};\n\nipcMain.on(\n    'set-global-shortcuts',\n    (\n        _event,\n        data: Record<BindingActions, { allowGlobal: boolean; hotkey: string; isGlobal: boolean }>,\n    ) => {\n        // Since we're not tracking the previous shortcuts, we need to unregister all of them\n        globalShortcut.unregisterAll();\n\n        for (const shortcut of Object.keys(data)) {\n            const isGlobalHotkey = data[shortcut as BindingActions].isGlobal;\n            const isValidHotkey =\n                data[shortcut as BindingActions].hotkey &&\n                data[shortcut as BindingActions].hotkey !== '';\n\n            if (isGlobalHotkey && isValidHotkey) {\n                const accelerator = hotkeyToElectronAccelerator(\n                    data[shortcut as BindingActions].hotkey,\n                );\n\n                globalShortcut.register(accelerator, () => {\n                    HOTKEY_ACTIONS[shortcut as BindingActions]();\n                });\n            }\n        }\n\n        const globalMediaKeysEnabled = store.get('global_media_hotkeys', true) as boolean;\n\n        if (globalMediaKeysEnabled) {\n            enableMediaKeys(mainWindow);\n        }\n    },\n);\n\nipcMain.on(\n    'logger',\n    (\n        _event,\n        data: {\n            message: string;\n            type: 'debug' | 'error' | 'info' | 'success' | 'verbose' | 'warning';\n        },\n    ) => {\n        createLog(data);\n    },\n);\n\nipcMain.handle('power-save-blocker-start', () => {\n    if (powerSaveBlockerId !== null) {\n        return powerSaveBlockerId;\n    }\n\n    powerSaveBlockerId = powerSaveBlocker.start('prevent-display-sleep');\n    return powerSaveBlockerId;\n});\n\nipcMain.handle('power-save-blocker-stop', () => {\n    if (powerSaveBlockerId !== null) {\n        const stopped = powerSaveBlocker.stop(powerSaveBlockerId);\n        powerSaveBlockerId = null;\n        return stopped;\n    }\n    return false;\n});\n\nipcMain.handle('power-save-blocker-is-started', () => {\n    return powerSaveBlockerId !== null && powerSaveBlocker.isStarted(powerSaveBlockerId);\n});\n\napp.on('window-all-closed', () => {\n    globalShortcut.unregisterAll();\n    // Respect the OSX convention of having the application in memory even\n    // after all windows have been closed\n    if (isMacOS()) {\n        mainWindow = null;\n    } else {\n        app.quit();\n    }\n});\n\nconst FONT_HEADERS = [\n    'font/collection',\n    'font/otf',\n    'font/sfnt',\n    'font/ttf',\n    'font/woff',\n    'font/woff2',\n];\n\nconst singleInstance = isDevelopment ? true : app.requestSingleInstanceLock();\n\nif (!singleInstance) {\n    app.quit();\n} else {\n    app.on('second-instance', () => {\n        if (mainWindow) {\n            if (mainWindow.isMinimized()) {\n                mainWindow.restore();\n            } else if (!mainWindow.isVisible()) {\n                mainWindow.show();\n            }\n\n            mainWindow.focus();\n        }\n    });\n\n    app.whenReady()\n        .then(() => {\n            protocol.handle('feishin', async (request) => {\n                const filePath = `file:${request.url.slice('feishin:'.length)}`;\n                const response = await net.fetch(filePath);\n                const contentType = response.headers.get('content-type');\n\n                if (!contentType || !FONT_HEADERS.includes(contentType)) {\n                    getMainWindow()?.webContents.send('custom-font-error', filePath);\n\n                    return new Response(null, {\n                        status: 403,\n                        statusText: 'Forbidden',\n                    });\n                }\n\n                return response;\n            });\n\n            createWindow();\n            if (store.get('window_enable_tray', true)) {\n                createTray();\n            }\n            app.on('activate', () => {\n                // On macOS it's common to re-create a window in the app when the\n                // dock icon is clicked and there are no other windows open.\n                if (mainWindow === null) createWindow(false);\n                else if (!mainWindow.isVisible()) {\n                    mainWindow.show();\n                    createWinThumbarButtons();\n                }\n            });\n        })\n        .catch(console.log);\n}\n\n// Register 'open-item' handler globally, ensuring it is only registered once\nif (!ipcMain.eventNames().includes('open-item')) {\n    ipcMain.handle('open-item', async (_event, path: string) => {\n        return new Promise<void>((resolve, reject) => {\n            access(path, constants.F_OK, (error) => {\n                if (error) {\n                    reject(error);\n                    return;\n                }\n\n                shell.showItemInFolder(path);\n                resolve();\n            });\n        });\n    });\n}\n\n// Register 'open-application-directory' handler globally, ensuring it is only registered once\nif (!ipcMain.eventNames().includes('open-application-directory')) {\n    ipcMain.handle('open-application-directory', async () => {\n        const userDataPath = app.getPath('userData');\n        shell.openPath(userDataPath);\n    });\n}\n"
  },
  {
    "path": "src/main/menu.ts",
    "content": "import { app, BrowserWindow, Menu, MenuItemConstructorOptions, shell } from 'electron';\n\ninterface DarwinMenuItemConstructorOptions extends MenuItemConstructorOptions {\n    selector?: string;\n    submenu?: DarwinMenuItemConstructorOptions[] | Menu;\n}\n\nexport default class MenuBuilder {\n    mainWindow: BrowserWindow;\n\n    constructor(mainWindow: BrowserWindow) {\n        this.mainWindow = mainWindow;\n    }\n\n    buildDarwinTemplate(): MenuItemConstructorOptions[] {\n        const subMenuAbout: DarwinMenuItemConstructorOptions = {\n            label: 'Electron',\n            submenu: [\n                {\n                    label: 'About Feishin',\n                    selector: 'orderFrontStandardAboutPanel:',\n                },\n                { type: 'separator' },\n                {\n                    accelerator: 'Command+,',\n                    click: () => {\n                        this.mainWindow.webContents.send('renderer-open-settings');\n                    },\n                    label: 'Settings',\n                },\n                { type: 'separator' },\n                { label: 'Services', submenu: [] },\n                { type: 'separator' },\n                {\n                    accelerator: 'Command+H',\n                    label: 'Hide Feishin',\n                    selector: 'hide:',\n                },\n                {\n                    accelerator: 'Command+Shift+H',\n                    label: 'Hide Others',\n                    selector: 'hideOtherApplications:',\n                },\n                { label: 'Show All', selector: 'unhideAllApplications:' },\n                { type: 'separator' },\n                {\n                    accelerator: 'Command+Q',\n                    click: () => {\n                        app.quit();\n                    },\n                    label: 'Quit',\n                },\n            ],\n        };\n        const subMenuEdit: DarwinMenuItemConstructorOptions = {\n            label: 'Edit',\n            submenu: [\n                { accelerator: 'Command+Z', label: 'Undo', selector: 'undo:' },\n                { accelerator: 'Shift+Command+Z', label: 'Redo', selector: 'redo:' },\n                { type: 'separator' },\n                { accelerator: 'Command+X', label: 'Cut', selector: 'cut:' },\n                { accelerator: 'Command+C', label: 'Copy', selector: 'copy:' },\n                { accelerator: 'Command+V', label: 'Paste', selector: 'paste:' },\n                {\n                    accelerator: 'Command+A',\n                    label: 'Select All',\n                    selector: 'selectAll:',\n                },\n            ],\n        };\n        const subMenuViewDev: MenuItemConstructorOptions = {\n            label: 'View',\n            submenu: [\n                {\n                    accelerator: 'Command+R',\n                    click: () => {\n                        this.mainWindow.webContents.reload();\n                    },\n                    label: 'Reload',\n                },\n                {\n                    accelerator: 'Ctrl+Command+F',\n                    click: () => {\n                        this.mainWindow.setFullScreen(!this.mainWindow.isFullScreen());\n                    },\n                    label: 'Toggle Full Screen',\n                },\n                {\n                    accelerator: 'Alt+Command+I',\n                    click: () => {\n                        this.mainWindow.webContents.toggleDevTools();\n                    },\n                    label: 'Toggle Developer Tools',\n                },\n            ],\n        };\n        const subMenuViewProd: MenuItemConstructorOptions = {\n            label: 'View',\n            submenu: [\n                {\n                    accelerator: 'Ctrl+Command+F',\n                    click: () => {\n                        this.mainWindow.setFullScreen(!this.mainWindow.isFullScreen());\n                    },\n                    label: 'Toggle Full Screen',\n                },\n            ],\n        };\n        const subMenuWindow: DarwinMenuItemConstructorOptions = {\n            label: 'Window',\n            submenu: [\n                {\n                    accelerator: 'Command+M',\n                    label: 'Minimize',\n                    selector: 'performMiniaturize:',\n                },\n                { accelerator: 'Command+W', label: 'Close', selector: 'performClose:' },\n                { type: 'separator' },\n                { label: 'Bring All to Front', selector: 'arrangeInFront:' },\n            ],\n        };\n        const subMenuHelp: MenuItemConstructorOptions = {\n            label: 'Help',\n            submenu: [\n                {\n                    click() {\n                        shell.openExternal('https://github.com/jeffvli/feishin');\n                    },\n                    label: 'Learn More',\n                },\n                {\n                    click() {\n                        shell.openExternal(\n                            'https://github.com/jeffvli/feishin?tab=readme-ov-file#getting-started',\n                        );\n                    },\n                    label: 'Documentation',\n                },\n                {\n                    click() {\n                        shell.openExternal('https://github.com/jeffvli/feishin/discussions');\n                    },\n                    label: 'Community Discussions',\n                },\n                {\n                    click() {\n                        shell.openExternal('https://github.com/jeffvli/feishin/issues');\n                    },\n                    label: 'Search Issues',\n                },\n            ],\n        };\n\n        const subMenuView =\n            process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true'\n                ? subMenuViewDev\n                : subMenuViewProd;\n\n        return [subMenuAbout, subMenuEdit, subMenuView, subMenuWindow, subMenuHelp];\n    }\n\n    buildDefaultTemplate(): MenuItemConstructorOptions[] {\n        const templateDefault: MenuItemConstructorOptions[] = [\n            {\n                label: '&File',\n                submenu: [\n                    {\n                        accelerator: 'Ctrl+O',\n                        label: '&Open',\n                    },\n                    {\n                        accelerator: 'Ctrl+,',\n                        click: () => {\n                            this.mainWindow.webContents.send('renderer-open-settings');\n                        },\n                        label: '&Settings...',\n                    },\n                    { type: 'separator' },\n                    {\n                        accelerator: 'Ctrl+W',\n                        click: () => {\n                            this.mainWindow.close();\n                        },\n                        label: '&Close',\n                    },\n                ],\n            },\n            {\n                label: '&View',\n                submenu:\n                    process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true'\n                        ? [\n                              {\n                                  accelerator: 'Ctrl+R',\n                                  click: () => {\n                                      this.mainWindow.webContents.reload();\n                                  },\n                                  label: '&Reload',\n                              },\n                              {\n                                  accelerator: 'F11',\n                                  click: () => {\n                                      this.mainWindow.setFullScreen(\n                                          !this.mainWindow.isFullScreen(),\n                                      );\n                                  },\n                                  label: 'Toggle &Full Screen',\n                              },\n                              {\n                                  accelerator: 'Alt+Ctrl+I',\n                                  click: () => {\n                                      this.mainWindow.webContents.toggleDevTools();\n                                  },\n                                  label: 'Toggle &Developer Tools',\n                              },\n                          ]\n                        : [\n                              {\n                                  accelerator: 'F11',\n                                  click: () => {\n                                      this.mainWindow.setFullScreen(\n                                          !this.mainWindow.isFullScreen(),\n                                      );\n                                  },\n                                  label: 'Toggle &Full Screen',\n                              },\n                          ],\n            },\n            {\n                label: 'Help',\n                submenu: [\n                    {\n                        click() {\n                            shell.openExternal('https://github.com/jeffvli/feishin');\n                        },\n                        label: 'Learn More',\n                    },\n                    {\n                        click() {\n                            shell.openExternal(\n                                'https://github.com/jeffvli/feishin?tab=readme-ov-file#getting-started',\n                            );\n                        },\n                        label: 'Documentation',\n                    },\n                    {\n                        click() {\n                            shell.openExternal('https://github.com/jeffvli/feishin/discussions');\n                        },\n                        label: 'Community Discussions',\n                    },\n                    {\n                        click() {\n                            shell.openExternal('https://github.com/jeffvli/feishin/issues');\n                        },\n                        label: 'Search Issues',\n                    },\n                ],\n            },\n        ];\n\n        return templateDefault;\n    }\n\n    buildMenu(): Menu {\n        if (process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true') {\n            this.setupDevelopmentEnvironment();\n        }\n\n        const template =\n            process.platform === 'darwin'\n                ? this.buildDarwinTemplate()\n                : this.buildDefaultTemplate();\n\n        const menu = Menu.buildFromTemplate(template);\n        Menu.setApplicationMenu(menu);\n\n        return menu;\n    }\n\n    setupDevelopmentEnvironment(): void {\n        this.mainWindow.webContents.on('context-menu', (_, props) => {\n            const { x, y } = props;\n\n            Menu.buildFromTemplate([\n                {\n                    click: () => {\n                        this.mainWindow.webContents.inspectElement(x, y);\n                    },\n                    label: 'Inspect element',\n                },\n            ]).popup({ window: this.mainWindow });\n        });\n    }\n}\n"
  },
  {
    "path": "src/main/utils.ts",
    "content": "import log from 'electron-log/main';\nimport path from 'path';\nimport process from 'process';\nimport { URL } from 'url';\n\nexport let resolveHtmlPath: (htmlFileName: string) => string;\n\nif (process.env.NODE_ENV === 'development') {\n    const port = process.env.PORT || 4343;\n    resolveHtmlPath = (htmlFileName: string) => {\n        const url = new URL(`http://localhost:${port}`);\n        url.pathname = htmlFileName;\n        return url.href;\n    };\n} else {\n    resolveHtmlPath = (htmlFileName: string) => {\n        return `file://${path.resolve(__dirname, '../renderer/', htmlFileName)}`;\n    };\n}\n\nexport const disableAutoUpdates = () => {\n    return process.env['DISABLE_AUTO_UPDATES'];\n};\n\nexport const isMacOS = () => {\n    return process.platform === 'darwin';\n};\n\nexport const isWindows = () => {\n    return process.platform === 'win32';\n};\n\nexport const isLinux = () => {\n    return process.platform === 'linux';\n};\n\nexport const hotkeyToElectronAccelerator = (hotkey: string) => {\n    let accelerator = hotkey;\n\n    const replacements = {\n        arrowdown: 'Down',\n        arrowleft: 'Left',\n        arrowright: 'Right',\n        arrowup: 'Up',\n        mod: 'CmdOrCtrl',\n        numpad: 'num',\n        numpadadd: 'numadd',\n        numpaddecimal: 'numdec',\n        numpaddivide: 'numdiv',\n        numpadenter: 'numenter',\n        numpadmultiply: 'nummult',\n        numpadsubtract: 'numsub',\n    };\n\n    Object.keys(replacements).forEach((key) => {\n        accelerator = accelerator.replace(key, replacements[key as keyof typeof replacements]);\n    });\n\n    return accelerator;\n};\n\nconst logMethod = {\n    debug: log.debug,\n    error: log.error,\n    info: log.info,\n    success: log.info,\n    verbose: log.verbose,\n    warning: log.warn,\n};\n\nconst logColor = {\n    debug: 'blue',\n    error: 'red',\n    info: 'blue',\n    success: 'green',\n    verbose: 'blue',\n    warning: 'yellow',\n};\n\nexport const createLog = (data: {\n    message: string;\n    type: 'debug' | 'error' | 'info' | 'success' | 'verbose' | 'warning';\n}) => {\n    logMethod[data.type](`%c${data.message}`, `color: ${logColor[data.type]}`);\n};\n\nexport const autoUpdaterLogInterface = {\n    debug: (message: string) => {\n        createLog({ message: `[SYSTEM] ${message}`, type: 'debug' });\n    },\n\n    error: (message: string) => {\n        createLog({ message: `[SYSTEM] ${message}`, type: 'error' });\n    },\n\n    info: (message: string) => {\n        createLog({ message: `[SYSTEM] ${message}`, type: 'info' });\n    },\n\n    warn: (message: string) => {\n        createLog({ message: `[SYSTEM] ${message}`, type: 'warning' });\n    },\n};\n"
  },
  {
    "path": "src/preload/autodiscover.ts",
    "content": "import { ipcRenderer } from 'electron';\n\nimport { DiscoveredServerItem } from '../shared/types/types';\n\nconst discover = (onReply: (server: DiscoveredServerItem) => void): Promise<void> => {\n    const { port1: local, port2: remote } = new MessageChannel();\n\n    ipcRenderer.postMessage('autodiscover-ping', {}, [remote]);\n\n    local.onmessage = (ev) => {\n        onReply(ev.data);\n    };\n\n    return new Promise<void>((resolve) => {\n        local.addEventListener('close', () => resolve());\n    });\n};\n\nexport const autodiscover = {\n    discover,\n};\n\nexport type AutoDiscover = typeof autodiscover;\n"
  },
  {
    "path": "src/preload/browser.ts",
    "content": "import { ipcRenderer } from 'electron';\n\nconst exit = () => {\n    ipcRenderer.send('window-close');\n};\n\nconst maximize = () => {\n    ipcRenderer.send('window-maximize');\n};\n\nconst minimize = () => {\n    ipcRenderer.send('window-minimize');\n};\n\nconst unmaximize = () => {\n    ipcRenderer.send('window-unmaximize');\n};\n\nconst quit = () => {\n    ipcRenderer.send('window-quit');\n};\n\nconst devtools = () => {\n    ipcRenderer.send('window-dev-tools');\n};\n\nconst clearCache = (): Promise<void> => {\n    return ipcRenderer.invoke('window-clear-cache');\n};\n\nexport const browser = {\n    clearCache,\n    devtools,\n    exit,\n    maximize,\n    minimize,\n    quit,\n    unmaximize,\n};\n\nexport type Browser = typeof browser;\n"
  },
  {
    "path": "src/preload/discord-rpc.ts",
    "content": "import { SetActivity } from '@xhayper/discord-rpc';\nimport { ipcRenderer } from 'electron';\n\nconst initialize = (clientId: string) => {\n    const client = ipcRenderer.invoke('discord-rpc-initialize', clientId);\n    return client;\n};\n\nconst isConnected = () => {\n    const isConnected = ipcRenderer.invoke('discord-rpc-is-connected');\n    return isConnected;\n};\n\nconst clearActivity = () => {\n    ipcRenderer.invoke('discord-rpc-clear-activity');\n};\n\nconst setActivity = (activity: SetActivity) => {\n    ipcRenderer.invoke('discord-rpc-set-activity', activity);\n};\n\nconst quit = () => {\n    ipcRenderer.invoke('discord-rpc-quit');\n};\n\nexport const discordRpc = {\n    clearActivity,\n    initialize,\n    isConnected,\n    quit,\n    setActivity,\n};\n\nexport type DiscordRpc = typeof discordRpc;\n"
  },
  {
    "path": "src/preload/index.d.ts",
    "content": "import { ElectronAPI } from '@electron-toolkit/preload';\n\nimport { PreloadApi } from './index';\n\ndeclare global {\n    interface Window {\n        api: PreloadApi;\n        electron: ElectronAPI;\n        LEGACY_AUTHENTICATION?: boolean;\n        queryLocalFonts?: () => Promise<Font[]>;\n        REMOTE_URL?: string;\n        SERVER_LOCK?: boolean;\n        SERVER_NAME?: string;\n        SERVER_TYPE?: ServerType;\n        SERVER_URL?: string;\n    }\n}\n"
  },
  {
    "path": "src/preload/index.ts",
    "content": "import { electronAPI } from '@electron-toolkit/preload';\nimport { contextBridge } from 'electron';\n\nimport { autodiscover } from './autodiscover';\nimport { browser } from './browser';\nimport { discordRpc } from './discord-rpc';\nimport { ipc } from './ipc';\nimport { localSettings } from './local-settings';\nimport { lyrics } from './lyrics';\nimport { mpris } from './mpris';\nimport { mpvPlayer, mpvPlayerListener } from './mpv-player';\nimport { remote } from './remote';\nimport { utils } from './utils';\n\n// Custom APIs for renderer\nconst api = {\n    autodiscover,\n    browser,\n    discordRpc,\n    ipc,\n    localSettings,\n    lyrics,\n    mpris,\n    mpvPlayer,\n    mpvPlayerListener,\n    remote,\n    utils,\n};\n\nexport type PreloadApi = typeof api;\n\n// Use `contextBridge` APIs to expose Electron APIs to\n// renderer only if context isolation is enabled, otherwise\n// just add to the DOM global.\nif (process.contextIsolated) {\n    try {\n        contextBridge.exposeInMainWorld('electron', electronAPI);\n        contextBridge.exposeInMainWorld('api', api);\n    } catch (error) {\n        console.error(error);\n    }\n} else {\n    // @ts-ignore (define in dts)\n    window.electron = electronAPI;\n    // @ts-ignore (define in dts)\n    window.api = api;\n}\n"
  },
  {
    "path": "src/preload/ipc.ts",
    "content": "import { ipcRenderer } from 'electron';\n\nconst removeAllListeners = (channel: string) => {\n    ipcRenderer.removeAllListeners(channel);\n};\n\nconst send = (channel: string, ...args: any[]) => {\n    ipcRenderer.send(channel, ...args);\n};\n\nconst invoke = (channel: string, ...args: any[]) => {\n    return ipcRenderer.invoke(channel, ...args);\n};\n\nconst on = (channel: string, listener: (event: any, ...args: any[]) => void) => {\n    ipcRenderer.on(channel, listener);\n};\n\nconst removeListener = (channel: string, listener: (event: any, ...args: any[]) => void) => {\n    ipcRenderer.removeListener(channel, listener);\n};\n\nexport const ipc = {\n    invoke,\n    on,\n    removeAllListeners,\n    removeListener,\n    send,\n};\n\nexport type Ipc = typeof ipc;\n"
  },
  {
    "path": "src/preload/local-settings.ts",
    "content": "import { ipcRenderer, IpcRendererEvent, OpenDialogOptions, webFrame } from 'electron';\n\nimport { TitleTheme } from '/@/shared/types/types';\n\nconst set = (\n    property: string,\n    value: boolean | Record<string, unknown> | string | string[] | undefined,\n) => {\n    ipcRenderer.send('settings-set', { property, value });\n};\n\nconst get = async (property: string) => {\n    return ipcRenderer.invoke('settings-get', { property });\n};\n\nconst restart = () => {\n    ipcRenderer.send('app-restart');\n};\n\nconst enableMediaKeys = () => {\n    ipcRenderer.send('global-media-keys-enable');\n};\n\nconst disableMediaKeys = () => {\n    ipcRenderer.send('global-media-keys-disable');\n};\n\nconst passwordGet = async (server: string): Promise<null | string> => {\n    return ipcRenderer.invoke('password-get', server);\n};\n\nconst passwordRemove = (server: string) => {\n    ipcRenderer.send('password-remove', server);\n};\n\nconst passwordSet = async (password: string, server: string): Promise<boolean> => {\n    return ipcRenderer.invoke('password-set', password, server);\n};\n\nconst setZoomFactor = (zoomFactor: number) => {\n    webFrame.setZoomFactor(zoomFactor / 100);\n};\n\nconst fontError = (cb: (event: IpcRendererEvent, file: string) => void) => {\n    ipcRenderer.on('custom-font-error', cb);\n};\n\nconst themeSet = (theme: TitleTheme): void => {\n    ipcRenderer.send('theme-set', theme);\n};\n\nconst openFileSelector = async (options?: OpenDialogOptions) => {\n    const result = await ipcRenderer.invoke('open-file-selector', options);\n    return result;\n};\n\nexport const toServerType = (value?: string): null | string => {\n    switch (value?.toLowerCase()) {\n        case 'jellyfin':\n            return 'jellyfin';\n        case 'navidrome':\n            return 'navidrome';\n        case 'subsonic':\n            return 'subsonic';\n        default:\n            return null;\n    }\n};\n\nconst SERVER_TYPE = toServerType(process.env.SERVER_TYPE);\n\nconst env = {\n    LEGACY_AUTHENTICATION:\n        SERVER_TYPE !== null\n            ? process.env.LEGACY_AUTHENTICATION?.toLocaleLowerCase() === 'true'\n            : false,\n    REMOTE_URL: process.env.REMOTE_URL ?? '',\n    SERVER_LOCK:\n        SERVER_TYPE !== null ? process.env.SERVER_LOCK?.toLocaleLowerCase() === 'true' : false,\n    SERVER_NAME: process.env.SERVER_NAME ?? '',\n    SERVER_TYPE,\n    SERVER_URL: process.env.SERVER_URL ?? 'http://',\n    START_MAXIMIZED: undefined as boolean | undefined,\n};\n\nget('maximized').then((value) => {\n    env.START_MAXIMIZED = value as boolean | undefined;\n});\n\nexport const localSettings = {\n    disableMediaKeys,\n    enableMediaKeys,\n    env,\n    fontError,\n    get,\n    openFileSelector,\n    passwordGet,\n    passwordRemove,\n    passwordSet,\n    restart,\n    set,\n    setZoomFactor,\n    themeSet,\n};\n\nexport type LocalSettings = typeof localSettings;\n"
  },
  {
    "path": "src/preload/lyrics.ts",
    "content": "import { ipcRenderer } from 'electron';\n\nimport {\n    InternetProviderLyricSearchResponse,\n    LyricGetQuery,\n    LyricSearchQuery,\n    LyricSource,\n} from '../main/features/core/lyrics';\n\nimport { QueueSong } from '/@/shared/types/domain-types';\n\nconst getRemoteLyricsBySong = (song: QueueSong) => {\n    const result = ipcRenderer.invoke('lyric-by-song', song);\n    return result;\n};\n\nconst searchRemoteLyrics = (\n    params: LyricSearchQuery,\n): Promise<Record<LyricSource, InternetProviderLyricSearchResponse[]>> => {\n    const result = ipcRenderer.invoke('lyric-search', params);\n    return result;\n};\n\nconst getRemoteLyricsByRemoteId = (id: LyricGetQuery) => {\n    const result = ipcRenderer.invoke('lyric-by-remote-id', id);\n    return result;\n};\n\nexport const lyrics = {\n    getRemoteLyricsByRemoteId,\n    getRemoteLyricsBySong,\n    searchRemoteLyrics,\n};\n\nexport type Lyrics = typeof lyrics;\n"
  },
  {
    "path": "src/preload/mpris.ts",
    "content": "import { ipcRenderer, IpcRendererEvent } from 'electron';\n\nimport { QueueSong } from '/@/shared/types/domain-types';\nimport { PlayerRepeat, PlayerStatus } from '/@/shared/types/types';\n\nconst updatePosition = (timeSec: number) => {\n    ipcRenderer.send('update-position', timeSec);\n};\n\nconst updateSeek = (timeSec: number) => {\n    ipcRenderer.send('update-seek', timeSec);\n};\n\nconst updateVolume = (volume: number) => {\n    ipcRenderer.send('update-volume', volume);\n};\n\nconst updateStatus = (status: PlayerStatus) => {\n    ipcRenderer.send('update-playback', status);\n};\n\nconst updateRepeat = (repeat: PlayerRepeat) => {\n    ipcRenderer.send('update-repeat', repeat);\n};\n\nconst updateShuffle = (shuffle: boolean) => {\n    ipcRenderer.send('update-shuffle', shuffle);\n};\n\nconst updateSong = (song: QueueSong | undefined, imageUrl?: null | string) => {\n    ipcRenderer.send('update-song', song, imageUrl);\n};\n\nconst requestSeek = (cb: (event: IpcRendererEvent, data: { offset: number }) => void) => {\n    ipcRenderer.on('request-seek', cb);\n};\n\nconst requestPosition = (cb: (event: IpcRendererEvent, data: { position: number }) => void) => {\n    ipcRenderer.on('request-position', cb);\n};\n\nconst requestToggleRepeat = (\n    cb: (event: IpcRendererEvent, data: { repeat: PlayerRepeat }) => void,\n) => {\n    ipcRenderer.on('mpris-request-toggle-repeat', cb);\n};\n\nconst requestToggleShuffle = (\n    cb: (event: IpcRendererEvent, data: { shuffle: boolean }) => void,\n) => {\n    ipcRenderer.on('mpris-request-toggle-shuffle', cb);\n};\n\nconst requestVolume = (cb: (event: IpcRendererEvent, data: { volume: number }) => void) => {\n    ipcRenderer.on('request-volume', cb);\n};\n\nexport const mpris = {\n    requestPosition,\n    requestSeek,\n    requestToggleRepeat,\n    requestToggleShuffle,\n    requestVolume,\n    updatePosition,\n    updateRepeat,\n    updateSeek,\n    updateShuffle,\n    updateSong,\n    updateStatus,\n    updateVolume,\n};\n\nexport type Mpris = typeof mpris;\n"
  },
  {
    "path": "src/preload/mpv-player.ts",
    "content": "import { ipcRenderer, IpcRendererEvent } from 'electron';\n\nimport { PlayerData } from '/@/shared/types/domain-types';\n\nconst initialize = (data: { extraParameters?: string[]; properties?: Record<string, any> }) => {\n    return ipcRenderer.invoke('player-initialize', data);\n};\n\nconst restart = (data: {\n    binaryPath?: string;\n    extraParameters?: string[];\n    properties?: Record<string, any>;\n}) => {\n    return ipcRenderer.invoke('player-restart', data);\n};\n\nconst isRunning = () => {\n    return ipcRenderer.invoke('player-is-running');\n};\n\nconst cleanup = () => {\n    return ipcRenderer.invoke('player-clean-up');\n};\n\nconst setProperties = (data: Record<string, any>) => {\n    ipcRenderer.send('player-set-properties', data);\n};\n\nconst autoNext = (url?: string) => {\n    ipcRenderer.send('player-auto-next', url);\n};\n\nconst currentTime = () => {\n    ipcRenderer.send('player-current-time');\n};\n\nconst mute = (mute: boolean) => {\n    ipcRenderer.send('player-mute', mute);\n};\n\nconst next = () => {\n    ipcRenderer.send('player-next');\n};\n\nconst pause = () => {\n    ipcRenderer.send('player-pause');\n};\n\nconst play = () => {\n    ipcRenderer.send('player-play');\n};\n\nconst previous = () => {\n    ipcRenderer.send('player-previous');\n};\n\nconst seek = (seconds: number) => {\n    ipcRenderer.send('player-seek', seconds);\n};\n\nconst seekTo = (seconds: number) => {\n    ipcRenderer.send('player-seek-to', seconds);\n};\n\nconst setQueue = (current?: string, next?: string, pause?: boolean) => {\n    ipcRenderer.send('player-set-queue', current, next, pause);\n};\n\nconst setQueueNext = (url?: string) => {\n    ipcRenderer.send('player-set-queue-next', url);\n};\n\nconst stop = () => {\n    ipcRenderer.send('player-stop');\n};\n\nconst volume = (value: number) => {\n    ipcRenderer.send('player-volume', value);\n};\n\nconst quit = () => {\n    ipcRenderer.send('player-quit');\n};\n\nconst getCurrentTime = async () => {\n    return ipcRenderer.invoke('player-get-time');\n};\n\nconst updateMetadata = (data: PlayerData) => {\n    ipcRenderer.send('player-update-metadata', data);\n};\n\nconst getMetadata = async () => {\n    return ipcRenderer.invoke('player-metadata');\n};\n\nconst getStreamMetadata = async () => {\n    return ipcRenderer.invoke('player-stream-metadata');\n};\n\nconst getAudioDevices = async () => {\n    return ipcRenderer.invoke('player-get-audio-devices');\n};\n\nconst rendererAutoNext = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {\n    ipcRenderer.on('renderer-player-auto-next', cb);\n};\n\nconst rendererCurrentTime = (cb: (event: IpcRendererEvent, data: number) => void) => {\n    ipcRenderer.on('renderer-player-current-time', cb);\n};\n\nconst rendererNext = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {\n    ipcRenderer.on('renderer-player-next', cb);\n};\n\nconst rendererPause = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {\n    ipcRenderer.on('renderer-player-pause', cb);\n};\n\nconst rendererPlay = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {\n    ipcRenderer.on('renderer-player-play', cb);\n};\n\nconst rendererPlayPause = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {\n    ipcRenderer.on('renderer-player-play-pause', cb);\n};\n\nconst rendererPrevious = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {\n    ipcRenderer.on('renderer-player-previous', cb);\n};\n\nconst rendererStop = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {\n    ipcRenderer.on('renderer-player-stop', cb);\n};\n\nconst rendererSkipForward = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {\n    ipcRenderer.on('renderer-player-skip-forward', cb);\n};\n\nconst rendererSkipBackward = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {\n    ipcRenderer.on('renderer-player-skip-backward', cb);\n};\n\nconst rendererVolumeUp = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {\n    ipcRenderer.on('renderer-player-volume-up', cb);\n};\n\nconst rendererVolumeDown = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {\n    ipcRenderer.on('renderer-player-volume-down', cb);\n};\n\nconst rendererVolumeMute = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {\n    ipcRenderer.on('renderer-player-volume-mute', cb);\n};\n\nconst rendererToggleRepeat = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {\n    ipcRenderer.on('renderer-player-toggle-repeat', cb);\n};\n\nconst rendererToggleShuffle = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {\n    ipcRenderer.on('renderer-player-toggle-shuffle', cb);\n};\n\nconst rendererQuit = (cb: (event: IpcRendererEvent) => void) => {\n    ipcRenderer.on('renderer-player-quit', cb);\n};\n\nconst rendererError = (cb: (event: IpcRendererEvent, data: string) => void) => {\n    ipcRenderer.on('renderer-player-error', cb);\n};\n\nconst rendererPlayerFallback = (cb: (event: IpcRendererEvent, data: boolean) => void) => {\n    ipcRenderer.on('renderer-player-fallback', cb);\n};\n\nexport const mpvPlayer = {\n    autoNext,\n    cleanup,\n    currentTime,\n    getAudioDevices,\n    getCurrentTime,\n    getMetadata,\n    getStreamMetadata,\n    initialize,\n    isRunning,\n    mute,\n    next,\n    pause,\n    play,\n    previous,\n    quit,\n    restart,\n    seek,\n    seekTo,\n    setProperties,\n    setQueue,\n    setQueueNext,\n    stop,\n    updateMetadata,\n    volume,\n};\n\nexport const mpvPlayerListener = {\n    rendererAutoNext,\n    rendererCurrentTime,\n    rendererError,\n    rendererNext,\n    rendererPause,\n    rendererPlay,\n    rendererPlayerFallback,\n    rendererPlayPause,\n    rendererPrevious,\n    rendererQuit,\n    rendererSkipBackward,\n    rendererSkipForward,\n    rendererStop,\n    rendererToggleRepeat,\n    rendererToggleShuffle,\n    rendererVolumeDown,\n    rendererVolumeMute,\n    rendererVolumeUp,\n};\n\nexport type MpvPLayer = typeof mpvPlayer;\nexport type MpvPlayerListener = typeof mpvPlayerListener;\n"
  },
  {
    "path": "src/preload/remote.ts",
    "content": "import { ipcRenderer, IpcRendererEvent } from 'electron';\n\nimport { QueueSong } from '/@/shared/types/domain-types';\nimport { PlayerStatus } from '/@/shared/types/types';\n\nconst requestFavorite = (\n    cb: (\n        event: IpcRendererEvent,\n        data: { favorite: boolean; id: string; serverId: string },\n    ) => void,\n) => {\n    ipcRenderer.on('request-favorite', cb);\n};\n\nconst requestPosition = (cb: (event: IpcRendererEvent, data: { position: number }) => void) => {\n    ipcRenderer.on('request-position', cb);\n};\n\nconst requestRating = (\n    cb: (event: IpcRendererEvent, data: { id: string; rating: number; serverId: string }) => void,\n) => {\n    ipcRenderer.on('request-rating', cb);\n};\n\nconst requestSeek = (cb: (event: IpcRendererEvent, data: { offset: number }) => void) => {\n    ipcRenderer.on('request-seek', cb);\n};\n\nconst requestVolume = (cb: (event: IpcRendererEvent, data: { volume: number }) => void) => {\n    ipcRenderer.on('request-volume', cb);\n};\n\nconst setRemoteEnabled = (enabled: boolean): Promise<null | string> => {\n    const result = ipcRenderer.invoke('remote-enable', enabled);\n    return result;\n};\n\nconst setRemotePort = (port: number): Promise<null | string> => {\n    const result = ipcRenderer.invoke('remote-port', port);\n    return result;\n};\n\nconst updateFavorite = (favorite: boolean, serverId: string, ids: string[]) => {\n    ipcRenderer.send('update-favorite', favorite, serverId, ids);\n};\n\nconst updatePassword = (password: string) => {\n    ipcRenderer.send('remote-password', password);\n};\n\nconst updatePlayback = (playback: PlayerStatus) => {\n    ipcRenderer.send('update-playback', playback);\n};\n\nconst updateSetting = (\n    enabled: boolean,\n    port: number,\n    username: string,\n    password: string,\n): Promise<null | string> => {\n    return ipcRenderer.invoke('remote-settings', enabled, port, username, password);\n};\n\nconst updateRating = (rating: number, serverId: string, ids: string[]) => {\n    ipcRenderer.send('update-rating', rating, serverId, ids);\n};\n\nconst updateRepeat = (repeat: string) => {\n    ipcRenderer.send('update-repeat', repeat);\n};\n\nconst updateShuffle = (shuffle: boolean) => {\n    ipcRenderer.send('update-shuffle', shuffle);\n};\n\nconst updateSong = (song: QueueSong | undefined, imageUrl?: null | string) => {\n    ipcRenderer.send('update-song', song, imageUrl);\n};\n\nconst updateUsername = (username: string) => {\n    ipcRenderer.send('remote-username', username);\n};\n\nconst updateVolume = (volume: number) => {\n    ipcRenderer.send('update-volume', volume);\n};\n\nconst updatePosition = (timeSec: number) => {\n    ipcRenderer.send('update-position', timeSec);\n};\n\nexport const remote = {\n    requestFavorite,\n    requestPosition,\n    requestRating,\n    requestSeek,\n    requestVolume,\n    setRemoteEnabled,\n    setRemotePort,\n    updateFavorite,\n    updatePassword,\n    updatePlayback,\n    updatePosition,\n    updateRating,\n    updateRepeat,\n    updateSetting,\n    updateShuffle,\n    updateSong,\n    updateUsername,\n    updateVolume,\n};\n\nexport type Remote = typeof remote;\n"
  },
  {
    "path": "src/preload/utils.ts",
    "content": "import { ipcRenderer, IpcRendererEvent, webFrame } from 'electron';\n\nimport { disableAutoUpdates, isLinux, isMacOS, isWindows } from '../main/utils';\n\nconst openItem = async (path: string) => {\n    return ipcRenderer.invoke('open-item', path);\n};\n\nconst openApplicationDirectory = async () => {\n    return ipcRenderer.invoke('open-application-directory');\n};\n\nconst playerErrorListener = (cb: (event: IpcRendererEvent, data: { code: number }) => void) => {\n    ipcRenderer.on('player-error-listener', cb);\n};\n\nconst mainMessageListener = (\n    cb: (\n        event: IpcRendererEvent,\n        data: { message: string; type: 'error' | 'info' | 'success' | 'warning' },\n    ) => void,\n) => {\n    ipcRenderer.on('toast-from-main', cb);\n};\n\nconst logger = (\n    cb: (\n        event: IpcRendererEvent,\n        data: {\n            message: string;\n            type: 'debug' | 'error' | 'info' | 'verbose' | 'warning';\n        },\n    ) => void,\n) => {\n    ipcRenderer.send('logger', cb);\n};\n\nconst download = (url: string) => {\n    ipcRenderer.send('download-url', url);\n};\n\nconst checkForUpdates = (): Promise<{ updateAvailable: boolean; version?: string }> => {\n    return ipcRenderer.invoke('app-check-for-updates');\n};\n\nconst forceGarbageCollection = (): boolean => {\n    try {\n        if (typeof global.gc === 'function') {\n            global.gc();\n            webFrame.clearCache();\n            return true;\n        }\n        if (typeof window.gc === 'function') {\n            window.gc();\n            webFrame.clearCache();\n            return true;\n        }\n        return false;\n    } catch {\n        return false;\n    }\n};\n\nconst rendererOpenSettings = (cb: (event: IpcRendererEvent) => void) => {\n    ipcRenderer.on('renderer-open-settings', cb);\n};\n\nexport const utils = {\n    checkForUpdates,\n    disableAutoUpdates,\n    download,\n    forceGarbageCollection,\n    isLinux,\n    isMacOS,\n    isWindows,\n    logger,\n    mainMessageListener,\n    openApplicationDirectory,\n    openItem,\n    playerErrorListener,\n    rendererOpenSettings,\n};\n\nexport type Utils = typeof utils;\n"
  },
  {
    "path": "src/remote/app.tsx",
    "content": "import { MantineProvider } from '@mantine/core';\nimport '@mantine/core/styles.css';\nimport '@mantine/notifications/styles.css';\n\nimport '/@/shared/styles/global.css';\n\nimport { useEffect } from 'react';\n\nimport { Shell } from '/@/remote/components/shell';\nimport { useIsDark, useReconnect } from '/@/remote/store';\nimport { useAppTheme } from '/@/renderer/themes/use-app-theme';\nimport { AppTheme } from '/@/shared/themes/app-theme-types';\n\nexport const App = () => {\n    const isDark = useIsDark();\n    const reconnect = useReconnect();\n\n    useEffect(() => {\n        reconnect();\n    }, [reconnect]);\n\n    const { mode, theme } = useAppTheme(isDark ? AppTheme.DEFAULT_DARK : AppTheme.DEFAULT_LIGHT);\n\n    return (\n        <MantineProvider defaultColorScheme={mode} theme={theme}>\n            <Shell />\n        </MantineProvider>\n    );\n};\n"
  },
  {
    "path": "src/remote/components/buttons/image-button.tsx",
    "content": "import { CiImageOff, CiImageOn } from 'react-icons/ci';\n\nimport { useShowImage, useToggleShowImage } from '/@/remote/store';\nimport { ActionIcon } from '/@/shared/components/action-icon/action-icon';\n\nexport const ImageButton = () => {\n    const showImage = useShowImage();\n    const toggleImage = useToggleShowImage();\n\n    return (\n        <ActionIcon\n            onClick={() => toggleImage()}\n            tooltip={{\n                label: showImage ? 'Hide Image' : 'Show Image',\n            }}\n            variant=\"default\"\n        >\n            {showImage ? <CiImageOff size={30} /> : <CiImageOn size={30} />}\n        </ActionIcon>\n    );\n};\n"
  },
  {
    "path": "src/remote/components/buttons/reconnect-button.tsx",
    "content": "import { RiRestartLine } from 'react-icons/ri';\n\nimport { useConnected, useReconnect } from '/@/remote/store';\nimport { ActionIcon } from '/@/shared/components/action-icon/action-icon';\n\nexport const ReconnectButton = () => {\n    const connected = useConnected();\n    const reconnect = useReconnect();\n\n    return (\n        <ActionIcon\n            onClick={() => reconnect()}\n            tooltip={{\n                label: connected ? 'Reconnect' : 'Not connected. Reconnect.',\n            }}\n            variant=\"default\"\n        >\n            <RiRestartLine\n                color={connected ? 'var(--theme-colors-primary)' : 'var(--theme-colors-foreground)'}\n                size={30}\n            />\n        </ActionIcon>\n    );\n};\n"
  },
  {
    "path": "src/remote/components/buttons/theme-button.tsx",
    "content": "import { useIsDark, useToggleDark } from '/@/remote/store';\nimport { ActionIcon } from '/@/shared/components/action-icon/action-icon';\nimport { Icon } from '/@/shared/components/icon/icon';\n\nexport const ThemeButton = () => {\n    const isDark = useIsDark();\n    const toggleDark = useToggleDark();\n\n    const handleToggleTheme = () => {\n        toggleDark();\n    };\n\n    return (\n        <ActionIcon\n            onClick={handleToggleTheme}\n            tooltip={{\n                label: 'Toggle Theme',\n            }}\n            variant=\"default\"\n        >\n            {isDark ? <Icon icon=\"themeLight\" size={30} /> : <Icon icon=\"themeDark\" size={30} />}\n        </ActionIcon>\n    );\n};\n"
  },
  {
    "path": "src/remote/components/player-image.module.css",
    "content": ".container {\n    width: 100%;\n    height: 40vh;\n    aspect-ratio: 1/1;\n    object-fit: var(--theme-image-fit);\n    border-radius: var(--theme-radius-md);\n}\n"
  },
  {
    "path": "src/remote/components/player-image.tsx",
    "content": "import styles from './player-image.module.css';\n\nimport { useSend } from '/@/remote/store';\n\ninterface PlayerImageProps {\n    src?: null | string;\n}\nexport const PlayerImage = ({ src }: PlayerImageProps) => {\n    const send = useSend();\n\n    return (\n        <img\n            className={styles.container}\n            onError={() => send({ event: 'proxy' })}\n            src={src?.replaceAll(/&(size|width|height)=\\d+/g, '')}\n        />\n    );\n};\n"
  },
  {
    "path": "src/remote/components/remote-container.module.css",
    "content": ""
  },
  {
    "path": "src/remote/components/remote-container.tsx",
    "content": "import formatDuration from 'format-duration';\nimport debounce from 'lodash/debounce';\nimport { useCallback } from 'react';\nimport { RiPauseFill, RiPlayFill, RiVolumeUpFill } from 'react-icons/ri';\n\nimport { PlayerImage } from '/@/remote/components/player-image';\nimport { WrappedSlider } from '/@/remote/components/wrapped-slider';\nimport { useInfo, useSend, useShowImage } from '/@/remote/store';\nimport { ActionIcon } from '/@/shared/components/action-icon/action-icon';\nimport { Flex } from '/@/shared/components/flex/flex';\nimport { Group } from '/@/shared/components/group/group';\nimport { Rating } from '/@/shared/components/rating/rating';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { Text } from '/@/shared/components/text/text';\nimport { Tooltip } from '/@/shared/components/tooltip/tooltip';\nimport { PlayerRepeat, PlayerStatus } from '/@/shared/types/types';\n\nexport const RemoteContainer = () => {\n    const { position, repeat, shuffle, song, status, volume } = useInfo();\n    const send = useSend();\n    const showImage = useShowImage();\n\n    const id = song?.id;\n\n    const setRating = useCallback(\n        (rating: number) => {\n            send({ event: 'rating', id: id!, rating });\n        },\n        [send, id],\n    );\n\n    const debouncedSetRating = debounce(setRating, 400);\n\n    return (\n        <Stack gap=\"md\" h=\"100dvh\" w=\"100%\">\n            {showImage && (\n                <Flex align=\"center\" justify=\"center\" w=\"100%\">\n                    <PlayerImage src={song?.imageUrl} />\n                </Flex>\n            )}\n            {id && (\n                <Stack gap=\"xs\">\n                    <Text\n                        fw={700}\n                        size=\"xl\"\n                        style={{\n                            overflow: 'hidden',\n                            textOverflow: 'ellipsis',\n                            whiteSpace: 'nowrap',\n                        }}\n                    >\n                        {song.name}\n                    </Text>\n                    <Text\n                        isMuted\n                        style={{\n                            overflow: 'hidden',\n                            textOverflow: 'ellipsis',\n                            whiteSpace: 'nowrap',\n                        }}\n                    >\n                        {song.album}\n                    </Text>\n                    <Text\n                        isMuted\n                        style={{\n                            overflow: 'hidden',\n                            textOverflow: 'ellipsis',\n                            whiteSpace: 'nowrap',\n                        }}\n                    >\n                        {song.artistName}\n                    </Text>\n                    <Group justify=\"space-between\">\n                        {song.releaseDate && (\n                            <Text isMuted>{new Date(song.releaseDate).toLocaleDateString()}</Text>\n                        )}\n                        <Text isMuted>Plays: {song.playCount}</Text>\n                    </Group>\n                </Stack>\n            )}\n            <Group gap={0} grow>\n                <ActionIcon\n                    disabled={!id}\n                    icon=\"favorite\"\n                    iconProps={{\n                        fill: song?.userFavorite ? 'primary' : 'default',\n                    }}\n                    onClick={() => {\n                        if (!id) return;\n\n                        send({ event: 'favorite', favorite: !song.userFavorite, id });\n                    }}\n                    tooltip={{\n                        label: song?.userFavorite ? 'Unfavorite' : 'Favorite',\n                    }}\n                    variant=\"transparent\"\n                />\n                {(song?._serverType === 'navidrome' || song?._serverType === 'subsonic') && (\n                    <div style={{ margin: 'auto' }}>\n                        <Tooltip label=\"Double click to clear\" openDelay={1000}>\n                            <Rating\n                                onChange={debouncedSetRating}\n                                onDoubleClick={() => debouncedSetRating(0)}\n                                style={{ margin: 'auto' }}\n                                value={song.userRating ?? 0}\n                            />\n                        </Tooltip>\n                    </div>\n                )}\n            </Group>\n            <Group gap=\"xs\" grow>\n                <ActionIcon\n                    disabled={!id}\n                    icon=\"mediaPrevious\"\n                    iconProps={{\n                        fill: 'default',\n                        size: 'lg',\n                    }}\n                    onClick={() => send({ event: 'previous' })}\n                    tooltip={{\n                        label: 'Previous track',\n                    }}\n                    variant=\"default\"\n                />\n                <ActionIcon\n                    disabled={!id}\n                    onClick={() => {\n                        if (status === PlayerStatus.PLAYING) {\n                            send({ event: 'pause' });\n                        } else if (status === PlayerStatus.PAUSED) {\n                            send({ event: 'play' });\n                        }\n                    }}\n                    tooltip={{\n                        label: id && status === PlayerStatus.PLAYING ? 'Pause' : 'Play',\n                    }}\n                    variant=\"default\"\n                >\n                    {id && status === PlayerStatus.PLAYING ? (\n                        <RiPauseFill size={25} />\n                    ) : (\n                        <RiPlayFill size={25} />\n                    )}\n                </ActionIcon>\n                <ActionIcon\n                    disabled={!id}\n                    icon=\"mediaNext\"\n                    iconProps={{\n                        fill: 'default',\n                        size: 'lg',\n                    }}\n                    onClick={() => send({ event: 'next' })}\n                    tooltip={{\n                        label: 'Next track',\n                    }}\n                    variant=\"default\"\n                />\n            </Group>\n            <Group gap=\"xs\" grow>\n                <ActionIcon\n                    icon=\"mediaShuffle\"\n                    iconProps={{\n                        fill: shuffle ? 'primary' : 'default',\n                        size: 'lg',\n                    }}\n                    onClick={() => send({ event: 'shuffle' })}\n                    tooltip={{\n                        label: shuffle ? 'Shuffle tracks' : 'Shuffle disabled',\n                    }}\n                    variant=\"default\"\n                />\n                <ActionIcon\n                    icon={\n                        repeat === undefined || repeat === PlayerRepeat.ONE\n                            ? 'mediaRepeatOne'\n                            : 'mediaRepeat'\n                    }\n                    iconProps={{\n                        fill:\n                            repeat !== undefined && repeat !== PlayerRepeat.NONE\n                                ? 'primary'\n                                : 'default',\n                        size: 'lg',\n                    }}\n                    onClick={() => send({ event: 'repeat' })}\n                    tooltip={{\n                        label: `Repeat ${\n                            repeat === PlayerRepeat.ONE\n                                ? 'One'\n                                : repeat === PlayerRepeat.ALL\n                                  ? 'all'\n                                  : 'none'\n                        }`,\n                    }}\n                    variant=\"default\"\n                />\n            </Group>\n            <Stack gap=\"lg\">\n                {id && position !== undefined && (\n                    <WrappedSlider\n                        label={(value) => formatDuration(value * 1e3)}\n                        leftLabel={formatDuration(position * 1e3)}\n                        max={song.duration / 1e3}\n                        onChangeEnd={(e) => send({ event: 'position', position: e })}\n                        rightLabel={formatDuration(song.duration)}\n                        value={position}\n                    />\n                )}\n                <WrappedSlider\n                    leftLabel={<RiVolumeUpFill size={20} />}\n                    max={100}\n                    onChangeEnd={(e) => send({ event: 'volume', volume: e })}\n                    rightLabel={\n                        <Text fw={600} size=\"xs\">\n                            {volume ?? 0}\n                        </Text>\n                    }\n                    value={volume ?? 0}\n                />\n            </Stack>\n        </Stack>\n    );\n};\n"
  },
  {
    "path": "src/remote/components/shell.tsx",
    "content": "import { AppShell, Flex, Grid, Image } from '@mantine/core';\n\nimport { ImageButton } from '/@/remote/components/buttons/image-button';\nimport { ReconnectButton } from '/@/remote/components/buttons/reconnect-button';\nimport { ThemeButton } from '/@/remote/components/buttons/theme-button';\nimport { RemoteContainer } from '/@/remote/components/remote-container';\nimport { useConnected } from '/@/remote/store';\nimport { Center } from '/@/shared/components/center/center';\nimport { Group } from '/@/shared/components/group/group';\nimport { Spinner } from '/@/shared/components/spinner/spinner';\n\nexport const Shell = () => {\n    const connected = useConnected();\n\n    return (\n        <AppShell h=\"100vh\" padding=\"md\" w=\"100vw\">\n            <AppShell.Header style={{ background: 'var(--theme-colors-surface)' }}>\n                <Grid px=\"md\" py=\"sm\">\n                    <Grid.Col span={4}>\n                        <Flex\n                            align=\"center\"\n                            direction=\"row\"\n                            h=\"100%\"\n                            justify=\"flex-start\"\n                            style={{\n                                justifySelf: 'flex-start',\n                            }}\n                        >\n                            <Image fit=\"contain\" height={32} src=\"/favicon.ico\" width={32} />\n                        </Flex>\n                    </Grid.Col>\n                    <Grid.Col span={8}>\n                        <Group gap=\"sm\" justify=\"flex-end\" wrap=\"nowrap\">\n                            <ReconnectButton />\n                            <ImageButton />\n                            <ThemeButton />\n                        </Group>\n                    </Grid.Col>\n                </Grid>\n            </AppShell.Header>\n            <AppShell.Main pt=\"60px\">\n                {connected ? (\n                    <RemoteContainer />\n                ) : (\n                    <Center h=\"100vh\" w=\"100vw\">\n                        <Spinner />\n                    </Center>\n                )}\n            </AppShell.Main>\n        </AppShell>\n    );\n};\n"
  },
  {
    "path": "src/remote/components/wrapped-slider.tsx",
    "content": "import { rem, Slider, SliderProps } from '@mantine/core';\nimport { ReactNode, useState } from 'react';\n\nimport { Group } from '/@/shared/components/group/group';\nimport { Text } from '/@/shared/components/text/text';\n\nconst PlayerbarSlider = ({ ...props }: SliderProps) => {\n    return (\n        <Slider\n            styles={{\n                bar: {\n                    transition: 'background-color 0.2s ease',\n                },\n                label: {\n                    fontSize: '1.1rem',\n                    fontWeight: 600,\n                    padding: '0 1rem',\n                },\n                root: {\n                    '&:hover': {\n                        '& .mantine-Slider-bar': {\n                            backgroundColor: 'var(--primary-color)',\n                        },\n                        '& .mantine-Slider-thumb': {\n                            opacity: 1,\n                        },\n                    },\n                },\n                thumb: {\n                    backgroundColor: 'var(--slider-thumb-bg)',\n                    borderColor: 'var(--primary-color)',\n                    borderWidth: rem(1),\n                    height: '1rem',\n                    opacity: 0,\n                    width: '1rem',\n                },\n                track: {\n                    '&::before': {\n                        right: 'calc(0.1rem * -1)',\n                    },\n                    height: '1rem',\n                },\n            }}\n            {...props}\n            onClick={(e) => {\n                e?.stopPropagation();\n            }}\n        />\n    );\n};\n\nexport interface WrappedProps extends Omit<SliderProps, 'onChangeEnd'> {\n    leftLabel?: ReactNode;\n    onChangeEnd: (value: number) => void;\n    rightLabel?: ReactNode;\n    value: number;\n}\n\nexport const WrappedSlider = ({ leftLabel, rightLabel, value, ...props }: WrappedProps) => {\n    const [isSeeking, setIsSeeking] = useState(false);\n    const [seek, setSeek] = useState(0);\n\n    return (\n        <Group align=\"center\" wrap=\"nowrap\">\n            {leftLabel && <Text size=\"sm\">{leftLabel}</Text>}\n            <PlayerbarSlider\n                {...props}\n                min={0}\n                onChange={(e) => {\n                    setIsSeeking(true);\n                    setSeek(e);\n                }}\n                onChangeEnd={(e) => {\n                    props.onChangeEnd(e);\n                    setIsSeeking(false);\n                }}\n                size={6}\n                value={!isSeeking ? (value ?? 0) : seek}\n                w=\"100%\"\n            />\n            {rightLabel && <Text size=\"sm\">{rightLabel}</Text>}\n        </Group>\n    );\n};\n"
  },
  {
    "path": "src/remote/index.html",
    "content": "<!DOCTYPE html>\n<html>\n\n<head>\n    <meta charset=\"utf-8\" />\n    <meta http-equiv=\"Content-Security-Policy\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n    <meta http-equiv=\"Cache-Control\" content=\"no-cache, no-store, must-revalidate\" />\n    <meta http-equiv=\"Pragma\" content=\"no-cache\" />\n    <meta http-equiv=\"Expires\" content=\"0\" />\n    <title>Feishin Remote</title>\n    <link rel=\"manifest\" href=\"manifest.json\">\n    <script>\n        if ('serviceWorker' in navigator) {\n            const version = encodeURIComponent(\"<%= version %>\");\n            const prod = encodeURIComponent(\"<%= prod %>\");\n            navigator.serviceWorker.register(`/worker.js?version=${version}&prod=${prod}`);\n        }\n    </script>\n    <link rel=\"icon\" href=\"./favicon.ico\">\n    <script defer=\"defer\" src=\"./remote.js\"></script>\n    <script defer=\"defer\" src=\"./worker.js\"></script>\n    <link rel=\"stylesheet\" href=\"./remote.css\">\n</head>\n\n<body>\n    <div id=\"root\"></div>\n</body>\n\n</html>\n"
  },
  {
    "path": "src/remote/index.tsx",
    "content": "import { createRoot } from 'react-dom/client';\n\nimport { App } from '/@/remote/app';\n\nconst container = document.getElementById('root')! as HTMLElement;\nconst root = createRoot(container);\n\nroot.render(<App />);\n"
  },
  {
    "path": "src/remote/manifest.json",
    "content": "{\n    \"name\": \"Feishin Remote\",\n    \"short_name\": \"Feishin Remote\",\n    \"start_url\": \"/\",\n    \"background_color\": \"#FFDCB5\",\n    \"theme_color\": \"#1E003D\",\n    \"icons\": [\n        {\n            \"src\": \"favicon.ico\",\n            \"sizes\": \"192x192\",\n            \"type\": \"image/png\",\n            \"purpose\": \"maskable any\"\n        }\n    ],\n    \"display\": \"standalone\",\n    \"orientation\": \"portrait\"\n}\n"
  },
  {
    "path": "src/remote/service-worker.ts",
    "content": "/// <reference lib=\"WebWorker\" />\n\nexport type {};\n\ndeclare const self: ServiceWorkerGlobalScope;\n\nconst url = new URL(location.toString());\nconst version = url.searchParams.get('version');\nconst prod = url.searchParams.get('prod') === 'true';\nconst cacheName = `Feishin-remote-${version}`;\n\nconst resourcesToCache = ['./', './remote.js', './favicon.ico'];\n\nif (prod) {\n    resourcesToCache.push('./remote.css');\n}\n\nself.addEventListener('install', (e) => {\n    e.waitUntil(\n        caches.open(cacheName).then((cache) => {\n            return cache.addAll(resourcesToCache);\n        }),\n    );\n});\n\nself.addEventListener('fetch', (e) => {\n    e.respondWith(\n        caches.match(e.request).then((response) => {\n            return response || fetch(e.request);\n        }),\n    );\n});\n\nself.addEventListener('activate', (e) => {\n    e.waitUntil(\n        caches.keys().then((keyList) => {\n            return Promise.all(\n                keyList.map((key) => {\n                    if (key !== cacheName) {\n                        return caches.delete(key);\n                    }\n                    return Promise.resolve();\n                }),\n            );\n        }),\n    );\n});\n"
  },
  {
    "path": "src/remote/store/index.ts",
    "content": "import merge from 'lodash/merge';\nimport { devtools, persist } from 'zustand/middleware';\nimport { immer } from 'zustand/middleware/immer';\nimport { createWithEqualityFn } from 'zustand/traditional';\n\nimport { LogCategory, logFn } from '/@/renderer/utils/logger';\nimport { logMsg } from '/@/renderer/utils/logger-message';\nimport { toast } from '/@/shared/components/toast/toast';\nimport { ClientEvent, ServerEvent, SongUpdateSocket } from '/@/shared/types/remote-types';\n\nexport interface SettingsSlice extends SettingsState {\n    actions: {\n        reconnect: () => void;\n        send: (data: ClientEvent) => void;\n        toggleIsDark: () => void;\n        toggleShowImage: () => void;\n    };\n}\n\ninterface SettingsState {\n    connected: boolean;\n    info: Omit<SongUpdateSocket, 'currentTime'>;\n    isDark: boolean;\n    showImage: boolean;\n    socket?: StatefulWebSocket;\n}\n\ninterface StatefulWebSocket extends WebSocket {\n    natural: boolean;\n}\n\nconst initialState: SettingsState = {\n    connected: false,\n    info: {},\n    isDark: window.matchMedia('(prefers-color-scheme: dark)').matches,\n    showImage: true,\n};\n\nexport const useRemoteStore = createWithEqualityFn<SettingsSlice>()(\n    persist(\n        devtools(\n            immer((set, get) => ({\n                actions: {\n                    reconnect: async () => {\n                        logFn.debug(logMsg[LogCategory.REMOTE].reconnectInitiated, {\n                            category: LogCategory.REMOTE,\n                        });\n                        const existing = get().socket;\n\n                        if (existing) {\n                            if (\n                                existing.readyState === WebSocket.OPEN ||\n                                existing.readyState === WebSocket.CONNECTING\n                            ) {\n                                logFn.debug(logMsg[LogCategory.REMOTE].closingExistingSocket, {\n                                    category: LogCategory.REMOTE,\n                                    meta: { readyState: existing.readyState },\n                                });\n                                existing.natural = true;\n                                existing.close(4001);\n                            }\n                        }\n\n                        let authHeader: string | undefined;\n\n                        try {\n                            logFn.debug(logMsg[LogCategory.REMOTE].fetchingCredentials, {\n                                category: LogCategory.REMOTE,\n                            });\n                            const credentials = await fetch('/credentials');\n                            authHeader = await credentials.text();\n                            logFn.debug(logMsg[LogCategory.REMOTE].credentialsFetched, {\n                                category: LogCategory.REMOTE,\n                                meta: { hasAuthHeader: !!authHeader },\n                            });\n                        } catch (error) {\n                            logFn.error(logMsg[LogCategory.REMOTE].failedToGetCredentials, {\n                                category: LogCategory.REMOTE,\n                                meta: { error },\n                            });\n                        }\n\n                        set((state) => {\n                            const wsUrl = location.href.replace('http', 'ws');\n                            logFn.debug(logMsg[LogCategory.REMOTE].creatingWebSocket, {\n                                category: LogCategory.REMOTE,\n                                meta: { url: wsUrl },\n                            });\n                            const socket = new WebSocket(wsUrl) as StatefulWebSocket;\n\n                            socket.natural = false;\n\n                            socket.addEventListener('message', (message) => {\n                                const { data, event } = JSON.parse(message.data) as ServerEvent;\n\n                                logFn.debug(logMsg[LogCategory.REMOTE].webSocketMessageReceived, {\n                                    category: LogCategory.REMOTE,\n                                    meta: { data, event },\n                                });\n\n                                switch (event) {\n                                    case 'error': {\n                                        logFn.error(\n                                            logMsg[LogCategory.REMOTE].webSocketErrorEvent,\n                                            {\n                                                category: LogCategory.REMOTE,\n                                                meta: { data },\n                                            },\n                                        );\n                                        toast.error({ message: data, title: 'Socket error' });\n                                        break;\n                                    }\n                                    case 'favorite': {\n                                        logFn.debug(\n                                            logMsg[LogCategory.REMOTE].favoriteEventReceived,\n                                            {\n                                                category: LogCategory.REMOTE,\n                                                meta: {\n                                                    favorite: data.favorite,\n                                                    id: data.id,\n                                                },\n                                            },\n                                        );\n                                        set((state) => {\n                                            if (state.info.song?.id === data.id) {\n                                                state.info.song.userFavorite = data.favorite;\n                                            }\n                                        });\n                                        break;\n                                    }\n                                    case 'playback': {\n                                        logFn.debug(\n                                            logMsg[LogCategory.REMOTE].playbackEventReceived,\n                                            {\n                                                category: LogCategory.REMOTE,\n                                                meta: { status: data },\n                                            },\n                                        );\n                                        set((state) => {\n                                            state.info.status = data;\n                                        });\n                                        break;\n                                    }\n                                    case 'position': {\n                                        logFn.debug(\n                                            logMsg[LogCategory.REMOTE].positionEventReceived,\n                                            {\n                                                category: LogCategory.REMOTE,\n                                                meta: { position: data },\n                                            },\n                                        );\n                                        set((state) => {\n                                            state.info.position = data;\n                                        });\n                                        break;\n                                    }\n                                    case 'proxy': {\n                                        logFn.debug(logMsg[LogCategory.REMOTE].proxyEventReceived, {\n                                            category: LogCategory.REMOTE,\n                                            meta: {\n                                                dataLength: data?.length,\n                                                hasData: !!data,\n                                            },\n                                        });\n                                        set((state) => {\n                                            if (state.info.song) {\n                                                state.info.song.imageUrl = `data:image/jpeg;base64,${data}`;\n                                            }\n                                        });\n                                        break;\n                                    }\n                                    case 'rating': {\n                                        logFn.debug(\n                                            logMsg[LogCategory.REMOTE].ratingEventReceived,\n                                            {\n                                                category: LogCategory.REMOTE,\n                                                meta: {\n                                                    id: data.id,\n                                                    rating: data.rating,\n                                                },\n                                            },\n                                        );\n                                        set((state) => {\n                                            if (state.info.song?.id === data.id) {\n                                                state.info.song.userRating = data.rating;\n                                            }\n                                        });\n                                        break;\n                                    }\n                                    case 'repeat': {\n                                        logFn.debug(\n                                            logMsg[LogCategory.REMOTE].repeatEventReceived,\n                                            {\n                                                category: LogCategory.REMOTE,\n                                                meta: { repeat: data },\n                                            },\n                                        );\n                                        set((state) => {\n                                            state.info.repeat = data;\n                                        });\n                                        break;\n                                    }\n                                    case 'shuffle': {\n                                        logFn.debug(\n                                            logMsg[LogCategory.REMOTE].shuffleEventReceived,\n                                            {\n                                                category: LogCategory.REMOTE,\n                                                meta: { shuffle: data },\n                                            },\n                                        );\n                                        set((state) => {\n                                            state.info.shuffle = data;\n                                        });\n                                        break;\n                                    }\n                                    case 'song': {\n                                        logFn.debug(logMsg[LogCategory.REMOTE].songEventReceived, {\n                                            category: LogCategory.REMOTE,\n                                            meta: {\n                                                artistName: data?.artistName,\n                                                id: data?.id,\n                                                name: data?.name,\n                                            },\n                                        });\n                                        set((state) => {\n                                            state.info.song = data;\n                                        });\n                                        break;\n                                    }\n                                    case 'state': {\n                                        logFn.debug(logMsg[LogCategory.REMOTE].stateEventReceived, {\n                                            category: LogCategory.REMOTE,\n                                            meta: {\n                                                hasSong: !!data.song,\n                                                position: data.position,\n                                                status: data.status,\n                                                volume: data.volume,\n                                            },\n                                        });\n                                        set((state) => {\n                                            state.info = data;\n                                        });\n                                        break;\n                                    }\n                                    case 'volume': {\n                                        logFn.debug(\n                                            logMsg[LogCategory.REMOTE].volumeEventReceived,\n                                            {\n                                                category: LogCategory.REMOTE,\n                                                meta: { volume: data },\n                                            },\n                                        );\n                                        set((state) => {\n                                            state.info.volume = data;\n                                        });\n                                    }\n                                }\n                            });\n\n                            socket.addEventListener('open', () => {\n                                logFn.debug(logMsg[LogCategory.REMOTE].webSocketOpened, {\n                                    category: LogCategory.REMOTE,\n                                    meta: {\n                                        hasAuthHeader: !!authHeader,\n                                        readyState: socket.readyState,\n                                    },\n                                });\n                                if (authHeader) {\n                                    logFn.debug(logMsg[LogCategory.REMOTE].sendingAuthentication, {\n                                        category: LogCategory.REMOTE,\n                                    });\n                                    socket.send(\n                                        JSON.stringify({\n                                            event: 'authenticate',\n                                            header: authHeader,\n                                        }),\n                                    );\n                                }\n                                set({ connected: true });\n                            });\n\n                            socket.addEventListener('close', (reason) => {\n                                logFn.debug(logMsg[LogCategory.REMOTE].webSocketClosed, {\n                                    category: LogCategory.REMOTE,\n                                    meta: {\n                                        code: reason.code,\n                                        natural: socket.natural,\n                                        reason: reason.reason,\n                                        wasClean: reason.wasClean,\n                                    },\n                                });\n                                if (reason.code === 4002 || reason.code === 4003) {\n                                    logFn.debug(logMsg[LogCategory.REMOTE].reloadingPage, {\n                                        category: LogCategory.REMOTE,\n                                        meta: { code: reason.code },\n                                    });\n                                    location.reload();\n                                } else if (reason.code === 4000) {\n                                    logFn.warn(logMsg[LogCategory.REMOTE].serverIsDown, {\n                                        category: LogCategory.REMOTE,\n                                    });\n                                    toast.warn({\n                                        message: 'Feishin remote server is down',\n                                        title: 'Connection closed',\n                                    });\n                                } else if (reason.code !== 4001 && !socket.natural) {\n                                    logFn.error(\n                                        logMsg[LogCategory.REMOTE].socketClosedUnexpectedly,\n                                        {\n                                            category: LogCategory.REMOTE,\n                                            meta: {\n                                                code: reason.code,\n                                                reason: reason.reason,\n                                            },\n                                        },\n                                    );\n                                    toast.error({\n                                        message: 'Socket closed for unexpected reason',\n                                        title: 'Connection closed',\n                                    });\n                                }\n\n                                if (!socket.natural) {\n                                    set({ connected: false, info: {} });\n                                }\n                            });\n\n                            state.socket = socket;\n                        });\n                    },\n                    send: (data: ClientEvent) => {\n                        const socket = get().socket;\n                        if (socket) {\n                            logFn.debug(logMsg[LogCategory.REMOTE].sendingEventToServer, {\n                                category: LogCategory.REMOTE,\n                                meta: {\n                                    data: data,\n                                    event: data.event,\n                                    readyState: socket.readyState,\n                                },\n                            });\n                            socket.send(JSON.stringify(data));\n                        } else {\n                            logFn.warn(logMsg[LogCategory.REMOTE].cannotSendEvent, {\n                                category: LogCategory.REMOTE,\n                                meta: { event: data.event },\n                            });\n                        }\n                    },\n                    toggleIsDark: () => {\n                        set((state) => {\n                            state.isDark = !state.isDark;\n                        });\n                    },\n                    toggleShowImage: () => {\n                        set((state) => {\n                            state.showImage = !state.showImage;\n                        });\n                    },\n                },\n                ...initialState,\n            })),\n            { name: 'store_settings' },\n        ),\n        {\n            merge: (persistedState, currentState) => merge(currentState, persistedState),\n            name: 'store_settings',\n            version: 7,\n        },\n    ),\n);\n\nexport const useConnected = () => useRemoteStore((state) => state.connected);\n\nexport const useInfo = () => useRemoteStore((state) => state.info);\n\nexport const useIsDark = () => useRemoteStore((state) => state.isDark);\n\nexport const useReconnect = () => useRemoteStore((state) => state.actions.reconnect);\n\nexport const useShowImage = () => useRemoteStore((state) => state.showImage);\n\nexport const useSend = () => useRemoteStore((state) => state.actions.send);\n\nexport const useToggleDark = () => useRemoteStore((state) => state.actions.toggleIsDark);\n\nexport const useToggleShowImage = () => useRemoteStore((state) => state.actions.toggleShowImage);\n"
  },
  {
    "path": "src/remote/worker.js",
    "content": ""
  },
  {
    "path": "src/renderer/api/controller.ts",
    "content": "import i18n from '/@/i18n/i18n';\nimport { JellyfinController } from '/@/renderer/api/jellyfin/jellyfin-controller';\nimport { NavidromeController } from '/@/renderer/api/navidrome/navidrome-controller';\nimport { SubsonicController } from '/@/renderer/api/subsonic/subsonic-controller';\nimport { mergeMusicFolderId } from '/@/renderer/api/utils-music-folder';\nimport { getServerById, useAuthStore, useSettingsStore } from '/@/renderer/store';\nimport { toast } from '/@/shared/components/toast/toast';\nimport {\n    AuthenticationResponse,\n    ControllerEndpoint,\n    InternalControllerEndpoint,\n    ServerType,\n} from '/@/shared/types/domain-types';\n\ntype ApiController = {\n    jellyfin: InternalControllerEndpoint;\n    navidrome: InternalControllerEndpoint;\n    subsonic: InternalControllerEndpoint;\n};\n\nconst endpoints: ApiController = {\n    jellyfin: JellyfinController,\n    navidrome: NavidromeController,\n    subsonic: SubsonicController,\n};\n\nconst apiController = <K extends keyof ControllerEndpoint>(\n    endpoint: K,\n    type?: ServerType,\n): NonNullable<InternalControllerEndpoint[K]> => {\n    const serverType = type || useAuthStore.getState().currentServer?.type;\n\n    if (!serverType) {\n        toast.error({\n            message: i18n.t('error.serverNotSelectedError', {\n                postProcess: 'sentenceCase',\n            }) as string,\n            title: i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' }) as string,\n        });\n        throw new Error(`No server selected`);\n    }\n\n    const controllerFn = endpoints?.[serverType]?.[endpoint];\n\n    if (typeof controllerFn !== 'function') {\n        toast.error({\n            message: `Endpoint ${endpoint} is not implemented for ${serverType}`,\n            title: i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' }) as string,\n        });\n\n        throw new Error(\n            i18n.t('error.endpointNotImplementedError', {\n                endpoint,\n                postProcess: 'sentenceCase',\n                serverType,\n            }) as string,\n        );\n    }\n\n    return controllerFn;\n};\n\nconst getPathReplaceSettings = () => {\n    const { pathReplace, pathReplaceWith } = useSettingsStore.getState().general;\n    return { pathReplace, pathReplaceWith };\n};\n\nconst addContext = <T extends { apiClientProps: any; context?: any }>(args: T): T => {\n    const pathSettings = getPathReplaceSettings();\n    return {\n        ...args,\n        context: {\n            ...(args.context || {}),\n            ...pathSettings,\n        },\n    };\n};\n\nexport interface GeneralController extends Omit<Required<ControllerEndpoint>, 'authenticate'> {\n    authenticate: (\n        url: string,\n        body: { legacy?: boolean; password: string; username: string },\n        type: ServerType,\n    ) => Promise<AuthenticationResponse>;\n}\n\nexport const controller: GeneralController = {\n    addToPlaylist(args) {\n        const server = getServerById(args.apiClientProps.serverId);\n\n        if (!server) {\n            throw new Error(\n                `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: addToPlaylist`,\n            );\n        }\n\n        return apiController(\n            'addToPlaylist',\n            server.type,\n        )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));\n    },\n    authenticate(url, body, type) {\n        return apiController('authenticate', type)(url, body);\n    },\n    createFavorite(args) {\n        const server = getServerById(args.apiClientProps.serverId);\n\n        if (!server) {\n            throw new Error(\n                `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: createFavorite`,\n            );\n        }\n\n        return apiController(\n            'createFavorite',\n            server.type,\n        )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));\n    },\n    createInternetRadioStation(args) {\n        const server = getServerById(args.apiClientProps.serverId);\n\n        if (!server) {\n            throw new Error(\n                `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: createInternetRadioStation`,\n            );\n        }\n\n        return apiController(\n            'createInternetRadioStation',\n            server.type,\n        )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));\n    },\n    createPlaylist(args) {\n        const server = getServerById(args.apiClientProps.serverId);\n\n        if (!server) {\n            throw new Error(\n                `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: createPlaylist`,\n            );\n        }\n\n        return apiController(\n            'createPlaylist',\n            server.type,\n        )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));\n    },\n    deleteFavorite(args) {\n        const server = getServerById(args.apiClientProps.serverId);\n\n        if (!server) {\n            throw new Error(\n                `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: deleteFavorite`,\n            );\n        }\n\n        return apiController(\n            'deleteFavorite',\n            server.type,\n        )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));\n    },\n    deleteInternetRadioStation(args) {\n        const server = getServerById(args.apiClientProps.serverId);\n\n        if (!server) {\n            throw new Error(\n                `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: deleteInternetRadioStation`,\n            );\n        }\n\n        return apiController(\n            'deleteInternetRadioStation',\n            server.type,\n        )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));\n    },\n    deletePlaylist(args) {\n        const server = getServerById(args.apiClientProps.serverId);\n\n        if (!server) {\n            throw new Error(\n                `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: deletePlaylist`,\n            );\n        }\n\n        return apiController(\n            'deletePlaylist',\n            server.type,\n        )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));\n    },\n    getAlbumArtistDetail(args) {\n        const server = getServerById(args.apiClientProps.serverId);\n\n        if (!server) {\n            throw new Error(\n                `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getAlbumArtistDetail`,\n            );\n        }\n\n        return apiController(\n            'getAlbumArtistDetail',\n            server.type,\n        )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));\n    },\n    getAlbumArtistInfo(args) {\n        const server = getServerById(args.apiClientProps.serverId);\n\n        if (!server) {\n            return Promise.resolve(null);\n        }\n\n        const fn = apiController('getAlbumArtistInfo', server.type);\n        return fn\n            ? fn(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }))\n            : Promise.resolve(null);\n    },\n    getAlbumArtistList(args) {\n        const server = getServerById(args.apiClientProps.serverId);\n\n        if (!server) {\n            throw new Error(\n                `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getAlbumArtistList`,\n            );\n        }\n\n        return apiController(\n            'getAlbumArtistList',\n            server.type,\n        )?.(\n            addContext({\n                ...args,\n                apiClientProps: { ...args.apiClientProps, server },\n                query: mergeMusicFolderId(args.query, server),\n            }),\n        );\n    },\n    getAlbumArtistListCount(args) {\n        const server = getServerById(args.apiClientProps.serverId);\n\n        if (!server) {\n            throw new Error(\n                `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getAlbumArtistListCount`,\n            );\n        }\n\n        return apiController(\n            'getAlbumArtistListCount',\n            server.type,\n        )?.(\n            addContext({\n                ...args,\n                apiClientProps: { ...args.apiClientProps, server },\n                query: mergeMusicFolderId(args.query, server),\n            }),\n        );\n    },\n    getAlbumDetail(args) {\n        const server = getServerById(args.apiClientProps.serverId);\n\n        if (!server) {\n            throw new Error(\n                `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getAlbumDetail`,\n            );\n        }\n\n        return apiController(\n            'getAlbumDetail',\n            server.type,\n        )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));\n    },\n    getAlbumInfo(args) {\n        const server = getServerById(args.apiClientProps.serverId);\n\n        if (!server) {\n            throw new Error(\n                `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getAlbumInfo`,\n            );\n        }\n\n        return apiController(\n            'getAlbumInfo',\n            server.type,\n        )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));\n    },\n    getAlbumList(args) {\n        const server = getServerById(args.apiClientProps.serverId);\n\n        if (!server) {\n            throw new Error(\n                `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getAlbumList`,\n            );\n        }\n\n        return apiController(\n            'getAlbumList',\n            server.type,\n        )?.(\n            addContext({\n                ...args,\n                apiClientProps: { ...args.apiClientProps, server },\n                query: mergeMusicFolderId(args.query, server),\n            }),\n        );\n    },\n    getAlbumListCount(args) {\n        const server = getServerById(args.apiClientProps.serverId);\n\n        if (!server) {\n            throw new Error(\n                `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getAlbumListCount`,\n            );\n        }\n\n        return apiController(\n            'getAlbumListCount',\n            server.type,\n        )?.(\n            addContext({\n                ...args,\n                apiClientProps: { ...args.apiClientProps, server },\n                query: mergeMusicFolderId(args.query, server),\n            }),\n        );\n    },\n    getAlbumRadio(args) {\n        const server = getServerById(args.apiClientProps.serverId);\n\n        if (!server) {\n            throw new Error(\n                `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getAlbumRadio`,\n            );\n        }\n\n        return apiController(\n            'getAlbumRadio',\n            server.type,\n        )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));\n    },\n    getArtistList(args) {\n        const server = getServerById(args.apiClientProps.serverId);\n\n        if (!server) {\n            throw new Error(\n                `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getArtistList`,\n            );\n        }\n\n        return apiController(\n            'getArtistList',\n            server.type,\n        )?.(\n            addContext({\n                ...args,\n                apiClientProps: { ...args.apiClientProps, server },\n                query: mergeMusicFolderId(args.query, server),\n            }),\n        );\n    },\n    getArtistListCount(args) {\n        const server = getServerById(args.apiClientProps.serverId);\n\n        if (!server) {\n            throw new Error(\n                `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getArtistListCount`,\n            );\n        }\n\n        return apiController(\n            'getArtistListCount',\n            server.type,\n        )?.(\n            addContext({\n                ...args,\n                apiClientProps: { ...args.apiClientProps, server },\n                query: mergeMusicFolderId(args.query, server),\n            }),\n        );\n    },\n    getArtistRadio(args) {\n        const server = getServerById(args.apiClientProps.serverId);\n\n        if (!server) {\n            throw new Error(\n                `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getArtistRadio`,\n            );\n        }\n\n        return apiController(\n            'getArtistRadio',\n            server.type,\n        )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));\n    },\n    getDownloadUrl(args) {\n        const server = getServerById(args.apiClientProps.serverId);\n\n        if (!server) {\n            throw new Error(\n                `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getDownloadUrl`,\n            );\n        }\n\n        return apiController(\n            'getDownloadUrl',\n            server.type,\n        )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));\n    },\n    getFolder(args) {\n        const server = getServerById(args.apiClientProps.serverId);\n\n        if (!server) {\n            throw new Error(\n                `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getFolder`,\n            );\n        }\n\n        return apiController(\n            'getFolder',\n            server.type,\n        )?.(\n            addContext({\n                ...args,\n                apiClientProps: { ...args.apiClientProps, server },\n                query: mergeMusicFolderId(args.query, server),\n            }),\n        );\n    },\n    getGenreList(args) {\n        const server = getServerById(args.apiClientProps.serverId);\n\n        if (!server) {\n            throw new Error(\n                `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getGenreList`,\n            );\n        }\n\n        return apiController(\n            'getGenreList',\n            server.type,\n        )?.(\n            addContext({\n                ...args,\n                apiClientProps: { ...args.apiClientProps, server },\n                query: mergeMusicFolderId(args.query, server),\n            }),\n        );\n    },\n    getImageRequest(args) {\n        const server = getServerById(args.apiClientProps.serverId);\n\n        if (!server) {\n            return null;\n        }\n\n        return (\n            apiController(\n                'getImageRequest',\n                server.type,\n            )?.(\n                addContext({\n                    ...args,\n                    apiClientProps: { ...args.apiClientProps, server },\n                }),\n            ) || null\n        );\n    },\n    getImageUrl(args) {\n        const server = getServerById(args.apiClientProps.serverId);\n\n        if (!server) {\n            return null;\n        }\n\n        return (\n            apiController(\n                'getImageUrl',\n                server.type,\n            )?.(\n                addContext({\n                    ...args,\n                    apiClientProps: { ...args.apiClientProps, server },\n                }),\n            ) || null\n        );\n    },\n    getInternetRadioStations(args) {\n        const server = getServerById(args.apiClientProps.serverId);\n\n        if (!server) {\n            throw new Error(\n                `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getInternetRadioStations`,\n            );\n        }\n        return apiController(\n            'getInternetRadioStations',\n            server.type,\n        )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));\n    },\n    getLyrics(args) {\n        const server = getServerById(args.apiClientProps.serverId);\n\n        if (!server) {\n            throw new Error(\n                `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getLyrics`,\n            );\n        }\n\n        return apiController(\n            'getLyrics',\n            server.type,\n        )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));\n    },\n    getMusicFolderList(args) {\n        const server = getServerById(args.apiClientProps.serverId);\n\n        if (!server) {\n            throw new Error(\n                `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getMusicFolderList`,\n            );\n        }\n\n        return apiController(\n            'getMusicFolderList',\n            server.type,\n        )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));\n    },\n    getPlaylistDetail(args) {\n        const server = getServerById(args.apiClientProps.serverId);\n\n        if (!server) {\n            throw new Error(\n                `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getPlaylistDetail`,\n            );\n        }\n\n        return apiController(\n            'getPlaylistDetail',\n            server.type,\n        )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));\n    },\n    getPlaylistList(args) {\n        const server = getServerById(args.apiClientProps.serverId);\n\n        if (!server) {\n            throw new Error(\n                `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getPlaylistList`,\n            );\n        }\n\n        return apiController(\n            'getPlaylistList',\n            server.type,\n        )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));\n    },\n    getPlaylistListCount(args) {\n        const server = getServerById(args.apiClientProps.serverId);\n\n        if (!server) {\n            throw new Error(\n                `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getPlaylistListCount`,\n            );\n        }\n\n        return apiController(\n            'getPlaylistListCount',\n            server.type,\n        )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));\n    },\n    getPlaylistSongList(args) {\n        const server = getServerById(args.apiClientProps.serverId);\n\n        if (!server) {\n            throw new Error(\n                `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getPlaylistSongList`,\n            );\n        }\n\n        return apiController(\n            'getPlaylistSongList',\n            server.type,\n        )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));\n    },\n    getPlayQueue(args) {\n        const server = getServerById(args.apiClientProps.serverId);\n\n        if (!server) {\n            throw new Error(\n                `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getPlayQueue`,\n            );\n        }\n\n        return apiController(\n            'getPlayQueue',\n            server.type,\n        )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));\n    },\n    getRandomSongList(args) {\n        const server = getServerById(args.apiClientProps.serverId);\n\n        if (!server) {\n            throw new Error(\n                `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getRandomSongList`,\n            );\n        }\n\n        return apiController(\n            'getRandomSongList',\n            server.type,\n        )?.(\n            addContext({\n                ...args,\n                apiClientProps: { ...args.apiClientProps, server },\n                query: mergeMusicFolderId(args.query, server),\n            }),\n        );\n    },\n    getRoles(args) {\n        const server = getServerById(args.apiClientProps.serverId);\n\n        if (!server) {\n            throw new Error(\n                `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getRoles`,\n            );\n        }\n\n        return apiController(\n            'getRoles',\n            server.type,\n        )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));\n    },\n    getServerInfo(args) {\n        const server = getServerById(args.apiClientProps.serverId);\n\n        if (!server) {\n            throw new Error(\n                `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getServerInfo`,\n            );\n        }\n\n        return apiController(\n            'getServerInfo',\n            server.type,\n        )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));\n    },\n    getSimilarSongs(args) {\n        const server = getServerById(args.apiClientProps.serverId);\n\n        if (!server) {\n            throw new Error(\n                `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getSimilarSongs`,\n            );\n        }\n\n        return apiController(\n            'getSimilarSongs',\n            server.type,\n        )?.(\n            addContext({\n                ...args,\n                apiClientProps: { ...args.apiClientProps, server },\n                query: mergeMusicFolderId(args.query, server),\n            }),\n        );\n    },\n    getSongDetail(args) {\n        const server = getServerById(args.apiClientProps.serverId);\n\n        if (!server) {\n            throw new Error(\n                `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getSongDetail`,\n            );\n        }\n\n        return apiController(\n            'getSongDetail',\n            server.type,\n        )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));\n    },\n    getSongList(args) {\n        const server = getServerById(args.apiClientProps.serverId);\n\n        if (!server) {\n            throw new Error(\n                `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getSongList`,\n            );\n        }\n\n        return apiController(\n            'getSongList',\n            server.type,\n        )?.(\n            addContext({\n                ...args,\n                apiClientProps: { ...args.apiClientProps, server },\n                query: mergeMusicFolderId(args.query, server),\n            }),\n        );\n    },\n    getSongListCount(args) {\n        const server = getServerById(args.apiClientProps.serverId);\n\n        if (!server) {\n            throw new Error(\n                `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getSongListCount`,\n            );\n        }\n\n        return apiController(\n            'getSongListCount',\n            server.type,\n        )?.(\n            addContext({\n                ...args,\n                apiClientProps: { ...args.apiClientProps, server },\n                query: mergeMusicFolderId(args.query, server),\n            }),\n        );\n    },\n    getStreamUrl(args) {\n        const server = getServerById(args.apiClientProps.serverId);\n\n        if (!server) {\n            return '';\n        }\n\n        return apiController(\n            'getStreamUrl',\n            server.type,\n        )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));\n    },\n    getStructuredLyrics(args) {\n        const server = getServerById(args.apiClientProps.serverId);\n\n        if (!server) {\n            throw new Error(\n                `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getStructuredLyrics`,\n            );\n        }\n\n        return apiController(\n            'getStructuredLyrics',\n            server.type,\n        )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));\n    },\n    getTagList(args) {\n        const server = getServerById(args.apiClientProps.serverId);\n\n        if (!server) {\n            throw new Error(\n                `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getTags`,\n            );\n        }\n\n        return apiController(\n            'getTagList',\n            server.type,\n        )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));\n    },\n    getTopSongs(args) {\n        const server = getServerById(args.apiClientProps.serverId);\n\n        if (!server) {\n            throw new Error(\n                `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getTopSongs`,\n            );\n        }\n\n        return apiController(\n            'getTopSongs',\n            server.type,\n        )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));\n    },\n    getUserInfo(args) {\n        const server = getServerById(args.apiClientProps.serverId);\n\n        if (!server) {\n            throw new Error(\n                `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getUserInfo`,\n            );\n        }\n\n        return apiController(\n            'getUserInfo',\n            server.type,\n        )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));\n    },\n    getUserList(args) {\n        const server = getServerById(args.apiClientProps.serverId);\n\n        if (!server) {\n            throw new Error(\n                `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getUserList`,\n            );\n        }\n\n        return apiController(\n            'getUserList',\n            server.type,\n        )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));\n    },\n    movePlaylistItem(args) {\n        const server = getServerById(args.apiClientProps.serverId);\n\n        if (!server) {\n            throw new Error(\n                `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: movePlaylistItem`,\n            );\n        }\n\n        return apiController(\n            'movePlaylistItem',\n            server.type,\n        )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));\n    },\n    removeFromPlaylist(args) {\n        const server = getServerById(args.apiClientProps.serverId);\n\n        if (!server) {\n            throw new Error(\n                `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: removeFromPlaylist`,\n            );\n        }\n\n        return apiController(\n            'removeFromPlaylist',\n            server.type,\n        )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));\n    },\n    replacePlaylist(args) {\n        const server = getServerById(args.apiClientProps.serverId);\n\n        if (!server) {\n            throw new Error(\n                `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: replacePlaylist`,\n            );\n        }\n\n        return apiController(\n            'replacePlaylist',\n            server.type,\n        )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));\n    },\n    savePlayQueue(args) {\n        const server = getServerById(args.apiClientProps.serverId);\n\n        if (!server) {\n            throw new Error(\n                `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: savePlayQueue`,\n            );\n        }\n\n        return apiController(\n            'savePlayQueue',\n            server.type,\n        )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));\n    },\n    scrobble(args) {\n        const server = getServerById(args.apiClientProps.serverId);\n\n        if (!server) {\n            throw new Error(\n                `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: scrobble`,\n            );\n        }\n\n        return apiController(\n            'scrobble',\n            server.type,\n        )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));\n    },\n    search(args) {\n        const server = getServerById(args.apiClientProps.serverId);\n\n        if (!server) {\n            throw new Error(\n                `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: search`,\n            );\n        }\n\n        return apiController(\n            'search',\n            server.type,\n        )?.(\n            addContext({\n                ...args,\n                apiClientProps: { ...args.apiClientProps, server },\n                query: mergeMusicFolderId(args.query, server),\n            }),\n        );\n    },\n    setRating(args) {\n        const server = getServerById(args.apiClientProps.serverId);\n\n        if (!server) {\n            throw new Error(\n                `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: setRating`,\n            );\n        }\n\n        return apiController(\n            'setRating',\n            server.type,\n        )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));\n    },\n    shareItem(args) {\n        const server = getServerById(args.apiClientProps.serverId);\n\n        if (!server) {\n            throw new Error(\n                `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: shareItem`,\n            );\n        }\n\n        return apiController(\n            'shareItem',\n            server.type,\n        )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));\n    },\n    updateInternetRadioStation(args) {\n        const server = getServerById(args.apiClientProps.serverId);\n\n        if (!server) {\n            throw new Error(\n                `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: updateInternetRadioStation`,\n            );\n        }\n\n        return apiController(\n            'updateInternetRadioStation',\n            server.type,\n        )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));\n    },\n    updatePlaylist(args) {\n        const server = getServerById(args.apiClientProps.serverId);\n\n        if (!server) {\n            throw new Error(\n                `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: updatePlaylist`,\n            );\n        }\n\n        return apiController(\n            'updatePlaylist',\n            server.type,\n        )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));\n    },\n};\n"
  },
  {
    "path": "src/renderer/api/index.ts",
    "content": "import { controller } from '/@/renderer/api/controller';\n\nexport const api = {\n    controller,\n};\n"
  },
  {
    "path": "src/renderer/api/jellyfin/jellyfin-api.ts",
    "content": "import { initClient, initContract } from '@ts-rest/core';\nimport axios, { AxiosError, AxiosResponse, isAxiosError, Method } from 'axios';\nimport omitBy from 'lodash/omitBy';\nimport qs from 'qs';\nimport { z } from 'zod';\n\nimport packageJson from '../../../../package.json';\n\nimport i18n from '/@/i18n/i18n';\nimport { authenticationFailure } from '/@/renderer/api/utils';\nimport { useAuthStore } from '/@/renderer/store';\nimport { getServerUrl } from '/@/renderer/utils/normalize-server-url';\nimport { jfType } from '/@/shared/api/jellyfin/jellyfin-types';\nimport { getClientType } from '/@/shared/api/utils';\nimport { ServerListItemWithCredential } from '/@/shared/types/domain-types';\n\nconst c = initContract();\n\nexport const contract = c.router({\n    addToPlaylist: {\n        body: z.null(),\n        method: 'POST',\n        path: 'playlists/:id/items',\n        query: jfType._parameters.addToPlaylist,\n        responses: {\n            204: jfType._response.addToPlaylist,\n            400: jfType._response.error,\n        },\n    },\n    authenticate: {\n        body: jfType._parameters.authenticate,\n        method: 'POST',\n        path: 'users/authenticatebyname',\n        responses: {\n            200: jfType._response.authenticate,\n            400: jfType._response.error,\n        },\n    },\n    createFavorite: {\n        body: jfType._parameters.favorite,\n        method: 'POST',\n        path: 'users/:userId/favoriteitems/:id',\n        responses: {\n            200: jfType._response.favorite,\n            400: jfType._response.error,\n        },\n    },\n    createPlaylist: {\n        body: jfType._parameters.createPlaylist,\n        method: 'POST',\n        path: 'playlists',\n        responses: {\n            200: jfType._response.createPlaylist,\n            400: jfType._response.error,\n        },\n    },\n    deletePlaylist: {\n        body: null,\n        method: 'DELETE',\n        path: 'items/:id',\n        responses: {\n            204: jfType._response.deletePlaylist,\n            400: jfType._response.error,\n        },\n    },\n    getAlbumArtistDetail: {\n        method: 'GET',\n        path: 'users/:userId/items/:id',\n        query: jfType._parameters.albumArtistDetail,\n        responses: {\n            200: jfType._response.albumArtist,\n            400: jfType._response.error,\n        },\n    },\n    getAlbumArtistList: {\n        method: 'GET',\n        path: 'artists/albumArtists',\n        query: jfType._parameters.albumArtistList,\n        responses: {\n            200: jfType._response.albumArtistList,\n            400: jfType._response.error,\n        },\n    },\n    getAlbumDetail: {\n        method: 'GET',\n        path: 'users/:userId/items/:id',\n        query: jfType._parameters.albumDetail,\n        responses: {\n            200: jfType._response.album,\n            400: jfType._response.error,\n        },\n    },\n    getAlbumList: {\n        method: 'GET',\n        path: 'users/:userId/items',\n        query: jfType._parameters.albumList,\n        responses: {\n            200: jfType._response.albumList,\n            400: jfType._response.error,\n        },\n    },\n    getArtistList: {\n        method: 'GET',\n        path: 'artists',\n        query: jfType._parameters.albumArtistList,\n        responses: {\n            200: jfType._response.albumArtistList,\n            400: jfType._response.error,\n        },\n    },\n    getFilterList: {\n        method: 'GET',\n        path: 'items/filters',\n        query: jfType._parameters.filterList,\n        responses: {\n            200: jfType._response.filters,\n            400: jfType._response.error,\n        },\n    },\n    getFolder: {\n        method: 'GET',\n        path: 'users/:userId/items',\n        query: jfType._parameters.folder,\n        responses: {\n            200: jfType._response.folderList,\n            400: jfType._response.error,\n        },\n    },\n    getGenreList: {\n        method: 'GET',\n        path: 'musicgenres',\n        query: jfType._parameters.genreList,\n        responses: {\n            200: jfType._response.genreList,\n            400: jfType._response.error,\n        },\n    },\n    getInstantMix: {\n        method: 'GET',\n        path: 'items/:itemId/InstantMix',\n        query: jfType._parameters.similarSongs,\n        responses: {\n            200: jfType._response.songList,\n            400: jfType._response.error,\n        },\n    },\n    getMusicFolderList: {\n        method: 'GET',\n        path: 'users/:userId/items',\n        responses: {\n            200: jfType._response.musicFolderList,\n            400: jfType._response.error,\n        },\n    },\n    getPlaylistDetail: {\n        method: 'GET',\n        path: 'users/:userId/items/:id',\n        query: jfType._parameters.playlistDetail,\n        responses: {\n            200: jfType._response.playlist,\n            400: jfType._response.error,\n        },\n    },\n    getPlaylistList: {\n        method: 'GET',\n        path: 'users/:userId/items',\n        query: jfType._parameters.playlistList,\n        responses: {\n            200: jfType._response.playlistList,\n            400: jfType._response.error,\n        },\n    },\n    getPlaylistSongList: {\n        method: 'GET',\n        path: 'playlists/:id/items',\n        query: jfType._parameters.songList,\n        responses: {\n            200: jfType._response.playlistSongList,\n            400: jfType._response.error,\n        },\n    },\n    getPlayQueue: {\n        method: 'GET',\n        path: 'sessions',\n        query: jfType._parameters.getQueue,\n        responses: {\n            200: jfType._response.getSessions,\n            400: jfType._response.error,\n        },\n    },\n    getServerInfo: {\n        method: 'GET',\n        path: 'system/info',\n        responses: {\n            200: jfType._response.serverInfo,\n            400: jfType._response.error,\n        },\n    },\n    getSimilarArtistList: {\n        method: 'GET',\n        path: 'artists/:id/similar',\n        query: jfType._parameters.similarArtistList,\n        responses: {\n            200: jfType._response.albumArtistList,\n            400: jfType._response.error,\n        },\n    },\n    getSimilarSongs: {\n        method: 'GET',\n        path: 'items/:itemId/similar',\n        query: jfType._parameters.similarSongs,\n        responses: {\n            200: jfType._response.similarSongs,\n            400: jfType._response.error,\n        },\n    },\n    getSongData: {\n        method: 'GET',\n        path: 'users/:userId/items/:id',\n        query: jfType._parameters.songDetail,\n        responses: {\n            200: jfType._response.song,\n            400: jfType._response.error,\n        },\n    },\n    getSongDetail: {\n        method: 'GET',\n        path: 'users/:userId/items/:id',\n        responses: {\n            200: jfType._response.song,\n            400: jfType._response.error,\n        },\n    },\n    getSongList: {\n        method: 'GET',\n        path: 'users/:userId/items',\n        query: jfType._parameters.songList,\n        responses: {\n            200: jfType._response.songList,\n            400: jfType._response.error,\n        },\n    },\n    getSongLyrics: {\n        method: 'GET',\n        path: 'audio/:id/Lyrics',\n        responses: {\n            200: jfType._response.lyrics,\n            404: jfType._response.error,\n        },\n    },\n    getStudioList: {\n        method: 'GET',\n        path: 'studios',\n        query: jfType._parameters.studioList,\n        responses: {\n            200: jfType._response.studioList,\n            400: jfType._response.error,\n        },\n    },\n    getTopSongsList: {\n        method: 'GET',\n        path: 'users/:userId/items',\n        query: jfType._parameters.songList,\n        responses: {\n            200: jfType._response.topSongsList,\n            400: jfType._response.error,\n        },\n    },\n    getUser: {\n        method: 'GET',\n        path: 'users/:id',\n        responses: {\n            200: jfType._response.user,\n            400: jfType._response.error,\n        },\n    },\n    movePlaylistItem: {\n        body: null,\n        method: 'POST',\n        path: 'playlists/:playlistId/items/:itemId/move/:newIdx',\n        responses: {\n            200: jfType._response.moveItem,\n            400: jfType._response.error,\n        },\n    },\n    removeFavorite: {\n        body: jfType._parameters.favorite,\n        method: 'DELETE',\n        path: 'users/:userId/favoriteitems/:id',\n        responses: {\n            200: jfType._response.favorite,\n            400: jfType._response.error,\n        },\n    },\n    removeFromPlaylist: {\n        body: null,\n        method: 'DELETE',\n        path: 'playlists/:id/items',\n        query: jfType._parameters.removeFromPlaylist,\n        responses: {\n            200: jfType._response.removeFromPlaylist,\n            400: jfType._response.error,\n        },\n    },\n    savePlayQueue: {\n        body: jfType._parameters.saveQueue,\n        method: 'POST',\n        path: 'sessions/playing',\n        responses: {\n            200: jfType._response.scrobble,\n            400: jfType._response.error,\n        },\n    },\n    scrobblePlaying: {\n        body: jfType._parameters.scrobble,\n        method: 'POST',\n        path: 'sessions/playing',\n        responses: {\n            200: jfType._response.scrobble,\n            400: jfType._response.error,\n        },\n    },\n    scrobbleProgress: {\n        body: jfType._parameters.scrobble,\n        method: 'POST',\n        path: 'sessions/playing/progress',\n        responses: {\n            200: jfType._response.scrobble,\n            400: jfType._response.error,\n        },\n    },\n    scrobbleStopped: {\n        body: jfType._parameters.scrobble,\n        method: 'POST',\n        path: 'sessions/playing/stopped',\n        responses: {\n            200: jfType._response.scrobble,\n            400: jfType._response.error,\n        },\n    },\n    search: {\n        method: 'GET',\n        path: 'users/:userId/items',\n        query: jfType._parameters.search,\n        responses: {\n            200: jfType._response.search,\n            400: jfType._response.error,\n        },\n    },\n    updatePlaylist: {\n        body: jfType._parameters.updatePlaylist,\n        method: 'POST',\n        path: 'playlists/:id',\n        responses: {\n            200: jfType._response.updatePlaylist,\n            400: jfType._response.error,\n        },\n    },\n});\n\nconst axiosClient = axios.create({});\n\naxiosClient.defaults.paramsSerializer = (params) => {\n    return qs.stringify(params, { arrayFormat: 'repeat' });\n};\n\naxiosClient.interceptors.response.use(\n    (response) => {\n        return response;\n    },\n    (error) => {\n        if (error.response && error.response.status === 401) {\n            const currentServer = useAuthStore.getState().currentServer;\n\n            if (currentServer) {\n                useAuthStore\n                    .getState()\n                    .actions.updateServer(currentServer.id, { credential: undefined });\n            }\n\n            authenticationFailure(currentServer);\n        }\n\n        return Promise.reject(error);\n    },\n);\n\nconst parsePath = (fullPath: string) => {\n    const [path, params] = fullPath.split('?');\n\n    const parsedParams = qs.parse(params);\n    const notNilParams = omitBy(parsedParams, (value) => value === 'undefined' || value === 'null');\n\n    return {\n        params: notNilParams,\n        path,\n    };\n};\n\nexport const createAuthHeader = (): string => {\n    return `MediaBrowser Client=\"Feishin\", Device=\"${getClientType()}\", DeviceId=\"${\n        useAuthStore.getState().deviceId\n    }\", Version=\"${packageJson.version}\"`;\n};\n\nexport const jfApiClient = (args: {\n    server: null | ServerListItemWithCredential;\n    signal?: AbortSignal;\n    url?: string;\n}) => {\n    const { server, signal, url } = args;\n\n    return initClient(contract, {\n        api: async ({ body, headers, method, path }) => {\n            let baseUrl: string | undefined;\n            let token: string | undefined;\n\n            const { params, path: api } = parsePath(path);\n\n            if (server) {\n                const serverUrl = getServerUrl(server);\n                baseUrl = serverUrl;\n                token = server?.credential;\n            } else {\n                baseUrl = url;\n            }\n\n            try {\n                const result = await axiosClient.request({\n                    data: body,\n                    headers: {\n                        ...headers,\n                        ...(token\n                            ? { Authorization: createAuthHeader().concat(`, Token=\"${token}\"`) }\n                            : { Authorization: createAuthHeader() }),\n                    },\n                    method: method as Method,\n                    params,\n                    signal,\n                    url: `${baseUrl}/${api}`,\n                });\n                return {\n                    body: result.data,\n                    headers: result.headers as any,\n                    status: result.status,\n                };\n            } catch (e: any | AxiosError | Error) {\n                if (isAxiosError(e)) {\n                    if (e.code === 'ERR_NETWORK') {\n                        throw new Error(\n                            i18n.t('error.networkError', {\n                                postProcess: 'sentenceCase',\n                            }) as string,\n                        );\n                    }\n\n                    const error = e as AxiosError;\n                    const response = error.response as AxiosResponse;\n                    return {\n                        body: response?.data,\n                        headers: response?.headers as any,\n                        status: response?.status,\n                    };\n                }\n                throw e;\n            }\n        },\n        baseHeaders: {\n            'Content-Type': 'application/json',\n        },\n        baseUrl: '',\n        jsonQuery: false,\n    });\n};\n"
  },
  {
    "path": "src/renderer/api/jellyfin/jellyfin-controller.ts",
    "content": "import { set } from 'idb-keyval';\nimport chunk from 'lodash/chunk';\nimport filter from 'lodash/filter';\nimport orderBy from 'lodash/orderBy';\nimport { z } from 'zod';\n\nimport { createAuthHeader, jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api';\nimport { useRadioStore } from '/@/renderer/features/radio/store/radio-store';\nimport { getServerUrl } from '/@/renderer/utils/normalize-server-url';\nimport { jfNormalize } from '/@/shared/api/jellyfin/jellyfin-normalize';\nimport { JFSongListSort, JFSortOrder, jfType } from '/@/shared/api/jellyfin/jellyfin-types';\nimport { getFeatures, hasFeature, sortSongList, VersionInfo } from '/@/shared/api/utils';\nimport {\n    albumArtistListSortMap,\n    albumListSortMap,\n    Folder,\n    genreListSortMap,\n    ImageArgs,\n    ImageRequest,\n    InternalControllerEndpoint,\n    LibraryItem,\n    Played,\n    playlistListSortMap,\n    ReplaceApiClientProps,\n    ServerType,\n    Song,\n    SongListSort,\n    songListSortMap,\n    SortOrder,\n    sortOrderMap,\n    Tag,\n} from '/@/shared/types/domain-types';\nimport { ServerFeature } from '/@/shared/types/features-types';\n\nconst getJellyfinImageRequest = ({\n    apiClientProps: { server },\n    baseUrl,\n    query,\n}: ReplaceApiClientProps<ImageArgs>): ImageRequest | null => {\n    const { id, size } = query;\n    const imageSize = size;\n\n    if (!server) {\n        return null;\n    }\n\n    const url = baseUrl || getServerUrl(server);\n\n    if (!url) {\n        return null;\n    }\n\n    return {\n        cacheKey: ['jellyfin', server.id, baseUrl || '', id, imageSize || ''].join(':'),\n        headers: server.credential\n            ? { Authorization: createAuthHeader().concat(`, Token=\"${server.credential}\"`) }\n            : { Authorization: createAuthHeader() },\n        url: `${url}/Items/${id}/Images/Primary?quality=96${imageSize ? `&width=${imageSize}` : ''}`,\n    };\n};\n\nconst formatCommaDelimitedString = (value: string[]) => {\n    return value.join(',');\n};\n\n// Limit the query to 50 at a time to be *extremely* conservative on the\n// length of the full URL, since the ids are part of the query string and\n// not the POST body\nconst MAX_ITEMS_PER_PLAYLIST_ADD = 50;\n\n// Defining a re-usable Collator instance for performance reasons.\nconst numericSortCollator = new Intl.Collator(undefined, { numeric: true });\nconst collator = new Intl.Collator();\n\nconst VERSION_INFO: VersionInfo = [\n    [\n        '10.9.0',\n        {\n            [ServerFeature.LYRICS_SINGLE_STRUCTURED]: [1],\n            [ServerFeature.PUBLIC_PLAYLIST]: [1],\n        },\n    ],\n    ['10.0.0', { [ServerFeature.TAGS]: [1] }],\n];\n\nconst JF_FIELDS = {\n    ALBUM_ARTIST_DETAIL: ['Genres', 'Overview', 'SortName', 'ProviderIds'],\n    ALBUM_ARTIST_LIST: [\n        'Genres',\n        'DateCreated',\n        'ExternalUrls',\n        'Overview',\n        'SortName',\n        'ProviderIds',\n    ],\n    ALBUM_DETAIL: ['Genres', 'DateCreated', 'ChildCount', 'People', 'Tags', 'ProviderIds'],\n    ALBUM_LIST: ['People', 'Tags', 'Studios', 'SortName', 'ProviderIds', 'ChildCount'],\n    FOLDER: ['Genres', 'DateCreated', 'MediaSources', 'ParentId'],\n    GENRE: ['ItemCounts'],\n    PLAYLIST_DETAIL: [\n        'Genres',\n        'DateCreated',\n        'MediaSources',\n        'ChildCount',\n        'ParentId',\n        'SortName',\n    ],\n    PLAYLIST_LIST: ['ChildCount', 'Genres', 'DateCreated', 'ParentId', 'Overview'],\n    SONG: [\n        'Genres',\n        'DateCreated',\n        'MediaSources',\n        'ParentId',\n        'People',\n        'Tags',\n        'SortName',\n        'ProviderIds',\n    ],\n} as const;\n\nexport const JellyfinController: InternalControllerEndpoint = {\n    addToPlaylist: async (args) => {\n        const { apiClientProps, body, query } = args;\n\n        if (!apiClientProps.server?.userId) {\n            throw new Error('No userId found');\n        }\n\n        const chunks = chunk(body.songId, MAX_ITEMS_PER_PLAYLIST_ADD);\n\n        for (const chunk of chunks) {\n            const res = await jfApiClient(apiClientProps).addToPlaylist({\n                body: null,\n                params: {\n                    id: query.id,\n                },\n                query: {\n                    Ids: chunk.join(','),\n                    UserId: apiClientProps.server?.userId,\n                },\n            });\n\n            if (res.status !== 204) {\n                throw new Error('Failed to add to playlist');\n            }\n        }\n\n        return null;\n    },\n    authenticate: async (url, body) => {\n        const cleanServerUrl = url.replace(/\\/$/, '');\n\n        const res = await jfApiClient({ server: null, url: cleanServerUrl }).authenticate({\n            body: {\n                Pw: body.password,\n                Username: body.username,\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to authenticate');\n        }\n\n        return {\n            credential: res.body.AccessToken,\n            isAdmin: Boolean(res.body.User.Policy.IsAdministrator),\n            userId: res.body.User.Id,\n            username: res.body.User.Name,\n        };\n    },\n    createFavorite: async (args) => {\n        const { apiClientProps, query } = args;\n\n        if (!apiClientProps.server?.userId) {\n            throw new Error('No userId found');\n        }\n\n        for (const id of query.id) {\n            await jfApiClient(apiClientProps).createFavorite({\n                body: {},\n                params: {\n                    id,\n                    userId: apiClientProps.server?.userId,\n                },\n            });\n        }\n\n        return null;\n    },\n    createInternetRadioStation: async (args) => {\n        const { apiClientProps, body } = args;\n\n        if (!apiClientProps.serverId) {\n            throw new Error('No serverId found');\n        }\n\n        const state = useRadioStore.getState();\n        if (!state?.actions?.createStation) {\n            throw new Error('Radio store not initialized');\n        }\n\n        state.actions.createStation(apiClientProps.serverId, {\n            homepageUrl: body.homepageUrl || null,\n            name: body.name,\n            streamUrl: body.streamUrl,\n        });\n\n        return null;\n    },\n    createPlaylist: async (args) => {\n        const { apiClientProps, body } = args;\n\n        if (!apiClientProps.server?.userId) {\n            throw new Error('No userId found');\n        }\n\n        const res = await jfApiClient(apiClientProps).createPlaylist({\n            body: {\n                IsPublic: body.public,\n                MediaType: 'Audio',\n                Name: body.name,\n                UserId: apiClientProps.server.userId,\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to create playlist');\n        }\n\n        return {\n            id: res.body.Id,\n        };\n    },\n    deleteFavorite: async (args) => {\n        const { apiClientProps, query } = args;\n\n        if (!apiClientProps.server?.userId) {\n            throw new Error('No userId found');\n        }\n\n        for (const id of query.id) {\n            await jfApiClient(apiClientProps).removeFavorite({\n                body: {},\n                params: {\n                    id,\n                    userId: apiClientProps.server?.userId,\n                },\n            });\n        }\n\n        return null;\n    },\n    deleteInternetRadioStation: async (args) => {\n        const { apiClientProps, query } = args;\n\n        if (!apiClientProps.serverId) {\n            throw new Error('No serverId found');\n        }\n\n        const state = useRadioStore.getState();\n        if (!state?.actions?.deleteStation) {\n            throw new Error('Radio store not initialized');\n        }\n\n        state.actions.deleteStation(apiClientProps.serverId, query.id);\n\n        return null;\n    },\n    deletePlaylist: async (args) => {\n        const { apiClientProps, query } = args;\n\n        const res = await jfApiClient(apiClientProps).deletePlaylist({\n            params: {\n                id: query.id,\n            },\n        });\n\n        if (res.status !== 204) {\n            throw new Error('Failed to delete playlist');\n        }\n\n        return null;\n    },\n    getAlbumArtistDetail: async (args) => {\n        const { apiClientProps, query } = args;\n\n        if (!apiClientProps.server?.userId) {\n            throw new Error('No userId found');\n        }\n\n        const res = await jfApiClient(apiClientProps).getAlbumArtistDetail({\n            params: {\n                id: query.id,\n                userId: apiClientProps.server?.userId,\n            },\n            query: {\n                Fields: ['Genres', 'Overview', 'SortName'],\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to get album artist detail');\n        }\n\n        return jfNormalize.albumArtist(res.body, apiClientProps.server);\n    },\n    getAlbumArtistInfo: async (args) => {\n        const { apiClientProps, query } = args;\n\n        const similarArtistsRes = await jfApiClient(apiClientProps).getSimilarArtistList({\n            params: {\n                id: query.id,\n            },\n            query: {\n                Limit: query.limit ?? 10,\n            },\n        });\n\n        if (similarArtistsRes.status !== 200) {\n            return null;\n        }\n\n        const items = similarArtistsRes.body?.Items?.filter(\n            (entry) => entry.Name !== 'Various Artists',\n        );\n        const similarArtists =\n            items?.map((entry) => ({\n                id: entry.Id,\n                imageId: entry.ImageTags?.Primary ? entry.Id : null,\n                imageUrl: null,\n                name: entry.Name,\n                userFavorite: entry.UserData?.IsFavorite || false,\n                userRating: null,\n            })) ?? null;\n\n        return {\n            similarArtists,\n        };\n    },\n    getAlbumArtistList: async (args) => {\n        const { apiClientProps, query } = args;\n\n        const res = await jfApiClient(apiClientProps).getAlbumArtistList({\n            query: {\n                Fields: JF_FIELDS.ALBUM_ARTIST_LIST,\n                ImageTypeLimit: 1,\n                IsFavorite: query.favorite,\n                Limit: query.limit,\n                ParentId: getLibraryId(query.musicFolderId),\n                Recursive: true,\n                SearchTerm: query.searchTerm,\n                SortBy: albumArtistListSortMap.jellyfin[query.sortBy] || 'SortName,Name',\n                SortOrder: sortOrderMap.jellyfin[query.sortOrder],\n                StartIndex: query.startIndex,\n                UserId: apiClientProps.server?.userId || undefined,\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to get album artist list');\n        }\n\n        return {\n            items: res.body.Items.map((item) =>\n                jfNormalize.albumArtist(item, apiClientProps.server),\n            ),\n            startIndex: query.startIndex,\n            totalRecordCount: res.body.TotalRecordCount,\n        };\n    },\n    getAlbumArtistListCount: async ({ apiClientProps, query }) =>\n        JellyfinController.getAlbumArtistList({\n            apiClientProps,\n            query: { ...query, limit: 1, startIndex: 0 },\n        }).then((result) => result!.totalRecordCount!),\n    getAlbumDetail: async (args) => {\n        const { apiClientProps, query } = args;\n\n        if (!apiClientProps.server?.userId) {\n            throw new Error('No userId found');\n        }\n\n        const res = await jfApiClient(apiClientProps).getAlbumDetail({\n            params: {\n                id: query.id,\n                userId: apiClientProps.server.userId,\n            },\n            query: {\n                Fields: JF_FIELDS.ALBUM_DETAIL,\n            },\n        });\n\n        const songsRes = await jfApiClient(apiClientProps).getSongList({\n            params: {\n                userId: apiClientProps.server.userId,\n            },\n            query: {\n                Fields: JF_FIELDS.SONG,\n                IncludeItemTypes: 'Audio',\n                ParentId: query.id,\n                SortBy: 'ParentIndexNumber,IndexNumber,SortName',\n            },\n        });\n\n        if (res.status !== 200 || songsRes.status !== 200) {\n            throw new Error('Failed to get album detail');\n        }\n\n        return jfNormalize.album(\n            { ...res.body, Songs: songsRes.body.Items },\n            apiClientProps.server,\n        );\n    },\n    getAlbumList: async (args) => {\n        const { apiClientProps, query } = args;\n\n        if (!apiClientProps.server?.userId) {\n            throw new Error('No userId found');\n        }\n\n        const yearsGroup: string[] = [];\n        if (query.minYear && query.maxYear) {\n            for (let i = Number(query.minYear); i <= Number(query.maxYear); i += 1) {\n                yearsGroup.push(String(i));\n            }\n        }\n\n        const yearsFilter = yearsGroup.length ? yearsGroup.join(',') : undefined;\n\n        let artistQuery:\n            | Omit<z.infer<typeof jfType._parameters.albumList>, 'IncludeItemTypes'>\n            | undefined;\n\n        if (query.artistIds) {\n            // Based mostly off of observation, this is the behavior I've seen:\n            // ContributingArtistIds is the _closest_ to where the album is a compilation and the artist is involved\n            // AlbumArtistIds is where the artist is an album artist\n            // ArtistIds is all credits\n            if (query.compilation) {\n                artistQuery = {\n                    ContributingArtistIds: formatCommaDelimitedString(query.artistIds),\n                };\n            } else if (query.compilation === false) {\n                artistQuery = { AlbumArtistIds: formatCommaDelimitedString(query.artistIds) };\n            } else {\n                artistQuery = { ArtistIds: formatCommaDelimitedString(query.artistIds) };\n            }\n        }\n\n        const res = await jfApiClient(apiClientProps).getAlbumList({\n            params: {\n                userId: apiClientProps.server?.userId,\n            },\n            query: {\n                ...artistQuery,\n                Fields: JF_FIELDS.ALBUM_LIST,\n                GenreIds: query.genreIds ? query.genreIds.join(',') : undefined,\n                IncludeItemTypes: 'MusicAlbum',\n                IsFavorite: query.favorite,\n                Limit: query.limit === -1 ? undefined : query.limit,\n                ParentId: getLibraryId(query.musicFolderId),\n                Recursive: true,\n                SearchTerm: query.searchTerm,\n                SortBy: albumListSortMap.jellyfin[query.sortBy] || 'SortName',\n                SortOrder: sortOrderMap.jellyfin[query.sortOrder],\n                StartIndex: query.startIndex,\n                ...query._custom,\n                Years: yearsFilter,\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to get album list');\n        }\n\n        return {\n            items: res.body.Items.map((item) => jfNormalize.album(item, apiClientProps.server)),\n            startIndex: query.startIndex,\n            totalRecordCount: res.body.TotalRecordCount,\n        };\n    },\n    getAlbumListCount: async ({ apiClientProps, query }) =>\n        JellyfinController.getAlbumList({\n            apiClientProps,\n            query: { ...query, limit: 1, startIndex: 0 },\n        }).then((result) => result!.totalRecordCount!),\n    getAlbumRadio: async (args) => {\n        const { apiClientProps, query } = args;\n\n        // For Jellyfin, use instant mix for album radio\n        const res = await jfApiClient(apiClientProps).getInstantMix({\n            params: {\n                itemId: query.albumId,\n            },\n            query: {\n                Fields: JF_FIELDS.SONG,\n                Limit: query.count,\n                UserId: apiClientProps.server?.userId || undefined,\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to get album radio songs');\n        }\n\n        return res.body.Items.map((song) =>\n            jfNormalize.song(\n                song,\n                apiClientProps.server,\n                args.context?.pathReplace,\n                args.context?.pathReplaceWith,\n            ),\n        );\n    },\n    getArtistList: async (args) => {\n        const { apiClientProps, query } = args;\n\n        const res = await jfApiClient(apiClientProps).getArtistList({\n            query: {\n                Fields: JF_FIELDS.ALBUM_ARTIST_LIST,\n                ImageTypeLimit: 1,\n                Limit: query.limit,\n                ParentId: getLibraryId(query.musicFolderId),\n                Recursive: true,\n                SearchTerm: query.searchTerm,\n                SortBy: albumArtistListSortMap.jellyfin[query.sortBy] || 'SortName,Name',\n                SortOrder: sortOrderMap.jellyfin[query.sortOrder],\n                StartIndex: query.startIndex,\n                UserId: apiClientProps.server?.userId || undefined,\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to get album artist list');\n        }\n\n        return {\n            items: res.body.Items.map((item) =>\n                jfNormalize.albumArtist(item, apiClientProps.server),\n            ),\n            startIndex: query.startIndex,\n            totalRecordCount: res.body.TotalRecordCount,\n        };\n    },\n    getArtistListCount: async ({ apiClientProps, query }) =>\n        JellyfinController.getArtistList({\n            apiClientProps,\n            query: { ...query, limit: 1, startIndex: 0 },\n        }).then((result) => result!.totalRecordCount!),\n    getArtistRadio: async (args) => {\n        const { apiClientProps, query } = args;\n\n        // For Jellyfin, use instant mix for artist radio\n        const res = await jfApiClient(apiClientProps).getInstantMix({\n            params: {\n                itemId: query.artistId,\n            },\n            query: {\n                Fields: JF_FIELDS.SONG,\n                Limit: query.count,\n                UserId: apiClientProps.server?.userId || undefined,\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to get artist radio songs');\n        }\n\n        return res.body.Items.map((song) =>\n            jfNormalize.song(\n                song,\n                apiClientProps.server,\n                args.context?.pathReplace,\n                args.context?.pathReplaceWith,\n            ),\n        );\n    },\n    getDownloadUrl: (args) => {\n        const { apiClientProps, query } = args;\n\n        return `${apiClientProps.server?.url}/items/${query.id}/download?apiKey=${apiClientProps.server?.credential}`;\n    },\n    getFolder: async ({ apiClientProps, query }) => {\n        const userId = apiClientProps.server?.userId;\n\n        if (!userId) throw new Error('No userId found');\n\n        const sortOrder = (query.sortOrder?.toLowerCase() ?? 'asc') as 'asc' | 'desc';\n        const isRootFolderId = query.id === '0';\n\n        if (isRootFolderId) {\n            if (query.musicFolderId) {\n                // If music folder is provided, directly get the folder\n                const musicFolderRes = await jfApiClient(apiClientProps).getFolder({\n                    params: {\n                        userId,\n                    },\n                    query: {\n                        ParentId: getLibraryId(query.musicFolderId)!,\n                    },\n                });\n\n                if (musicFolderRes.status !== 200) {\n                    throw new Error('Failed to get music folder list');\n                }\n\n                let items = musicFolderRes.body.Items.filter((item) => item.Type !== 'Audio');\n\n                if (query.searchTerm) {\n                    items = filter(items, (item) => {\n                        return item.Name.toLowerCase().includes(query.searchTerm!.toLowerCase());\n                    });\n                }\n\n                const folders = items\n                    .filter((item) => item.Type !== 'Audio')\n                    .map((item) => jfNormalize.folder(item, apiClientProps.server));\n\n                const sortedFolders = orderBy(folders, [(v) => v.name.toLowerCase()], [sortOrder]);\n\n                return {\n                    _itemType: LibraryItem.FOLDER,\n                    _serverId: apiClientProps.server?.id || 'unknown',\n                    _serverType: ServerType.JELLYFIN,\n                    children: {\n                        folders: sortedFolders,\n                        songs: [],\n                    },\n                    id: query.id,\n                    name: '~',\n                    parentId: undefined,\n                };\n            } else {\n                // Use the root music folder list if no music folder id is provided\n                const musicFolderRes = await jfApiClient(apiClientProps).getMusicFolderList({\n                    params: {\n                        userId,\n                    },\n                });\n\n                if (musicFolderRes.status !== 200) {\n                    throw new Error('Failed to get music folder list');\n                }\n\n                let items = musicFolderRes.body.Items.filter((item) => item.Type !== 'Audio');\n\n                if (query.searchTerm) {\n                    items = filter(items, (item) => {\n                        return item.Name.toLowerCase().includes(query.searchTerm!.toLowerCase());\n                    });\n                }\n\n                const folders = items\n                    .filter((item) => item.Type !== 'Audio')\n                    .map((item) =>\n                        jfNormalize.folder(\n                            item as unknown as z.infer<typeof jfType._response.folder>,\n                            apiClientProps.server,\n                        ),\n                    );\n\n                const sortedFolders = orderBy(folders, [(v) => v.name.toLowerCase()], [sortOrder]);\n\n                return {\n                    _itemType: LibraryItem.FOLDER,\n                    _serverId: apiClientProps.server?.id || 'unknown',\n                    _serverType: ServerType.JELLYFIN,\n                    children: {\n                        folders: sortedFolders,\n                        songs: [],\n                    },\n                    id: query.id,\n                    name: '~',\n                    parentId: undefined,\n                };\n            }\n        }\n\n        const folderDetailRes = await jfApiClient(apiClientProps).getFolder({\n            params: {\n                userId,\n            },\n            query: {\n                Fields: JF_FIELDS.FOLDER,\n                ParentId: query.id,\n                SortBy: query.sortBy\n                    ? (songListSortMap.jellyfin[query.sortBy] as string) || 'SortName'\n                    : 'SortName',\n                SortOrder: sortOrderMap.jellyfin[query.sortOrder || SortOrder.ASC],\n            },\n        });\n\n        if (folderDetailRes.status !== 200) {\n            throw new Error('Failed to get folder');\n        }\n\n        // Get parent folder info - we'll use the first child's ParentId to infer the folder's parentId\n        // The folder name will be inferred from the query.id or we can try to get it from a parent query\n        let parentId: string | undefined;\n        let folderName = 'Unknown folder';\n\n        if (folderDetailRes.body.Items?.length > 0) {\n            const firstItem = folderDetailRes.body.Items[0];\n            parentId = firstItem.ParentId;\n\n            // Try to get the folder name by querying its parent's children\n            if (parentId) {\n                const parentFolderRes = await jfApiClient(apiClientProps).getFolder({\n                    params: {\n                        userId,\n                    },\n                    query: {\n                        Fields: JF_FIELDS.FOLDER,\n                        ParentId: parentId,\n                    },\n                });\n\n                if (parentFolderRes.status === 200) {\n                    const parentFolderItem = parentFolderRes.body.Items?.find(\n                        (item) => item.Id === query.id,\n                    );\n                    if (parentFolderItem) {\n                        folderName = parentFolderItem.Name || 'Unknown folder';\n                        parentId = parentFolderItem.ParentId;\n                    }\n                }\n            }\n        }\n\n        const items = folderDetailRes.body.Items || [];\n\n        let filteredFolders = items\n            .filter((item) => item.Type !== 'Audio')\n            .map((item) => jfNormalize.folder(item, apiClientProps.server));\n        let filteredSongs = items\n            .filter(\n                (item) =>\n                    item.Type === 'Audio' &&\n                    (item as unknown as z.infer<typeof jfType._response.song>).MediaSources,\n            )\n            .map((item) =>\n                jfNormalize.song(\n                    item as unknown as z.infer<typeof jfType._response.song>,\n                    apiClientProps.server,\n                ),\n            );\n\n        if (query.searchTerm) {\n            const searchTermLower = query.searchTerm.toLowerCase();\n            filteredFolders = filter(filteredFolders, (f) =>\n                f.name.toLowerCase().includes(searchTermLower),\n            );\n            filteredSongs = filter(filteredSongs, (s) => {\n                const name = s.name?.toLowerCase() || '';\n                const album = s.album?.toLowerCase() || '';\n                const artist = s.artistName?.toLowerCase() || '';\n                return (\n                    name.includes(searchTermLower) ||\n                    album.includes(searchTermLower) ||\n                    artist.includes(searchTermLower)\n                );\n            });\n        }\n\n        filteredFolders = orderBy(filteredFolders, [(v) => v.name.toLowerCase()], [sortOrder]);\n\n        if (filteredSongs.length > 0) {\n            filteredSongs = sortSongList(\n                filteredSongs,\n                query.sortBy || SongListSort.NAME,\n                query.sortOrder || SortOrder.ASC,\n            );\n        }\n\n        const folder: Folder = {\n            _itemType: LibraryItem.FOLDER,\n            _serverId: apiClientProps.server?.id || 'unknown',\n            _serverType: ServerType.JELLYFIN,\n            children: {\n                folders: filteredFolders,\n                songs: filteredSongs,\n            },\n            id: query.id,\n            name: folderName,\n            parentId,\n        };\n\n        return folder;\n    },\n    getGenreList: async (args) => {\n        const { apiClientProps, query } = args;\n\n        if (!apiClientProps.server?.userId) {\n            throw new Error('No userId found');\n        }\n\n        const res = await jfApiClient(apiClientProps).getGenreList({\n            query: {\n                EnableTotalRecordCount: true,\n                Fields: JF_FIELDS.GENRE,\n                Limit: query.limit === -1 ? undefined : query.limit,\n                ParentId: getLibraryId(query.musicFolderId),\n                Recursive: true,\n                SearchTerm: query?.searchTerm,\n                SortBy: genreListSortMap.jellyfin[query.sortBy] || 'SortName',\n                SortOrder: sortOrderMap.jellyfin[query.sortOrder],\n                StartIndex: query.startIndex,\n                UserId: apiClientProps.server?.userId,\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to get genre list');\n        }\n\n        return {\n            items: res.body.Items.map((item) => jfNormalize.genre(item, apiClientProps.server)),\n            startIndex: query.startIndex || 0,\n            totalRecordCount: res.body?.TotalRecordCount || 0,\n        };\n    },\n    getImageRequest: getJellyfinImageRequest,\n    getImageUrl: (args) => getJellyfinImageRequest(args)?.url || null,\n    getInternetRadioStations: async (args) => {\n        const { apiClientProps } = args;\n\n        if (!apiClientProps.serverId) {\n            throw new Error('No serverId found');\n        }\n\n        const state = useRadioStore.getState();\n        if (!state?.actions?.getStations) {\n            throw new Error('Radio store not initialized');\n        }\n\n        return state.actions.getStations(apiClientProps.serverId);\n    },\n    getLyrics: async (args) => {\n        const { apiClientProps, query } = args;\n\n        if (!apiClientProps.server?.userId) {\n            throw new Error('No userId found');\n        }\n\n        const res = await jfApiClient(apiClientProps).getSongLyrics({\n            params: {\n                id: query.songId,\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to get lyrics');\n        }\n\n        if (res.body.Lyrics.length > 0 && res.body.Lyrics[0].Start === undefined) {\n            return res.body.Lyrics.map((lyric) => lyric.Text).join('\\n');\n        }\n\n        return res.body.Lyrics.map((lyric) => [lyric.Start! / 1e4, lyric.Text]);\n    },\n    getMusicFolderList: async (args) => {\n        const { apiClientProps } = args;\n        const userId = apiClientProps.server?.userId;\n\n        if (!userId) throw new Error('No userId found');\n\n        const res = await jfApiClient(apiClientProps).getMusicFolderList({\n            params: {\n                userId,\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to get genre list');\n        }\n\n        const musicFolders = res.body.Items.filter(\n            (folder) => folder.CollectionType === jfType._enum.collection.MUSIC,\n        );\n\n        return {\n            items: musicFolders.map(jfNormalize.musicFolder),\n            startIndex: 0,\n            totalRecordCount: musicFolders?.length || 0,\n        };\n    },\n    getPlaylistDetail: async (args) => {\n        const { apiClientProps, query } = args;\n\n        if (!apiClientProps.server?.userId) {\n            throw new Error('No userId found');\n        }\n\n        const res = await jfApiClient(apiClientProps).getPlaylistDetail({\n            params: {\n                id: query.id,\n                userId: apiClientProps.server?.userId,\n            },\n            query: {\n                Fields: JF_FIELDS.PLAYLIST_DETAIL,\n                Ids: query.id,\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to get playlist detail');\n        }\n\n        return jfNormalize.playlist(res.body, apiClientProps.server);\n    },\n    getPlaylistList: async (args) => {\n        const { apiClientProps, query } = args;\n\n        if (!apiClientProps.server?.userId) {\n            throw new Error('No userId found');\n        }\n\n        const res = await jfApiClient(apiClientProps).getPlaylistList({\n            params: {\n                userId: apiClientProps.server?.userId,\n            },\n            query: {\n                Fields: JF_FIELDS.PLAYLIST_LIST,\n                IncludeItemTypes: 'Playlist',\n                Limit: query.limit,\n                Recursive: true,\n                SearchTerm: query.searchTerm,\n                SortBy: playlistListSortMap.jellyfin[query.sortBy],\n                SortOrder: sortOrderMap.jellyfin[query.sortOrder],\n                StartIndex: query.startIndex,\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to get playlist list');\n        }\n\n        return {\n            items: res.body.Items.map((item) => jfNormalize.playlist(item, apiClientProps.server)),\n            startIndex: 0,\n            totalRecordCount: res.body.TotalRecordCount,\n        };\n    },\n    getPlaylistListCount: async ({ apiClientProps, query }) =>\n        JellyfinController.getPlaylistList({\n            apiClientProps,\n            query: { ...query, limit: 1, startIndex: 0 },\n        }).then((result) => result!.totalRecordCount!),\n    getPlaylistSongList: async (args) => {\n        const { apiClientProps, query } = args;\n\n        if (!apiClientProps.server?.userId) {\n            throw new Error('No userId found');\n        }\n\n        const res = await jfApiClient(apiClientProps).getPlaylistSongList({\n            params: {\n                id: query.id,\n            },\n            query: {\n                Fields: JF_FIELDS.SONG,\n                IncludeItemTypes: 'Audio',\n                UserId: apiClientProps.server?.userId,\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to get playlist song list');\n        }\n\n        return {\n            items: res.body.Items.map((item) =>\n                jfNormalize.song(\n                    item,\n                    apiClientProps.server,\n                    args.context?.pathReplace,\n                    args.context?.pathReplaceWith,\n                ),\n            ),\n            startIndex: 0,\n            totalRecordCount: res.body.TotalRecordCount,\n        };\n    },\n    getPlayQueue: async () => {\n        throw new Error('Not supported');\n    },\n    getRandomSongList: async (args) => {\n        const { apiClientProps, query } = args;\n\n        if (!apiClientProps.server?.userId) {\n            throw new Error('No userId found');\n        }\n\n        const yearsGroup: string[] = [];\n        if (query.minYear && query.maxYear) {\n            for (let i = Number(query.minYear); i <= Number(query.maxYear); i += 1) {\n                yearsGroup.push(String(i));\n            }\n        }\n\n        const yearsFilter = yearsGroup.length ? formatCommaDelimitedString(yearsGroup) : undefined;\n\n        const res = await jfApiClient(apiClientProps).getSongList({\n            params: {\n                userId: apiClientProps.server?.userId,\n            },\n            query: {\n                Fields: JF_FIELDS.SONG,\n                GenreIds: query.genre ? query.genre : undefined,\n                IncludeItemTypes: 'Audio',\n                IsPlayed:\n                    query.played === Played.Never\n                        ? false\n                        : query.played === Played.Played\n                          ? true\n                          : undefined,\n                Limit: query.limit,\n                ParentId: getLibraryId(query.musicFolderId),\n                Recursive: true,\n                SortBy: JFSongListSort.RANDOM,\n                SortOrder: JFSortOrder.ASC,\n                StartIndex: 0,\n                Years: yearsFilter,\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to get random songs');\n        }\n\n        return {\n            items: res.body.Items.map((item) =>\n                jfNormalize.song(\n                    item,\n                    apiClientProps.server,\n                    args.context?.pathReplace,\n                    args.context?.pathReplaceWith,\n                ),\n            ),\n            startIndex: 0,\n            totalRecordCount: res.body.Items.length || 0,\n        };\n    },\n    getRoles: async () => [],\n    getServerInfo: async (args) => {\n        const { apiClientProps } = args;\n\n        const res = await jfApiClient(apiClientProps).getServerInfo();\n\n        if (res.status !== 200) {\n            throw new Error('Failed to get server info');\n        }\n\n        const defaultFeatures = {};\n\n        const features = {\n            ...defaultFeatures,\n            ...getFeatures(VERSION_INFO, res.body.Version),\n        };\n\n        return {\n            features,\n            id: apiClientProps.server?.id,\n            version: res.body.Version,\n        };\n    },\n    getSimilarSongs: async (args) => {\n        const { apiClientProps, query } = args;\n\n        if (apiClientProps.server?.preferInstantMix !== true) {\n            // Prefer getSimilarSongs, where possible, and not overridden.\n            // InstantMix can be overridden by plugins, so this may be preferred by the user.\n            // Otherwise, similarSongs may have a better output than InstantMix, if sufficient\n            // data exists from the server.\n            const res = await jfApiClient(apiClientProps).getSimilarSongs({\n                params: {\n                    itemId: query.songId,\n                },\n                query: {\n                    Fields: JF_FIELDS.SONG,\n                    Limit: query.count,\n                    UserId: apiClientProps.server?.userId || undefined,\n                },\n            });\n\n            if (res.status === 200 && res.body.Items.length) {\n                const results = res.body.Items.reduce<Song[]>((acc, song) => {\n                    if (song.Id !== query.songId) {\n                        acc.push(\n                            jfNormalize.song(\n                                song,\n                                apiClientProps.server,\n                                args.context?.pathReplace,\n                                args.context?.pathReplaceWith,\n                            ),\n                        );\n                    }\n\n                    return acc;\n                }, []);\n\n                if (results.length > 0) {\n                    return results;\n                }\n            }\n        }\n\n        const mix = await jfApiClient(apiClientProps).getInstantMix({\n            params: {\n                itemId: query.songId,\n            },\n            query: {\n                Fields: JF_FIELDS.SONG,\n                Limit: query.count,\n                UserId: apiClientProps.server?.userId || undefined,\n            },\n        });\n\n        if (mix.status !== 200) {\n            throw new Error('Failed to get similar songs');\n        }\n\n        return mix.body.Items.reduce<Song[]>((acc, song) => {\n            if (song.Id !== query.songId) {\n                acc.push(\n                    jfNormalize.song(\n                        song,\n                        apiClientProps.server,\n                        args.context?.pathReplace,\n                        args.context?.pathReplaceWith,\n                    ),\n                );\n            }\n\n            return acc;\n        }, []);\n    },\n    getSongDetail: async (args) => {\n        const { apiClientProps, query } = args;\n\n        const res = await jfApiClient(apiClientProps).getSongDetail({\n            params: {\n                id: query.id,\n                userId: apiClientProps.server?.userId ?? '',\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to get song detail');\n        }\n\n        return jfNormalize.song(\n            res.body,\n            apiClientProps.server,\n            args.context?.pathReplace,\n            args.context?.pathReplaceWith,\n        );\n    },\n    getSongList: async (args) => {\n        const { apiClientProps, query } = args;\n\n        if (!apiClientProps.server?.userId) {\n            throw new Error('No userId found');\n        }\n\n        const yearsGroup: string[] = [];\n        if (query.minYear && query.maxYear) {\n            for (let i = Number(query.minYear); i <= Number(query.maxYear); i += 1) {\n                yearsGroup.push(String(i));\n            }\n        }\n\n        const yearsFilter = yearsGroup.length ? formatCommaDelimitedString(yearsGroup) : undefined;\n        const artistIdsFilter = query.artistIds\n            ? formatCommaDelimitedString(query.artistIds)\n            : query.albumArtistIds\n              ? formatCommaDelimitedString(query.albumArtistIds)\n              : undefined;\n\n        let items: z.infer<typeof jfType._response.song>[] = [];\n        let totalRecordCount = 0;\n        const batchSize = 50;\n\n        // Handle albumIds fetches in batches to prevent HTTP 414 errors\n        if (query.albumIds && query.albumIds.length > batchSize) {\n            const albumIdBatches = chunk(query.albumIds, batchSize);\n\n            for (const batch of albumIdBatches) {\n                const albumIdsFilter = formatCommaDelimitedString(batch);\n\n                const res = await jfApiClient(apiClientProps).getSongList({\n                    params: {\n                        userId: apiClientProps.server?.userId,\n                    },\n                    query: {\n                        AlbumIds: albumIdsFilter,\n                        ArtistIds: artistIdsFilter,\n                        Fields: JF_FIELDS.SONG,\n                        GenreIds: query.genreIds?.join(','),\n                        IncludeItemTypes: 'Audio',\n                        IsFavorite: query.favorite,\n                        Limit: query.limit === -1 ? undefined : query.limit,\n                        ParentId: getLibraryId(query.musicFolderId),\n                        Recursive: true,\n                        SearchTerm: query.searchTerm,\n                        SortBy: songListSortMap.jellyfin[query.sortBy] || 'Album,SortName',\n                        SortOrder: sortOrderMap.jellyfin[query.sortOrder],\n                        StartIndex: query.startIndex,\n                        ...query._custom,\n                        Years: yearsFilter,\n                    },\n                });\n\n                if (res.status !== 200) {\n                    throw new Error('Failed to get song list');\n                }\n\n                items = [...items, ...res.body.Items];\n                totalRecordCount += res.body.Items.length;\n            }\n        } else {\n            const albumIdsFilter = query.albumIds\n                ? formatCommaDelimitedString(query.albumIds)\n                : undefined;\n\n            const res = await jfApiClient(apiClientProps).getSongList({\n                params: {\n                    userId: apiClientProps.server?.userId,\n                },\n                query: {\n                    AlbumIds: albumIdsFilter,\n                    ArtistIds: artistIdsFilter,\n                    Fields: JF_FIELDS.SONG,\n                    GenreIds: query.genreIds?.join(','),\n                    IncludeItemTypes: 'Audio',\n                    IsFavorite: query.favorite,\n                    Limit: query.limit === -1 ? undefined : query.limit,\n                    ParentId: getLibraryId(query.musicFolderId),\n                    Recursive: true,\n                    SearchTerm: query.searchTerm,\n                    SortBy: songListSortMap.jellyfin[query.sortBy] || 'Album,SortName',\n                    SortOrder: sortOrderMap.jellyfin[query.sortOrder],\n                    StartIndex: query.startIndex,\n                    ...query._custom,\n                    Years: yearsFilter,\n                },\n            });\n\n            if (res.status !== 200) {\n                throw new Error('Failed to get song list');\n            }\n\n            // Jellyfin Bodge because of code from https://github.com/jellyfin/jellyfin/blob/c566ccb63bf61f9c36743ddb2108a57c65a2519b/Emby.Server.Implementations/Data/SqliteItemRepository.cs#L3622\n            // If the Album ID filter is passed, Jellyfin will search for\n            //  1. the matching album id\n            //  2. An album with the name of the album.\n            // It is this second condition causing issues,\n            if (query.albumIds) {\n                const albumIdSet = new Set(query.albumIds);\n                items = res.body.Items.filter((item) => albumIdSet.has(item.AlbumId!));\n                totalRecordCount = items.length;\n            } else {\n                items = res.body.Items;\n                totalRecordCount = res.body.TotalRecordCount;\n            }\n        }\n\n        return {\n            items: items.map((item) =>\n                jfNormalize.song(\n                    item,\n                    apiClientProps.server,\n                    args.context?.pathReplace,\n                    args.context?.pathReplaceWith,\n                ),\n            ),\n            startIndex: query.startIndex,\n            totalRecordCount,\n        };\n    },\n    getSongListCount: async ({ apiClientProps, query }) =>\n        JellyfinController.getSongList({\n            apiClientProps,\n            query: { ...query, limit: 1, startIndex: 0 },\n        }).then((result) => result!.totalRecordCount!),\n    getStreamUrl: ({ apiClientProps: { server }, query }) => {\n        const { bitrate, format, id, transcode } = query;\n        const deviceId = '';\n\n        let url = `${server?.url}/Items/${id}/Download?apiKey=${server?.credential}&playSessionId=${deviceId}`;\n\n        if (transcode) {\n            // Some format appears to be required. Fall back to trusty MP3 if not specified\n            // Otherwise, ffmpeg appears to crash\n            const realFormat = format || 'mp3';\n\n            url =\n                `${server?.url}/audio` +\n                `/${id}/universal` +\n                `?userId=${server?.userId}` +\n                `&deviceId=${deviceId}` +\n                '&audioCodec=aac' +\n                `&apiKey=${server?.credential}` +\n                `&playSessionId=${deviceId}` +\n                '&container=opus,mp3,aac,m4a,m4b,flac,wav,ogg';\n\n            url += `&transcodingProtocol=http&transcodingContainer=${realFormat}`;\n            url = url.replace('audioCodec=aac', `audioCodec=${realFormat}`);\n            url = url.replace(\n                '&container=opus,mp3,aac,m4a,m4b,flac,wav,ogg',\n                `&container=${realFormat}`,\n            );\n\n            if (bitrate !== undefined) {\n                url += `&maxStreamingBitrate=${bitrate * 1000}`;\n            }\n        }\n\n        return url;\n    },\n    getTagList: async (args) => {\n        const { apiClientProps, query } = args;\n\n        if (!hasFeature(apiClientProps.server, ServerFeature.TAGS)) {\n            return { boolTags: undefined, enumTags: undefined, excluded: { album: [], song: [] } };\n        }\n\n        const res = await jfApiClient(apiClientProps).getFilterList({\n            query: {\n                IncludeItemTypes: query.type === LibraryItem.SONG ? 'Audio' : 'MusicAlbum',\n                ParentId: query.folder,\n                UserId: apiClientProps.server?.userId ?? '',\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('failed to get tags');\n        }\n\n        const studioRes = await jfApiClient(apiClientProps).getStudioList({\n            query: {\n                EnableTotalRecordCount: true,\n                IncludeItemTypes: query.type === LibraryItem.SONG ? 'Audio' : 'MusicAlbum',\n                ParentId: query.folder,\n            },\n        });\n\n        if (studioRes.status !== 200) {\n            throw new Error('failed to get studios');\n        }\n\n        const tags: Tag[] = [];\n        if (res.body.Tags?.length) {\n            tags.push({\n                name: 'Tags',\n                options: res.body.Tags.sort((a, b) => {\n                    return numericSortCollator.compare(\n                        a.toLocaleLowerCase(),\n                        b.toLocaleLowerCase(),\n                    );\n                }).map((tag) => ({ id: tag, name: tag })),\n            });\n        }\n\n        if (studioRes.body.Items.length) {\n            tags.push({\n                name: 'Studios',\n                options: studioRes.body.Items.sort((a, b) =>\n                    collator.compare(a.Name.toLocaleLowerCase(), b.Name.toLocaleLowerCase()),\n                ).map((option) => ({ id: option.Name, name: option.Name })),\n            });\n        }\n\n        return { excluded: { album: [], song: [] }, tags };\n    },\n    getTopSongs: async (args) => {\n        const { apiClientProps, query } = args;\n\n        if (!apiClientProps.server?.userId) {\n            throw new Error('No userId found');\n        }\n\n        const type = query.type === 'personal' ? 'personal' : 'community';\n\n        const res = await jfApiClient(apiClientProps).getTopSongsList({\n            params: {\n                userId: apiClientProps.server?.userId,\n            },\n            query: {\n                ArtistIds: query.artistId,\n                Fields: JF_FIELDS.SONG,\n                IncludeItemTypes: 'Audio',\n                Limit: query.limit,\n                Recursive: true,\n                SortBy:\n                    type === 'personal'\n                        ? JFSongListSort.PLAY_COUNT\n                        : JFSongListSort.COMMUNITY_RATING,\n                SortOrder: 'Descending',\n                UserId: apiClientProps.server?.userId,\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to get top song list');\n        }\n\n        const items = res.body.Items.map((item) =>\n            jfNormalize.song(\n                item,\n                apiClientProps.server,\n                args.context?.pathReplace,\n                args.context?.pathReplaceWith,\n            ),\n        );\n\n        if (type === 'personal') {\n            const sorted = orderBy(\n                items,\n                ['playCount', 'albumId', 'trackNumber'],\n                ['desc', 'asc', 'asc'],\n            );\n\n            return {\n                items: sorted,\n                startIndex: 0,\n                totalRecordCount: res.body.TotalRecordCount,\n            };\n        }\n\n        return {\n            items,\n            startIndex: 0,\n            totalRecordCount: res.body.TotalRecordCount,\n        };\n    },\n    getUserInfo: async (args) => {\n        const { apiClientProps, query } = args;\n\n        const res = await jfApiClient(apiClientProps).getUser({\n            params: {\n                id: query.id,\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to get user info');\n        }\n\n        return {\n            id: res.body.Id,\n            isAdmin: Boolean(res.body.Policy.IsAdministrator),\n            name: res.body.Name,\n        };\n    },\n    movePlaylistItem: async (args) => {\n        const { apiClientProps, query } = args;\n\n        const res = await jfApiClient(apiClientProps).movePlaylistItem({\n            params: {\n                itemId: query.trackId,\n                newIdx: query.endingIndex.toString(),\n                playlistId: query.playlistId,\n            },\n        });\n\n        if (res.status !== 204) {\n            throw new Error('Failed to move item in playlist');\n        }\n    },\n    removeFromPlaylist: async (args) => {\n        const { apiClientProps, query } = args;\n\n        const chunks = chunk(query.songId, MAX_ITEMS_PER_PLAYLIST_ADD);\n\n        for (const chunk of chunks) {\n            const res = await jfApiClient(apiClientProps).removeFromPlaylist({\n                params: {\n                    id: query.id,\n                },\n                query: {\n                    EntryIds: chunk.join(','),\n                },\n            });\n\n            if (res.status !== 204) {\n                throw new Error('Failed to remove from playlist');\n            }\n        }\n\n        return null;\n    },\n    replacePlaylist: async (args) => {\n        const { apiClientProps, body, query } = args;\n\n        if (!apiClientProps.server?.userId) {\n            throw new Error('No userId found');\n        }\n\n        // 1. Fetch existing songs from the playlist\n        const existingSongsRes = await jfApiClient(apiClientProps).getPlaylistSongList({\n            params: {\n                id: query.id,\n            },\n            query: {\n                Fields: JF_FIELDS.SONG,\n                IncludeItemTypes: 'Audio',\n                UserId: apiClientProps.server?.userId,\n            },\n        });\n\n        if (existingSongsRes.status !== 200) {\n            throw new Error('Failed to fetch existing playlist songs');\n        }\n\n        const existingSongs = existingSongsRes.body.Items.map((item) =>\n            jfNormalize.song(\n                item,\n                apiClientProps.server,\n                args.context?.pathReplace,\n                args.context?.pathReplaceWith,\n            ),\n        );\n\n        // 2. Get playlist detail to get the name\n        const playlistDetailRes = await jfApiClient(apiClientProps).getPlaylistDetail({\n            params: {\n                id: query.id,\n                userId: apiClientProps.server?.userId,\n            },\n            query: {\n                Fields: JF_FIELDS.PLAYLIST_DETAIL,\n                Ids: query.id,\n            },\n        });\n\n        if (playlistDetailRes.status !== 200) {\n            throw new Error('Failed to get playlist detail');\n        }\n\n        const playlist = jfNormalize.playlist(playlistDetailRes.body, apiClientProps.server);\n\n        // 3. Make a backup of the playlist ids and their order, along with the id of the playlist and name\n        const backup = {\n            id: query.id,\n            name: playlist.name,\n            songIds: existingSongs.map((song) => song.id),\n            timestamp: Date.now(),\n        };\n\n        // Store backup in IndexedDB using idb-keyval\n        const backupKey = `playlist-backup-${query.id}`;\n        await set(backupKey, backup);\n\n        // 4. Remove all songs from the playlist\n        if (existingSongs.length > 0) {\n            const existingPlaylistItemIds = existingSongs\n                .map((song) => song.playlistItemId)\n                .filter((id): id is string => id !== undefined && id !== null);\n\n            if (existingPlaylistItemIds.length > 0) {\n                const chunks = chunk(existingPlaylistItemIds, MAX_ITEMS_PER_PLAYLIST_ADD);\n\n                for (const chunk of chunks) {\n                    const removeRes = await jfApiClient(apiClientProps).removeFromPlaylist({\n                        params: {\n                            id: query.id,\n                        },\n                        query: {\n                            EntryIds: chunk.join(','),\n                        },\n                    });\n\n                    if (removeRes.status !== 204) {\n                        throw new Error('Failed to remove songs from playlist');\n                    }\n                }\n            }\n        }\n\n        // 5. Add the new song ids to the playlist\n        if (body.songId.length > 0) {\n            const chunks = chunk(body.songId, MAX_ITEMS_PER_PLAYLIST_ADD);\n\n            for (const chunk of chunks) {\n                const addRes = await jfApiClient(apiClientProps).addToPlaylist({\n                    body: null,\n                    params: {\n                        id: query.id,\n                    },\n                    query: {\n                        Ids: chunk.join(','),\n                        UserId: apiClientProps.server?.userId,\n                    },\n                });\n\n                if (addRes.status !== 204) {\n                    throw new Error('Failed to add songs to playlist');\n                }\n            }\n        }\n\n        return null;\n    },\n    savePlayQueue: async () => {\n        throw new Error('Not supported');\n    },\n    scrobble: async (args) => {\n        const { apiClientProps, query } = args;\n\n        const position = query.position && Math.round(query.position);\n\n        if (query.submission) {\n            // Checked by jellyfin-plugin-lastfm for whether or not to send the \"finished\" scrobble (uses PositionTicks)\n            jfApiClient(apiClientProps).scrobbleStopped({\n                body: {\n                    IsPaused: true,\n                    ItemId: query.id,\n                    PositionTicks: position,\n                },\n            });\n\n            return null;\n        }\n\n        if (query.event === 'start') {\n            jfApiClient(apiClientProps).scrobblePlaying({\n                body: {\n                    ItemId: query.id,\n                    PositionTicks: position,\n                },\n            });\n\n            return null;\n        }\n\n        if (query.event === 'pause') {\n            jfApiClient(apiClientProps).scrobbleProgress({\n                body: {\n                    EventName: query.event,\n                    IsPaused: true,\n                    ItemId: query.id,\n                    PositionTicks: position,\n                },\n            });\n\n            return null;\n        }\n\n        if (query.event === 'unpause') {\n            jfApiClient(apiClientProps).scrobbleProgress({\n                body: {\n                    EventName: query.event,\n                    IsPaused: false,\n                    ItemId: query.id,\n                    PositionTicks: position,\n                },\n            });\n\n            return null;\n        }\n\n        jfApiClient(apiClientProps).scrobbleProgress({\n            body: {\n                ItemId: query.id,\n                PositionTicks: position,\n            },\n        });\n\n        return null;\n    },\n    search: async (args) => {\n        const { apiClientProps, query } = args;\n\n        if (!apiClientProps.server?.userId) {\n            throw new Error('No userId found');\n        }\n\n        let albums: z.infer<typeof jfType._response.albumList>['Items'] = [];\n        let albumArtists: z.infer<typeof jfType._response.albumArtistList>['Items'] = [];\n        let songs: z.infer<typeof jfType._response.songList>['Items'] = [];\n\n        if (query.albumLimit) {\n            const res = await jfApiClient(apiClientProps).getAlbumList({\n                params: {\n                    userId: apiClientProps.server?.userId,\n                },\n                query: {\n                    EnableTotalRecordCount: true,\n                    Fields: JF_FIELDS.ALBUM_LIST,\n                    ImageTypeLimit: 1,\n                    IncludeItemTypes: 'MusicAlbum',\n                    Limit: query.albumLimit,\n                    Recursive: true,\n                    SearchTerm: query.query,\n                    SortBy: 'SortName',\n                    SortOrder: 'Ascending',\n                    StartIndex: query.albumStartIndex || 0,\n                },\n            });\n\n            if (res.status !== 200) {\n                throw new Error('Failed to get album list');\n            }\n\n            albums = res.body.Items;\n        }\n\n        if (query.albumArtistLimit) {\n            const res = await jfApiClient(apiClientProps).getAlbumArtistList({\n                query: {\n                    EnableTotalRecordCount: true,\n                    Fields: JF_FIELDS.ALBUM_ARTIST_LIST,\n                    ImageTypeLimit: 1,\n                    IncludeArtists: true,\n                    Limit: query.albumArtistLimit,\n                    Recursive: true,\n                    SearchTerm: query.query,\n                    StartIndex: query.albumArtistStartIndex || 0,\n                    UserId: apiClientProps.server?.userId,\n                },\n            });\n\n            if (res.status !== 200) {\n                throw new Error('Failed to get album artist list');\n            }\n\n            albumArtists = res.body.Items;\n        }\n\n        if (query.songLimit) {\n            const res = await jfApiClient(apiClientProps).getSongList({\n                params: {\n                    userId: apiClientProps.server?.userId,\n                },\n                query: {\n                    EnableTotalRecordCount: true,\n                    Fields: JF_FIELDS.SONG,\n                    IncludeItemTypes: 'Audio',\n                    Limit: query.songLimit,\n                    Recursive: true,\n                    SearchTerm: query.query,\n                    SortBy: 'Album,SortName',\n                    SortOrder: 'Ascending',\n                    StartIndex: query.songStartIndex || 0,\n                    UserId: apiClientProps.server?.userId,\n                },\n            });\n\n            if (res.status !== 200) {\n                throw new Error('Failed to get song list');\n            }\n\n            songs = res.body.Items;\n        }\n\n        return {\n            albumArtists: albumArtists.map((item) =>\n                jfNormalize.albumArtist(item, apiClientProps.server),\n            ),\n            albums: albums.map((item) => jfNormalize.album(item, apiClientProps.server)),\n            songs: songs.map((item) =>\n                jfNormalize.song(\n                    item,\n                    apiClientProps.server,\n                    args.context?.pathReplace,\n                    args.context?.pathReplaceWith,\n                ),\n            ),\n        };\n    },\n    updateInternetRadioStation: async (args) => {\n        const { apiClientProps, body, query } = args;\n\n        if (!apiClientProps.serverId) {\n            throw new Error('No serverId found');\n        }\n\n        const state = useRadioStore.getState();\n        if (!state?.actions?.updateStation) {\n            throw new Error('Radio store not initialized');\n        }\n\n        state.actions.updateStation(apiClientProps.serverId, query.id, {\n            homepageUrl: body.homepageUrl || null,\n            name: body.name,\n            streamUrl: body.streamUrl,\n        });\n\n        return null;\n    },\n    updatePlaylist: async (args) => {\n        const { apiClientProps, body, query } = args;\n\n        if (!apiClientProps.server?.userId) {\n            throw new Error('No userId found');\n        }\n\n        const res = await jfApiClient(apiClientProps).updatePlaylist({\n            body: {\n                Genres: body.genres?.map((item) => ({ Id: item.id, Name: item.name })) || [],\n                IsPublic: body.public,\n                MediaType: 'Audio',\n                Name: body.name,\n                PremiereDate: null,\n                ProviderIds: {},\n                Tags: [],\n                UserId: apiClientProps.server?.userId, // Required\n            },\n            params: {\n                id: query.id,\n            },\n        });\n\n        if (res.status !== 204) {\n            throw new Error('Failed to update playlist');\n        }\n\n        return null;\n    },\n};\n\n// const getArtistList = async (args: ArtistListArgs): Promise<AlbumArtistListResponse> => {\n//     const { query, apiClientProps } = args;\n\n//     const res = await jfApiClient(apiClientProps).getAlbumArtistList({\n//         query: {\n//             Limit: query.limit,\n//             ParentId: query.musicFolderId,\n//             Recursive: true,\n//             SortBy: artistListSortMap.jellyfin[query.sortBy] || 'SortName,Name',\n//             SortOrder: sortOrderMap.jellyfin[query.sortOrder],\n//             StartIndex: query.startIndex,\n//         },\n//     });\n\n//     if (res.status !== 200) {\n//         throw new Error('Failed to get artist list');\n//     }\n\n//     return {\n//         items: res.body.Items.map((item) => jfNormalize.albumArtist(item, apiClientProps.server)),\n//         startIndex: query.startIndex,\n//         totalRecordCount: res.body.TotalRecordCount,\n//     };\n// };\n\nfunction getLibraryId(musicFolderId?: string | string[]) {\n    return Array.isArray(musicFolderId) ? musicFolderId[0] : musicFolderId;\n}\n"
  },
  {
    "path": "src/renderer/api/navidrome/navidrome-api.ts",
    "content": "import { initClient, initContract } from '@ts-rest/core';\nimport axios, { AxiosError, AxiosResponse, isAxiosError, Method } from 'axios';\nimport isElectron from 'is-electron';\nimport debounce from 'lodash/debounce';\nimport omitBy from 'lodash/omitBy';\nimport qs from 'qs';\n\nimport i18n from '/@/i18n/i18n';\nimport { authenticationFailure } from '/@/renderer/api/utils';\nimport { useAuthStore } from '/@/renderer/store';\nimport { getServerUrl } from '/@/renderer/utils/normalize-server-url';\nimport { ndType } from '/@/shared/api/navidrome/navidrome-types';\nimport { resultWithHeaders } from '/@/shared/api/utils';\nimport { toast } from '/@/shared/components/toast/toast';\nimport { ServerListItemWithCredential } from '/@/shared/types/domain-types';\n\nconst localSettings = isElectron() ? window.api.localSettings : null;\n\nconst c = initContract();\n\nexport const contract = c.router({\n    addToPlaylist: {\n        body: ndType._parameters.addToPlaylist,\n        method: 'POST',\n        path: 'playlist/:id/tracks',\n        responses: {\n            200: resultWithHeaders(ndType._response.addToPlaylist),\n            500: resultWithHeaders(ndType._response.error),\n        },\n    },\n    authenticate: {\n        body: ndType._parameters.authenticate,\n        method: 'POST',\n        path: 'auth/login',\n        responses: {\n            200: resultWithHeaders(ndType._response.authenticate),\n            500: resultWithHeaders(ndType._response.error),\n        },\n    },\n    createPlaylist: {\n        body: ndType._parameters.createPlaylist,\n        method: 'POST',\n        path: 'playlist',\n        responses: {\n            200: resultWithHeaders(ndType._response.createPlaylist),\n            500: resultWithHeaders(ndType._response.error),\n        },\n    },\n    deletePlaylist: {\n        body: null,\n        method: 'DELETE',\n        path: 'playlist/:id',\n        responses: {\n            200: resultWithHeaders(ndType._response.deletePlaylist),\n            500: resultWithHeaders(ndType._response.error),\n        },\n    },\n    getAlbumArtistDetail: {\n        method: 'GET',\n        path: 'artist/:id',\n        responses: {\n            200: resultWithHeaders(ndType._response.albumArtist),\n            500: resultWithHeaders(ndType._response.error),\n        },\n    },\n    getAlbumArtistList: {\n        method: 'GET',\n        path: 'artist',\n        query: ndType._parameters.albumArtistList,\n        responses: {\n            200: resultWithHeaders(ndType._response.albumArtistList),\n            500: resultWithHeaders(ndType._response.error),\n        },\n    },\n    getAlbumDetail: {\n        method: 'GET',\n        path: 'album/:id',\n        responses: {\n            200: resultWithHeaders(ndType._response.album),\n            500: resultWithHeaders(ndType._response.error),\n        },\n    },\n    getAlbumList: {\n        method: 'GET',\n        path: 'album',\n        query: ndType._parameters.albumList,\n        responses: {\n            200: resultWithHeaders(ndType._response.albumList),\n            500: resultWithHeaders(ndType._response.error),\n        },\n    },\n    getGenreList: {\n        method: 'GET',\n        path: 'genre',\n        query: ndType._parameters.genreList,\n        responses: {\n            200: resultWithHeaders(ndType._response.genreList),\n            500: resultWithHeaders(ndType._response.error),\n        },\n    },\n    getPlaylistDetail: {\n        method: 'GET',\n        path: 'playlist/:id',\n        responses: {\n            200: resultWithHeaders(ndType._response.playlist),\n            500: resultWithHeaders(ndType._response.error),\n        },\n    },\n    getPlaylistList: {\n        method: 'GET',\n        path: 'playlist',\n        query: ndType._parameters.playlistList,\n        responses: {\n            200: resultWithHeaders(ndType._response.playlistList),\n            500: resultWithHeaders(ndType._response.error),\n        },\n    },\n    getPlaylistSongList: {\n        method: 'GET',\n        path: 'playlist/:id/tracks',\n        query: ndType._parameters.songList,\n        responses: {\n            200: resultWithHeaders(ndType._response.playlistSongList),\n            500: resultWithHeaders(ndType._response.error),\n        },\n    },\n    getQueue: {\n        method: 'GET',\n        path: 'queue',\n        responses: {\n            200: resultWithHeaders(ndType._response.queue),\n            500: resultWithHeaders(ndType._response.error),\n        },\n    },\n    getSongDetail: {\n        method: 'GET',\n        path: 'song/:id',\n        responses: {\n            200: resultWithHeaders(ndType._response.song),\n            500: resultWithHeaders(ndType._response.error),\n        },\n    },\n    getSongList: {\n        method: 'GET',\n        path: 'song',\n        query: ndType._parameters.songList,\n        responses: {\n            200: resultWithHeaders(ndType._response.songList),\n            500: resultWithHeaders(ndType._response.error),\n        },\n    },\n    getTagList: {\n        method: 'GET',\n        path: 'tag',\n        query: ndType._parameters.tagList,\n        responses: {\n            200: resultWithHeaders(ndType._response.tagList),\n            500: resultWithHeaders(ndType._response.error),\n        },\n    },\n    getUserList: {\n        method: 'GET',\n        path: 'user',\n        query: ndType._parameters.userList,\n        responses: {\n            200: resultWithHeaders(ndType._response.userList),\n            500: resultWithHeaders(ndType._response.error),\n        },\n    },\n    movePlaylistItem: {\n        body: ndType._parameters.moveItem,\n        method: 'PUT',\n        path: 'playlist/:playlistId/tracks/:trackNumber',\n        responses: {\n            200: resultWithHeaders(ndType._response.moveItem),\n            400: resultWithHeaders(ndType._response.error),\n        },\n    },\n    removeFromPlaylist: {\n        body: null,\n        method: 'DELETE',\n        path: 'playlist/:id/tracks',\n        query: ndType._parameters.removeFromPlaylist,\n        responses: {\n            200: resultWithHeaders(ndType._response.removeFromPlaylist),\n            500: resultWithHeaders(ndType._response.error),\n        },\n    },\n    saveQueue: {\n        body: ndType._parameters.saveQueue,\n        method: 'POST',\n        path: 'queue',\n        responses: {\n            200: resultWithHeaders(ndType._response.saveQueue),\n            500: resultWithHeaders(ndType._response.error),\n        },\n    },\n    shareItem: {\n        body: ndType._parameters.shareItem,\n        method: 'POST',\n        path: 'share',\n        responses: {\n            200: resultWithHeaders(ndType._response.shareItem),\n            404: resultWithHeaders(ndType._response.error),\n            500: resultWithHeaders(ndType._response.error),\n        },\n    },\n    updatePlaylist: {\n        body: ndType._parameters.updatePlaylist,\n        method: 'PUT',\n        path: 'playlist/:id',\n        responses: {\n            200: resultWithHeaders(ndType._response.updatePlaylist),\n            500: resultWithHeaders(ndType._response.error),\n        },\n    },\n});\n\nconst axiosClient = axios.create({});\n\naxiosClient.defaults.paramsSerializer = (params) => {\n    return qs.stringify(params, { arrayFormat: 'repeat' });\n};\n\nconst parsePath = (fullPath: string) => {\n    const [path, params] = fullPath.split('?');\n\n    const parsedParams = qs.parse(params);\n\n    // Convert indexed object to array\n    const newParams: Record<string, any> = {};\n    Object.keys(parsedParams).forEach((key) => {\n        const isIndexedArrayObject =\n            typeof parsedParams[key] === 'object' &&\n            Object.keys(parsedParams[key] || {}).includes('0');\n\n        if (!isIndexedArrayObject) {\n            newParams[key] = parsedParams[key];\n        } else {\n            newParams[key] = Object.values(parsedParams[key] || {});\n        }\n    });\n\n    const notNilParams = omitBy(newParams, (value) => value === 'undefined' || value === 'null');\n\n    return {\n        params: notNilParams,\n        path,\n    };\n};\n\nlet authSuccess = true;\nlet shouldDelay = false;\n\nconst RETRY_DELAY_MS = 1000;\nconst MAX_RETRIES = 5;\n\nconst waitForResult = async (count = 0): Promise<void> => {\n    return new Promise((resolve) => {\n        if (count === MAX_RETRIES || !shouldDelay) resolve();\n\n        setTimeout(() => {\n            waitForResult(count + 1)\n                .then(resolve)\n                .catch(resolve);\n        }, RETRY_DELAY_MS);\n    });\n};\n\nconst limitedFail = debounce(authenticationFailure, RETRY_DELAY_MS);\nconst TIMEOUT_ERROR = Error();\n\naxiosClient.interceptors.response.use(\n    (response) => {\n        const serverId = useAuthStore.getState().currentServer?.id;\n\n        if (serverId) {\n            const headerCredential = response.headers['x-nd-authorization'] as string | undefined;\n\n            if (headerCredential) {\n                useAuthStore.getState().actions.updateServer(serverId, {\n                    ndCredential: headerCredential,\n                });\n            }\n        }\n\n        authSuccess = true;\n\n        return response;\n    },\n    (error) => {\n        if (error.response && error.response.status === 401) {\n            const currentServer = useAuthStore.getState().currentServer;\n\n            if (localSettings && currentServer?.savePassword) {\n                return localSettings\n                    .passwordGet(currentServer.id)\n                    .then(async (password: null | string) => {\n                        authSuccess = false;\n\n                        if (password === null) {\n                            throw error;\n                        }\n\n                        if (shouldDelay) {\n                            await waitForResult();\n\n                            // Hopefully the delay was sufficient for authentication.\n                            // Otherwise, it will require manual intervention\n                            if (authSuccess) {\n                                return axiosClient.request(error.config);\n                            }\n\n                            throw error;\n                        }\n\n                        shouldDelay = true;\n\n                        // Do not use axiosClient. Instead, manually make a post\n                        const res = await axios.post(`${currentServer.url}/auth/login`, {\n                            password,\n                            username: currentServer.username,\n                        });\n\n                        if (res.status === 429) {\n                            toast.error({\n                                message: i18n.t('error.loginRateError', {\n                                    postProcess: 'sentenceCase',\n                                }) as string,\n                                title: i18n.t('error.sessionExpiredError', {\n                                    postProcess: 'sentenceCase',\n                                }) as string,\n                            });\n\n                            const serverId = currentServer.id;\n                            useAuthStore.getState().actions.updateServer(serverId, {\n                                credential: undefined,\n                                ndCredential: undefined,\n                            });\n                            useAuthStore.getState().actions.setCurrentServer(null);\n\n                            // special error to prevent sending a second message, and stop other messages that could be enqueued\n                            limitedFail.cancel();\n                            throw TIMEOUT_ERROR;\n                        }\n                        if (res.status !== 200) {\n                            throw new Error(\n                                i18n.t('error.authenticatedFailed', {\n                                    postProcess: 'sentenceCase',\n                                }) as string,\n                            );\n                        }\n\n                        const newCredential = res.data.token;\n                        const subsonicCredential = `u=${currentServer.username}&s=${res.data.subsonicSalt}&t=${res.data.subsonicToken}`;\n\n                        useAuthStore.getState().actions.updateServer(currentServer.id, {\n                            credential: subsonicCredential,\n                            ndCredential: newCredential,\n                        });\n\n                        error.config.headers['x-nd-authorization'] = `Bearer ${newCredential}`;\n\n                        authSuccess = true;\n\n                        return axiosClient.request(error.config);\n                    })\n                    .catch((newError: any) => {\n                        if (newError !== TIMEOUT_ERROR) {\n                            console.error('Error when trying to reauthenticate: ', newError);\n\n                            if (isAxiosError(newError) && newError.code === 'ERR_NETWORK') {\n                                console.log(\n                                    'Network error during reauthentication - preserving credentials',\n                                );\n                            } else {\n                                limitedFail(currentServer);\n                            }\n                        }\n\n                        // make sure to pass the error so axios will error later on\n                        throw newError;\n                    })\n                    .finally(() => {\n                        shouldDelay = false;\n                    });\n            }\n\n            if (isAxiosError(error) && error.code === 'ERR_NETWORK') {\n                console.log('Network error during authentication - preserving credentials');\n            } else {\n                limitedFail(currentServer);\n            }\n        }\n\n        return Promise.reject(error);\n    },\n);\n\nexport const ndApiClient = (args: {\n    server: null | ServerListItemWithCredential;\n    signal?: AbortSignal;\n    url?: string;\n}) => {\n    const { server, signal, url } = args;\n\n    return initClient(contract, {\n        api: async ({ body, headers, method, path }) => {\n            let baseUrl: string | undefined;\n            let token: string | undefined;\n\n            const { params, path: api } = parsePath(path);\n\n            if (server) {\n                const serverUrl = getServerUrl(server);\n                baseUrl = serverUrl ? `${serverUrl}/api` : undefined;\n                token = server?.ndCredential;\n            } else {\n                baseUrl = url;\n            }\n\n            try {\n                if (shouldDelay) await waitForResult();\n\n                const result = await axiosClient.request({\n                    data: body,\n                    headers: {\n                        ...headers,\n                        ...(token && { 'x-nd-authorization': `Bearer ${token}` }),\n                    },\n                    method: method as Method,\n                    params,\n                    signal,\n                    url: `${baseUrl}/${api}`,\n                });\n                return {\n                    body: { data: result.data, headers: result.headers },\n                    headers: result.headers as any,\n                    status: result.status,\n                };\n            } catch (e: any | AxiosError | Error) {\n                if (isAxiosError(e)) {\n                    if (e.code === 'ERR_NETWORK') {\n                        throw new Error(\n                            i18n.t('error.networkError', {\n                                postProcess: 'sentenceCase',\n                            }) as string,\n                        );\n                    }\n\n                    const error = e as AxiosError;\n                    const response = error.response as AxiosResponse;\n                    return {\n                        body: { data: response?.data, headers: response?.headers },\n                        headers: response?.headers as any,\n                        status: response?.status,\n                    };\n                }\n                throw e;\n            }\n        },\n        baseHeaders: {\n            'Content-Type': 'application/json',\n        },\n        baseUrl: '',\n        jsonQuery: false,\n    });\n};\n"
  },
  {
    "path": "src/renderer/api/navidrome/navidrome-controller.ts",
    "content": "import { set } from 'idb-keyval';\nimport orderBy from 'lodash/orderBy';\n\nimport { ndApiClient } from '/@/renderer/api/navidrome/navidrome-api';\nimport { ssApiClient } from '/@/renderer/api/subsonic/subsonic-api';\nimport { SubsonicController } from '/@/renderer/api/subsonic/subsonic-controller';\nimport { ndNormalize } from '/@/shared/api/navidrome/navidrome-normalize';\nimport { NDSongListSort } from '/@/shared/api/navidrome/navidrome-types';\nimport { ssNormalize } from '/@/shared/api/subsonic/subsonic-normalize';\nimport { getFeatures, hasFeature, hasFeatureWithVersion, VersionInfo } from '/@/shared/api/utils';\nimport {\n    albumArtistListSortMap,\n    albumListSortMap,\n    AuthenticationResponse,\n    genreListSortMap,\n    InternalControllerEndpoint,\n    playlistListSortMap,\n    PlaylistSongListArgs,\n    PlaylistSongListResponse,\n    ServerListItemWithCredential,\n    SongListSort,\n    songListSortMap,\n    SortOrder,\n    sortOrderMap,\n    tagListSortMap,\n    userListSortMap,\n} from '/@/shared/types/domain-types';\nimport { ServerFeature } from '/@/shared/types/features-types';\n\nconst VERSION_INFO: VersionInfo = [\n    // Why 2? Subsonic controller will return 1 for its own implementation\n    // Use 2 to denote that Navidrome's own API has a different endpoint\n    ['0.60.4', { [ServerFeature.TRACK_YES_NO_RATING_FILTER]: [1] }],\n    ['0.57.0', { [ServerFeature.SERVER_PLAY_QUEUE]: [2] }],\n    ['0.56.0', { [ServerFeature.TRACK_ALBUM_ARTIST_SEARCH]: [1] }],\n    ['0.55.0', { [ServerFeature.BFR]: [1], [ServerFeature.TAGS]: [1] }],\n    ['0.49.3', { [ServerFeature.SHARING_ALBUM_SONG]: [1] }],\n    ['0.48.0', { [ServerFeature.PLAYLISTS_SMART]: [1] }],\n];\n\nconst NAVIDROME_ROLES: Array<string | { label: string; value: string }> = [\n    { label: 'all artists', value: '' },\n    'arranger',\n    'artist',\n    'composer',\n    'conductor',\n    'director',\n    'djmixer',\n    'engineer',\n    'lyricist',\n    'mixer',\n    'performer',\n    'producer',\n    'remixer',\n];\n\n// Tags that are irrelevant or non-functional as filters\nconst EXCLUDED_TAGS = new Set<string>([\n    'genre', // Duplicate of genre filter\n]);\n\nconst EXCLUDED_ALBUM_TAGS = new Set<string>([\n    'asin',\n    'barcode',\n    'copyright',\n    'encodedby',\n    'isrc',\n    'key',\n    'language',\n    'musicbrainz_workid',\n    'script',\n    'website',\n    'work',\n]);\n\nconst EXCLUDED_SONG_TAGS = new Set<string>(['disctotal', 'tracktotal']);\n\n// Defining a re-usable Collator instance for performance reasons.\nconst numericSortCollator = new Intl.Collator(undefined, { numeric: true });\nconst collator = new Intl.Collator();\n\n// Tags that use IDs as values as opposed to the tag value\nconst ID_TAGS = new Set<string>(['albumversion', 'mood']);\n\nconst excludeMissing = (server?: null | ServerListItemWithCredential) => {\n    if (!server) {\n        return undefined;\n    }\n\n    if (hasFeature(server, ServerFeature.BFR)) {\n        return { missing: false };\n    }\n\n    return undefined;\n};\n\nconst getLibraryId = (musicFolderId?: string | string[]): string[] | undefined => {\n    if (!musicFolderId) {\n        return undefined;\n    }\n\n    return Array.isArray(musicFolderId) ? musicFolderId : [musicFolderId];\n};\n\nconst getArtistSongKey = (server: null | ServerListItemWithCredential) =>\n    hasFeature(server, ServerFeature.TRACK_ALBUM_ARTIST_SEARCH) ? 'artists_id' : 'album_artist_id';\n\nexport const NavidromeController: InternalControllerEndpoint = {\n    addToPlaylist: async (args) => {\n        const { apiClientProps, body, query } = args;\n\n        const res = await ndApiClient(apiClientProps).addToPlaylist({\n            body: {\n                ids: body.songId,\n            },\n            params: {\n                id: query.id,\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to add to playlist');\n        }\n\n        return null;\n    },\n    authenticate: async (url, body): Promise<AuthenticationResponse> => {\n        const cleanServerUrl = url.replace(/\\/$/, '');\n\n        const res = await ndApiClient({ server: null, url: cleanServerUrl }).authenticate({\n            body: {\n                password: body.password,\n                username: body.username,\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to authenticate');\n        }\n\n        return {\n            credential: `u=${body.username}&s=${res.body.data.subsonicSalt}&t=${res.body.data.subsonicToken}`,\n            isAdmin: Boolean(res.body.data.isAdmin),\n            ndCredential: res.body.data.token,\n            userId: res.body.data.id,\n            username: res.body.data.username,\n        };\n    },\n    createFavorite: SubsonicController.createFavorite,\n    createInternetRadioStation: SubsonicController.createInternetRadioStation,\n    createPlaylist: async (args) => {\n        const { apiClientProps, body } = args;\n\n        const res = await ndApiClient(apiClientProps).createPlaylist({\n            body: {\n                comment: body.comment,\n                name: body.name,\n                ownerId: body.ownerId,\n                public: body.public,\n                rules: body.queryBuilderRules,\n                sync: body.sync,\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to create playlist');\n        }\n\n        return {\n            id: res.body.data.id,\n        };\n    },\n    deleteFavorite: SubsonicController.deleteFavorite,\n    deleteInternetRadioStation: SubsonicController.deleteInternetRadioStation,\n    deletePlaylist: async (args) => {\n        const { apiClientProps, query } = args;\n\n        const res = await ndApiClient(apiClientProps).deletePlaylist({\n            params: {\n                id: query.id,\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to delete playlist');\n        }\n\n        return null;\n    },\n    getAlbumArtistDetail: async (args) => {\n        const { apiClientProps, query } = args;\n\n        const res = await ndApiClient(apiClientProps).getAlbumArtistDetail({\n            params: {\n                id: query.id,\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to get album artist detail');\n        }\n\n        if (!apiClientProps.serverId) {\n            throw new Error('Server is required');\n        }\n\n        return ndNormalize.albumArtist(res.body.data, apiClientProps.server);\n    },\n    getAlbumArtistInfo: async (args) => {\n        const { apiClientProps, query } = args;\n\n        const artistInfoRes = await ssApiClient(apiClientProps).getArtistInfo({\n            query: {\n                id: query.id,\n                ...(query.limit != null && { count: query.limit }),\n            },\n        });\n\n        if (artistInfoRes.status !== 200) {\n            return null;\n        }\n\n        const artistInfo = artistInfoRes.body.artistInfo;\n        const imageUrl =\n            artistInfo?.largeImageUrl ||\n            artistInfo?.mediumImageUrl ||\n            artistInfo?.smallImageUrl ||\n            null;\n\n        return {\n            biography: artistInfo?.biography || null,\n            imageUrl,\n            similarArtists:\n                artistInfo?.similarArtist?.map((artist) => ({\n                    id: artist.id,\n                    imageId: null,\n                    imageUrl: artist?.artistImageUrl?.replace(/\\?size=\\d+/, '') ?? null,\n                    name: artist.name,\n                    userFavorite: Boolean(artist.starred) || false,\n                    userRating: artist.userRating ?? null,\n                })) ?? null,\n        };\n    },\n    getAlbumArtistList: async (args) => {\n        const { apiClientProps, query } = args;\n\n        const res = await ndApiClient(apiClientProps).getAlbumArtistList({\n            query: {\n                _end: query.startIndex + (query.limit || 0),\n                _order: sortOrderMap.navidrome[query.sortOrder],\n                _sort: albumArtistListSortMap.navidrome[query.sortBy],\n                _start: query.startIndex,\n                library_id: getLibraryId(query.musicFolderId),\n                name: query.searchTerm,\n                starred: query.favorite,\n                ...query._custom,\n                role: hasFeature(apiClientProps.server, ServerFeature.BFR) ? 'albumartist' : '',\n                ...excludeMissing(apiClientProps.server),\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to get album artist list');\n        }\n\n        return {\n            items: res.body.data.map((albumArtist) =>\n                // Navidrome native API will return only external URL small/medium/large\n                // image URL. Set large image to undefined to force `albumArtist` to use\n                // /rest/getCoverArt.view?id=ar-...\n                ndNormalize.albumArtist(\n                    {\n                        ...albumArtist,\n                        largeImageUrl: undefined,\n                    },\n                    apiClientProps.server,\n                ),\n            ),\n            startIndex: query.startIndex,\n            totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),\n        };\n    },\n    getAlbumArtistListCount: async ({ apiClientProps, query }) =>\n        NavidromeController.getAlbumArtistList({\n            apiClientProps,\n            query: { ...query, limit: 1, startIndex: 0 },\n        }).then((result) => result!.totalRecordCount!),\n    getAlbumDetail: async (args) => {\n        const { apiClientProps, context, query } = args;\n\n        const albumRes = await ndApiClient(apiClientProps).getAlbumDetail({\n            params: {\n                id: query.id,\n            },\n        });\n\n        const songsData = await ndApiClient(apiClientProps).getSongList({\n            query: {\n                _end: 0,\n                _order: 'ASC',\n                _sort: NDSongListSort.ALBUM,\n                _start: 0,\n                album_id: [query.id],\n                ...excludeMissing(apiClientProps.server),\n            },\n        });\n\n        if (albumRes.status !== 200 || songsData.status !== 200) {\n            throw new Error('Failed to get album detail');\n        }\n\n        return ndNormalize.album(\n            { ...albumRes.body.data, songs: songsData.body.data },\n            apiClientProps.server,\n            context?.pathReplace,\n            context?.pathReplaceWith,\n        );\n    },\n    getAlbumInfo: async (args) => {\n        const { apiClientProps, query } = args;\n\n        const albumInfo = await ssApiClient(apiClientProps).getAlbumInfo2({\n            query: {\n                id: query.id,\n            },\n        });\n\n        if (albumInfo.status !== 200) {\n            throw new Error('Failed to get album info');\n        }\n\n        const info = albumInfo.body.albumInfo;\n\n        return {\n            imageUrl: info.largeImageUrl || info.mediumImageUrl || info.smallImageUrl || null,\n            notes: info.notes || null,\n        };\n    },\n    getAlbumList: async (args) => {\n        const { apiClientProps, context, query } = args;\n\n        const genres = hasFeature(apiClientProps.server, ServerFeature.BFR)\n            ? query.genreIds\n            : query.genreIds?.[0];\n\n        const artistIds = hasFeature(apiClientProps.server, ServerFeature.BFR)\n            ? query.artistIds\n            : query.artistIds?.[0];\n\n        const res = await ndApiClient(apiClientProps).getAlbumList({\n            query: {\n                _end: query.startIndex + (query.limit || 0),\n                _order: sortOrderMap.navidrome[query.sortOrder],\n                _sort: albumListSortMap.navidrome[query.sortBy],\n                _start: query.startIndex,\n                artist_id: artistIds,\n                compilation: query.compilation,\n                genre_id: genres,\n                has_rating: query.hasRating,\n                library_id: getLibraryId(query.musicFolderId),\n                name: query.searchTerm,\n                recently_played: query.isRecentlyPlayed,\n                starred: query.favorite,\n                year: query.maxYear || query.minYear,\n                ...query._custom,\n                ...excludeMissing(apiClientProps.server),\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to get album list');\n        }\n\n        return {\n            items: res.body.data.map((album) =>\n                ndNormalize.album(\n                    album,\n                    apiClientProps.server,\n                    context?.pathReplace,\n                    context?.pathReplaceWith,\n                ),\n            ),\n            startIndex: query?.startIndex || 0,\n            totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),\n        };\n    },\n    getAlbumListCount: async ({ apiClientProps, query }) =>\n        NavidromeController.getAlbumList({\n            apiClientProps,\n            query: { ...query, limit: 1, startIndex: 0 },\n        }).then((result) => result!.totalRecordCount!),\n    getAlbumRadio: async (args) => {\n        const { apiClientProps, query } = args;\n\n        // Use getSimilarSongs API for album radio\n        const res = await ssApiClient({\n            ...apiClientProps,\n            silent: true,\n        }).getSimilarSongs({\n            query: {\n                count: query.count,\n                id: query.albumId,\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to get album radio songs');\n        }\n\n        if (!res.body.similarSongs?.song) {\n            return [];\n        }\n\n        return res.body.similarSongs.song.map((song) =>\n            ssNormalize.song(song, apiClientProps.server),\n        );\n    },\n    getArtistList: async (args) => {\n        const { apiClientProps, query } = args;\n\n        const res = await ndApiClient(apiClientProps).getAlbumArtistList({\n            query: {\n                _end: query.startIndex + (query.limit || 0),\n                _order: sortOrderMap.navidrome[query.sortOrder],\n                _sort: albumArtistListSortMap.navidrome[query.sortBy],\n                _start: query.startIndex,\n                library_id: getLibraryId(query.musicFolderId),\n                name: query.searchTerm,\n                role: query.role || undefined,\n                starred: query.favorite,\n                ...query._custom,\n                ...excludeMissing(apiClientProps.server),\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to get artist list');\n        }\n\n        return {\n            items: res.body.data.map((albumArtist) =>\n                // Navidrome native API will return only external URL small/medium/large\n                // image URL. Set large image to undefined to force `albumArtist` to use\n                // /rest/getCoverArt.view?id=ar-...\n                ndNormalize.albumArtist(\n                    {\n                        ...albumArtist,\n                        largeImageUrl: undefined,\n                    },\n                    apiClientProps.server,\n                ),\n            ),\n            startIndex: query.startIndex,\n            totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),\n        };\n    },\n    getArtistListCount: async ({ apiClientProps, query }) =>\n        NavidromeController.getArtistList({\n            apiClientProps,\n            query: { ...query, limit: 1, startIndex: 0 },\n        }).then((result) => result!.totalRecordCount!),\n    getArtistRadio: async (args) => {\n        const { apiClientProps, query } = args;\n\n        // Use getSimilarSongs2 API for artist radio\n        const res = await ssApiClient({\n            ...apiClientProps,\n            silent: true,\n        }).getSimilarSongs2({\n            query: {\n                count: query.count,\n                id: query.artistId,\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to get artist radio songs');\n        }\n\n        if (!res.body.similarSongs2?.song) {\n            return [];\n        }\n\n        return res.body.similarSongs2.song.map((song) =>\n            ssNormalize.song(song, apiClientProps.server),\n        );\n    },\n    getDownloadUrl: SubsonicController.getDownloadUrl,\n    getFolder: SubsonicController.getFolder,\n    getGenreList: async (args) => {\n        const { apiClientProps, query } = args;\n\n        if (hasFeature(apiClientProps.server, ServerFeature.BFR)) {\n            const res = await ndApiClient(apiClientProps).getTagList({\n                query: {\n                    _end: query.startIndex + (query.limit || 0),\n                    _order: sortOrderMap.navidrome[query.sortOrder],\n                    _sort: tagListSortMap.navidrome[query.sortBy],\n                    _start: query.startIndex,\n                    library_id: getLibraryId(query.musicFolderId),\n                    tag_name: 'genre',\n                    tag_value: query.searchTerm,\n                },\n            });\n\n            if (res.status !== 200) {\n                throw new Error('Failed to get genre list');\n            }\n\n            return {\n                items: res.body.data.map((genre) =>\n                    ndNormalize.genre(\n                        {\n                            albumCount: genre.albumCount,\n                            id: genre.id,\n                            name: genre.tagValue,\n                            songCount: genre.songCount,\n                        },\n                        apiClientProps.server,\n                    ),\n                ),\n                startIndex: query.startIndex || 0,\n                totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),\n            };\n        }\n\n        const res = await ndApiClient(apiClientProps).getGenreList({\n            query: {\n                _end: query.startIndex + (query.limit || 0),\n                _order: sortOrderMap.navidrome[query.sortOrder],\n                _sort: genreListSortMap.navidrome[query.sortBy],\n                _start: query.startIndex,\n                library_id: getLibraryId(query.musicFolderId),\n                name: query.searchTerm,\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to get genre list');\n        }\n\n        return {\n            items: res.body.data.map((genre) => ndNormalize.genre(genre, apiClientProps.server)),\n            startIndex: query.startIndex || 0,\n            totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),\n        };\n    },\n    getImageRequest: SubsonicController.getImageRequest,\n    getImageUrl: SubsonicController.getImageUrl,\n    getInternetRadioStations: SubsonicController.getInternetRadioStations,\n    getLyrics: SubsonicController.getLyrics,\n    getMusicFolderList: SubsonicController.getMusicFolderList,\n    getPlaylistDetail: async (args) => {\n        const { apiClientProps, query } = args;\n\n        const res = await ndApiClient(apiClientProps).getPlaylistDetail({\n            params: {\n                id: query.id,\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to get playlist detail');\n        }\n\n        return ndNormalize.playlist(res.body.data, apiClientProps.server);\n    },\n    getPlaylistList: async (args) => {\n        const { apiClientProps, query } = args;\n\n        const res = await ndApiClient(apiClientProps).getPlaylistList({\n            query: {\n                _end: query.startIndex + (query.limit || 0),\n                _order: sortOrderMap.navidrome[query.sortOrder],\n                _sort: query.sortBy ? playlistListSortMap.navidrome[query.sortBy] : undefined,\n                _start: query.startIndex,\n                q: query.searchTerm,\n                smart: query.excludeSmartPlaylists ? false : undefined,\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to get playlist list');\n        }\n\n        return {\n            items: res.body.data.map((item) => ndNormalize.playlist(item, apiClientProps.server)),\n            startIndex: query?.startIndex || 0,\n            totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),\n        };\n    },\n    getPlaylistListCount: async ({ apiClientProps, query }) =>\n        NavidromeController.getPlaylistList({\n            apiClientProps,\n            query: { ...query, limit: 1, startIndex: 0 },\n        }).then((result) => result!.totalRecordCount!),\n    getPlaylistSongList: async (args: PlaylistSongListArgs): Promise<PlaylistSongListResponse> => {\n        const { apiClientProps, query } = args;\n\n        const res = await ndApiClient(apiClientProps as any).getPlaylistSongList({\n            params: {\n                id: query.id,\n            },\n            query: {\n                _end: -1,\n                _order: 'ASC',\n                _start: 0,\n                ...excludeMissing(apiClientProps.server),\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to get playlist song list');\n        }\n\n        return {\n            items: res.body.data.map((item) =>\n                ndNormalize.song(\n                    item,\n                    apiClientProps.server,\n                    args.context?.pathReplace,\n                    args.context?.pathReplaceWith,\n                ),\n            ),\n            startIndex: 0,\n            totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),\n        };\n    },\n    getPlayQueue: async (args) => {\n        const { apiClientProps } = args;\n\n        if (hasFeatureWithVersion(apiClientProps.server, ServerFeature.SERVER_PLAY_QUEUE, 2)) {\n            const res = await ndApiClient(apiClientProps).getQueue();\n\n            if (res.status !== 200) {\n                throw new Error('Failed to get play queue');\n            }\n\n            const { changedBy, current, items = [], position, updatedAt } = res.body.data; // if there is no queue saved, items is undefined\n\n            const entries = items.map((song) =>\n                ndNormalize.song(\n                    song,\n                    apiClientProps.server,\n                    args.context?.pathReplace,\n                    args.context?.pathReplaceWith,\n                ),\n            );\n\n            return {\n                changed: updatedAt,\n                changedBy,\n                currentIndex: current !== undefined ? current : 0,\n                entry: entries,\n                positionMs: position,\n                username: apiClientProps.server?.username ?? '',\n            };\n        }\n\n        return SubsonicController.getPlayQueue(args);\n    },\n    getRandomSongList: SubsonicController.getRandomSongList,\n    getRoles: async ({ apiClientProps }) =>\n        hasFeature(apiClientProps.server, ServerFeature.BFR) ? NAVIDROME_ROLES : [],\n    getServerInfo: async (args) => {\n        const { apiClientProps } = args;\n\n        // Navidrome will always populate serverVersion\n        const ping = await ssApiClient(apiClientProps).ping();\n\n        if (ping.status !== 200) {\n            throw new Error('Failed to ping server');\n        }\n\n        if (ping.body.serverVersion?.includes('pr-2709')) {\n            ping.body.serverVersion = '0.55.0';\n        }\n\n        const navidromeFeatures = getFeatures(VERSION_INFO, ping.body.serverVersion!);\n        const subsonicArgs = await SubsonicController.getServerInfo(args);\n\n        const features = {\n            ...subsonicArgs.features,\n            ...navidromeFeatures,\n            publicPlaylist: [1],\n            [ServerFeature.ALBUM_YES_NO_RATING_FILTER]: [1],\n            [ServerFeature.MUSIC_FOLDER_MULTISELECT]: [1],\n        };\n\n        if (subsonicArgs.features.serverPlayQueue && navidromeFeatures.serverPlayQueue) {\n            features.serverPlayQueue = navidromeFeatures.serverPlayQueue.concat(\n                subsonicArgs.features.serverPlayQueue,\n            );\n        }\n\n        return {\n            features,\n            id: apiClientProps.serverId,\n            version: ping.body.serverVersion!,\n        };\n    },\n    getSimilarSongs: async (args) => {\n        const { apiClientProps, query } = args;\n\n        // Prefer getSimilarSongs (which queries last.fm) where available\n        // otherwise find other tracks by the same album artist\n        const res = await ssApiClient({\n            ...apiClientProps,\n            silent: true,\n        }).getSimilarSongs({\n            query: {\n                count: query.count,\n                id: query.songId,\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to get similar songs');\n        }\n\n        return (\n            (res.body.similarSongs?.song || [])\n                .filter((song) => song.id !== query.songId)\n                .map((song) => ssNormalize.song(song, apiClientProps.server)) || []\n        );\n    },\n    getSongDetail: async (args) => {\n        const { apiClientProps, query } = args;\n\n        const res = await ndApiClient(apiClientProps).getSongDetail({\n            params: {\n                id: query.id,\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to get song detail');\n        }\n\n        return ndNormalize.song(\n            res.body.data,\n            apiClientProps.server,\n            args.context?.pathReplace,\n            args.context?.pathReplaceWith,\n        );\n    },\n\n    getSongList: async (args) => {\n        const { apiClientProps, query } = args;\n\n        const ALBUM_IDS_BATCH_SIZE = 500;\n        const albumIds = query.albumIds;\n        const shouldBatch = albumIds && albumIds.length > ALBUM_IDS_BATCH_SIZE;\n\n        const fetchAlbums = async (albumIdBatch: string[] | undefined) => {\n            const res = await ndApiClient(apiClientProps).getSongList({\n                query: {\n                    _end: query.startIndex + (query.limit || -1),\n                    _order: sortOrderMap.navidrome[query.sortOrder],\n                    _sort: songListSortMap.navidrome[query.sortBy],\n                    _start: query.startIndex,\n                    album_id: albumIdBatch ?? query.albumIds,\n                    genre_id: query.genreIds,\n                    [getArtistSongKey(apiClientProps.server)]:\n                        query.artistIds ?? query.albumArtistIds,\n                    ...(hasFeature(\n                        apiClientProps.server,\n                        ServerFeature.TRACK_YES_NO_RATING_FILTER,\n                    ) && query.hasRating !== undefined\n                        ? { has_rating: query.hasRating }\n                        : {}),\n                    library_id: getLibraryId(query.musicFolderId),\n                    starred: query.favorite,\n                    title: query.searchTerm,\n                    year: query.maxYear || query.minYear,\n                    ...query._custom,\n                    ...excludeMissing(apiClientProps.server),\n                },\n            });\n\n            if (res.status !== 200) {\n                throw new Error('Failed to get song list');\n            }\n\n            return {\n                items: res.body.data.map((song) =>\n                    ndNormalize.song(\n                        song,\n                        apiClientProps.server,\n                        args.context?.pathReplace,\n                        args.context?.pathReplaceWith,\n                    ),\n                ),\n                totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),\n            };\n        };\n\n        if (shouldBatch && albumIds) {\n            const batches: string[][] = [];\n            for (let i = 0; i < albumIds.length; i += ALBUM_IDS_BATCH_SIZE) {\n                batches.push(albumIds.slice(i, i + ALBUM_IDS_BATCH_SIZE));\n            }\n\n            const results = await Promise.all(batches.map((batch) => fetchAlbums(batch)));\n\n            return {\n                items: results.flatMap((r) => r.items),\n                startIndex: query?.startIndex ?? 0,\n                totalRecordCount: results.reduce((sum, r) => sum + r.totalRecordCount, 0),\n            };\n        }\n\n        const albums = await fetchAlbums(undefined);\n\n        return {\n            items: albums.items,\n            startIndex: query?.startIndex ?? 0,\n            totalRecordCount: albums.totalRecordCount,\n        };\n    },\n    getSongListCount: async ({ apiClientProps, query }) =>\n        NavidromeController.getSongList({\n            apiClientProps,\n            query: { ...query, limit: 1, startIndex: 0 },\n        }).then((result) => result!.totalRecordCount!),\n    getStreamUrl: SubsonicController.getStreamUrl,\n    getStructuredLyrics: SubsonicController.getStructuredLyrics,\n    getTagList: async (args) => {\n        const { apiClientProps } = args;\n\n        if (!hasFeature(apiClientProps.server, ServerFeature.TAGS)) {\n            return { excluded: { album: [], song: [] } };\n        }\n\n        const res = await ndApiClient(apiClientProps).getTagList({\n            query: {},\n        });\n\n        if (res.status !== 200) {\n            throw new Error('failed to get tags');\n        }\n\n        const tagsToValues = new Map<string, { id: string; name: string }[]>();\n\n        for (const tag of res.body.data) {\n            if (!EXCLUDED_TAGS.has(tag.tagName)) {\n                if (tagsToValues.has(tag.tagName)) {\n                    tagsToValues.get(tag.tagName)!.push({\n                        id: ID_TAGS.has(tag.tagName) ? tag.id : tag.tagValue,\n                        name: tag.tagValue,\n                    });\n                } else {\n                    tagsToValues.set(tag.tagName, [\n                        {\n                            id: ID_TAGS.has(tag.tagName) ? tag.id : tag.tagValue,\n                            name: tag.tagValue,\n                        },\n                    ]);\n                }\n            }\n        }\n\n        const tags = Array.from(tagsToValues)\n            .map((data) => ({\n                name: data[0],\n                options: data[1]\n                    .sort((a, b) => {\n                        return numericSortCollator.compare(\n                            a.name.toLocaleLowerCase(),\n                            b.name.toLocaleLowerCase(),\n                        );\n                    })\n                    .map((option) => ({ id: option.id, name: option.name })),\n            }))\n            .sort((a, b) =>\n                collator.compare(a.name.toLocaleLowerCase(), b.name.toLocaleLowerCase()),\n            );\n\n        const excludedAlbumTags = Array.from(EXCLUDED_ALBUM_TAGS.values());\n        const excludedSongTags = Array.from(EXCLUDED_SONG_TAGS.values());\n\n        return {\n            excluded: {\n                album: excludedAlbumTags,\n                song: excludedSongTags,\n            },\n            tags,\n        };\n    },\n    getTopSongs: async (args) => {\n        const { apiClientProps, query } = args;\n\n        const type = query.type === 'personal' ? 'personal' : 'community';\n\n        if (type === 'community') {\n            const res = await ssApiClient(apiClientProps).getTopSongsList({\n                query: {\n                    artist: query.artist,\n                    count: query.limit,\n                },\n            });\n\n            if (res.status !== 200) {\n                throw new Error('Failed to get top songs');\n            }\n\n            return {\n                items: (res.body.topSongs?.song || []).map((song) =>\n                    ssNormalize.song(\n                        song,\n                        apiClientProps.server,\n                        args.context?.pathReplace,\n                        args.context?.pathReplaceWith,\n                    ),\n                ),\n                startIndex: 0,\n                totalRecordCount: res.body.topSongs?.song?.length || 0,\n            };\n        }\n\n        const res = await NavidromeController.getSongList({\n            apiClientProps,\n            query: {\n                artistIds: [query.artistId],\n                sortBy: SongListSort.PLAY_COUNT,\n                sortOrder: SortOrder.DESC,\n                startIndex: 0,\n            },\n        });\n\n        const songsWithPlayCount = orderBy(\n            res.items.filter((song) => song.playCount > 0),\n            ['playCount', 'albumId', 'trackNumber'],\n            ['desc', 'asc', 'asc'],\n        );\n\n        return {\n            items: songsWithPlayCount,\n            startIndex: 0,\n            totalRecordCount: res.totalRecordCount,\n        };\n    },\n    getUserInfo: SubsonicController.getUserInfo,\n    getUserList: async (args) => {\n        const { apiClientProps, query } = args;\n\n        const res = await ndApiClient(apiClientProps).getUserList({\n            query: {\n                _end: query.startIndex + (query.limit || 0),\n                _order: sortOrderMap.navidrome[query.sortOrder],\n                _sort: userListSortMap.navidrome[query.sortBy],\n                _start: query.startIndex,\n                ...query._custom,\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to get user list');\n        }\n\n        return {\n            items: res.body.data.map((user) => ndNormalize.user(user)),\n            startIndex: query?.startIndex || 0,\n            totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),\n        };\n    },\n    movePlaylistItem: async (args) => {\n        const { apiClientProps, query } = args;\n\n        const res = await ndApiClient(apiClientProps).movePlaylistItem({\n            body: {\n                insert_before: (query.endingIndex + 1).toString(),\n            },\n            params: {\n                playlistId: query.playlistId,\n                trackNumber: query.startingIndex.toString(),\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to move item in playlist');\n        }\n    },\n    removeFromPlaylist: async (args) => {\n        const { apiClientProps, query } = args;\n\n        const res = await ndApiClient(apiClientProps).removeFromPlaylist({\n            params: {\n                id: query.id,\n            },\n            query: {\n                id: query.songId,\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to remove from playlist');\n        }\n\n        return null;\n    },\n    replacePlaylist: async (args) => {\n        const { apiClientProps, body, query } = args;\n\n        // 1. Fetch existing songs from the playlist without any sorts\n        const existingSongsRes = await ndApiClient(apiClientProps as any).getPlaylistSongList({\n            params: {\n                id: query.id,\n            },\n            query: {\n                _end: -1,\n                _order: 'ASC',\n                _start: 0,\n                ...excludeMissing(apiClientProps.server),\n            },\n        });\n\n        if (existingSongsRes.status !== 200) {\n            throw new Error('Failed to fetch existing playlist songs');\n        }\n\n        const existingSongs = existingSongsRes.body.data.map((item) =>\n            ndNormalize.song(\n                item,\n                apiClientProps.server,\n                args.context?.pathReplace,\n                args.context?.pathReplaceWith,\n            ),\n        );\n\n        // 2. Get playlist detail to get the name\n        const playlistDetailRes = await ndApiClient(apiClientProps).getPlaylistDetail({\n            params: {\n                id: query.id,\n            },\n        });\n\n        if (playlistDetailRes.status !== 200) {\n            throw new Error('Failed to get playlist detail');\n        }\n\n        const playlist = ndNormalize.playlist(playlistDetailRes.body.data, apiClientProps.server);\n\n        // 3. Make a backup of the playlist ids and their order, along with the id of the playlist and name\n        const backup = {\n            id: query.id,\n            name: playlist.name,\n            songIds: existingSongs.map((song) => song.id),\n            timestamp: Date.now(),\n        };\n\n        // Store backup in IndexedDB using idb-keyval\n        const backupKey = `playlist-backup-${query.id}`;\n        await set(backupKey, backup);\n\n        // 4. Remove all songs from the playlist\n        if (existingSongs.length > 0) {\n            const existingPlaylistItemIds = existingSongs\n                .map((song) => song.playlistItemId)\n                .filter((id): id is string => id !== undefined && id !== null);\n\n            if (existingPlaylistItemIds.length > 0) {\n                const removeRes = await ndApiClient(apiClientProps).removeFromPlaylist({\n                    params: {\n                        id: query.id,\n                    },\n                    query: {\n                        id: existingPlaylistItemIds,\n                    },\n                });\n\n                if (removeRes.status !== 200) {\n                    throw new Error('Failed to remove songs from playlist');\n                }\n            }\n        }\n\n        // 5. Add the new song ids to the playlist\n        if (body.songId.length > 0) {\n            const addRes = await ndApiClient(apiClientProps).addToPlaylist({\n                body: {\n                    ids: body.songId,\n                },\n                params: {\n                    id: query.id,\n                },\n            });\n\n            if (addRes.status !== 200) {\n                throw new Error('Failed to add songs to playlist');\n            }\n        }\n\n        return null;\n    },\n    savePlayQueue: async (args) => {\n        const { apiClientProps, query } = args;\n\n        // Prefer using Navidrome's API only in the situation where the OpenSubsonic extension is not present\n        // OpenSubsonic extension is preferable as the credentials never expire\n        if (\n            hasFeatureWithVersion(apiClientProps.server, ServerFeature.SERVER_PLAY_QUEUE, 2) &&\n            !hasFeatureWithVersion(apiClientProps.server, ServerFeature.SERVER_PLAY_QUEUE, 1)\n        ) {\n            const res = await ndApiClient(apiClientProps).saveQueue({\n                body: {\n                    current: query.currentIndex !== undefined ? query.currentIndex : undefined,\n                    ids: query.songs,\n                    position: query.positionMs,\n                },\n            });\n\n            if (res.status !== 200) {\n                throw new Error('Failed to save play queue');\n            }\n            return;\n        }\n\n        return SubsonicController.savePlayQueue(args);\n    },\n    scrobble: SubsonicController.scrobble,\n    search: SubsonicController.search,\n    setRating: SubsonicController.setRating,\n    shareItem: async (args) => {\n        const { apiClientProps, body } = args;\n\n        const res = await ndApiClient(apiClientProps).shareItem({\n            body: {\n                description: body.description,\n                downloadable: body.downloadable,\n                expires: body.expires,\n                resourceIds: body.resourceIds,\n                resourceType: body.resourceType,\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to share item');\n        }\n\n        return {\n            id: res.body.data.id,\n        };\n    },\n    updateInternetRadioStation: SubsonicController.updateInternetRadioStation,\n    updatePlaylist: async (args) => {\n        const { apiClientProps, body, query } = args;\n\n        const res = await ndApiClient(apiClientProps).updatePlaylist({\n            body: {\n                comment: body.comment || '',\n                name: body.name,\n                ownerId: body.ownerId,\n                public: body?.public || false,\n                rules: body.queryBuilderRules,\n                sync: body.sync,\n                ...body._custom,\n            },\n            params: {\n                id: query.id,\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to update playlist');\n        }\n\n        return null;\n    },\n};\n"
  },
  {
    "path": "src/renderer/api/query-keys.ts",
    "content": "import type {\n    AlbumArtistDetailQuery,\n    AlbumArtistInfoQuery,\n    AlbumArtistListQuery,\n    AlbumDetailQuery,\n    AlbumListQuery,\n    AlbumRadioQuery,\n    ArtistListQuery,\n    ArtistRadioQuery,\n    FolderQuery,\n    GenreListQuery,\n    LyricSearchQuery,\n    LyricsQuery,\n    PlaylistDetailQuery,\n    PlaylistListQuery,\n    RandomSongListQuery,\n    SearchQuery,\n    SimilarSongsQuery,\n    SongDetailQuery,\n    SongListQuery,\n    TopSongListQuery,\n    UserListQuery,\n} from '/@/shared/types/domain-types';\n\nimport { QueryFunctionContext } from '@tanstack/react-query';\n\nimport { LyricSource } from '/@/shared/types/domain-types';\n\nexport const splitPaginatedQuery = (key: any) => {\n    const { limit, startIndex, ...filter } = key || {};\n\n    if (startIndex !== undefined || limit !== undefined) {\n        return {\n            filter,\n            pagination: {\n                limit,\n                startIndex,\n            },\n        };\n    }\n\n    return {\n        filter,\n        pagination: undefined,\n    };\n};\n\nexport type QueryPagination = {\n    limit?: number;\n    startIndex?: number;\n};\n\nexport const queryKeys: Record<\n    string,\n    Record<string, (...props: any) => QueryFunctionContext['queryKey']>\n> = {\n    albumArtists: {\n        count: (serverId: string, query?: AlbumArtistListQuery) => {\n            const { filter, pagination } = splitPaginatedQuery(query);\n\n            if (query && pagination) {\n                return [serverId, 'albumArtists', 'count', filter, pagination] as const;\n            }\n\n            if (query) {\n                return [serverId, 'albumArtists', 'count', filter] as const;\n            }\n\n            return [serverId, 'albumArtists', 'count'] as const;\n        },\n        detail: (serverId: string, query?: AlbumArtistDetailQuery) => {\n            if (query) {\n                return [serverId, 'albumArtists', 'detail', query] as const;\n            }\n\n            return [serverId, 'albumArtists', 'detail'] as const;\n        },\n        favoriteSongs: (serverId: string, artistId?: string) => {\n            if (artistId) {\n                return [serverId, 'albumArtists', 'favoriteSongs', artistId] as const;\n            }\n\n            return [serverId, 'albumArtists', 'favoriteSongs'] as const;\n        },\n        infiniteList: (serverId: string, query?: AlbumArtistListQuery) => {\n            const { filter, pagination } = splitPaginatedQuery(query);\n            if (query && pagination) {\n                return [serverId, 'albumArtists', 'infiniteList', filter, pagination] as const;\n            }\n\n            if (query) {\n                return [serverId, 'albumArtists', 'infiniteList', filter] as const;\n            }\n\n            return [serverId, 'albumArtists', 'infiniteList'] as const;\n        },\n        info: (serverId: string, query?: AlbumArtistInfoQuery) => {\n            if (query) {\n                return [serverId, 'albumArtists', 'info', query] as const;\n            }\n\n            return [serverId, 'albumArtists', 'info'] as const;\n        },\n        list: (serverId: string, query?: AlbumArtistListQuery) => {\n            const { filter, pagination } = splitPaginatedQuery(query);\n            if (query && pagination) {\n                return [serverId, 'albumArtists', 'list', filter, pagination] as const;\n            }\n\n            if (query) {\n                return [serverId, 'albumArtists', 'list', filter] as const;\n            }\n\n            return [serverId, 'albumArtists', 'list'] as const;\n        },\n        root: (serverId: string) => [serverId, 'albumArtists'] as const,\n        topSongs: (serverId: string, query?: TopSongListQuery) => {\n            if (query) return [serverId, 'albumArtists', 'topSongs', query] as const;\n            return [serverId, 'albumArtists', 'topSongs'] as const;\n        },\n    },\n    albums: {\n        count: (serverId: string, query?: AlbumListQuery, artistId?: string) => {\n            const { filter, pagination } = splitPaginatedQuery(query);\n\n            if (query && pagination && artistId) {\n                return [serverId, 'albums', 'count', artistId, filter, pagination] as const;\n            }\n\n            if (query && pagination) {\n                return [serverId, 'albums', 'count', filter, pagination] as const;\n            }\n\n            if (query && artistId) {\n                return [serverId, 'albums', 'count', artistId, filter] as const;\n            }\n\n            if (query) {\n                return [serverId, 'albums', 'count', filter] as const;\n            }\n\n            return [serverId, 'albums', 'count'] as const;\n        },\n        detail: (serverId: string, query?: AlbumDetailQuery) => {\n            if (query) {\n                return [serverId, 'albums', 'detail', query] as const;\n            }\n\n            return [serverId, 'albums', 'detail'] as const;\n        },\n        infiniteList: (serverId: string, query?: AlbumListQuery, artistId?: string) => {\n            const { filter, pagination } = splitPaginatedQuery(query);\n\n            if (query && pagination && artistId) {\n                return [serverId, 'albums', 'infiniteList', artistId, filter, pagination] as const;\n            }\n\n            if (query && pagination) {\n                return [serverId, 'albums', 'infiniteList', filter, pagination] as const;\n            }\n\n            if (query && artistId) {\n                return [serverId, 'albums', 'infiniteList', artistId, filter] as const;\n            }\n\n            if (query) {\n                return [serverId, 'albums', 'infiniteList', filter] as const;\n            }\n\n            return [serverId, 'albums', 'infiniteList'] as const;\n        },\n        list: (serverId: string, query?: AlbumListQuery, artistId?: string) => {\n            const { filter, pagination } = splitPaginatedQuery(query);\n\n            if (query && pagination && artistId) {\n                return [serverId, 'albums', 'list', artistId, filter, pagination] as const;\n            }\n\n            if (query && pagination) {\n                return [serverId, 'albums', 'list', filter, pagination] as const;\n            }\n\n            if (query && artistId) {\n                return [serverId, 'albums', 'list', artistId, filter] as const;\n            }\n\n            if (query) {\n                return [serverId, 'albums', 'list', filter] as const;\n            }\n\n            return [serverId, 'albums', 'list'] as const;\n        },\n        related: (serverId: string, id: string, query?: AlbumDetailQuery) => {\n            if (query) {\n                return [serverId, 'albums', id, 'related', query] as const;\n            }\n\n            return [serverId, 'albums', id, 'related'] as const;\n        },\n        root: (serverId: string) => [serverId, 'albums'],\n        serverRoot: (serverId: string) => [serverId, 'albums'],\n        songs: (serverId: string, query: SongListQuery) =>\n            [serverId, 'albums', 'songs', query] as const,\n    },\n    artists: {\n        count: (serverId: string, query?: ArtistListQuery) => {\n            const { filter, pagination } = splitPaginatedQuery(query);\n\n            if (query && pagination) {\n                return [serverId, 'artists', 'count', filter, pagination] as const;\n            }\n\n            if (query) {\n                return [serverId, 'artists', 'count', filter] as const;\n            }\n\n            return [serverId, 'artists', 'count'] as const;\n        },\n        infiniteList: (serverId: string, query?: ArtistListQuery) => {\n            const { filter, pagination } = splitPaginatedQuery(query);\n            if (query && pagination) {\n                return [serverId, 'artists', 'infiniteList', filter, pagination] as const;\n            }\n\n            if (query) {\n                return [serverId, 'artists', 'infiniteList', filter] as const;\n            }\n\n            return [serverId, 'artists', 'infiniteList'] as const;\n        },\n        list: (serverId: string, query?: ArtistListQuery) => {\n            const { filter, pagination } = splitPaginatedQuery(query);\n            if (query && pagination) {\n                return [serverId, 'artists', 'list', filter, pagination] as const;\n            }\n\n            if (query) {\n                return [serverId, 'artists', 'list', filter] as const;\n            }\n\n            return [serverId, 'artists', 'list'] as const;\n        },\n        root: (serverId: string) => [serverId, 'artists'] as const,\n    },\n    folders: {\n        folder: (serverId: string, query?: FolderQuery) => {\n            if (query) {\n                return [serverId, 'folders', 'folder', query] as const;\n            }\n\n            return [serverId, 'folders', 'folder'] as const;\n        },\n    },\n    genres: {\n        count: (serverId: string, query?: GenreListQuery) => {\n            const { filter, pagination } = splitPaginatedQuery(query);\n\n            if (query && pagination) {\n                return [serverId, 'genres', 'count', filter, pagination] as const;\n            }\n\n            if (query) {\n                return [serverId, 'genres', 'count', filter] as const;\n            }\n\n            return [serverId, 'genres', 'count'] as const;\n        },\n        list: (serverId: string, query?: GenreListQuery) => {\n            const { filter, pagination } = splitPaginatedQuery(query);\n            if (query && pagination) {\n                return [serverId, 'genres', 'list', filter, pagination] as const;\n            }\n\n            if (query) {\n                return [serverId, 'genres', 'list', filter] as const;\n            }\n\n            return [serverId, 'genres', 'list'] as const;\n        },\n        root: (serverId: string) => [serverId, 'genres'] as const,\n    },\n    musicFolders: {\n        list: (serverId: string) => [serverId, 'musicFolders', 'list'] as const,\n    },\n    player: {\n        fetch: (meta?: any) => {\n            if (meta) {\n                return ['player', 'fetch', meta] as const;\n            }\n\n            return ['player', 'fetch'] as const;\n        },\n    },\n    playlists: {\n        count: (serverId: string, query?: PlaylistListQuery) => {\n            const { filter, pagination } = splitPaginatedQuery(query);\n\n            if (query && pagination) {\n                return [serverId, 'playlists', 'count', filter, pagination] as const;\n            }\n\n            if (query) {\n                return [serverId, 'playlists', 'count', filter] as const;\n            }\n\n            return [serverId, 'playlists', 'count'] as const;\n        },\n        detail: (serverId: string, id?: string, query?: PlaylistDetailQuery) => {\n            const { filter, pagination } = splitPaginatedQuery(query);\n            if (query && pagination) {\n                return [serverId, 'playlists', id, 'detail', filter, pagination] as const;\n            }\n\n            if (query) {\n                return [serverId, 'playlists', id, 'detail', filter] as const;\n            }\n\n            if (id) return [serverId, 'playlists', id, 'detail'] as const;\n            return [serverId, 'playlists', 'detail'] as const;\n        },\n        list: (serverId: string, query?: PlaylistListQuery) => {\n            const { filter, pagination } = splitPaginatedQuery(query);\n            if (query && pagination) {\n                return [serverId, 'playlists', 'list', filter, pagination] as const;\n            }\n\n            if (query) {\n                return [serverId, 'playlists', 'list', filter] as const;\n            }\n\n            return [serverId, 'playlists', 'list'] as const;\n        },\n        root: (serverId: string) => [serverId, 'playlists'] as const,\n        songList: (serverId: string, id?: string) => {\n            if (id) {\n                return [serverId, 'playlists', 'songList', id] as const;\n            }\n\n            return [serverId, 'playlists', 'songList'] as const;\n        },\n    },\n    radio: {\n        list: (serverId: string) => [serverId, 'radio', 'list'] as const,\n        root: (serverId: string) => [serverId, 'radio'] as const,\n    },\n    roles: {\n        list: (serverId: string) => [serverId, 'roles'] as const,\n    },\n    search: {\n        infiniteList: (\n            serverId: string,\n            type: 'albumArtists' | 'albums' | 'songs',\n            searchTerm: string,\n        ) => [serverId, 'search', 'infiniteList', type, searchTerm] as const,\n        list: (serverId: string, query?: SearchQuery) => {\n            if (query) return [serverId, 'search', 'list', query] as const;\n            return [serverId, 'search', 'list'] as const;\n        },\n        root: (serverId: string) => [serverId, 'search'] as const,\n    },\n    server: {\n        root: (serverId: string) => [serverId] as const,\n    },\n    songs: {\n        albumRadio: (serverId: string, query?: AlbumRadioQuery) => {\n            if (query) return [serverId, 'songs', 'albumRadio', query] as const;\n            return [serverId, 'songs', 'albumRadio'] as const;\n        },\n        artistRadio: (serverId: string, query?: ArtistRadioQuery) => {\n            if (query) return [serverId, 'songs', 'artistRadio', query] as const;\n            return [serverId, 'songs', 'artistRadio'] as const;\n        },\n        count: (serverId: string, query?: SongListQuery) => {\n            const { filter, pagination } = splitPaginatedQuery(query);\n            if (query && pagination) {\n                return [serverId, 'songs', 'count', filter, pagination] as const;\n            }\n\n            if (query) {\n                return [serverId, 'songs', 'count', filter] as const;\n            }\n\n            return [serverId, 'songs', 'count'] as const;\n        },\n        detail: (serverId: string, query?: SongDetailQuery) => {\n            if (query) {\n                return [serverId, 'songs', 'detail', query] as const;\n            }\n\n            return [serverId, 'songs', 'detail'] as const;\n        },\n        infiniteList: (serverId: string, query?: SongListQuery) => {\n            const { filter, pagination } = splitPaginatedQuery(query);\n            if (query && pagination) {\n                return [serverId, 'songs', 'infiniteList', filter, pagination] as const;\n            }\n\n            if (query) {\n                return [serverId, 'songs', 'infiniteList', filter] as const;\n            }\n\n            return [serverId, 'songs', 'infiniteList'] as const;\n        },\n        list: (serverId: string, query?: SongListQuery) => {\n            const { filter, pagination } = splitPaginatedQuery(query);\n            if (query && pagination) {\n                return [serverId, 'songs', 'list', filter, pagination] as const;\n            }\n\n            if (query) {\n                return [serverId, 'songs', 'list', filter] as const;\n            }\n\n            return [serverId, 'songs', 'list'] as const;\n        },\n        lyrics: (serverId: string, query?: LyricsQuery) => {\n            if (query) return [serverId, 'song', 'lyrics', 'select', query] as const;\n            return [serverId, 'song', 'lyrics'] as const;\n        },\n        lyricsByRemoteId: (searchQuery: { remoteSongId: string; remoteSource: LyricSource }) => {\n            return ['song', 'lyrics', 'remote', searchQuery] as const;\n        },\n        lyricsSearch: (query?: LyricSearchQuery) => {\n            if (query) return ['lyrics', 'search', query] as const;\n            return ['lyrics', 'search'] as const;\n        },\n        randomSongList: (serverId: string, query?: RandomSongListQuery) => {\n            if (query) return [serverId, 'songs', 'randomSongList', query] as const;\n            return [serverId, 'songs', 'randomSongList'] as const;\n        },\n        root: (serverId: string) => [serverId, 'songs'] as const,\n        similar: (serverId: string, query?: SimilarSongsQuery) => {\n            if (query) return [serverId, 'song', 'similar', query] as const;\n            return [serverId, 'song', 'similar'] as const;\n        },\n    },\n    tags: {\n        list: (serverId: string, type: string) => [serverId, 'tags', type] as const,\n    },\n    users: {\n        list: (serverId: string, query?: UserListQuery) => {\n            if (query) return [serverId, 'users', 'list', query] as const;\n            return [serverId, 'users', 'list'] as const;\n        },\n        root: (serverId: string) => [serverId, 'users'] as const,\n    },\n};\n"
  },
  {
    "path": "src/renderer/api/subsonic/subsonic-api.ts",
    "content": "import { initClient, initContract } from '@ts-rest/core';\nimport axios, { AxiosError, AxiosRequestConfig, AxiosResponse, isAxiosError } from 'axios';\nimport omitBy from 'lodash/omitBy';\nimport qs from 'qs';\nimport { z } from 'zod';\n\nimport i18n from '/@/i18n/i18n';\nimport { getServerUrl } from '/@/renderer/utils/normalize-server-url';\nimport { ssType } from '/@/shared/api/subsonic/subsonic-types';\nimport { hasFeature } from '/@/shared/api/utils';\nimport { toast } from '/@/shared/components/toast/toast';\nimport { ServerListItemWithCredential } from '/@/shared/types/domain-types';\nimport { ServerFeature } from '/@/shared/types/features-types';\n\nconst c = initContract();\n\nexport const contract = c.router({\n    authenticate: {\n        method: 'GET',\n        path: 'getUser.view',\n        query: ssType._parameters.authenticate,\n        responses: {\n            200: ssType._response.authenticate,\n        },\n    },\n    createFavorite: {\n        method: 'GET',\n        path: 'star.view',\n        query: ssType._parameters.createFavorite,\n        responses: {\n            200: ssType._response.createFavorite,\n        },\n    },\n    createInternetRadioStation: {\n        method: 'GET',\n        path: 'createInternetRadioStation.view',\n        query: ssType._parameters.createInternetRadioStation,\n        responses: {\n            200: ssType._response.createInternetRadioStation,\n        },\n    },\n    createPlaylist: {\n        method: 'GET',\n        path: 'createPlaylist.view',\n        query: ssType._parameters.createPlaylist,\n        responses: {\n            200: ssType._response.createPlaylist,\n        },\n    },\n    deleteInternetRadioStation: {\n        method: 'GET',\n        path: 'deleteInternetRadioStation.view',\n        query: ssType._parameters.deleteInternetRadioStation,\n        responses: {\n            200: ssType._response.deleteInternetRadioStation,\n        },\n    },\n    deletePlaylist: {\n        method: 'GET',\n        path: 'deletePlaylist.view',\n        query: ssType._parameters.deletePlaylist,\n        responses: {\n            200: ssType._response.baseResponse,\n        },\n    },\n    getAlbum: {\n        method: 'GET',\n        path: 'getAlbum.view',\n        query: ssType._parameters.getAlbum,\n        responses: {\n            200: ssType._response.getAlbum,\n        },\n    },\n    getAlbumInfo2: {\n        method: 'GET',\n        path: 'getAlbumInfo2.view',\n        query: ssType._parameters.albumInfo,\n        responses: {\n            200: ssType._response.albumInfo,\n        },\n    },\n    getAlbumList2: {\n        method: 'GET',\n        path: 'getAlbumList2.view',\n        query: ssType._parameters.getAlbumList2,\n        responses: {\n            200: ssType._response.getAlbumList2,\n        },\n    },\n    getArtist: {\n        method: 'GET',\n        path: 'getArtist.view',\n        query: ssType._parameters.getArtist,\n        responses: {\n            200: ssType._response.getArtist,\n        },\n    },\n    getArtistInfo: {\n        method: 'GET',\n        path: 'getArtistInfo.view',\n        query: ssType._parameters.artistInfo,\n        responses: {\n            200: ssType._response.artistInfo,\n        },\n    },\n    getArtists: {\n        method: 'GET',\n        path: 'getArtists.view',\n        query: ssType._parameters.getArtists,\n        responses: {\n            200: ssType._response.getArtists,\n        },\n    },\n    getGenres: {\n        method: 'GET',\n        path: 'getGenres.view',\n        query: ssType._parameters.getGenres,\n        responses: {\n            200: ssType._response.getGenres,\n        },\n    },\n    getIndexes: {\n        method: 'GET',\n        path: 'getIndexes.view',\n        query: ssType._parameters.getIndexes,\n        responses: {\n            200: ssType._response.getIndexes,\n        },\n    },\n    getInternetRadioStations: {\n        method: 'GET',\n        path: 'getInternetRadioStations.view',\n        responses: {\n            200: ssType._response.getInternetRadioStations,\n        },\n    },\n    getMusicDirectory: {\n        method: 'GET',\n        path: 'getMusicDirectory.view',\n        query: ssType._parameters.getMusicDirectory,\n        responses: {\n            200: ssType._response.getMusicDirectory,\n        },\n    },\n    getMusicFolderList: {\n        method: 'GET',\n        path: 'getMusicFolders.view',\n        responses: {\n            200: ssType._response.musicFolderList,\n        },\n    },\n    getPlaylist: {\n        method: 'GET',\n        path: 'getPlaylist.view',\n        query: ssType._parameters.getPlaylist,\n        responses: {\n            200: ssType._response.getPlaylist,\n        },\n    },\n    getPlaylists: {\n        method: 'GET',\n        path: 'getPlaylists.view',\n        query: ssType._parameters.getPlaylists,\n        responses: {\n            200: ssType._response.getPlaylists,\n        },\n    },\n    getPlayQueue: {\n        method: 'GET',\n        path: 'getPlayQueue.view',\n        responses: {\n            200: ssType._response.playQueue,\n        },\n    },\n    getPlayQueueByIndex: {\n        method: 'GET',\n        path: 'getPlayQueueByIndex.view',\n        responses: {\n            200: ssType._response.playQueueByIndex,\n        },\n    },\n    getRandomSongList: {\n        method: 'GET',\n        path: 'getRandomSongs.view',\n        query: ssType._parameters.randomSongList,\n        responses: {\n            200: ssType._response.randomSongList,\n        },\n    },\n    getServerInfo: {\n        method: 'GET',\n        path: 'getOpenSubsonicExtensions.view',\n        responses: {\n            200: ssType._response.serverInfo,\n        },\n    },\n    getSimilarSongs: {\n        method: 'GET',\n        path: 'getSimilarSongs',\n        query: ssType._parameters.similarSongs,\n        responses: {\n            200: ssType._response.similarSongs,\n        },\n    },\n    getSimilarSongs2: {\n        method: 'GET',\n        path: 'getSimilarSongs2',\n        query: ssType._parameters.similarSongs2,\n        responses: {\n            200: ssType._response.similarSongs2,\n        },\n    },\n    getSong: {\n        method: 'GET',\n        path: 'getSong.view',\n        query: ssType._parameters.getSong,\n        responses: {\n            200: ssType._response.getSong,\n        },\n    },\n    getSongsByGenre: {\n        method: 'GET',\n        path: 'getSongsByGenre.view',\n        query: ssType._parameters.getSongsByGenre,\n        responses: {\n            200: ssType._response.getSongsByGenre,\n        },\n    },\n    getStarred: {\n        method: 'GET',\n        path: 'getStarred.view',\n        query: ssType._parameters.getStarred,\n        responses: {\n            200: ssType._response.getStarred,\n        },\n    },\n    getStructuredLyrics: {\n        method: 'GET',\n        path: 'getLyricsBySongId.view',\n        query: ssType._parameters.structuredLyrics,\n        responses: {\n            200: ssType._response.structuredLyrics,\n        },\n    },\n    getTopSongsList: {\n        method: 'GET',\n        path: 'getTopSongs.view',\n        query: ssType._parameters.topSongsList,\n        responses: {\n            200: ssType._response.topSongsList,\n        },\n    },\n    getUser: {\n        method: 'GET',\n        path: 'getUser.view',\n        query: ssType._parameters.user,\n        responses: {\n            200: ssType._response.user,\n        },\n    },\n    ping: {\n        method: 'GET',\n        path: 'ping.view',\n        responses: {\n            200: ssType._response.ping,\n        },\n    },\n    removeFavorite: {\n        method: 'GET',\n        path: 'unstar.view',\n        query: ssType._parameters.removeFavorite,\n        responses: {\n            200: ssType._response.removeFavorite,\n        },\n    },\n    savePlayQueue: {\n        method: 'GET',\n        path: 'savePlayQueue.view',\n        query: ssType._parameters.saveQueue,\n        responses: {\n            200: ssType._response.saveQueue,\n        },\n    },\n    savePlayQueueByIndex: {\n        method: 'GET',\n        path: 'savePlayQueueByIndex.view',\n        query: ssType._parameters.savePlayQueueByIndex,\n        responses: {\n            200: ssType._response.saveQueue,\n        },\n    },\n    scrobble: {\n        method: 'GET',\n        path: 'scrobble.view',\n        query: ssType._parameters.scrobble,\n        responses: {\n            200: ssType._response.scrobble,\n        },\n    },\n    search3: {\n        method: 'GET',\n        path: 'search3.view',\n        query: ssType._parameters.search3,\n        responses: {\n            200: ssType._response.search3,\n        },\n    },\n    setRating: {\n        method: 'GET',\n        path: 'setRating.view',\n        query: ssType._parameters.setRating,\n        responses: {\n            200: ssType._response.setRating,\n        },\n    },\n    updateInternetRadioStation: {\n        method: 'GET',\n        path: 'updateInternetRadioStation.view',\n        query: ssType._parameters.updateInternetRadioStation,\n        responses: {\n            200: ssType._response.updateInternetRadioStation,\n        },\n    },\n    updatePlaylist: {\n        method: 'GET',\n        path: 'updatePlaylist.view',\n        query: ssType._parameters.updatePlaylist,\n        responses: {\n            200: ssType._response.baseResponse,\n        },\n    },\n});\n\nconst axiosClient = axios.create({});\n\naxiosClient.defaults.paramsSerializer = (params) => {\n    return qs.stringify(params, { arrayFormat: 'repeat' });\n};\n\naxiosClient.interceptors.response.use(\n    (response) => {\n        const data = response.data;\n        if (data['subsonic-response'].status !== 'ok') {\n            // Suppress code related to non-linked lastfm or spotify from Navidrome\n            if (data['subsonic-response'].error.code !== 0) {\n                toast.error({\n                    message: data['subsonic-response'].error.message,\n                    title: i18n.t('error.genericError', { postProcess: 'sentenceCase' }) as string,\n                });\n\n                // Since we do status === 200, override this value with the error code\n                response.status = data['subsonic-response'].error.code;\n            }\n        }\n\n        return response;\n    },\n    (error) => {\n        return Promise.reject(error);\n    },\n);\n\nconst parsePath = (fullPath: string) => {\n    const [path, params] = fullPath.split('?');\n\n    const parsedParams = qs.parse(params, { arrayLimit: 99999, parameterLimit: 99999 });\n    const notNilParams = omitBy(parsedParams, (value) => value === 'undefined' || value === 'null');\n\n    return {\n        params: notNilParams,\n        path,\n    };\n};\n\nconst silentlyTransformResponse = (data: any) => {\n    const jsonBody = JSON.parse(data);\n    const status = jsonBody ? jsonBody['subsonic-response']?.status : undefined;\n\n    if (status && status !== 'ok') {\n        jsonBody['subsonic-response'].error.code = 0;\n    }\n\n    return jsonBody;\n};\n\nexport const ssApiClient = (args: {\n    server: null | ServerListItemWithCredential;\n    signal?: AbortSignal;\n    silent?: boolean;\n    url?: string;\n}) => {\n    const { server, signal, silent, url } = args;\n\n    return initClient(contract, {\n        api: async ({ headers, method, path }) => {\n            let baseUrl: string | undefined;\n            const authParams: Record<string, any> = {};\n\n            const { params, path: api } = parsePath(path);\n\n            if (server) {\n                const serverUrl = getServerUrl(server);\n                baseUrl = serverUrl ? `${serverUrl}/rest` : undefined;\n                const token = server.credential;\n                const params = token.split(/&?\\w=/gm);\n\n                authParams.u = decodeURIComponent(server.username);\n                if (params?.length === 4) {\n                    authParams.s = params[2];\n                    authParams.t = params[3];\n                } else if (params?.length === 3) {\n                    authParams.p = decodeURIComponent(params[2]);\n                }\n            } else {\n                baseUrl = url;\n            }\n\n            const request: AxiosRequestConfig = {\n                headers,\n                signal,\n                // In cases where we have a fallback, don't notify the error\n                transformResponse: silent ? silentlyTransformResponse : undefined,\n                url: `${baseUrl}/${api}`,\n            };\n\n            const data = {\n                c: 'Feishin',\n                f: 'json',\n                v: '1.13.0',\n                ...authParams,\n                ...params,\n            };\n\n            if (hasFeature(server, ServerFeature.OS_FORM_POST)) {\n                headers['Content-Type'] = 'application/x-www-form-urlencoded';\n                request.method = 'POST';\n                request.data = qs.stringify(data, { arrayFormat: 'repeat' });\n            } else {\n                request.method = method;\n                request.params = data;\n            }\n\n            try {\n                const result =\n                    await axiosClient.request<z.infer<typeof ssType._response.baseResponse>>(\n                        request,\n                    );\n\n                return {\n                    body: result.data['subsonic-response'],\n                    headers: result.headers as any,\n                    status: result.status,\n                };\n            } catch (e: any | AxiosError | Error) {\n                if (isAxiosError(e)) {\n                    if (e.code === 'ERR_NETWORK') {\n                        throw new Error(\n                            i18n.t('error.networkError', {\n                                postProcess: 'sentenceCase',\n                            }) as string,\n                        );\n                    }\n\n                    const error = e as AxiosError;\n                    const response = error.response as AxiosResponse;\n\n                    return {\n                        body: response?.data,\n                        headers: response?.headers as any,\n                        status: response?.status,\n                    };\n                }\n                throw e;\n            }\n        },\n        baseHeaders: {\n            'Content-Type': 'application/json',\n        },\n        baseUrl: '',\n    });\n};\n"
  },
  {
    "path": "src/renderer/api/subsonic/subsonic-controller.ts",
    "content": "import type { ServerInferResponses } from '@ts-rest/core';\n\nimport dayjs from 'dayjs';\nimport { set } from 'idb-keyval';\nimport filter from 'lodash/filter';\nimport orderBy from 'lodash/orderBy';\nimport md5 from 'md5';\nimport { z } from 'zod';\n\nimport { contract, ssApiClient } from '/@/renderer/api/subsonic/subsonic-api';\nimport { randomString } from '/@/renderer/utils';\nimport { getServerUrl } from '/@/renderer/utils/normalize-server-url';\nimport { ssNormalize } from '/@/shared/api/subsonic/subsonic-normalize';\nimport {\n    AlbumListSortType,\n    ssType,\n    SubsonicExtensions,\n} from '/@/shared/api/subsonic/subsonic-types';\nimport { hasFeature, sortAlbumArtistList, sortAlbumList, sortSongList } from '/@/shared/api/utils';\nimport {\n    AlbumListSort,\n    GenreListSort,\n    ImageArgs,\n    ImageRequest,\n    InternalControllerEndpoint,\n    LibraryItem,\n    PlaylistListSort,\n    ReplaceApiClientProps,\n    ServerType,\n    Song,\n    SongListSort,\n    SortOrder,\n} from '/@/shared/types/domain-types';\nimport { ServerFeature, ServerFeatures } from '/@/shared/types/features-types';\n\nconst getSubsonicImageRequest = ({\n    apiClientProps: { server },\n    baseUrl,\n    query,\n}: ReplaceApiClientProps<ImageArgs>): ImageRequest | null => {\n    const { id, size } = query;\n    const imageSize = size;\n    const url = baseUrl || getServerUrl(server);\n\n    if (!url || !server?.credential) {\n        return null;\n    }\n\n    // Check for default placeholder image ID\n    if (id.match('2a96cbd8b46e442fc41c2b86b821562f')) {\n        return null;\n    }\n\n    return {\n        cacheKey: ['subsonic', server.id, baseUrl || '', id, imageSize || ''].join(':'),\n        url:\n            `${url}/rest/getCoverArt.view` +\n            `?id=${id}` +\n            `&${server.credential}` +\n            '&v=1.13.0' +\n            '&c=Feishin' +\n            (imageSize ? `&size=${imageSize}` : ''),\n    };\n};\n\nconst ALBUM_LIST_SORT_MAPPING: Record<AlbumListSort, AlbumListSortType | undefined> = {\n    [AlbumListSort.ALBUM_ARTIST]: AlbumListSortType.ALPHABETICAL_BY_ARTIST,\n    [AlbumListSort.ARTIST]: undefined,\n    [AlbumListSort.COMMUNITY_RATING]: undefined,\n    [AlbumListSort.CRITIC_RATING]: undefined,\n    [AlbumListSort.DURATION]: undefined,\n    [AlbumListSort.EXPLICIT_STATUS]: undefined,\n    [AlbumListSort.FAVORITED]: AlbumListSortType.STARRED,\n    [AlbumListSort.ID]: undefined,\n    [AlbumListSort.NAME]: AlbumListSortType.ALPHABETICAL_BY_NAME,\n    [AlbumListSort.PLAY_COUNT]: AlbumListSortType.FREQUENT,\n    [AlbumListSort.RANDOM]: AlbumListSortType.RANDOM,\n    [AlbumListSort.RATING]: undefined,\n    [AlbumListSort.RECENTLY_ADDED]: AlbumListSortType.NEWEST,\n    [AlbumListSort.RECENTLY_PLAYED]: AlbumListSortType.RECENT,\n    [AlbumListSort.RELEASE_DATE]: AlbumListSortType.BY_YEAR,\n    [AlbumListSort.SONG_COUNT]: undefined,\n    [AlbumListSort.SORT_NAME]: AlbumListSortType.ALPHABETICAL_BY_NAME,\n    [AlbumListSort.YEAR]: AlbumListSortType.BY_YEAR,\n};\n\nconst MAX_SUBSONIC_ITEMS = 500;\nconst SUBSONIC_FAST_BATCH_SIZE = MAX_SUBSONIC_ITEMS * 10;\n\nfunction sortAndPaginate<T>(\n    items: T[],\n    options: {\n        limit?: number;\n        sortBy?: any;\n        sortFn?: (items: T[], sortBy: any, sortOrder: SortOrder) => T[];\n        sortOrder?: SortOrder;\n        startIndex?: number;\n    },\n): {\n    items: T[];\n    startIndex: number;\n    totalRecordCount: number;\n} {\n    let sortedItems = items;\n\n    if (options.sortFn && options.sortBy) {\n        const sortOrder = options.sortOrder || SortOrder.ASC;\n        sortedItems = options.sortFn(items, options.sortBy, sortOrder);\n    }\n\n    const totalCount = sortedItems.length;\n    const startIndex = options.startIndex || 0;\n    const limit = options.limit || totalCount;\n    const paginatedItems = sortedItems.slice(startIndex, startIndex + limit);\n\n    return {\n        items: paginatedItems,\n        startIndex: startIndex,\n        totalRecordCount: totalCount,\n    };\n}\n\nexport const SubsonicController: InternalControllerEndpoint = {\n    addToPlaylist: async ({ apiClientProps, body, query }) => {\n        const res = await ssApiClient(apiClientProps).updatePlaylist({\n            query: {\n                playlistId: query.id,\n                songIdToAdd: body.songId,\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to add to playlist');\n        }\n\n        return null;\n    },\n    authenticate: async (url, body) => {\n        let credential: string;\n        let credentialParams: {\n            p?: string;\n            s?: string;\n            t?: string;\n            u: string;\n        };\n\n        const cleanServerUrl = `${url.replace(/\\/$/, '')}/rest`;\n\n        if (body.legacy) {\n            credential = `u=${encodeURIComponent(body.username)}&p=${encodeURIComponent(body.password)}`;\n            credentialParams = {\n                p: body.password,\n                u: body.username,\n            };\n        } else {\n            const salt = randomString(12);\n            const hash = md5(body.password + salt);\n\n            credential = `u=${encodeURIComponent(body.username)}&s=${encodeURIComponent(salt)}&t=${encodeURIComponent(hash)}`;\n            credentialParams = {\n                s: salt,\n                t: hash,\n                u: body.username,\n            };\n        }\n\n        const resp = await ssApiClient({ server: null, url: cleanServerUrl }).authenticate({\n            query: {\n                c: 'Feishin',\n                f: 'json',\n                username: body.username,\n                v: '1.13.0',\n                ...credentialParams,\n            },\n        });\n\n        if (resp.status !== 200) {\n            throw new Error('Failed to log in');\n        }\n\n        return {\n            credential,\n            isAdmin: Boolean(resp.body.user.adminRole),\n            userId: resp.body.user.username,\n            username: body.username,\n        };\n    },\n    createFavorite: async (args) => {\n        const { apiClientProps, query } = args;\n\n        const res = await ssApiClient(apiClientProps).createFavorite({\n            query: {\n                albumId: query.type === LibraryItem.ALBUM ? query.id : undefined,\n                artistId:\n                    query.type === LibraryItem.ALBUM_ARTIST || query.type === LibraryItem.ARTIST\n                        ? query.id\n                        : undefined,\n                id: query.type === LibraryItem.SONG ? query.id : undefined,\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to create favorite');\n        }\n\n        return null;\n    },\n    createInternetRadioStation: async (args) => {\n        const { apiClientProps, body } = args;\n\n        const res = await ssApiClient(apiClientProps).createInternetRadioStation({\n            query: {\n                homepageUrl: body.homepageUrl,\n                name: body.name,\n                streamUrl: body.streamUrl,\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to create internet radio station');\n        }\n\n        return null;\n    },\n    createPlaylist: async ({ apiClientProps, body }) => {\n        const res = await ssApiClient(apiClientProps).createPlaylist({\n            query: {\n                name: body.name,\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to create playlist');\n        }\n\n        return {\n            id: res.body.playlist.id.toString(),\n            name: res.body.playlist.name,\n        };\n    },\n    deleteFavorite: async (args) => {\n        const { apiClientProps, query } = args;\n\n        const res = await ssApiClient(apiClientProps).removeFavorite({\n            query: {\n                albumId: query.type === LibraryItem.ALBUM ? query.id : undefined,\n                artistId:\n                    query.type === LibraryItem.ALBUM_ARTIST || query.type === LibraryItem.ARTIST\n                        ? query.id\n                        : undefined,\n                id: query.type === LibraryItem.SONG ? query.id : undefined,\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to delete favorite');\n        }\n\n        return null;\n    },\n    deleteInternetRadioStation: async (args) => {\n        const { apiClientProps, query } = args;\n\n        const res = await ssApiClient(apiClientProps).deleteInternetRadioStation({\n            query: {\n                id: query.id,\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to delete internet radio station');\n        }\n\n        return null;\n    },\n    deletePlaylist: async (args) => {\n        const { apiClientProps, query } = args;\n\n        const res = await ssApiClient(apiClientProps).deletePlaylist({\n            query: {\n                id: query.id,\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to delete playlist');\n        }\n\n        return null;\n    },\n    getAlbumArtistDetail: async (args) => {\n        const { apiClientProps, query } = args;\n\n        const res = await ssApiClient(apiClientProps).getArtist({\n            query: {\n                id: query.id,\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to get album artist detail');\n        }\n\n        const artist = res.body.artist;\n\n        return {\n            ...ssNormalize.albumArtist(artist, apiClientProps.server),\n            albums: artist.album?.map((album) =>\n                ssNormalize.album(\n                    album,\n                    apiClientProps.server,\n                    args.context?.pathReplace,\n                    args.context?.pathReplaceWith,\n                ),\n            ),\n            similarArtists: null,\n        };\n    },\n    getAlbumArtistInfo: async (args) => {\n        const { apiClientProps, query } = args;\n\n        const artistInfoRes = await ssApiClient(apiClientProps).getArtistInfo({\n            query: {\n                id: query.id,\n                ...(query.limit != null && { count: query.limit }),\n            },\n        });\n\n        if (artistInfoRes.status !== 200) {\n            return null;\n        }\n\n        const artistInfo = artistInfoRes.body.artistInfo;\n\n        return {\n            biography: artistInfo?.biography || null,\n            similarArtists:\n                artistInfo?.similarArtist?.map((artist) => ({\n                    id: artist.id,\n                    imageId: null,\n                    imageUrl: null,\n                    name: artist.name,\n                    userFavorite: Boolean(artist.starred) || false,\n                    userRating: artist.userRating ?? null,\n                })) ?? null,\n        };\n    },\n    getAlbumArtistList: async (args) => {\n        const { apiClientProps, query } = args;\n\n        const res = await ssApiClient(apiClientProps).getArtists({\n            query: {\n                musicFolderId: getLibraryId(query.musicFolderId),\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to get album artist list');\n        }\n\n        const artists = (res.body.artists?.index || []).flatMap((index) => index.artist);\n\n        let results = artists.map((artist) =>\n            ssNormalize.albumArtist(artist, apiClientProps.server),\n        );\n\n        if (query.searchTerm) {\n            const searchResults = filter(results, (artist) => {\n                return artist.name.toLowerCase().includes(query.searchTerm!.toLowerCase());\n            });\n\n            results = searchResults;\n        }\n\n        if (query.favorite) {\n            results = results.filter((artist) => artist.userFavorite);\n        }\n\n        return sortAndPaginate(results, {\n            limit: query.limit,\n            sortBy: query.sortBy,\n            sortFn: query.sortBy ? sortAlbumArtistList : undefined,\n            sortOrder: query.sortOrder,\n            startIndex: query.startIndex,\n        });\n    },\n    getAlbumArtistListCount: (args) =>\n        SubsonicController.getAlbumArtistList({\n            ...args,\n            context: args.context,\n            query: { ...args.query, startIndex: 0 },\n        }).then((res) => res!.totalRecordCount!),\n    getAlbumDetail: async (args) => {\n        const { apiClientProps, query } = args;\n\n        const res = await ssApiClient(apiClientProps).getAlbum({\n            query: {\n                id: query.id,\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to get album detail');\n        }\n\n        return ssNormalize.album(\n            res.body.album,\n            apiClientProps.server,\n            args.context?.pathReplace,\n            args.context?.pathReplaceWith,\n        );\n    },\n    getAlbumList: async (args) => {\n        const { apiClientProps, query } = args;\n\n        if (query.searchTerm) {\n            const res = await ssApiClient(apiClientProps).search3({\n                query: {\n                    albumCount: query.limit,\n                    albumOffset: query.startIndex,\n                    artistCount: 0,\n                    artistOffset: 0,\n                    musicFolderId: getLibraryId(query.musicFolderId),\n                    query: query.searchTerm || '',\n                    songCount: 0,\n                    songOffset: 0,\n                },\n            });\n\n            if (res.status !== 200) {\n                throw new Error('Failed to get album list');\n            }\n\n            const results =\n                res.body.searchResult3?.album?.map((album) =>\n                    ssNormalize.album(\n                        album,\n                        apiClientProps.server,\n                        args.context?.pathReplace,\n                        args.context?.pathReplaceWith,\n                    ),\n                ) || [];\n\n            return {\n                items: results,\n                startIndex: query.startIndex,\n                totalRecordCount: null,\n            };\n        }\n\n        let type = ALBUM_LIST_SORT_MAPPING[query.sortBy] ?? AlbumListSortType.ALPHABETICAL_BY_NAME;\n\n        if (query.artistIds) {\n            const promises: Promise<ServerInferResponses<typeof contract.getArtist>>[] = [];\n\n            for (const artistId of query.artistIds) {\n                promises.push(\n                    ssApiClient(apiClientProps).getArtist({\n                        query: {\n                            id: artistId,\n                        },\n                    }),\n                );\n            }\n\n            const artistResult = await Promise.all(promises);\n\n            const albums = artistResult.flatMap((artist) => {\n                if (artist.status !== 200) {\n                    return [];\n                }\n\n                return artist.body.artist.album ?? [];\n            });\n\n            const items = albums.map((album) =>\n                ssNormalize.album(\n                    album,\n                    apiClientProps.server,\n                    args.context?.pathReplace,\n                    args.context?.pathReplaceWith,\n                ),\n            );\n\n            return {\n                items: sortAlbumList(items, query.sortBy, query.sortOrder),\n                startIndex: 0,\n                totalRecordCount: albums.length,\n            };\n        }\n\n        if (query.favorite) {\n            const res = await ssApiClient(apiClientProps).getStarred({\n                query: {\n                    musicFolderId: getLibraryId(query.musicFolderId),\n                },\n            });\n\n            if (res.status !== 200) {\n                throw new Error('Failed to get album list');\n            }\n\n            const allResults =\n                res.body.starred?.album?.map((album) =>\n                    ssNormalize.album(\n                        album,\n                        apiClientProps.server,\n                        args.context?.pathReplace,\n                        args.context?.pathReplaceWith,\n                    ),\n                ) || [];\n\n            return sortAndPaginate(allResults, {\n                limit: query.limit,\n                sortBy: query.sortBy,\n                sortFn: sortAlbumList,\n                sortOrder: query.sortOrder,\n                startIndex: query.startIndex,\n            });\n        }\n\n        if (query.genreIds?.length) {\n            type = AlbumListSortType.BY_GENRE;\n        }\n\n        if (query.minYear || query.maxYear) {\n            type = AlbumListSortType.BY_YEAR;\n        }\n\n        let fromYear: number | undefined;\n        let toYear: number | undefined;\n\n        if (query.minYear) {\n            fromYear = query.minYear;\n            toYear = dayjs().year();\n        }\n\n        if (query.maxYear) {\n            toYear = query.maxYear;\n\n            if (!query.minYear) {\n                fromYear = 0;\n            }\n        }\n\n        if (type === AlbumListSortType.BY_YEAR && !fromYear && !toYear) {\n            if (query.sortOrder === SortOrder.ASC) {\n                fromYear = 0;\n                toYear = dayjs().year();\n            } else {\n                fromYear = dayjs().year();\n                toYear = 0;\n            }\n        }\n\n        const res = await ssApiClient(apiClientProps).getAlbumList2({\n            query: {\n                fromYear,\n                genre: query.genreIds?.length ? query.genreIds[0] : undefined,\n                musicFolderId: getLibraryId(query.musicFolderId),\n                offset: query.startIndex,\n                size: query.limit,\n                toYear,\n                type,\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to get album list');\n        }\n\n        return {\n            items:\n                res.body.albumList2.album?.map((album) =>\n                    ssNormalize.album(\n                        album,\n                        apiClientProps.server,\n                        args.context?.pathReplace,\n                        args.context?.pathReplaceWith,\n                    ),\n                ) || [],\n            startIndex: query.startIndex,\n            totalRecordCount: null,\n        };\n    },\n    getAlbumListCount: async (args) => {\n        const { apiClientProps, query } = args;\n\n        if (query.searchTerm) {\n            let fetchNextPage = true;\n            let startIndex = 0;\n            let totalRecordCount = 0;\n\n            while (fetchNextPage) {\n                const res = await ssApiClient(apiClientProps).search3({\n                    query: {\n                        albumCount: MAX_SUBSONIC_ITEMS,\n                        albumOffset: startIndex,\n                        artistCount: 0,\n                        artistOffset: 0,\n                        musicFolderId: getLibraryId(query.musicFolderId),\n                        query: query.searchTerm || '',\n                        songCount: 0,\n                        songOffset: 0,\n                    },\n                });\n\n                if (res.status !== 200) {\n                    throw new Error('Failed to get album list count');\n                }\n\n                const albumCount = (res.body.searchResult3?.album || [])?.length;\n\n                totalRecordCount += albumCount;\n                startIndex += albumCount;\n\n                fetchNextPage = albumCount === MAX_SUBSONIC_ITEMS;\n            }\n\n            return totalRecordCount;\n        }\n\n        if (query.artistIds) {\n            const promises: Promise<ServerInferResponses<typeof contract.getArtist>>[] = [];\n\n            for (const artistId of query.artistIds) {\n                promises.push(\n                    ssApiClient(apiClientProps).getArtist({\n                        query: {\n                            id: artistId,\n                        },\n                    }),\n                );\n            }\n\n            const artistResult = await Promise.all(promises);\n\n            const albums = artistResult.reduce((total: number, artist) => {\n                if (artist.status !== 200) {\n                    return 0;\n                }\n\n                const length = artist.body.artist.album?.length ?? 0;\n                return length + total;\n            }, 0);\n\n            return albums;\n        }\n\n        if (query.favorite) {\n            const res = await ssApiClient(apiClientProps).getStarred({\n                query: {\n                    musicFolderId: getLibraryId(query.musicFolderId),\n                },\n            });\n\n            if (res.status !== 200) {\n                throw new Error('Failed to get album list');\n            }\n\n            return (res.body.starred?.album || []).length || 0;\n        }\n\n        let type = AlbumListSortType.ALPHABETICAL_BY_NAME;\n\n        let fetchNextPage = true;\n        let startIndex = 0;\n        let totalRecordCount = 0;\n\n        if (query.genreIds?.length) {\n            type = AlbumListSortType.BY_GENRE;\n        }\n\n        if (query.minYear || query.maxYear) {\n            type = AlbumListSortType.BY_YEAR;\n        }\n\n        let fromYear: number | undefined;\n        let toYear: number | undefined;\n\n        if (query.minYear) {\n            fromYear = query.minYear;\n            toYear = dayjs().year();\n        }\n\n        if (query.maxYear) {\n            toYear = query.maxYear;\n\n            if (!query.minYear) {\n                fromYear = 0;\n            }\n        }\n\n        while (fetchNextPage) {\n            const res = await ssApiClient(apiClientProps).getAlbumList2({\n                query: {\n                    fromYear,\n                    genre: query.genreIds?.length ? query.genreIds[0] : undefined,\n                    musicFolderId: getLibraryId(query.musicFolderId),\n                    offset: startIndex,\n                    size: MAX_SUBSONIC_ITEMS,\n                    toYear,\n                    type,\n                },\n            });\n\n            const headers = res.headers;\n\n            // Navidrome returns the total count in the header\n            if (headers.get('x-total-count')) {\n                fetchNextPage = false;\n                totalRecordCount = Number(headers.get('x-total-count'));\n                break;\n            }\n\n            if (res.status !== 200) {\n                throw new Error('Failed to get album list count');\n            }\n\n            const albumCount = res.body.albumList2.album.length;\n\n            totalRecordCount += albumCount;\n            startIndex += albumCount;\n\n            fetchNextPage = albumCount === MAX_SUBSONIC_ITEMS;\n        }\n\n        return totalRecordCount;\n    },\n    getAlbumRadio: async (args) => {\n        const { apiClientProps, context, query } = args;\n\n        const res = await ssApiClient(apiClientProps).getSimilarSongs({\n            query: {\n                count: query.count,\n                id: query.albumId,\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to get album radio songs');\n        }\n\n        if (!res.body.similarSongs?.song) {\n            return [];\n        }\n\n        return res.body.similarSongs.song.map((song) =>\n            ssNormalize.song(\n                song,\n                apiClientProps.server,\n                context?.pathReplace,\n                context?.pathReplaceWith,\n            ),\n        );\n    },\n    getArtistList: async (args) => {\n        const { apiClientProps, query } = args;\n\n        const res = await ssApiClient(apiClientProps).getArtists({\n            query: {\n                musicFolderId: getLibraryId(query.musicFolderId),\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to get artist list');\n        }\n\n        let artists = (res.body.artists?.index || []).flatMap((index) => index.artist);\n        if (query.role) {\n            artists = artists.filter(\n                (artist) => !artist.roles || artist.roles.includes(query.role!),\n            );\n        }\n\n        let results = artists.map((artist) =>\n            ssNormalize.albumArtist(artist, apiClientProps.server),\n        );\n\n        if (query.searchTerm) {\n            const searchResults = filter(results, (artist) => {\n                return artist.name.toLowerCase().includes(query.searchTerm!.toLowerCase());\n            });\n\n            results = searchResults;\n        }\n\n        return sortAndPaginate(results, {\n            limit: query.limit,\n            sortBy: query.sortBy,\n            sortFn: query.sortBy ? sortAlbumArtistList : undefined,\n            sortOrder: query.sortOrder,\n            startIndex: query.startIndex,\n        });\n    },\n    getArtistListCount: async (args) =>\n        SubsonicController.getArtistList({\n            ...args,\n            context: args.context,\n            query: { ...args.query, startIndex: 0 },\n        }).then((res) => res!.totalRecordCount!),\n    getArtistRadio: async (args) => {\n        const { apiClientProps, context, query } = args;\n\n        const res = await ssApiClient(apiClientProps).getSimilarSongs2({\n            query: {\n                count: query.count,\n                id: query.artistId,\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to get artist radio songs');\n        }\n\n        if (!res.body.similarSongs2?.song) {\n            return [];\n        }\n\n        return res.body.similarSongs2.song.map((song) =>\n            ssNormalize.song(\n                song,\n                apiClientProps.server,\n                context?.pathReplace,\n                context?.pathReplaceWith,\n            ),\n        );\n    },\n    getDownloadUrl: (args) => {\n        const { apiClientProps, query } = args;\n\n        return (\n            `${apiClientProps.server?.url}/rest/download.view` +\n            `?id=${query.id}` +\n            `&${apiClientProps.server?.credential}` +\n            '&v=1.13.0' +\n            '&c=Feishin'\n        );\n    },\n    getFolder: async ({ apiClientProps, context, query }) => {\n        const sortOrder = (query.sortOrder?.toLowerCase() ?? 'asc') as 'asc' | 'desc';\n\n        const isRootFolderId = query.id === '0';\n\n        if (isRootFolderId) {\n            const res = await ssApiClient(apiClientProps).getIndexes({\n                query: {\n                    musicFolderId: getLibraryId(query.musicFolderId),\n                },\n            });\n\n            if (res.status !== 200) {\n                throw new Error(`Failed to get folder list: ${JSON.stringify(res.body)}`);\n            }\n\n            let items =\n                res.body.indexes?.index?.flatMap((idx) =>\n                    idx.artist.map((artist) => ({\n                        artist: artist.name,\n                        coverArt: artist.coverArt,\n                        id: artist.id.toString(),\n                        isDir: true,\n                        title: artist.name,\n                    })),\n                ) || [];\n\n            if (query.searchTerm) {\n                items = filter(items, (item) => {\n                    return item.title.toLowerCase().includes(query.searchTerm!.toLowerCase());\n                });\n            }\n\n            let folders = items.map((item) =>\n                ssNormalize.folder(\n                    item,\n                    apiClientProps.server,\n                    context?.pathReplace,\n                    context?.pathReplaceWith,\n                ),\n            );\n\n            folders = orderBy(folders, [(v) => v.name.toLowerCase()], [sortOrder]);\n\n            return {\n                _itemType: LibraryItem.FOLDER,\n                _serverId: apiClientProps.server?.id || 'unknown',\n                _serverType: ServerType.SUBSONIC,\n                children: {\n                    folders,\n                    songs: [],\n                },\n                id: query.id,\n                name: '~',\n                parentId: undefined,\n            };\n        }\n\n        const directoryRes = await ssApiClient(apiClientProps).getMusicDirectory({\n            query: {\n                id: query.id,\n            },\n        });\n\n        if (directoryRes.status !== 200) {\n            throw new Error('Failed to get folder');\n        }\n\n        const folder = ssNormalize.folder(\n            directoryRes.body.directory,\n            apiClientProps.server,\n            context?.pathReplace,\n            context?.pathReplaceWith,\n        );\n\n        let filteredFolders = folder.children?.folders || [];\n        let filteredSongs = folder.children?.songs || [];\n\n        if (query.searchTerm) {\n            const searchTermLower = query.searchTerm.toLowerCase();\n            filteredFolders = filter(filteredFolders, (f) =>\n                f.name.toLowerCase().includes(searchTermLower),\n            );\n            filteredSongs = filter(filteredSongs, (s) => {\n                const name = s.name?.toLowerCase() || '';\n                const album = s.album?.toLowerCase() || '';\n                const artist = s.artistName?.toLowerCase() || '';\n                return (\n                    name.includes(searchTermLower) ||\n                    album.includes(searchTermLower) ||\n                    artist.includes(searchTermLower)\n                );\n            });\n        }\n\n        filteredFolders = orderBy(filteredFolders, [(v) => v.name.toLowerCase()], [sortOrder]);\n\n        if (filteredSongs.length > 0) {\n            filteredSongs = sortSongList(\n                filteredSongs,\n                query.sortBy || SongListSort.NAME,\n                query.sortOrder || SortOrder.ASC,\n            );\n        }\n\n        return {\n            ...folder,\n            children: {\n                folders: filteredFolders,\n                songs: filteredSongs,\n            },\n        };\n    },\n    getGenreList: async ({ apiClientProps, query }) => {\n        const sortOrder = (query.sortOrder?.toLowerCase() ?? 'asc') as 'asc' | 'desc';\n\n        const res = await ssApiClient(apiClientProps).getGenres({});\n\n        if (res.status !== 200) {\n            throw new Error('Failed to get genre list');\n        }\n\n        let results = res.body.genres?.genre || [];\n\n        if (query.searchTerm) {\n            const searchResults = filter(results, (genre) =>\n                genre.value.toLowerCase().includes(query.searchTerm!.toLowerCase()),\n            );\n\n            results = searchResults;\n        }\n\n        switch (query.sortBy) {\n            case GenreListSort.NAME:\n                results = orderBy(results, [(v) => v.value.toLowerCase()], [sortOrder]);\n                break;\n            default:\n                break;\n        }\n\n        const genres = results.map((genre) => ssNormalize.genre(genre, apiClientProps.server));\n\n        return sortAndPaginate(genres, {\n            limit: query.limit,\n            startIndex: query.startIndex,\n        });\n    },\n    getImageRequest: getSubsonicImageRequest,\n    getImageUrl: (args) => getSubsonicImageRequest(args)?.url || null,\n    getInternetRadioStations: async (args) => {\n        const { apiClientProps } = args;\n\n        const res = await ssApiClient(apiClientProps).getInternetRadioStations();\n\n        if (res.status !== 200) {\n            throw new Error('Failed to get internet radio stations');\n        }\n\n        const stations = res.body.internetRadioStations?.internetRadioStation || [];\n\n        return stations.map((station) => ssNormalize.internetRadioStation(station));\n    },\n    getMusicFolderList: async (args) => {\n        const { apiClientProps } = args;\n\n        const res = await ssApiClient(apiClientProps).getMusicFolderList({});\n\n        if (res.status !== 200) {\n            throw new Error('Failed to get music folder list');\n        }\n\n        return {\n            items: res.body.musicFolders.musicFolder.map((folder) => ({\n                id: folder.id.toString(),\n                name: folder.name,\n            })),\n            startIndex: 0,\n            totalRecordCount: res.body.musicFolders.musicFolder.length,\n        };\n    },\n\n    getPlaylistDetail: async (args) => {\n        const { apiClientProps, query } = args;\n\n        const res = await ssApiClient(apiClientProps).getPlaylist({\n            query: {\n                id: query.id,\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to get playlist detail');\n        }\n\n        return ssNormalize.playlist(res.body.playlist, apiClientProps.server);\n    },\n    getPlaylistList: async ({ apiClientProps, query }) => {\n        const sortOrder = query.sortOrder.toLowerCase() as 'asc' | 'desc';\n\n        const res = await ssApiClient(apiClientProps).getPlaylists({});\n\n        if (res.status !== 200) {\n            throw new Error('Failed to get playlist list');\n        }\n\n        let results = res.body.playlists?.playlist || [];\n\n        if (query.searchTerm) {\n            const searchResults = filter(results, (playlist) => {\n                return playlist.name.toLowerCase().includes(query.searchTerm!.toLowerCase());\n            });\n\n            results = searchResults;\n        }\n\n        switch (query.sortBy) {\n            case PlaylistListSort.DURATION:\n                results = orderBy(results, ['duration'], [sortOrder]);\n                break;\n            case PlaylistListSort.NAME:\n                results = orderBy(results, [(v) => v.name?.toLowerCase()], [sortOrder]);\n                break;\n            case PlaylistListSort.OWNER:\n                results = orderBy(results, [(v) => v.owner?.toLowerCase()], [sortOrder]);\n                break;\n            case PlaylistListSort.PUBLIC:\n                results = orderBy(results, ['public'], [sortOrder]);\n                break;\n            case PlaylistListSort.SONG_COUNT:\n                results = orderBy(results, ['songCount'], [sortOrder]);\n                break;\n            case PlaylistListSort.UPDATED_AT:\n                results = orderBy(results, ['changed'], [sortOrder]);\n                break;\n            default:\n                break;\n        }\n\n        const playlists = results.map((playlist) =>\n            ssNormalize.playlist(playlist, apiClientProps.server),\n        );\n\n        return sortAndPaginate(playlists, {\n            limit: query.limit,\n            startIndex: query.startIndex,\n        });\n    },\n    getPlaylistListCount: async ({ apiClientProps, query }) => {\n        const res = await ssApiClient(apiClientProps).getPlaylists({});\n\n        if (res.status !== 200) {\n            throw new Error('Failed to get playlist list');\n        }\n\n        let results = res.body.playlists?.playlist || [];\n\n        if (query.searchTerm) {\n            const searchResults = filter(results, (playlist) => {\n                return playlist.name.toLowerCase().includes(query.searchTerm!.toLowerCase());\n            });\n\n            results = searchResults;\n        }\n\n        return results.length;\n    },\n    getPlaylistSongList: async ({ apiClientProps, context, query }) => {\n        const res = await ssApiClient(apiClientProps).getPlaylist({\n            query: {\n                id: query.id,\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to get playlist song list');\n        }\n\n        const items =\n            res.body.playlist.entry?.map((song, index) =>\n                ssNormalize.song(\n                    song,\n                    apiClientProps.server,\n                    context?.pathReplace,\n                    context?.pathReplaceWith,\n                    index,\n                ),\n            ) || [];\n\n        return {\n            items,\n            startIndex: 0,\n            totalRecordCount: items.length,\n        };\n    },\n    getPlayQueue: async ({ apiClientProps, context }) => {\n        if (hasFeature(apiClientProps.server, ServerFeature.SERVER_PLAY_QUEUE)) {\n            const res = await ssApiClient(apiClientProps).getPlayQueueByIndex();\n\n            if (res.status !== 200) {\n                throw new Error('Failed to get play queue');\n            }\n\n            const { changed, changedBy, currentIndex, entry, position, username } =\n                res.body.playQueueByIndex || {}; // if there is no queue saved, playQueueByIndex may be undefined from a bug in Navidrome\n\n            return {\n                changed: changed ?? '',\n                changedBy: changedBy ?? '',\n                currentIndex: currentIndex ?? 0,\n                entry:\n                    entry?.map((song) =>\n                        ssNormalize.song(\n                            song,\n                            apiClientProps.server,\n                            context?.pathReplace,\n                            context?.pathReplaceWith,\n                        ),\n                    ) || [],\n                positionMs: position ?? 0,\n                username: username ?? '',\n            };\n        } else {\n            const res = await ssApiClient(apiClientProps).getPlayQueue();\n\n            if (res.status !== 200) {\n                throw new Error('Failed to get play queue');\n            }\n\n            const { changed, changedBy, current, entry, position, username } = res.body.playQueue;\n\n            return {\n                changed,\n                changedBy,\n                currentIndex: current ? entry.findIndex((item) => item.id === current) : 0,\n                entry:\n                    entry?.map((song) =>\n                        ssNormalize.song(\n                            song,\n                            apiClientProps.server,\n                            context?.pathReplace,\n                            context?.pathReplaceWith,\n                        ),\n                    ) || [],\n                positionMs: position ?? 0,\n                username,\n            };\n        }\n    },\n    getRandomSongList: async (args) => {\n        const { apiClientProps, context, query } = args;\n\n        const res = await ssApiClient(apiClientProps).getRandomSongList({\n            query: {\n                fromYear: query.minYear,\n                genre: query.genre,\n                musicFolderId: getLibraryId(query.musicFolderId),\n                size: query.limit,\n                toYear: query.maxYear,\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to get random songs');\n        }\n\n        const results = res.body.randomSongs?.song || [];\n        const normalizedResults = results.map((song) =>\n            ssNormalize.song(\n                song,\n                apiClientProps.server,\n                context?.pathReplace,\n                context?.pathReplaceWith,\n            ),\n        );\n\n        return {\n            items: normalizedResults,\n            startIndex: 0,\n            totalRecordCount: normalizedResults.length,\n        };\n    },\n    getRoles: async (args) => {\n        const { apiClientProps } = args;\n\n        const res = await ssApiClient(apiClientProps).getArtists({});\n\n        if (res.status !== 200) {\n            throw new Error('Failed to get artist list');\n        }\n\n        const roles = new Set<string>();\n\n        for (const index of res.body.artists?.index || []) {\n            for (const artist of index.artist) {\n                for (const role of artist.roles || []) {\n                    roles.add(role);\n                }\n            }\n        }\n\n        const final: Array<string | { label: string; value: string }> = Array.from(roles).sort();\n        // Always add 'all artist' filter, even if there are no other roles\n        // This is relevant when switching from a server which has roles to one with\n        // no roles.\n        final.splice(0, 0, { label: 'all artists', value: '' });\n        return final;\n    },\n    getServerInfo: async (args) => {\n        const { apiClientProps } = args;\n\n        const ping = await ssApiClient(apiClientProps).ping();\n\n        if (ping.status !== 200) {\n            throw new Error('Failed to ping server');\n        }\n\n        const features: ServerFeatures = {};\n\n        if (!ping.body.openSubsonic || !ping.body.serverVersion) {\n            return { features, version: ping.body.version };\n        }\n\n        const res = await ssApiClient(apiClientProps).getServerInfo();\n\n        if (res.status !== 200) {\n            throw new Error('Failed to get server extensions');\n        }\n\n        const subsonicFeatures: Record<string, number[]> = {};\n        if (Array.isArray(res.body.openSubsonicExtensions)) {\n            for (const extension of res.body.openSubsonicExtensions) {\n                subsonicFeatures[extension.name] = extension.versions;\n            }\n        }\n\n        if (subsonicFeatures[SubsonicExtensions.SONG_LYRICS]) {\n            features.lyricsMultipleStructured = [1];\n        }\n\n        if (subsonicFeatures[SubsonicExtensions.FORM_POST]) {\n            features.osFormPost = [1];\n        }\n\n        if (subsonicFeatures[SubsonicExtensions.INDEX_BASED_QUEUE]) {\n            features.serverPlayQueue = [1];\n        }\n\n        return { features, id: apiClientProps.server?.id, version: ping.body.serverVersion };\n    },\n    getSimilarSongs: async (args) => {\n        const { apiClientProps, context, query } = args;\n\n        const res = await ssApiClient(apiClientProps).getSimilarSongs({\n            query: {\n                count: query.count,\n                id: query.songId,\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to get similar songs');\n        }\n\n        if (!res.body.similarSongs?.song) {\n            return [];\n        }\n\n        return res.body.similarSongs.song.reduce<Song[]>((acc, song) => {\n            if (song.id !== query.songId) {\n                acc.push(\n                    ssNormalize.song(\n                        song,\n                        apiClientProps.server,\n                        context?.pathReplace,\n                        context?.pathReplaceWith,\n                    ),\n                );\n            }\n\n            return acc;\n        }, []);\n    },\n    getSongDetail: async (args) => {\n        const { apiClientProps, context, query } = args;\n\n        const res = await ssApiClient(apiClientProps).getSong({\n            query: {\n                id: query.id,\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to get song detail');\n        }\n\n        return ssNormalize.song(\n            res.body.song,\n            apiClientProps.server,\n            context?.pathReplace,\n            context?.pathReplaceWith,\n        );\n    },\n    getSongList: async ({ apiClientProps, context, query }) => {\n        const fromAlbumPromises: Promise<ServerInferResponses<typeof contract.getAlbum>>[] = [];\n        const artistDetailPromises: Promise<ServerInferResponses<typeof contract.getArtist>>[] = [];\n\n        if (query.searchTerm) {\n            const res = await ssApiClient(apiClientProps).search3({\n                query: {\n                    albumCount: 0,\n                    albumOffset: 0,\n                    artistCount: 0,\n                    artistOffset: 0,\n                    musicFolderId: getLibraryId(query.musicFolderId),\n                    query: query.searchTerm || '',\n                    songCount: query.limit,\n                    songOffset: query.startIndex,\n                },\n            });\n\n            if (res.status !== 200) {\n                throw new Error('Failed to get song list');\n            }\n\n            return {\n                items:\n                    res.body.searchResult3?.song?.map((song) =>\n                        ssNormalize.song(\n                            song,\n                            apiClientProps.server,\n                            context?.pathReplace,\n                            context?.pathReplaceWith,\n                        ),\n                    ) || [],\n                startIndex: query.startIndex,\n                totalRecordCount: null,\n            };\n        }\n\n        if (query.genreIds) {\n            const res = await ssApiClient(apiClientProps).getSongsByGenre({\n                query: {\n                    count: query.limit,\n                    genre: query.genreIds[0],\n                    musicFolderId: getLibraryId(query.musicFolderId),\n                    offset: query.startIndex,\n                },\n            });\n\n            if (res.status !== 200) {\n                throw new Error('Failed to get song list');\n            }\n\n            const results = res.body.songsByGenre?.song || [];\n\n            return {\n                items:\n                    results.map((song) =>\n                        ssNormalize.song(\n                            song,\n                            apiClientProps.server,\n                            context?.pathReplace,\n                            context?.pathReplaceWith,\n                        ),\n                    ) || [],\n                startIndex: 0,\n                totalRecordCount: null,\n            };\n        }\n\n        if (query.favorite) {\n            const res = await ssApiClient(apiClientProps).getStarred({\n                query: {\n                    musicFolderId: getLibraryId(query.musicFolderId),\n                },\n            });\n\n            if (res.status !== 200) {\n                throw new Error('Failed to get song list');\n            }\n\n            let allResults =\n                (res.body.starred?.song || []).map((song) =>\n                    ssNormalize.song(\n                        song,\n                        apiClientProps.server,\n                        context?.pathReplace,\n                        context?.pathReplaceWith,\n                    ),\n                ) || [];\n\n            const filterArtistIds = query.albumArtistIds || query.artistIds;\n\n            if (filterArtistIds?.length) {\n                const idSet = new Set(filterArtistIds);\n                allResults = allResults.filter((song) =>\n                    song.albumArtists?.some((aa) => idSet.has(aa.id)),\n                );\n            }\n\n            return sortAndPaginate(allResults, {\n                limit: query.limit,\n                sortBy: query.sortBy,\n                sortFn: sortSongList,\n                sortOrder: query.sortOrder,\n                startIndex: query.startIndex,\n            });\n        }\n\n        const artistIds = query.albumArtistIds || query.artistIds;\n\n        if (query.albumIds || artistIds) {\n            if (query.albumIds) {\n                for (const albumId of query.albumIds) {\n                    fromAlbumPromises.push(\n                        ssApiClient(apiClientProps).getAlbum({\n                            query: {\n                                id: albumId,\n                            },\n                        }),\n                    );\n                }\n            }\n\n            if (artistIds) {\n                for (const artistId of artistIds) {\n                    artistDetailPromises.push(\n                        ssApiClient(apiClientProps).getArtist({\n                            query: {\n                                id: artistId,\n                            },\n                        }),\n                    );\n                }\n\n                const artistResult = await Promise.all(artistDetailPromises);\n\n                const albums = artistResult.flatMap((artist) => {\n                    if (artist.status !== 200) {\n                        return [];\n                    }\n\n                    return artist.body.artist.album ?? [];\n                });\n\n                const albumIds = albums.map((album) => album.id);\n\n                for (const albumId of albumIds) {\n                    fromAlbumPromises.push(\n                        ssApiClient(apiClientProps).getAlbum({\n                            query: {\n                                id: albumId.toString(),\n                            },\n                        }),\n                    );\n                }\n            }\n\n            let results: z.infer<typeof ssType._response.song>[] = [];\n\n            if (fromAlbumPromises) {\n                const albumsResult = await Promise.all(fromAlbumPromises);\n\n                results = albumsResult.flatMap((album) => {\n                    if (album.status !== 200) {\n                        return [];\n                    }\n\n                    return album.body.album.song;\n                });\n            }\n\n            return {\n                items:\n                    results.map((song) =>\n                        ssNormalize.song(\n                            song,\n                            apiClientProps.server,\n                            context?.pathReplace,\n                            context?.pathReplaceWith,\n                        ),\n                    ) || [],\n                startIndex: 0,\n                totalRecordCount: results.length,\n            };\n        }\n\n        const res = await ssApiClient(apiClientProps).search3({\n            query: {\n                albumCount: 0,\n                albumOffset: 0,\n                artistCount: 0,\n                artistOffset: 0,\n                musicFolderId: getLibraryId(query.musicFolderId),\n                query: query.searchTerm || '',\n                songCount: query.limit,\n                songOffset: query.startIndex,\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to get song list');\n        }\n\n        return {\n            items:\n                res.body.searchResult3?.song?.map((song) =>\n                    ssNormalize.song(\n                        song,\n                        apiClientProps.server,\n                        context?.pathReplace,\n                        context?.pathReplaceWith,\n                    ),\n                ) || [],\n            startIndex: 0,\n            totalRecordCount: null,\n        };\n    },\n    getSongListCount: async (args) => {\n        const { apiClientProps, query } = args;\n\n        let fetchNextPage = true;\n        let startIndex = 0;\n\n        let fetchNextSection = true;\n        let sectionIndex = 0;\n\n        if (query.searchTerm) {\n            let fetchNextPage = true;\n            let startIndex = 0;\n            let totalRecordCount = 0;\n\n            while (fetchNextPage) {\n                const res = await ssApiClient(apiClientProps).search3({\n                    query: {\n                        albumCount: 0,\n                        albumOffset: 0,\n                        artistCount: 0,\n                        artistOffset: 0,\n                        musicFolderId: getLibraryId(query.musicFolderId),\n                        query: query.searchTerm || '',\n                        songCount: MAX_SUBSONIC_ITEMS,\n                        songOffset: startIndex,\n                    },\n                });\n\n                if (res.status !== 200) {\n                    throw new Error('Failed to get song list count');\n                }\n\n                const songCount = (res.body.searchResult3?.song || []).length || 0;\n\n                totalRecordCount += songCount;\n                startIndex += songCount;\n\n                fetchNextPage = songCount === MAX_SUBSONIC_ITEMS;\n            }\n\n            return totalRecordCount;\n        }\n\n        if (query.genreIds) {\n            let totalRecordCount = 0;\n\n            // Rather than just do `getSongsByGenre` by groups of 500, instead\n            // jump the offset 10x, and then backtrack on the last chunk. This improves\n            // performance for extremely large libraries\n            while (fetchNextSection) {\n                const res = await ssApiClient(apiClientProps).getSongsByGenre({\n                    query: {\n                        count: 1,\n                        genre: query.genreIds[0],\n                        musicFolderId: getLibraryId(query.musicFolderId),\n                        offset: sectionIndex,\n                    },\n                });\n\n                if (res.status !== 200) {\n                    throw new Error('Failed to get song list count');\n                }\n\n                const numberOfResults = (res.body.songsByGenre?.song || []).length || 0;\n\n                if (numberOfResults !== 1) {\n                    fetchNextSection = false;\n                    startIndex = sectionIndex === 0 ? 0 : sectionIndex - SUBSONIC_FAST_BATCH_SIZE;\n                    break;\n                } else {\n                    sectionIndex += SUBSONIC_FAST_BATCH_SIZE;\n                }\n            }\n\n            while (fetchNextPage) {\n                const res = await ssApiClient(apiClientProps).getSongsByGenre({\n                    query: {\n                        count: MAX_SUBSONIC_ITEMS,\n                        genre: query.genreIds[0],\n                        musicFolderId: getLibraryId(query.musicFolderId),\n                        offset: startIndex,\n                    },\n                });\n\n                if (res.status !== 200) {\n                    throw new Error('Failed to get song list count');\n                }\n\n                const numberOfResults = (res.body.songsByGenre?.song || []).length || 0;\n\n                totalRecordCount = startIndex + numberOfResults;\n                startIndex += numberOfResults;\n\n                fetchNextPage = numberOfResults === MAX_SUBSONIC_ITEMS;\n            }\n\n            return totalRecordCount;\n        }\n\n        if (query.favorite) {\n            const res = await ssApiClient(apiClientProps).getStarred({\n                query: {\n                    musicFolderId: getLibraryId(query.musicFolderId),\n                },\n            });\n\n            if (res.status !== 200) {\n                throw new Error('Failed to get song list');\n            }\n\n            return (res.body.starred?.song || []).length || 0;\n        }\n\n        const artistIds = query.albumArtistIds || query.artistIds;\n\n        if (query.albumIds || artistIds) {\n            const fromAlbumPromises: Promise<ServerInferResponses<typeof contract.getAlbum>>[] = [];\n            const artistDetailPromises: Promise<ServerInferResponses<typeof contract.getArtist>>[] =\n                [];\n\n            if (query.albumIds) {\n                for (const albumId of query.albumIds) {\n                    fromAlbumPromises.push(\n                        ssApiClient(apiClientProps).getAlbum({\n                            query: {\n                                id: albumId,\n                            },\n                        }),\n                    );\n                }\n            }\n\n            if (artistIds) {\n                for (const artistId of artistIds) {\n                    artistDetailPromises.push(\n                        ssApiClient(apiClientProps).getArtist({\n                            query: {\n                                id: artistId,\n                            },\n                        }),\n                    );\n                }\n\n                const artistResult = await Promise.all(artistDetailPromises);\n\n                const albums = artistResult.flatMap((artist) => {\n                    if (artist.status !== 200) {\n                        return [];\n                    }\n\n                    return artist.body.artist.album ?? [];\n                });\n\n                const albumIds = albums.map((album) => album.id);\n\n                for (const albumId of albumIds) {\n                    fromAlbumPromises.push(\n                        ssApiClient(apiClientProps).getAlbum({\n                            query: {\n                                id: albumId.toString(),\n                            },\n                        }),\n                    );\n                }\n            }\n\n            let results: z.infer<typeof ssType._response.song>[] = [];\n\n            if (fromAlbumPromises.length > 0) {\n                const albumsResult = await Promise.all(fromAlbumPromises);\n\n                results = albumsResult.flatMap((album) => {\n                    if (album.status !== 200) {\n                        return [];\n                    }\n\n                    return album.body.album.song;\n                });\n            }\n\n            return results.length;\n        }\n\n        let totalRecordCount = 0;\n\n        // Rather than just do `search3` by groups of 500, instead\n        // jump the offset 10x, and then backtrack on the last chunk. This improves\n        // performance for extremely large libraries\n        while (fetchNextSection) {\n            const res = await ssApiClient(apiClientProps).search3({\n                query: {\n                    albumCount: 0,\n                    albumOffset: 0,\n                    artistCount: 0,\n                    artistOffset: 0,\n                    musicFolderId: getLibraryId(query.musicFolderId),\n                    query: query.searchTerm || '',\n                    songCount: 1,\n                    songOffset: sectionIndex,\n                },\n            });\n\n            if (res.status !== 200) {\n                throw new Error('Failed to get song list count');\n            }\n\n            const numberOfResults = (res.body.searchResult3?.song || []).length || 0;\n\n            if (numberOfResults !== 1) {\n                fetchNextSection = false;\n                startIndex = sectionIndex === 0 ? 0 : sectionIndex - SUBSONIC_FAST_BATCH_SIZE;\n                break;\n            } else {\n                sectionIndex += SUBSONIC_FAST_BATCH_SIZE;\n            }\n        }\n\n        while (fetchNextPage) {\n            const res = await ssApiClient(apiClientProps).search3({\n                query: {\n                    albumCount: 0,\n                    albumOffset: 0,\n                    artistCount: 0,\n                    artistOffset: 0,\n                    musicFolderId: getLibraryId(query.musicFolderId),\n                    query: query.searchTerm || '',\n                    songCount: MAX_SUBSONIC_ITEMS,\n                    songOffset: startIndex,\n                },\n            });\n\n            if (res.status !== 200) {\n                throw new Error('Failed to get song list count');\n            }\n\n            const numberOfResults = (res.body.searchResult3?.song || []).length || 0;\n\n            totalRecordCount = startIndex + numberOfResults;\n            startIndex += numberOfResults;\n\n            fetchNextPage = numberOfResults === MAX_SUBSONIC_ITEMS;\n        }\n\n        return totalRecordCount;\n    },\n    getStreamUrl: ({ apiClientProps: { server }, query }) => {\n        const { bitrate, format, id, transcode } = query;\n        let url = `${server?.url}/rest/stream.view?id=${id}&v=1.13.0&c=Feishin&${server?.credential}`;\n\n        if (transcode) {\n            if (format) {\n                url += `&format=${format}`;\n            }\n            if (bitrate !== undefined) {\n                url += `&maxBitRate=${bitrate}`;\n            }\n        }\n\n        return url;\n    },\n    getStructuredLyrics: async (args) => {\n        const { apiClientProps, query } = args;\n\n        const res = await ssApiClient(apiClientProps).getStructuredLyrics({\n            query: {\n                id: query.songId,\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to get structured lyrics');\n        }\n\n        const lyrics = res.body.lyricsList?.structuredLyrics;\n\n        if (!lyrics) {\n            return [];\n        }\n\n        return lyrics.map((lyric) => {\n            const baseLyric = {\n                artist: lyric.displayArtist || '',\n                lang: lyric.lang,\n                name: lyric.displayTitle || '',\n                remote: false,\n                source: apiClientProps.server?.name || 'music server',\n            };\n\n            if (lyric.synced) {\n                return {\n                    ...baseLyric,\n                    lyrics: lyric.line.map((line) => [line.start!, line.value]),\n                    synced: true,\n                };\n            }\n            return {\n                ...baseLyric,\n                lyrics: lyric.line.map((line) => [line.value]).join('\\n'),\n                synced: false,\n            };\n        });\n    },\n    getTopSongs: async (args) => {\n        const { apiClientProps, context, query } = args;\n\n        const type = query.type === 'personal' ? 'personal' : 'community';\n\n        if (type === 'community') {\n            const res = await ssApiClient(apiClientProps).getTopSongsList({\n                query: {\n                    artist: query.artist,\n                    count: query.limit,\n                },\n            });\n\n            if (res.status !== 200) {\n                throw new Error('Failed to get top songs');\n            }\n\n            return {\n                items: (res.body.topSongs?.song || []).map((song) =>\n                    ssNormalize.song(\n                        song,\n                        apiClientProps.server,\n                        context?.pathReplace,\n                        context?.pathReplaceWith,\n                    ),\n                ),\n                startIndex: 0,\n                totalRecordCount: res.body.topSongs?.song?.length || 0,\n            };\n        }\n\n        const res = await SubsonicController.getSongList({\n            apiClientProps,\n            query: {\n                artistIds: [query.artistId],\n                sortBy: SongListSort.PLAY_COUNT,\n                sortOrder: SortOrder.DESC,\n                startIndex: 0,\n            },\n        });\n\n        const songsWithPlayCount = orderBy(\n            res.items.filter((song) => song.playCount > 0),\n            ['playCount', 'albumId', 'trackNumber'],\n            ['desc', 'asc', 'asc'],\n        );\n\n        return {\n            items: songsWithPlayCount,\n            startIndex: 0,\n            totalRecordCount: res.totalRecordCount,\n        };\n    },\n    getUserInfo: async (args) => {\n        const { apiClientProps, query } = args;\n\n        const res = await ssApiClient(apiClientProps).getUser({\n            query: {\n                username: query.username,\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to get user info');\n        }\n\n        return {\n            id: res.body.user.username,\n            isAdmin: Boolean(res.body.user.adminRole),\n            name: res.body.user.username,\n        };\n    },\n    removeFromPlaylist: async ({ apiClientProps, query }) => {\n        const res = await ssApiClient(apiClientProps).updatePlaylist({\n            query: {\n                playlistId: query.id,\n                songIndexToRemove: query.songId,\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to add to playlist');\n        }\n\n        return null;\n    },\n    replacePlaylist: async (args) => {\n        const { apiClientProps, body, context, query } = args;\n\n        // 1. Fetch existing songs from the playlist\n        const existingSongsRes = await ssApiClient(apiClientProps).getPlaylist({\n            query: {\n                id: query.id,\n            },\n        });\n\n        if (existingSongsRes.status !== 200) {\n            throw new Error('Failed to fetch existing playlist songs');\n        }\n\n        const existingSongs =\n            existingSongsRes.body.playlist.entry?.map((song) =>\n                ssNormalize.song(\n                    song,\n                    apiClientProps.server,\n                    context?.pathReplace,\n                    context?.pathReplaceWith,\n                ),\n            ) || [];\n\n        // 2. Get playlist detail to get the name\n        const playlistDetailRes = await ssApiClient(apiClientProps).getPlaylist({\n            query: {\n                id: query.id,\n            },\n        });\n\n        if (playlistDetailRes.status !== 200) {\n            throw new Error('Failed to get playlist detail');\n        }\n\n        const playlist = ssNormalize.playlist(\n            playlistDetailRes.body.playlist,\n            apiClientProps.server,\n        );\n\n        // 3. Make a backup of the playlist ids and their order, along with the id of the playlist and name\n        const backup = {\n            id: query.id,\n            name: playlist.name,\n            songIds: existingSongs.map((song) => song.id),\n            timestamp: Date.now(),\n        };\n\n        // Store backup in IndexedDB using idb-keyval\n        const backupKey = `playlist-backup-${query.id}`;\n        await set(backupKey, backup);\n\n        // 4. Remove all songs from the playlist (Subsonic uses indices, not IDs)\n        if (existingSongs.length > 0) {\n            // Get indices of all songs (0-based)\n            // Remove in reverse order to avoid index shifting issues\n            const songIndices = existingSongs.map((_, index) => index).reverse();\n\n            const removeRes = await ssApiClient(apiClientProps).updatePlaylist({\n                query: {\n                    playlistId: query.id,\n                    songIndexToRemove: songIndices.map((index) => index.toString()),\n                },\n            });\n\n            if (removeRes.status !== 200) {\n                throw new Error('Failed to remove songs from playlist');\n            }\n        }\n\n        // 5. Add the new song ids to the playlist\n        if (body.songId.length > 0) {\n            const addRes = await ssApiClient(apiClientProps).updatePlaylist({\n                query: {\n                    playlistId: query.id,\n                    songIdToAdd: body.songId,\n                },\n            });\n\n            if (addRes.status !== 200) {\n                throw new Error('Failed to add songs to playlist');\n            }\n        }\n\n        return null;\n    },\n    savePlayQueue: async ({ apiClientProps, query }) => {\n        if (hasFeature(apiClientProps.server, ServerFeature.SERVER_PLAY_QUEUE)) {\n            const res = await ssApiClient(apiClientProps).savePlayQueueByIndex({\n                query: {\n                    currentIndex: query.currentIndex !== undefined ? query.currentIndex : undefined,\n                    id: query.songs,\n                    position: query.positionMs,\n                },\n            });\n\n            if (res.status !== 200) {\n                throw new Error('Failed to save play queue');\n            }\n        } else {\n            const res = await ssApiClient(apiClientProps).savePlayQueue({\n                query: {\n                    current:\n                        query.currentIndex !== undefined && query.currentIndex < query.songs.length\n                            ? query.songs[query.currentIndex]\n                            : undefined,\n                    id: query.songs,\n                    position: query.positionMs,\n                },\n            });\n\n            if (res.status !== 200) {\n                throw new Error('Failed to save play queue');\n            }\n        }\n    },\n    scrobble: async (args) => {\n        const { apiClientProps, query } = args;\n\n        const res = await ssApiClient(apiClientProps).scrobble({\n            query: {\n                id: query.id,\n                submission: query.submission,\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to scrobble');\n        }\n\n        return null;\n    },\n    search: async (args) => {\n        const { apiClientProps, context, query } = args;\n\n        const res = await ssApiClient(apiClientProps).search3({\n            query: {\n                albumCount: query.albumLimit,\n                albumOffset: query.albumStartIndex,\n                artistCount: query.albumArtistLimit,\n                artistOffset: query.albumArtistStartIndex,\n                musicFolderId: getLibraryId(query.musicFolderId),\n                query: query.query,\n                songCount: query.songLimit,\n                songOffset: query.songStartIndex,\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to search');\n        }\n\n        return {\n            albumArtists: (res.body.searchResult3?.artist || [])?.map((artist) =>\n                ssNormalize.albumArtist(artist, apiClientProps.server),\n            ),\n            albums: (res.body.searchResult3?.album || []).map((album) =>\n                ssNormalize.album(\n                    album,\n                    apiClientProps.server,\n                    args.context?.pathReplace,\n                    args.context?.pathReplaceWith,\n                ),\n            ),\n            songs: (res.body.searchResult3?.song || []).map((song) =>\n                ssNormalize.song(\n                    song,\n                    apiClientProps.server,\n                    context?.pathReplace,\n                    context?.pathReplaceWith,\n                ),\n            ),\n        };\n    },\n    setRating: async (args) => {\n        const { apiClientProps, query } = args;\n\n        const itemIds = query.id;\n\n        for (const id of itemIds) {\n            await ssApiClient(apiClientProps).setRating({\n                query: {\n                    id,\n                    rating: query.rating,\n                },\n            });\n        }\n\n        return null;\n    },\n    updateInternetRadioStation: async (args) => {\n        const { apiClientProps, body, query } = args;\n\n        const res = await ssApiClient(apiClientProps).updateInternetRadioStation({\n            query: {\n                homepageUrl: body.homepageUrl,\n                id: query.id,\n                name: body.name,\n                streamUrl: body.streamUrl,\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to update internet radio station');\n        }\n\n        return null;\n    },\n    updatePlaylist: async (args) => {\n        const { apiClientProps, body, query } = args;\n\n        const res = await ssApiClient(apiClientProps).updatePlaylist({\n            query: {\n                comment: body.comment,\n                name: body.name,\n                playlistId: query.id,\n                public: body.public,\n            },\n        });\n\n        if (res.status !== 200) {\n            throw new Error('Failed to add to playlist');\n        }\n\n        return null;\n    },\n};\n\nfunction getLibraryId(musicFolderId?: string | string[]) {\n    return Array.isArray(musicFolderId) ? musicFolderId[0] : musicFolderId;\n}\n"
  },
  {
    "path": "src/renderer/api/utils-list-count.ts",
    "content": "import { QueryClient } from '@tanstack/react-query';\n\nimport { getServerById } from '/@/renderer/store';\nimport { ServerType } from '/@/shared/types/domain-types';\n\ninterface OptimizedListCountOptions<TQuery, TListQuery, TResponse> {\n    client: QueryClient;\n    listQueryFn: (args: {\n        apiClientProps: { serverId: string; signal?: AbortSignal };\n        query: TListQuery;\n    }) => Promise<TResponse>;\n    listQueryKeyFn: (serverId: string, query: TListQuery) => readonly unknown[];\n    query: TQuery;\n    serverId: string;\n    signal?: AbortSignal;\n}\n\nexport const getOptimizedListCount = async <\n    TQuery,\n    TListQuery extends { limit?: number; startIndex?: number },\n    TResponse extends { totalRecordCount: null | number },\n>({\n    client,\n    listQueryFn,\n    listQueryKeyFn,\n    query,\n    serverId,\n    signal,\n}: OptimizedListCountOptions<TQuery, TListQuery, TResponse>): Promise<null | number> => {\n    const server = getServerById(serverId);\n\n    if (server?.type !== ServerType.NAVIDROME && server?.type !== ServerType.JELLYFIN) {\n        return null;\n    }\n\n    const limit =\n        typeof query === 'object' &&\n        query !== null &&\n        'limit' in query &&\n        typeof (query as any).limit === 'number' &&\n        (query as any).limit > 0\n            ? (query as any).limit\n            : 100;\n\n    // In most cases, the list count is called when entering the first page, so we fetch from the first page\n    // This optimization will only help in this case, otherwise we still need 2 requests to get both the count and the data\n    const pageQuery = {\n        ...query,\n        limit,\n        startIndex: 0,\n    } as unknown as TListQuery;\n\n    const pageQueryKey = listQueryKeyFn(serverId, pageQuery);\n    const cachedPage = client.getQueryData(pageQueryKey);\n\n    if (cachedPage && typeof cachedPage === 'object' && 'totalRecordCount' in cachedPage) {\n        return (cachedPage as TResponse).totalRecordCount ?? 0;\n    }\n\n    const pageResult = await listQueryFn({\n        apiClientProps: { serverId, signal },\n        query: pageQuery,\n    });\n\n    const keyContainsRandom = JSON.stringify(pageQueryKey).toLowerCase().includes('random');\n\n    if (!keyContainsRandom) {\n        client.setQueryData(pageQueryKey, pageResult);\n    }\n\n    return pageResult.totalRecordCount ?? 0;\n};\n"
  },
  {
    "path": "src/renderer/api/utils-music-folder.ts",
    "content": "import { ServerListItemWithCredential } from '/@/shared/types/domain-types';\n\nexport const mergeMusicFolderId = <T extends { musicFolderId?: string | string[] }>(\n    query: T,\n    server: null | ServerListItemWithCredential,\n): T => {\n    if (\n        !server ||\n        !server.musicFolderId ||\n        server.musicFolderId.length === 0 ||\n        query.musicFolderId\n    ) {\n        return query;\n    }\n\n    // Only merge if server matches and musicFolderId is not already in query\n    const musicFolderId =\n        server.musicFolderId.length === 1 ? server.musicFolderId[0] : server.musicFolderId;\n\n    return {\n        ...query,\n        musicFolderId,\n    };\n};\n"
  },
  {
    "path": "src/renderer/api/utils.ts",
    "content": "import { useAuthStore } from '/@/renderer/store';\nimport { toast } from '/@/shared/components/toast/toast';\nimport { ServerListItem } from '/@/shared/types/types';\n\nexport const authenticationFailure = (currentServer: null | ServerListItem) => {\n    toast.error({\n        message: 'Your session has expired.',\n    });\n\n    if (currentServer) {\n        const serverId = currentServer.id;\n        const token = currentServer.ndCredential;\n        console.error(`token is expired: ${token}`);\n        useAuthStore.getState().actions.updateServer(serverId, { ndCredential: undefined });\n        useAuthStore.getState().actions.setCurrentServer(null);\n    }\n};\n"
  },
  {
    "path": "src/renderer/app.tsx",
    "content": "/* eslint-disable perfectionist/sort-imports */\nimport { MantineProvider } from '@mantine/core';\nimport { Notifications } from '@mantine/notifications';\nimport 'overlayscrollbars/overlayscrollbars.css';\nimport '/styles/overlayscrollbars.css';\nimport '@mantine/core/styles.css';\nimport '@mantine/dates/styles.css';\nimport '@mantine/notifications/styles.css';\nimport isElectron from 'is-electron';\nimport { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react';\n\nimport i18n from '/@/i18n/i18n';\nimport { openSettingsModal } from '/@/renderer/features/settings/utils/open-settings-modal';\nimport { WebAudioContext } from '/@/renderer/features/player/context/webaudio-context';\nimport { useCheckForUpdates } from '/@/renderer/hooks/use-check-for-updates';\nimport { useSyncSettingsToMain } from '/@/renderer/hooks/use-sync-settings-to-main';\nimport { AppRouter } from '/@/renderer/router/app-router';\nimport { useCssSettings, useHotkeySettings, useLanguage } from '/@/renderer/store';\nimport { useAppTheme } from '/@/renderer/themes/use-app-theme';\nimport { sanitizeCss } from '/@/renderer/utils/sanitize';\nimport { WebAudio } from '/@/shared/types/types';\nimport '/@/shared/styles/global.css';\nimport { PlayerProvider } from '/@/renderer/features/player/context/player-context';\nimport { AudioPlayers } from '/@/renderer/features/player/components/audio-players';\n\nconst ReleaseNotesModal = lazy(() =>\n    import('./release-notes-modal').then((module) => ({\n        default: module.ReleaseNotesModal,\n    })),\n);\n\nconst UpdateAvailableDialog = lazy(() =>\n    import('./update-available-dialog').then((module) => ({\n        default: module.UpdateAvailableDialog,\n    })),\n);\n\nconst ipc = isElectron() ? window.api.ipc : null;\n\nexport const App = () => {\n    const { mode, theme } = useAppTheme();\n    const language = useLanguage();\n\n    const { content, enabled } = useCssSettings();\n    const { bindings } = useHotkeySettings();\n    const cssRef = useRef<HTMLStyleElement | null>(null);\n\n    useSyncSettingsToMain();\n    useCheckForUpdates();\n\n    const [webAudio, setWebAudio] = useState<WebAudio>();\n\n    useEffect(() => {\n        if (enabled && content) {\n            // Yes, CSS is sanitized here as well. Prevent a suer from changing the\n            // localStorage to bypass sanitizing.\n            const sanitized = sanitizeCss(content);\n            if (!cssRef.current) {\n                cssRef.current = document.createElement('style');\n                document.body.appendChild(cssRef.current);\n            }\n\n            cssRef.current.textContent = sanitized;\n\n            return () => {\n                cssRef.current!.textContent = '';\n            };\n        }\n\n        return () => {};\n    }, [content, enabled]);\n\n    const webAudioProvider = useMemo(() => {\n        return { setWebAudio, webAudio };\n    }, [webAudio]);\n\n    useEffect(() => {\n        if (isElectron()) {\n            ipc?.send('set-global-shortcuts', bindings);\n        }\n    }, [bindings]);\n\n    useEffect(() => {\n        if (language) {\n            i18n.changeLanguage(language);\n        }\n    }, [language]);\n\n    useEffect(() => {\n        if (isElectron()) {\n            window.api.utils.rendererOpenSettings(() => {\n                openSettingsModal();\n            });\n\n            return () => {\n                ipc?.removeAllListeners('renderer-open-settings');\n            };\n        }\n        return undefined;\n    }, []);\n\n    const notificationStyles = useMemo(\n        () => ({\n            root: {\n                marginBottom: 90,\n            },\n        }),\n        [],\n    );\n\n    return (\n        <MantineProvider forceColorScheme={mode} theme={theme}>\n            <Notifications\n                containerWidth=\"300px\"\n                position=\"bottom-center\"\n                styles={notificationStyles}\n                zIndex={50000}\n            />\n            <WebAudioContext.Provider value={webAudioProvider}>\n                <PlayerProvider>\n                    <AudioPlayers />\n                    <AppRouter />\n                </PlayerProvider>\n            </WebAudioContext.Provider>\n            <Suspense fallback={null}>\n                <ReleaseNotesModal />\n                <UpdateAvailableDialog />\n            </Suspense>\n        </MantineProvider>\n    );\n};\n"
  },
  {
    "path": "src/renderer/assets/assets.d.ts",
    "content": "type Styles = Record<string, string>;\n\ndeclare module '*.svg' {\n    const content: string;\n    export default content;\n}\n\ndeclare module '*.png' {\n    const content: string;\n    export default content;\n}\n\ndeclare module '*.jpg' {\n    const content: string;\n    export default content;\n}\n\ndeclare module '*.scss' {\n    const content: Styles;\n    export default content;\n}\n\ndeclare module '*.sass' {\n    const content: Styles;\n    export default content;\n}\n\ndeclare module '*.css' {\n    const content: Styles;\n    export default content;\n}\n"
  },
  {
    "path": "src/renderer/assets/entitlements.mac.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n  <dict>\n    <key>com.apple.security.cs.allow-unsigned-executable-memory</key>\n    <true/>\n    <key>com.apple.security.cs.allow-jit</key>\n    <true/>\n  </dict>\n</plist>\n"
  },
  {
    "path": "src/renderer/components/drag-preview/drag-preview.module.css",
    "content": ".container {\n    position: relative;\n    pointer-events: none;\n    user-select: none;\n    transform-style: preserve-3d;\n    perspective: 1000px;\n}\n\n.preview {\n    position: relative;\n    display: flex;\n    align-items: center;\n    width: 300px;\n    min-height: 80px;\n    padding: var(--theme-spacing-md);\n    color: var(--theme-colors-foreground);\n    background: var(--theme-colors-background);\n    border: 1px solid var(--theme-colors-border);\n    border-radius: var(--theme-radius-lg);\n    backdrop-filter: blur(12px) saturate(180%);\n}\n\n.content {\n    display: flex;\n    gap: var(--theme-spacing-md);\n    align-items: center;\n    width: 100%;\n}\n\n.image-container {\n    position: relative;\n    flex-shrink: 0;\n    width: 48px;\n    height: 48px;\n    overflow: hidden;\n    border-radius: var(--theme-radius-md);\n    box-shadow:\n        0 8px 16px rgb(0 0 0 / 25%),\n        0 0 0 1px rgb(255 255 255 / 5%);\n    transform: translateZ(15px) scale(1.05);\n    transition: transform 0.2s ease-out;\n}\n\n.image {\n    width: 100%;\n    height: 100%;\n    object-fit: cover;\n}\n\n.image-overlay {\n    position: absolute;\n    inset: 0;\n    pointer-events: none;\n    background: linear-gradient(135deg, rgb(255 255 255 / 10%) 0%, rgb(0 0 0 / 10%) 100%);\n}\n\n.icon-container {\n    display: flex;\n    flex-shrink: 0;\n    align-items: center;\n    justify-content: center;\n    width: 48px;\n    height: 48px;\n    background: linear-gradient(\n        135deg,\n        var(--theme-colors-surface) 0%,\n        var(--theme-colors-background) 100%\n    );\n    border: 1px solid var(--theme-colors-border);\n    border-radius: var(--theme-radius-md);\n    box-shadow:\n        0 8px 16px rgb(0 0 0 / 25%),\n        0 0 0 1px rgb(255 255 255 / 5%);\n    transform: translateZ(15px) scale(1.05);\n    transition: transform 0.2s ease-out;\n}\n\n.text-container {\n    display: flex;\n    flex: 1;\n    flex-direction: column;\n    gap: var(--theme-spacing-xs);\n    min-width: 0;\n    user-select: none;\n}\n\n.name {\n    overflow: hidden;\n    text-overflow: ellipsis;\n    font-size: var(--theme-font-size-sm);\n    font-weight: 600;\n    line-height: 1.4;\n    color: var(--theme-colors-foreground);\n    white-space: nowrap;\n}\n\n.count {\n    font-size: var(--theme-font-size-sm);\n    font-weight: 500;\n    line-height: 1.2;\n    color: var(--theme-colors-foreground-muted);\n}\n"
  },
  {
    "path": "src/renderer/components/drag-preview/drag-preview.tsx",
    "content": "import { memo } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport styles from './drag-preview.module.css';\n\nimport { useItemImageUrl } from '/@/renderer/components/item-image/item-image';\nimport { Icon } from '/@/shared/components/icon/icon';\nimport { LibraryItem } from '/@/shared/types/domain-types';\nimport { DragData } from '/@/shared/types/drag-and-drop';\n\ninterface DragPreviewProps {\n    data: DragData;\n}\n\nconst getItemName = (item: unknown): string => {\n    if (item && typeof item === 'object') {\n        if ('name' in item && typeof item.name === 'string') {\n            return item.name;\n        }\n        if ('title' in item && typeof item.title === 'string') {\n            return item.title;\n        }\n    }\n    return 'Item';\n};\n\nexport const DragPreview = memo(({ data }: DragPreviewProps) => {\n    const items = data.item || [];\n    const { t } = useTranslation();\n    const itemCount = items.length;\n    const firstItem = items[0];\n    const itemName = firstItem ? getItemName(firstItem) : 'Item';\n\n    const itemImage = useItemImageUrl({\n        id: (firstItem as { imageId: string })?.imageId,\n        itemType: data.itemType || LibraryItem.SONG,\n        type: 'table',\n    });\n\n    const isMultiple = itemCount > 1;\n\n    return (\n        <div className={styles.container}>\n            <div className={styles.preview}>\n                <div className={styles.content}>\n                    {itemImage ? (\n                        <div className={styles['image-container']}>\n                            <img alt={itemName} className={styles.image} src={itemImage} />\n                            <div className={styles['image-overlay']} />\n                        </div>\n                    ) : (\n                        <div className={styles['icon-container']}>\n                            {data.itemType === LibraryItem.ALBUM && <Icon icon=\"album\" size=\"xl\" />}\n                            {data.itemType === LibraryItem.SONG && (\n                                <Icon icon=\"itemSong\" size=\"xl\" />\n                            )}\n                            {data.itemType === LibraryItem.ARTIST && (\n                                <Icon icon=\"artist\" size=\"xl\" />\n                            )}\n                            {data.itemType === LibraryItem.PLAYLIST && (\n                                <Icon icon=\"playlist\" size=\"xl\" />\n                            )}\n                            {data.itemType === LibraryItem.GENRE && <Icon icon=\"genre\" size=\"xl\" />}\n                            {!data.itemType && <Icon icon=\"library\" size=\"xl\" />}\n                        </div>\n                    )}\n                    <div className={styles['text-container']}>\n                        <div className={styles.name}>{itemName}</div>\n                        {isMultiple && (\n                            <div className={styles.count}>\n                                +{t('common.itemsMore', { count: itemCount - 1 })}\n                            </div>\n                        )}\n                    </div>\n                </div>\n            </div>\n        </div>\n    );\n});\n\nDragPreview.displayName = 'DragPreview';\n"
  },
  {
    "path": "src/renderer/components/export-import-settings-modal/export-import-settings-modal.tsx",
    "content": "import { t } from 'i18next';\nimport { useCallback, useState } from 'react';\nimport { ZodError } from 'zod';\n\nimport { DiffVisualiser } from '/@/renderer/components/settings-diff-visualiser/settings-diff-visualiser';\nimport {\n    migrateSettings,\n    type SettingsState,\n    useSettingsForExport,\n    useSettingsStoreActions,\n    ValidationSettingsStateSchema,\n    VersionedSettings,\n} from '/@/renderer/store';\nimport { Button } from '/@/shared/components/button/button';\nimport { DragDropZone } from '/@/shared/components/drag-drop-zone/drag-drop-zone';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { Text } from '/@/shared/components/text/text';\n\nenum SCREENS {\n    FILE_PICKER,\n    DIFF_VISUALS,\n    IMPORT_COMPLETE,\n}\n\nexport const ExportImportSettingsModal = () => {\n    // eslint-disable-next-line @typescript-eslint/no-unused-vars -- Version needs to be omitted from the settings object\n    const { version, ...settings } = useSettingsForExport();\n    const { setSettings } = useSettingsStoreActions();\n\n    const [currentScreen, setCurrentScreen] = useState<SCREENS>(SCREENS.FILE_PICKER);\n    const [selectedSettingsFile, setSettingsFile] = useState<SettingsState>();\n\n    const onItemSelected = useCallback((itemContents: string) => {\n        const settingsFile = JSON.parse(itemContents) as VersionedSettings;\n        // eslint-disable-next-line @typescript-eslint/no-unused-vars -- Version needs to be omitted from the settings object\n        const { version, ...settings } = settingsFile;\n        const parsedResult = settings as SettingsState;\n        setSettingsFile(parsedResult);\n        setCurrentScreen(SCREENS.DIFF_VISUALS);\n    }, []);\n\n    const validateItemSelected = useCallback(\n        (itemContents: string): { error?: string; isValid: boolean } => {\n            try {\n                JSON.parse(itemContents);\n                // eslint-disable-next-line @typescript-eslint/no-unused-vars -- \"err\" is not useful and the catch cannot be empty\n            } catch (err) {\n                return {\n                    error: t('setting.exportImportSettings_notValidJSON'),\n                    isValid: false,\n                };\n            }\n\n            const content = JSON.parse(itemContents);\n\n            const migratedSettings = migrateSettings(content, content?.version || 0);\n            const validationRes = ValidationSettingsStateSchema.safeParse(migratedSettings);\n\n            if (!validationRes.success) {\n                const error = validationRes.error as ZodError;\n                const firstError = error.errors.pop();\n\n                const dotPath = firstError?.path.join('.');\n                const reason = firstError?.message;\n\n                return {\n                    error: t('setting.exportImportSettings_offendingKeyError', {\n                        offendingKey: dotPath,\n                        reason,\n                    }),\n                    isValid: false,\n                };\n            }\n\n            return {\n                isValid: true,\n            };\n        },\n        [],\n    );\n\n    const onImportClick = useCallback(() => {\n        if (selectedSettingsFile) {\n            setSettings(selectedSettingsFile);\n            setCurrentScreen(SCREENS.IMPORT_COMPLETE);\n        }\n    }, [selectedSettingsFile, setSettings]);\n\n    return (\n        <>\n            {currentScreen === SCREENS.FILE_PICKER ? (\n                <Stack>\n                    <DragDropZone\n                        icon=\"fileJson\"\n                        onItemSelected={onItemSelected}\n                        validateItem={validateItemSelected}\n                    />\n                </Stack>\n            ) : null}\n            {currentScreen === SCREENS.DIFF_VISUALS ? (\n                <Stack>\n                    <DiffVisualiser\n                        newSettings={selectedSettingsFile!}\n                        originalSettings={settings}\n                    />\n                    <Text size=\"sm\" ta=\"center\">\n                        {t('setting.exportImportSettings_destructiveWarning').toString()}\n                    </Text>\n                    <Button onClick={onImportClick} variant=\"state-info\">\n                        {t('setting.exportImportSettings_importBtn').toString()}\n                    </Button>\n                </Stack>\n            ) : null}\n            {currentScreen === SCREENS.IMPORT_COMPLETE ? (\n                <Text py=\"md\" ta=\"center\">\n                    {t('setting.exportImportSettings_importSuccess').toString()}\n                </Text>\n            ) : null}\n        </>\n    );\n};\n"
  },
  {
    "path": "src/renderer/components/feature-carousel/feature-carousel.module.css",
    "content": ".carousel-container {\n    position: relative;\n    width: 100%;\n    margin-bottom: var(--theme-spacing-md);\n    container-type: inline-size;\n    overflow: hidden;\n    border-radius: var(--theme-radius-md);\n}\n\n.carousel {\n    position: relative;\n    display: grid;\n    grid-template-columns: repeat(var(--items-per-row, 1), 1fr);\n    gap: var(--theme-spacing-sm);\n    width: 100%;\n    min-height: 280px;\n    padding: 0 var(--theme-spacing-md);\n    overflow: hidden;\n}\n\n.carousel-item {\n    position: relative;\n    width: 100%;\n    min-height: 280px;\n    overflow: hidden;\n    border-radius: var(--theme-radius-md);\n    isolation: isolate;\n}\n\n.blurred-background {\n    position: absolute;\n    top: 0;\n    left: 0;\n    z-index: 0;\n    width: 100%;\n    height: 100%;\n    background-repeat: no-repeat;\n    background-position: center;\n    background-size: cover;\n    opacity: 0.8;\n    transform: scale(1.1);\n}\n\n.carousel-item :global(.overlay) {\n    border-radius: var(--theme-radius-md);\n}\n\n.carousel-link {\n    display: block;\n    width: 100%;\n    height: 100%;\n    color: inherit;\n    text-decoration: none;\n}\n\n.content {\n    position: relative;\n    z-index: 10;\n    display: flex;\n    flex-direction: column;\n    gap: var(--theme-spacing-xs);\n    align-items: center;\n    justify-content: flex-start;\n    width: 100%;\n    height: 100%;\n    min-height: 280px;\n    padding: var(--theme-spacing-md);\n}\n\n.single-carousel-container .carousel {\n    min-height: 240px;\n}\n\n.single-carousel-container .carousel-item {\n    min-height: 240px;\n}\n\n.single-carousel-container .carousel-item .content {\n    flex-direction: row;\n    gap: var(--theme-spacing-md);\n    align-items: flex-end;\n    min-height: 240px;\n    padding: var(--theme-spacing-xl);\n}\n\n.title-section {\n    display: flex;\n    flex-shrink: 0;\n    align-items: flex-start;\n    justify-content: center;\n    width: 100%;\n    height: 40px;\n    min-height: 40px;\n    max-height: 40px;\n    text-align: left;\n}\n\n.image-section {\n    position: relative;\n    display: flex;\n    flex-shrink: 0;\n    align-items: center;\n    justify-content: center;\n    width: 100%;\n    height: 160px;\n    min-height: 160px;\n    max-height: 160px;\n}\n\n.single-carousel-container .carousel-item .content .image-section {\n    flex-shrink: 0;\n    justify-content: flex-start;\n    width: auto;\n    height: auto;\n    min-height: auto;\n    max-height: none;\n}\n\n.play-button-overlay {\n    position: absolute;\n    top: 50%;\n    left: 50%;\n    z-index: 20;\n    pointer-events: none;\n    opacity: 0;\n    transform: translate(-50%, -50%);\n    transition: opacity 0.3s ease;\n}\n\n.image-section:hover .play-button-overlay {\n    pointer-events: auto;\n    opacity: 1;\n}\n\n.metadata-section {\n    display: flex;\n    flex-shrink: 0;\n    flex-direction: column;\n    align-items: center;\n    justify-content: center;\n    width: 100%;\n    height: 60px;\n    min-height: 60px;\n    max-height: 60px;\n    text-align: center;\n}\n\n.single-carousel-container .carousel-item .content .metadata-section {\n    flex: 1;\n    align-items: flex-start;\n    justify-content: center;\n    height: auto;\n    min-height: auto;\n    max-height: none;\n    text-align: left;\n}\n\n/* Hide metadata on screens smaller than xs */\n@media (width < 36em) {\n    .single-carousel-container .carousel-item .content .metadata-section {\n        display: none;\n    }\n}\n\n.image-link {\n    display: block;\n    transition: transform 0.3s ease;\n}\n\n.image-link:hover {\n    transform: scale(1.02);\n}\n\n.image-link:active {\n    transform: scale(0.98);\n}\n\n.album-image-container {\n    position: relative;\n    width: 100%;\n    max-width: 120px;\n    overflow: hidden;\n    border-radius: var(--theme-radius-md);\n    filter: drop-shadow(0 6px 20px rgb(0 0 0 / 50%)) drop-shadow(0 2px 8px rgb(0 0 0 / 40%));\n    transition: filter 0.3s ease;\n}\n\n.single-carousel-container .album-image-container {\n    width: 200px;\n    max-width: 200px;\n}\n\n.album-image-container::before {\n    position: absolute;\n    top: 0;\n    left: 0;\n    z-index: 10;\n    width: 100%;\n    height: 100%;\n    pointer-events: none;\n    content: '';\n    background-color: rgb(0 0 0 / 0%);\n    border-radius: var(--theme-radius-md);\n    transition: background-color 0.3s ease;\n}\n\n.image-section:hover .album-image-container::before {\n    background-color: rgb(0 0 0 / 40%);\n}\n\n.album-image {\n    width: 100%;\n    height: 100%;\n    object-fit: cover;\n    border-radius: var(--theme-radius-md);\n}\n\n.carousel-item:hover .album-image-container,\n.carousel-link:hover .album-image-container {\n    filter: drop-shadow(0 16px 40px rgb(0 0 0 / 60%)) drop-shadow(0 6px 16px rgb(0 0 0 / 50%));\n}\n\n/* Single carousel: remove hover shadow effect */\n.single-carousel-container .carousel-item:hover .album-image-container,\n.single-carousel-container .carousel-link:hover .album-image-container {\n    filter: drop-shadow(0 6px 20px rgb(0 0 0 / 50%)) drop-shadow(0 2px 8px rgb(0 0 0 / 40%));\n}\n\n.artist-link {\n    display: inline-block;\n    color: inherit;\n    text-decoration: none;\n    transition: opacity 0.2s ease;\n}\n\n.artist-link:hover {\n    opacity: 0.8;\n}\n\n.title {\n    margin-bottom: 0;\n    color: white;\n    text-shadow: 0 0 8px rgb(0 0 0 / 50%);\n}\n\n.artist {\n    width: 100%;\n    color: white;\n    text-shadow: 0 0 8px rgb(0 0 0 / 50%);\n}\n\n.badge {\n    color: white;\n    text-shadow: 0 0 8px rgb(0 0 0 / 50%);\n}\n\n.nav-arrow-left,\n.nav-arrow-right {\n    position: absolute;\n    top: 50%;\n    z-index: 20;\n    border: 1px solid rgb(255 255 255 / 25%);\n    backdrop-filter: blur(10px);\n    transform: translateY(-50%);\n    transition: all 0.2s ease;\n}\n\n.nav-arrow-left {\n    left: var(--theme-spacing-xs);\n}\n\n.nav-arrow-right {\n    right: var(--theme-spacing-xs);\n}\n\n.nav-arrow-left:hover,\n.nav-arrow-right:hover {\n    background: transparent !important;\n    border-color: rgb(255 255 255 / 35%);\n    transform: translateY(-50%) scale(1.1);\n}\n\n.nav-arrow-left:active,\n.nav-arrow-right:active {\n    transform: translateY(-50%) scale(0.95);\n}\n\n.single-carousel-container .nav-arrow-left,\n.single-carousel-container .nav-arrow-right {\n    pointer-events: none;\n    opacity: 0;\n    transition:\n        opacity 0.2s ease,\n        transform 0.2s ease;\n}\n\n.single-carousel-container:hover .nav-arrow-left,\n.single-carousel-container:hover .nav-arrow-right {\n    pointer-events: auto;\n    opacity: 1;\n}\n\n@container (min-width: $mantine-breakpoint-xs) {\n    .carousel-item {\n        min-height: 300px;\n    }\n\n    .content {\n        min-height: 300px;\n    }\n\n    .title-section {\n        height: 45px;\n        min-height: 45px;\n        max-height: 45px;\n    }\n\n    .image-section {\n        height: 180px;\n        min-height: 180px;\n        max-height: 180px;\n    }\n\n    .metadata-section {\n        height: 65px;\n        min-height: 65px;\n        max-height: 65px;\n    }\n\n    .album-image-container {\n        max-width: 140px;\n    }\n}\n\n@container (min-width: $mantine-breakpoint-sm) {\n    .carousel {\n        gap: var(--theme-spacing-md);\n    }\n\n    .carousel-item {\n        min-height: 320px;\n    }\n\n    .content {\n        min-height: 320px;\n    }\n\n    .title-section {\n        height: 50px;\n        min-height: 50px;\n        max-height: 50px;\n    }\n\n    .image-section {\n        height: 200px;\n        min-height: 200px;\n        max-height: 200px;\n    }\n\n    .metadata-section {\n        height: 70px;\n        min-height: 70px;\n        max-height: 70px;\n    }\n\n    .album-image-container {\n        max-width: 160px;\n    }\n}\n\n@container (min-width: $mantine-breakpoint-md) {\n    .carousel-item {\n        min-height: 340px;\n    }\n\n    .content {\n        min-height: 340px;\n    }\n\n    .title-section {\n        height: 55px;\n        min-height: 55px;\n        max-height: 55px;\n    }\n\n    .image-section {\n        height: 220px;\n        min-height: 220px;\n        max-height: 220px;\n    }\n\n    .metadata-section {\n        height: 75px;\n        min-height: 75px;\n        max-height: 75px;\n    }\n\n    .album-image-container {\n        max-width: 180px;\n    }\n}\n\n@container (min-width: $mantine-breakpoint-xl) {\n    .carousel-item {\n        min-height: 360px;\n    }\n\n    .content {\n        min-height: 360px;\n    }\n\n    .title-section {\n        height: 60px;\n        min-height: 60px;\n        max-height: 60px;\n    }\n\n    .image-section {\n        height: 240px;\n        min-height: 240px;\n        max-height: 240px;\n    }\n\n    .metadata-section {\n        height: 80px;\n        min-height: 80px;\n        max-height: 80px;\n    }\n\n    .album-image-container {\n        max-width: 200px;\n    }\n}\n"
  },
  {
    "path": "src/renderer/components/feature-carousel/feature-carousel.tsx",
    "content": "import type { MouseEvent } from 'react';\n\nimport { AnimatePresence, motion } from 'motion/react';\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport { generatePath, Link } from 'react-router';\n\nimport styles from './feature-carousel.module.css';\n\nimport { ItemImage, useItemImageUrl } from '/@/renderer/components/item-image/item-image';\nimport { usePlayer } from '/@/renderer/features/player/context/player-context';\nimport { BackgroundOverlay } from '/@/renderer/features/shared/components/library-background-overlay';\nimport { PlayButtonGroup } from '/@/renderer/features/shared/components/play-button-group';\nimport { useContainerQuery, useFastAverageColor } from '/@/renderer/hooks';\nimport { AppRoute } from '/@/renderer/router/routes';\nimport { useCurrentServer } from '/@/renderer/store';\nimport { ActionIcon } from '/@/shared/components/action-icon/action-icon';\nimport { Badge } from '/@/shared/components/badge/badge';\nimport { Group } from '/@/shared/components/group/group';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { Text } from '/@/shared/components/text/text';\nimport { Album, LibraryItem } from '/@/shared/types/domain-types';\nimport { Play } from '/@/shared/types/types';\n\nconst containerVariants = {\n    animate: {},\n    exit: {},\n    initial: {},\n};\n\nconst itemVariants = {\n    animate: {\n        opacity: 1,\n        scale: 1,\n        transition: {\n            duration: 0.2,\n            ease: 'easeOut' as const,\n        },\n        y: 0,\n    },\n    exit: {\n        opacity: 0,\n        transition: {\n            duration: 0.3,\n            ease: 'easeIn' as const,\n        },\n        y: 0,\n    },\n    initial: {\n        opacity: 0,\n        y: 0,\n    },\n};\n\ninterface FeatureCarouselProps {\n    data: Album[] | undefined;\n    onNearEnd?: () => void;\n}\n\nconst getItemsPerRow = (breakpoints: {\n    is2xl: boolean;\n    is3xl: boolean;\n    isLg: boolean;\n    isMd: boolean;\n    isSm: boolean;\n    isXl: boolean;\n}) => {\n    if (breakpoints.is3xl) return 6;\n    if (breakpoints.is2xl) return 5;\n    if (breakpoints.isXl) return 5;\n    if (breakpoints.isLg) return 4;\n    if (breakpoints.isMd) return 3;\n    if (breakpoints.isSm) return 2;\n    return 2;\n};\n\ninterface CarouselItemProps {\n    album: Album;\n}\n\nconst CarouselItem = ({ album }: CarouselItemProps) => {\n    const imageUrl = useItemImageUrl({\n        id: album.imageId || undefined,\n        itemType: LibraryItem.ALBUM,\n        type: 'itemCard',\n    });\n\n    const { background: backgroundColor } = useFastAverageColor({\n        algorithm: 'dominant',\n        src: imageUrl || null,\n        srcLoaded: true,\n    });\n\n    const server = useCurrentServer();\n    const { addToQueueByFetch } = usePlayer();\n\n    const handlePlay = (type: Play) => {\n        if (!server?.id) return;\n        addToQueueByFetch(server.id, [album.id], LibraryItem.ALBUM, type);\n    };\n\n    return (\n        <div className={styles.carouselItem}>\n            <BackgroundOverlay backgroundColor={backgroundColor} opacity={0.7} />\n            <Link\n                className={styles.carouselLink}\n                state={{ item: album }}\n                to={generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {\n                    albumId: album.id,\n                })}\n            >\n                <div className={styles.content}>\n                    <div className={styles.titleSection}>\n                        <Text className={styles.title} fw={700} lineClamp={2} size=\"lg\" ta=\"center\">\n                            {album.name}\n                        </Text>\n                    </div>\n\n                    <div className={styles.imageSection}>\n                        <ItemImage\n                            className={styles.albumImage}\n                            containerClassName={styles.albumImageContainer}\n                            enableDebounce={false}\n                            enableViewport={false}\n                            explicitStatus={album.explicitStatus}\n                            fetchPriority=\"high\"\n                            id={album.imageId}\n                            itemType={LibraryItem.ALBUM}\n                            src={imageUrl}\n                            type=\"itemCard\"\n                        />\n                        <div className={styles.playButtonOverlay}>\n                            <PlayButtonGroup onPlay={handlePlay} />\n                        </div>\n                    </div>\n\n                    <div className={styles.metadataSection}>\n                        <Stack gap=\"sm\">\n                            {album.albumArtists?.[0] && (\n                                <Text\n                                    className={styles.artist}\n                                    fw={500}\n                                    lineClamp={1}\n                                    size=\"md\"\n                                    ta=\"center\"\n                                >\n                                    {album.albumArtists[0].name}\n                                </Text>\n                            )}\n                            <Group gap=\"xs\" justify=\"center\" wrap=\"wrap\">\n                                {album.genres?.slice(0, 2).map((genre) => (\n                                    <Badge\n                                        classNames={{ label: styles.badge }}\n                                        key={`genre-${genre.id}`}\n                                        size=\"sm\"\n                                        variant=\"transparent\"\n                                    >\n                                        {genre.name}\n                                    </Badge>\n                                ))}\n                                {album.releaseYear && (\n                                    <Badge\n                                        classNames={{ label: styles.badge }}\n                                        size=\"sm\"\n                                        variant=\"transparent\"\n                                    >\n                                        {album.releaseYear}\n                                    </Badge>\n                                )}\n                            </Group>\n                        </Stack>\n                    </div>\n                </div>\n            </Link>\n        </div>\n    );\n};\n\nexport const FeatureCarousel = ({ data, onNearEnd }: FeatureCarouselProps) => {\n    const [startIndex, setStartIndex] = useState(0);\n    const directionRef = useRef<{ isNext: boolean }>({ isNext: true });\n    const {\n        is2xl,\n        is3xl,\n        isLg,\n        isMd,\n        isSm,\n        isXl,\n        ref: containerRef,\n    } = useContainerQuery({\n        '2xl': 1920,\n        '3xl': 2560,\n        lg: 1024,\n        md: 768,\n        sm: 640,\n        xl: 1440,\n    });\n\n    const itemsPerRow = useMemo(\n        () => getItemsPerRow({ is2xl, is3xl, isLg, isMd, isSm, isXl }),\n        [is2xl, is3xl, isLg, isMd, isSm, isXl],\n    );\n\n    const visibleItems = useMemo(() => {\n        if (!data) return [];\n        const items: Album[] = [];\n        for (let i = 0; i < itemsPerRow; i++) {\n            const index = (startIndex + i) % data.length;\n            items.push(data[index]);\n        }\n        return items;\n    }, [data, startIndex, itemsPerRow]);\n\n    // Check if we're near the end and trigger loading more\n    useEffect(() => {\n        if (!data || !onNearEnd) return;\n        const remainingItems = data.length - startIndex;\n        // Trigger when we have less than 2 rows worth of items remaining\n        if (remainingItems < itemsPerRow * 2) {\n            onNearEnd();\n        }\n    }, [data, startIndex, itemsPerRow, onNearEnd]);\n\n    const handleNext = useCallback(\n        (e?: MouseEvent<HTMLButtonElement>) => {\n            e?.preventDefault();\n            e?.stopPropagation();\n            if (!data) return;\n            directionRef.current = { isNext: true };\n            setStartIndex((prev) => (prev + itemsPerRow) % data.length);\n        },\n        [data, itemsPerRow],\n    );\n\n    const handlePrevious = useCallback(\n        (e?: MouseEvent<HTMLButtonElement>) => {\n            e?.preventDefault();\n            e?.stopPropagation();\n            if (!data) return;\n            directionRef.current = { isNext: false };\n            setStartIndex((prev) => (prev - itemsPerRow + data.length) % data.length);\n        },\n        [data, itemsPerRow],\n    );\n\n    const canNavigate = data && data.length > itemsPerRow;\n\n    const wheelCooldownRef = useRef(0);\n    const wheelThreshold = 10;\n    const wheelCooldownMs = 250;\n\n    const handleWheel = useCallback(\n        (event: React.WheelEvent<HTMLDivElement>) => {\n            if (!canNavigate || !data) {\n                return;\n            }\n\n            if (!event.shiftKey) {\n                return;\n            }\n\n            const now = Date.now();\n            const elapsed = now - wheelCooldownRef.current;\n\n            const horizontalDelta = Math.abs(event.deltaY);\n\n            if (horizontalDelta < wheelThreshold || elapsed < wheelCooldownMs) {\n                return;\n            }\n\n            if (event.deltaY > 0) {\n                wheelCooldownRef.current = now;\n                handleNext();\n            } else if (event.deltaY < 0) {\n                wheelCooldownRef.current = now;\n                handlePrevious();\n            }\n        },\n        [canNavigate, data, handleNext, handlePrevious, wheelCooldownMs, wheelThreshold],\n    );\n\n    if (!data || data.length === 0) {\n        return null;\n    }\n\n    return (\n        <div className={styles.carouselContainer} onWheel={handleWheel} ref={containerRef}>\n            <AnimatePresence initial={false} mode=\"popLayout\">\n                <motion.div\n                    animate=\"animate\"\n                    className={styles.carousel}\n                    exit=\"exit\"\n                    initial=\"initial\"\n                    key={`carousel-${startIndex}`}\n                    style={{ '--items-per-row': itemsPerRow } as React.CSSProperties}\n                    variants={containerVariants}\n                >\n                    {visibleItems.map((album, index) => (\n                        <motion.div\n                            key={`item-${album.id}-${startIndex}-${index}`}\n                            variants={itemVariants}\n                        >\n                            <CarouselItem album={album} />\n                        </motion.div>\n                    ))}\n                </motion.div>\n            </AnimatePresence>\n\n            {data.length > itemsPerRow && (\n                <>\n                    <ActionIcon\n                        className={styles.navArrowLeft}\n                        icon=\"arrowLeftS\"\n                        iconProps={{ size: 'xl' }}\n                        onClick={handlePrevious}\n                        radius=\"50%\"\n                        size=\"md\"\n                        styles={{\n                            icon: {\n                                color: 'white',\n                                fill: 'white',\n                            },\n                        }}\n                        variant=\"subtle\"\n                    />\n                    <ActionIcon\n                        className={styles.navArrowRight}\n                        icon=\"arrowRightS\"\n                        iconProps={{ size: 'xl' }}\n                        onClick={handleNext}\n                        radius=\"50%\"\n                        size=\"md\"\n                        styles={{\n                            icon: {\n                                color: 'white',\n                                fill: 'white',\n                            },\n                        }}\n                        variant=\"subtle\"\n                    />\n                </>\n            )}\n        </div>\n    );\n};\n"
  },
  {
    "path": "src/renderer/components/feature-carousel/single-feature-carousel.tsx",
    "content": "import type { MouseEvent } from 'react';\n\nimport { AnimatePresence, motion } from 'motion/react';\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport { generatePath, Link } from 'react-router';\n\nimport styles from './feature-carousel.module.css';\n\nimport { ItemImage, useItemImageUrl } from '/@/renderer/components/item-image/item-image';\nimport { usePlayer } from '/@/renderer/features/player/context/player-context';\nimport { BackgroundOverlay } from '/@/renderer/features/shared/components/library-background-overlay';\nimport { calculateTitleSize } from '/@/renderer/features/shared/components/library-header';\nimport { PlayButtonGroup } from '/@/renderer/features/shared/components/play-button-group';\nimport { useContainerQuery, useFastAverageColor } from '/@/renderer/hooks';\nimport { AppRoute } from '/@/renderer/router/routes';\nimport { useCurrentServer } from '/@/renderer/store';\nimport { ActionIcon } from '/@/shared/components/action-icon/action-icon';\nimport { Group } from '/@/shared/components/group/group';\nimport { Separator } from '/@/shared/components/separator/separator';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { TextTitle } from '/@/shared/components/text-title/text-title';\nimport { Text } from '/@/shared/components/text/text';\nimport { Album, LibraryItem } from '/@/shared/types/domain-types';\nimport { Play } from '/@/shared/types/types';\n\nconst containerVariants = {\n    animate: {},\n    exit: {},\n    initial: {},\n};\n\nconst itemVariants = {\n    animate: {\n        opacity: 1,\n        scale: 1,\n        transition: {\n            duration: 0.2,\n            ease: 'easeOut' as const,\n        },\n        y: 0,\n    },\n    exit: {\n        opacity: 0,\n        transition: {\n            duration: 0.3,\n            ease: 'easeIn' as const,\n        },\n        y: 0,\n    },\n    initial: {\n        opacity: 0,\n        y: 0,\n    },\n};\n\ninterface CarouselItemProps {\n    album: Album;\n}\n\ninterface SingleFeatureCarouselProps {\n    data: Album[] | undefined;\n    onNearEnd?: () => void;\n}\n\n// const CAROUSEL_AUTOPLAY_INTERVAL = 10000;\n\nconst CarouselItem = ({ album }: CarouselItemProps) => {\n    const imageUrl = useItemImageUrl({\n        id: album.imageId || undefined,\n        itemType: LibraryItem.ALBUM,\n        type: 'itemCard',\n    });\n\n    const { background: backgroundColor } = useFastAverageColor({\n        algorithm: 'dominant',\n        src: imageUrl || null,\n        srcLoaded: true,\n    });\n\n    const server = useCurrentServer();\n    const { addToQueueByFetch } = usePlayer();\n\n    const handlePlay = (type: Play) => {\n        if (!server?.id) return;\n        addToQueueByFetch(server.id, [album.id], LibraryItem.ALBUM, type);\n    };\n\n    const metadataItems = useMemo(() => {\n        return [\n            ...(album.genres?.slice(0, 2).map((genre) => genre.name) || []),\n            album.releaseYear ? album.releaseYear.toString() : null,\n        ].filter(Boolean);\n    }, [album]);\n\n    return (\n        <div className={styles.carouselItem}>\n            {imageUrl && (\n                <div\n                    className={styles.blurredBackground}\n                    style={{\n                        backgroundImage: `url(${imageUrl})`,\n                        filter: 'blur(3rem)',\n                    }}\n                />\n            )}\n            <BackgroundOverlay backgroundColor={backgroundColor} opacity={0.7} />\n            <Link\n                className={styles.carouselLink}\n                state={{ item: album }}\n                to={generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {\n                    albumId: album.id,\n                })}\n            >\n                <div className={styles.content}>\n                    <div className={styles.imageSection}>\n                        <ItemImage\n                            className={styles.albumImage}\n                            containerClassName={styles.albumImageContainer}\n                            enableDebounce={false}\n                            enableViewport={false}\n                            explicitStatus={album.explicitStatus}\n                            fetchPriority=\"high\"\n                            id={album.imageId}\n                            itemType={LibraryItem.ALBUM}\n                            type=\"itemCard\"\n                        />\n                        <div className={styles.playButtonOverlay}>\n                            <PlayButtonGroup onPlay={handlePlay} />\n                        </div>\n                    </div>\n\n                    <div className={styles.metadataSection}>\n                        <Stack gap=\"sm\">\n                            <TextTitle\n                                className={styles.title}\n                                fw={900}\n                                lh={1.1}\n                                order={1}\n                                style={{ fontSize: calculateTitleSize(album.name) }}\n                                ta=\"left\"\n                            >\n                                {album.name}\n                            </TextTitle>\n                            {album.albumArtistName && (\n                                <TextTitle\n                                    className={styles.title}\n                                    fw={700}\n                                    lh={1.1}\n                                    order={5}\n                                    ta=\"left\"\n                                >\n                                    {album.albumArtistName}\n                                </TextTitle>\n                            )}\n                            <Group gap=\"xs\" justify=\"flex-start\" wrap=\"wrap\">\n                                {metadataItems.map((item, index) => (\n                                    <Text\n                                        className={styles.title}\n                                        fw={600}\n                                        key={`metadata-${item}`}\n                                        size=\"sm\"\n                                    >\n                                        {item}\n                                        {index < metadataItems.length - 1 && <Separator />}\n                                    </Text>\n                                ))}\n                            </Group>\n                        </Stack>\n                    </div>\n                </div>\n            </Link>\n        </div>\n    );\n};\n\nexport const SingleFeatureCarousel = ({ data, onNearEnd }: SingleFeatureCarouselProps) => {\n    const [currentIndex, setCurrentIndex] = useState(0);\n    const directionRef = useRef<{ isNext: boolean }>({ isNext: true });\n    const { ref: containerRef } = useContainerQuery({\n        '2xl': 1920,\n        '3xl': 2560,\n        lg: 1024,\n        md: 768,\n        sm: 640,\n        xl: 1440,\n    });\n\n    // Check if we're near the end and trigger loading more\n    useEffect(() => {\n        if (!data || !onNearEnd) return;\n        const remainingItems = data.length - currentIndex;\n        // Trigger when we have less than 3 items remaining\n        if (remainingItems < 3) {\n            onNearEnd();\n        }\n    }, [data, currentIndex, onNearEnd]);\n\n    // useEffect(() => {\n    //     if (!data || data.length <= 1 || isPaused) {\n    //         if (intervalRef.current) {\n    //             clearInterval(intervalRef.current);\n    //             intervalRef.current = null;\n    //         }\n    //         return;\n    //     }\n\n    //     if (intervalRef.current) {\n    //         clearInterval(intervalRef.current);\n    //     }\n\n    //     intervalRef.current = setInterval(() => {\n    //         setCurrentIndex((prev) => (prev + 1) % data.length);\n    //         directionRef.current = { isNext: true };\n    //     }, CAROUSEL_AUTOPLAY_INTERVAL);\n\n    //     return () => {\n    //         if (intervalRef.current) {\n    //             clearInterval(intervalRef.current);\n    //             intervalRef.current = null;\n    //         }\n    //     };\n    // }, [data, isPaused, intervalKey]);\n\n    const handleNext = useCallback(\n        (e?: MouseEvent<HTMLButtonElement>) => {\n            e?.preventDefault();\n            e?.stopPropagation();\n            if (!data) return;\n            directionRef.current = { isNext: true };\n            setCurrentIndex((prev) => (prev + 1) % data.length);\n            // setIntervalKey((prev) => prev + 1);\n        },\n        [data],\n    );\n\n    const handlePrevious = useCallback(\n        (e?: MouseEvent<HTMLButtonElement>) => {\n            e?.preventDefault();\n            e?.stopPropagation();\n            if (!data) return;\n            directionRef.current = { isNext: false };\n            setCurrentIndex((prev) => (prev - 1 + data.length) % data.length);\n            // setIntervalKey((prev) => prev + 1);\n        },\n        [data],\n    );\n\n    const canNavigate = data && data.length > 1;\n\n    const wheelCooldownRef = useRef(0);\n    const wheelThreshold = 10;\n    const wheelCooldownMs = 250;\n\n    const handleWheel = useCallback(\n        (event: React.WheelEvent<HTMLDivElement>) => {\n            if (!canNavigate || !data) {\n                return;\n            }\n\n            if (!event.shiftKey) {\n                return;\n            }\n\n            const now = Date.now();\n            const elapsed = now - wheelCooldownRef.current;\n\n            const horizontalDelta = Math.abs(event.deltaY);\n\n            if (horizontalDelta < wheelThreshold || elapsed < wheelCooldownMs) {\n                return;\n            }\n\n            if (event.deltaY > 0) {\n                wheelCooldownRef.current = now;\n                handleNext();\n            } else if (event.deltaY < 0) {\n                wheelCooldownRef.current = now;\n                handlePrevious();\n            }\n        },\n        [canNavigate, data, handleNext, handlePrevious, wheelCooldownMs, wheelThreshold],\n    );\n\n    if (!data || data.length === 0) {\n        return null;\n    }\n\n    const currentAlbum = data[currentIndex];\n\n    return (\n        <div\n            className={`${styles.carouselContainer} ${styles.singleCarouselContainer}`}\n            // onMouseEnter={() => setIsPaused(true)}\n            // onMouseLeave={() => setIsPaused(false)}\n            onWheel={handleWheel}\n            ref={containerRef}\n        >\n            <AnimatePresence initial={false} mode=\"popLayout\">\n                <motion.div\n                    animate=\"animate\"\n                    className={styles.carousel}\n                    exit=\"exit\"\n                    initial=\"initial\"\n                    key={`carousel-${currentIndex}`}\n                    style={{ '--items-per-row': 1 } as React.CSSProperties}\n                    variants={containerVariants}\n                >\n                    <motion.div\n                        key={`item-${currentAlbum.id}-${currentIndex}`}\n                        variants={itemVariants}\n                    >\n                        <CarouselItem album={currentAlbum} />\n                    </motion.div>\n                </motion.div>\n            </AnimatePresence>\n\n            {data.length > 1 && (\n                <>\n                    <ActionIcon\n                        className={styles.navArrowLeft}\n                        icon=\"arrowLeftS\"\n                        iconProps={{ size: 'xl' }}\n                        onClick={handlePrevious}\n                        radius=\"50%\"\n                        size=\"md\"\n                        styles={{\n                            icon: {\n                                color: 'white',\n                                fill: 'white',\n                            },\n                        }}\n                        variant=\"subtle\"\n                    />\n                    <ActionIcon\n                        className={styles.navArrowRight}\n                        icon=\"arrowRightS\"\n                        iconProps={{ size: 'xl' }}\n                        onClick={handleNext}\n                        radius=\"50%\"\n                        size=\"md\"\n                        styles={{\n                            icon: {\n                                color: 'white',\n                                fill: 'white',\n                            },\n                        }}\n                        variant=\"subtle\"\n                    />\n                </>\n            )}\n        </div>\n    );\n};\n"
  },
  {
    "path": "src/renderer/components/grid-carousel/grid-carousel-v2.tsx",
    "content": "import type { Variants } from 'motion/react';\nimport type { ReactNode } from 'react';\n\nimport { AnimatePresence, motion } from 'motion/react';\nimport { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';\n\nimport styles from './grid-carousel.module.css';\n\nimport { DataRow, MemoizedItemCard } from '/@/renderer/components/item-card/item-card';\nimport { useContainerQuery } from '/@/renderer/hooks';\nimport { ActionIcon } from '/@/shared/components/action-icon/action-icon';\nimport { Group } from '/@/shared/components/group/group';\nimport { TextTitle } from '/@/shared/components/text-title/text-title';\nimport { LibraryItem } from '/@/shared/types/domain-types';\n\nexport const useGridCarouselContainerQuery = () => {\n    return useContainerQuery({\n        '2xl': 1280,\n        '3xl': 1440,\n        lg: 960,\n        md: 720,\n        sm: 520,\n        xl: 1152,\n        xs: 360,\n    });\n};\n\ninterface Card {\n    content: ReactNode;\n    id: string;\n}\n\ninterface GridCarouselProps {\n    cards: Card[];\n    containerQuery?: ReturnType<typeof useGridCarouselContainerQuery>;\n    enableRefresh?: boolean;\n    hasNextPage?: boolean;\n    isFetchingNextPage?: boolean;\n    loadNextPage?: () => void;\n    onNextPage: (page: number) => void;\n    onPrevPage: (page: number) => void;\n    onRefresh?: () => void;\n    placeholderItemType?: LibraryItem;\n    placeholderRows?: DataRow[];\n    rowCount?: number;\n    title?: ReactNode | string;\n}\n\nconst MemoizedCard = memo(({ content }: { content: ReactNode }) => (\n    <div className={styles.card}>{content}</div>\n));\n\nMemoizedCard.displayName = 'MemoizedCard';\n\nconst pageVariants: Variants = {\n    animate: { opacity: 1, transition: { duration: 0.3, ease: 'easeOut' }, x: 0 },\n    exit: (custom: { isNext: boolean }) => ({\n        opacity: 0,\n        transition: { duration: 0.3, ease: 'easeIn' },\n        x: custom.isNext ? -100 : 100,\n    }),\n    initial: (custom: { isNext: boolean }) => ({ opacity: 0, x: custom.isNext ? 100 : -100 }),\n};\n\nfunction BaseGridCarousel(props: GridCarouselProps) {\n    const {\n        cards,\n        containerQuery: providedContainerQuery,\n        enableRefresh = false,\n        hasNextPage,\n        isFetchingNextPage,\n        loadNextPage,\n        onNextPage,\n        onPrevPage,\n        onRefresh,\n        placeholderItemType,\n        placeholderRows,\n        rowCount = 1,\n        title,\n    } = props;\n    const defaultContainerQuery = useGridCarouselContainerQuery();\n    const containerQuery = providedContainerQuery || defaultContainerQuery;\n    const { ref, ...cq } = containerQuery;\n\n    const [currentPage, setCurrentPage] = useState({\n        isNext: false,\n        page: 0,\n    });\n\n    const handlePrevPage = useCallback(() => {\n        setCurrentPage((prev) => ({\n            isNext: false,\n            page: prev.page > 0 ? prev.page - 1 : 0,\n        }));\n        onPrevPage(currentPage.page);\n    }, [currentPage, onPrevPage]);\n\n    const handleNextPage = useCallback(() => {\n        setCurrentPage((prev) => ({\n            isNext: true,\n            page: prev.page + 1,\n        }));\n        onNextPage(currentPage.page);\n    }, [currentPage, onNextPage]);\n\n    const cardsToShow = getCardsToShow({\n        isLargerThan2xl: cq.is2xl,\n        isLargerThan3xl: cq.is3xl,\n        isLargerThanLg: cq.isLg,\n        isLargerThanMd: cq.isMd,\n        isLargerThanSm: cq.isSm,\n        isLargerThanXl: cq.isXl,\n    });\n\n    const visibleCards = useMemo(() => {\n        const startIndex = currentPage.page * cardsToShow * rowCount;\n        const endIndex = (currentPage.page + 1) * cardsToShow * rowCount;\n        const slicedCards = cards.slice(startIndex, endIndex);\n        const expectedCardCount = cardsToShow * rowCount;\n        const missingCardCount = expectedCardCount - slicedCards.length;\n\n        // Add placeholder cards during loading state\n        if (\n            missingCardCount > 0 &&\n            hasNextPage &&\n            isFetchingNextPage &&\n            placeholderItemType &&\n            placeholderRows\n        ) {\n            const placeholderCards: Card[] = Array.from(\n                { length: missingCardCount },\n                (_, index) => ({\n                    content: (\n                        <MemoizedItemCard\n                            data={undefined}\n                            itemType={placeholderItemType}\n                            rows={placeholderRows}\n                            type=\"poster\"\n                        />\n                    ),\n                    id: `placeholder-${startIndex + slicedCards.length + index}`,\n                }),\n            );\n            return [...slicedCards, ...placeholderCards];\n        }\n\n        return slicedCards;\n    }, [\n        currentPage.page,\n        cardsToShow,\n        rowCount,\n        cards,\n        hasNextPage,\n        isFetchingNextPage,\n        placeholderItemType,\n        placeholderRows,\n    ]);\n\n    const shouldLoadNextPage = visibleCards.length < cardsToShow * rowCount;\n\n    useEffect(() => {\n        if (shouldLoadNextPage) {\n            loadNextPage?.();\n        }\n    }, [loadNextPage, shouldLoadNextPage]);\n\n    const isPrevDisabled = currentPage.page === 0;\n    const hasMoreCards = (currentPage.page + 1) * cardsToShow * rowCount < cards.length;\n    const isNextDisabled = !hasMoreCards && (hasNextPage === false || hasNextPage === undefined);\n\n    const wheelCooldownRef = useRef(0);\n    const wheelThreshold = 10;\n    const wheelCooldownMs = 250;\n\n    const handleWheel = useCallback(\n        (event: React.WheelEvent<HTMLDivElement>) => {\n            if (!event.shiftKey) {\n                return;\n            }\n\n            const now = Date.now();\n            const elapsed = now - wheelCooldownRef.current;\n\n            const horizontalDelta = Math.abs(event.deltaY);\n\n            if (horizontalDelta < wheelThreshold || elapsed < wheelCooldownMs) {\n                return;\n            }\n\n            if (event.deltaY > 0 && !isNextDisabled) {\n                wheelCooldownRef.current = now;\n                handleNextPage();\n            } else if (event.deltaY < 0 && !isPrevDisabled) {\n                wheelCooldownRef.current = now;\n                handlePrevPage();\n            }\n        },\n        [\n            handleNextPage,\n            handlePrevPage,\n            isNextDisabled,\n            isPrevDisabled,\n            wheelCooldownMs,\n            wheelThreshold,\n        ],\n    );\n\n    const swipeCooldownRef = useRef(0);\n    const dragStartTargetRef = useRef<HTMLElement | null>(null);\n    const swipeCooldownMs = 300;\n    const swipeThreshold = 50;\n    const swipeVelocityThreshold = 500;\n\n    const handleDragStart = useCallback((event: MouseEvent | PointerEvent | TouchEvent) => {\n        dragStartTargetRef.current = (event.target as HTMLElement) || null;\n    }, []);\n\n    const handleDragEnd = useCallback(\n        (\n            _event: MouseEvent | PointerEvent | TouchEvent,\n            info: { offset: { x: number }; velocity: { x: number } },\n        ) => {\n            const startTarget = dragStartTargetRef.current;\n            if (startTarget) {\n                if (startTarget.closest('button, a, input, select, textarea, [role=\"button\"]')) {\n                    dragStartTargetRef.current = null;\n                    return;\n                }\n            }\n\n            const now = Date.now();\n            const elapsed = now - swipeCooldownRef.current;\n\n            if (elapsed < swipeCooldownMs) {\n                dragStartTargetRef.current = null;\n                return;\n            }\n\n            const { offset, velocity } = info;\n            const absOffset = Math.abs(offset.x);\n            const absVelocity = Math.abs(velocity.x);\n\n            if (absOffset > swipeThreshold || absVelocity > swipeVelocityThreshold) {\n                swipeCooldownRef.current = now;\n\n                if (offset.x > 0 && !isPrevDisabled) {\n                    handlePrevPage();\n                } else if (offset.x < 0 && !isNextDisabled) {\n                    handleNextPage();\n                }\n            }\n\n            dragStartTargetRef.current = null;\n        },\n        [handleNextPage, handlePrevPage, isNextDisabled, isPrevDisabled],\n    );\n\n    return (\n        <div className={styles.gridCarousel} ref={ref}>\n            {cq.isCalculated && (\n                <>\n                    <motion.div\n                        className={styles.navigation}\n                        drag=\"x\"\n                        dragConstraints={{ left: 0, right: 0 }}\n                        dragElastic={0}\n                        dragMomentum={false}\n                        dragPropagation={false}\n                        onDragEnd={handleDragEnd}\n                        onDragStart={handleDragStart}\n                    >\n                        {typeof title === 'string' ? (\n                            <Group gap=\"xs\" justify=\"space-between\" w=\"100%\">\n                                <Group gap=\"xs\">\n                                    <TextTitle fw={700} isNoSelect order={3}>\n                                        {title}\n                                    </TextTitle>\n                                    {enableRefresh && onRefresh && (\n                                        <ActionIcon\n                                            icon=\"refresh\"\n                                            iconProps={{ size: 'xs' }}\n                                            onClick={onRefresh}\n                                            size=\"xs\"\n                                            tooltip={{ label: 'Refresh' }}\n                                            variant=\"transparent\"\n                                        />\n                                    )}\n                                </Group>\n                                <Group gap=\"xs\" justify=\"end\">\n                                    <ActionIcon\n                                        disabled={isPrevDisabled}\n                                        icon=\"arrowLeftS\"\n                                        iconProps={{ size: 'lg' }}\n                                        onClick={handlePrevPage}\n                                        size=\"xs\"\n                                        variant=\"subtle\"\n                                    />\n                                    <ActionIcon\n                                        disabled={isNextDisabled}\n                                        icon=\"arrowRightS\"\n                                        iconProps={{ size: 'lg' }}\n                                        onClick={handleNextPage}\n                                        size=\"xs\"\n                                        variant=\"subtle\"\n                                    />\n                                </Group>\n                            </Group>\n                        ) : (\n                            <div className={styles.customTitleContainer}>\n                                <div className={styles.customTitleContent}>{title}</div>\n                                <Group gap=\"xs\" justify=\"end\">\n                                    <ActionIcon\n                                        disabled={isPrevDisabled}\n                                        icon=\"arrowLeftS\"\n                                        iconProps={{ size: 'lg' }}\n                                        onClick={handlePrevPage}\n                                        size=\"xs\"\n                                        variant=\"subtle\"\n                                    />\n                                    <ActionIcon\n                                        disabled={isNextDisabled}\n                                        icon=\"arrowRightS\"\n                                        iconProps={{ size: 'lg' }}\n                                        onClick={handleNextPage}\n                                        size=\"xs\"\n                                        variant=\"subtle\"\n                                    />\n                                </Group>\n                            </div>\n                        )}\n                    </motion.div>\n                    <AnimatePresence custom={currentPage} initial={false} mode=\"wait\">\n                        <motion.div\n                            animate=\"animate\"\n                            className={styles.grid}\n                            custom={currentPage}\n                            exit=\"exit\"\n                            initial=\"initial\"\n                            key={currentPage.page}\n                            onWheel={handleWheel}\n                            style={\n                                {\n                                    '--cards-to-show': cardsToShow,\n                                    '--row-count': rowCount,\n                                } as React.CSSProperties\n                            }\n                            variants={pageVariants}\n                        >\n                            {visibleCards.map((card) => (\n                                <MemoizedCard content={card.content} key={card.id} />\n                            ))}\n                        </motion.div>\n                    </AnimatePresence>\n                </>\n            )}\n        </div>\n    );\n}\n\nexport const GridCarousel = memo(BaseGridCarousel);\n\nGridCarousel.displayName = 'GridCarousel';\n\ninterface GridCarouselSkeletonProps {\n    containerQuery?: ReturnType<typeof useGridCarouselContainerQuery>;\n    enableRefresh?: boolean;\n    placeholderItemType: LibraryItem;\n    placeholderRound?: boolean;\n    placeholderRows: DataRow[];\n    rowCount?: number;\n    title?: ReactNode | string;\n}\n\nconst GridCarouselSkeleton = (props: GridCarouselSkeletonProps) => {\n    const {\n        containerQuery: providedContainerQuery,\n        enableRefresh = false,\n        placeholderItemType,\n        placeholderRound = false,\n        placeholderRows,\n        rowCount = 1,\n        title,\n    } = props;\n\n    const defaultContainerQuery = useGridCarouselContainerQuery();\n    const containerQuery = providedContainerQuery ?? defaultContainerQuery;\n    const { ...cq } = containerQuery;\n\n    const cardsToShow = cq.isCalculated\n        ? getCardsToShow({\n              isLargerThan2xl: cq.is2xl,\n              isLargerThan3xl: cq.is3xl,\n              isLargerThanLg: cq.isLg,\n              isLargerThanMd: cq.isMd,\n              isLargerThanSm: cq.isSm,\n              isLargerThanXl: cq.isXl,\n          })\n        : 6;\n\n    const placeholderCards = useMemo(() => {\n        const cardCount = cardsToShow * rowCount;\n        return Array.from({ length: cardCount }, (_, index) => ({\n            content: (\n                <MemoizedItemCard\n                    data={undefined}\n                    isRound={placeholderRound}\n                    itemType={placeholderItemType}\n                    rows={placeholderRows}\n                    type=\"poster\"\n                />\n            ),\n            id: `skeleton-${index}`,\n        }));\n    }, [cardsToShow, placeholderRound, rowCount, placeholderItemType, placeholderRows]);\n\n    return (\n        <GridCarousel\n            cards={placeholderCards}\n            containerQuery={containerQuery}\n            enableRefresh={enableRefresh}\n            hasNextPage={false}\n            isFetchingNextPage={false}\n            onNextPage={() => {}}\n            onPrevPage={() => {}}\n            placeholderItemType={placeholderItemType}\n            placeholderRows={placeholderRows}\n            rowCount={rowCount}\n            title={title}\n        />\n    );\n};\n\nexport const GridCarouselSkeletonFallback = memo(GridCarouselSkeleton);\n\nGridCarouselSkeletonFallback.displayName = 'GridCarouselSkeletonFallback';\n\nfunction getCardsToShow(breakpoints: {\n    isLargerThan2xl: boolean;\n    isLargerThan3xl: boolean;\n    isLargerThanLg: boolean;\n    isLargerThanMd: boolean;\n    isLargerThanSm: boolean;\n    isLargerThanXl: boolean;\n}) {\n    if (breakpoints.isLargerThan3xl) {\n        return 8;\n    }\n\n    if (breakpoints.isLargerThan2xl) {\n        return 7;\n    }\n\n    if (breakpoints.isLargerThanXl) {\n        return 6;\n    }\n\n    if (breakpoints.isLargerThanLg) {\n        return 5;\n    }\n\n    if (breakpoints.isLargerThanMd) {\n        return 4;\n    }\n\n    if (breakpoints.isLargerThanSm) {\n        return 3;\n    }\n\n    return 2;\n}\n"
  },
  {
    "path": "src/renderer/components/grid-carousel/grid-carousel.module.css",
    "content": ".grid-carousel {\n    display: flex;\n    flex-direction: column;\n    gap: var(--theme-spacing-md);\n    width: 100%;\n    margin: 0 auto;\n    container-name: grid-carousel;\n    container-type: inline-size;\n}\n\n.navigation {\n    display: flex;\n    flex-shrink: 0;\n    align-items: center;\n    justify-content: space-between;\n    width: 100%;\n    touch-action: pan-x;\n    cursor: grab;\n    user-select: none;\n}\n\n.navigation:active {\n    cursor: grabbing;\n}\n\n.custom-title-container {\n    display: flex;\n    gap: var(--theme-spacing-sm);\n    align-items: center;\n    justify-content: space-between;\n    width: 100%;\n}\n\n.custom-title-content {\n    flex: 1;\n    min-width: 0;\n}\n\n.grid {\n    display: grid;\n    grid-template-columns: repeat(var(--cards-to-show, 2), minmax(0, 1fr));\n    gap: var(--theme-spacing-md);\n    contain: layout paint;\n    content-visibility: auto;\n    overflow: hidden;\n    will-change: transform;\n}\n\n.card {\n    min-height: 0;\n}\n\n.page-indicator {\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    padding: var(--theme-spacing-sm) 0;\n    cursor: grab;\n    user-select: none;\n}\n\n.page-indicator:active {\n    cursor: grabbing;\n}\n\n.indicator-track {\n    width: 20px;\n    height: 4px;\n    touch-action: none;\n    cursor: grab;\n    border-radius: 2px;\n\n    @mixin light {\n        background-color: darken(var(--theme-colors-background), 10%);\n    }\n\n    @mixin dark {\n        background-color: lighten(var(--theme-colors-background), 15%);\n    }\n}\n\n.indicator-track:active {\n    cursor: grabbing;\n}\n"
  },
  {
    "path": "src/renderer/components/item-card/item-card-controls.module.css",
    "content": ".container {\n    position: absolute;\n    top: 0;\n    left: 0;\n    z-index: 100;\n    display: grid;\n    grid-template-rows: repeat(3, minmax(0, 1fr));\n    grid-template-columns: minmax(0, 1fr);\n    gap: var(--theme-spacing-sm);\n    width: 100%;\n    height: 100%;\n}\n\n.container.compact {\n    opacity: 0;\n    transform: scale(0.8);\n    transition:\n        opacity 0.2s ease-in-out,\n        transform 0.2s ease-in-out;\n}\n\n.container.visible.compact {\n    opacity: 1;\n    transform: scale(1);\n}\n\n.top-controls {\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    width: 100%;\n    height: 100%;\n}\n\n.secondary-controls {\n    position: absolute;\n    right: 0;\n    bottom: 0;\n    display: flex;\n    gap: var(--theme-spacing-xs);\n    justify-content: flex-end;\n    width: 100%;\n    height: 15%;\n    margin-right: var(--theme-spacing-sm);\n    margin-bottom: var(--theme-spacing-lg);\n}\n\n.play-button {\n    position: absolute;\n    top: 50%;\n    transform: translate(-50%, -50%) scale(var(--play-button-scale, 1));\n\n    &:hover {\n        opacity: 1;\n        transform: translate(-50%, -50%) scale(1.1);\n    }\n\n    &:active {\n        opacity: 1;\n        transform: translate(-50%, -50%) scale(0.9);\n    }\n}\n\n.primary {\n    left: 50%;\n    width: 25%;\n    height: 25%;\n\n    svg {\n        fill: rgb(0 0 0);\n    }\n}\n\n.secondary {\n    width: 15%;\n    height: 15%;\n}\n\n.left {\n    left: 25%;\n}\n\n.right {\n    left: 75%;\n}\n\n.secondary-button {\n    all: unset;\n    position: absolute;\n    padding: var(--theme-spacing-md);\n    border-radius: var(--theme-radius-md);\n    opacity: 1;\n    transition: opacity 0.2s ease-in-out;\n    transition: scale 0.2s linear;\n\n    @mixin light {\n        svg {\n            stroke: var(--theme-colors-background);\n        }\n    }\n\n    &:hover {\n        opacity: 1;\n        transform: scale(1.1);\n    }\n\n    &:active {\n        opacity: 1;\n        transform: scale(0.9);\n    }\n}\n\n.user-data {\n    position: absolute;\n    top: 0;\n    right: 0;\n}\n\n.favorite {\n    svg {\n        stroke: white;\n        stroke-width: 1.5px;\n    }\n}\n\n.rating {\n    position: absolute;\n    top: 0;\n    right: 0;\n    padding: var(--theme-spacing-md);\n\n    svg {\n        stroke: white;\n        stroke-width: 1.5px;\n    }\n}\n\n.secondary-button.options {\n    right: 0;\n    bottom: 0;\n}\n\n.secondary-button.expand {\n    bottom: 0;\n    left: 0;\n}\n"
  },
  {
    "path": "src/renderer/components/item-card/item-card-controls.tsx",
    "content": "import clsx from 'clsx';\nimport { motion } from 'motion/react';\nimport { memo, MouseEvent, useMemo } from 'react';\n\nimport styles from './item-card-controls.module.css';\n\nimport { ItemListStateActions } from '/@/renderer/components/item-list/helpers/item-list-state';\nimport { ItemControls } from '/@/renderer/components/item-list/types';\nimport { PlayButton } from '/@/renderer/features/shared/components/play-button';\nimport { PlayTooltip } from '/@/renderer/features/shared/components/play-button-group';\nimport { useIsMutatingCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation';\nimport { useIsMutatingDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation';\nimport { useIsMutatingRating } from '/@/renderer/features/shared/mutations/set-rating-mutation';\nimport { animationVariants } from '/@/shared/components/animations/animation-variants';\nimport { AppIcon, Icon, IconProps } from '/@/shared/components/icon/icon';\nimport { Rating } from '/@/shared/components/rating/rating';\nimport { Tooltip } from '/@/shared/components/tooltip/tooltip';\nimport {\n    Album,\n    AlbumArtist,\n    Artist,\n    Genre,\n    LibraryItem,\n    Playlist,\n    ServerType,\n    Song,\n} from '/@/shared/types/domain-types';\nimport { Play } from '/@/shared/types/types';\n\ninterface ItemCardControlsProps {\n    controls?: ItemControls;\n    enableExpansion?: boolean;\n    internalState?: ItemListStateActions;\n    item: Album | AlbumArtist | Artist | Genre | Playlist | Song | undefined;\n    itemType: LibraryItem;\n    showRating: boolean;\n    type?: 'compact' | 'default' | 'poster';\n}\n\nconst containerProps = {\n    compact: {\n        animate: 'show',\n        exit: 'hidden',\n        initial: 'hidden',\n        variants: animationVariants.combine(animationVariants.zoomIn, animationVariants.fadeIn),\n    },\n    default: {\n        animate: 'show',\n        exit: 'hidden',\n        initial: 'hidden',\n        variants: animationVariants.combine(animationVariants.zoomIn, animationVariants.fadeIn),\n    },\n    poster: {\n        animate: 'show',\n        exit: 'hidden',\n        initial: 'hidden',\n        variants: animationVariants.combine(animationVariants.slideInUp, animationVariants.fadeIn),\n    },\n};\n\nconst createPlayHandler =\n    (\n        controls: ItemControls | undefined,\n        item: Album | AlbumArtist | Artist | Genre | Playlist | Song | undefined,\n        internalState: ItemListStateActions | undefined,\n        itemType: LibraryItem,\n        playType: Play,\n    ) =>\n    (e: MouseEvent<HTMLButtonElement>) => {\n        e.stopPropagation();\n        e.preventDefault();\n\n        if (!item) {\n            return;\n        }\n\n        const isSongItem =\n            itemType === LibraryItem.SONG ||\n            itemType === LibraryItem.PLAYLIST_SONG ||\n            (item as { _itemType: LibraryItem })._itemType === LibraryItem.SONG;\n\n        if (isSongItem && controls?.onDoubleClick && internalState) {\n            const rowId = internalState.extractRowId(item);\n\n            if (rowId) {\n                const index = internalState.findItemIndex(rowId);\n                return controls.onDoubleClick({\n                    event: null,\n                    index,\n                    internalState,\n                    item,\n                    itemType,\n                    meta: {\n                        playType,\n                    },\n                });\n            }\n        }\n\n        controls?.onPlay?.({\n            event: e,\n            internalState,\n            item,\n            itemType,\n            playType,\n        });\n    };\n\nconst createFavoriteHandler =\n    (\n        controls: ItemControls | undefined,\n        item: Album | AlbumArtist | Artist | Genre | Playlist | Song | undefined,\n        internalState: ItemListStateActions | undefined,\n        itemType: LibraryItem,\n    ) =>\n    (e: MouseEvent<HTMLButtonElement>) => {\n        e.stopPropagation();\n        e.preventDefault();\n\n        if (!item) {\n            return;\n        }\n\n        const newFavorite = !(item as { userFavorite: boolean }).userFavorite;\n        controls?.onFavorite?.({\n            event: e,\n            favorite: newFavorite,\n            internalState,\n            item,\n            itemType,\n        });\n    };\n\nconst createRatingChangeHandler =\n    (\n        controls: ItemControls | undefined,\n        item: Album | AlbumArtist | Artist | Genre | Playlist | Song | undefined,\n        internalState: ItemListStateActions | undefined,\n        itemType: LibraryItem,\n    ) =>\n    (rating: number) => {\n        if (!item) {\n            return;\n        }\n\n        let newRating = rating;\n\n        if (rating === (item as { userRating: number }).userRating) {\n            newRating = 0;\n        }\n\n        controls?.onRating?.({\n            event: null,\n            internalState,\n            item,\n            itemType,\n            rating: newRating,\n        });\n    };\n\nconst moreDoubleClickHandler = (e: MouseEvent<HTMLButtonElement>) => {\n    e.stopPropagation();\n    e.preventDefault();\n};\n\nconst createMoreHandler =\n    (\n        controls: ItemControls | undefined,\n        item: Album | AlbumArtist | Artist | Genre | Playlist | Song | undefined,\n        internalState: ItemListStateActions | undefined,\n        itemType: LibraryItem,\n    ) =>\n    (e: MouseEvent<HTMLButtonElement>) => {\n        e.stopPropagation();\n        e.preventDefault();\n        controls?.onMore?.({\n            event: e,\n            internalState,\n            item,\n            itemType,\n        });\n    };\n\nconst createExpandHandler =\n    (\n        controls: ItemControls | undefined,\n        item: Album | AlbumArtist | Artist | Genre | Playlist | Song | undefined,\n        internalState: ItemListStateActions | undefined,\n        itemType: LibraryItem,\n    ) =>\n    (e: MouseEvent<HTMLButtonElement>) => {\n        e.stopPropagation();\n        e.preventDefault();\n        controls?.onExpand?.({\n            event: e,\n            internalState,\n            item,\n            itemType,\n        });\n    };\n\nexport const ItemCardControls = ({\n    controls,\n    enableExpansion,\n    internalState,\n    item,\n    itemType,\n    showRating,\n    type = 'default',\n}: ItemCardControlsProps) => {\n    const playNowHandler = useMemo(\n        () => createPlayHandler(controls, item, internalState, itemType, Play.NOW),\n        [controls, item, internalState, itemType],\n    );\n\n    const playNextHandler = useMemo(\n        () => createPlayHandler(controls, item, internalState, itemType, Play.NEXT),\n        [controls, item, internalState, itemType],\n    );\n\n    const playLastHandler = useMemo(\n        () => createPlayHandler(controls, item, internalState, itemType, Play.LAST),\n        [controls, item, internalState, itemType],\n    );\n\n    const playShuffleHandler = useMemo(\n        () => createPlayHandler(controls, item, internalState, itemType, Play.SHUFFLE),\n        [controls, item, internalState, itemType],\n    );\n\n    const playNextShuffleHandler = useMemo(\n        () => createPlayHandler(controls, item, internalState, itemType, Play.NEXT_SHUFFLE),\n        [controls, item, internalState, itemType],\n    );\n\n    const playLastShuffleHandler = useMemo(\n        () => createPlayHandler(controls, item, internalState, itemType, Play.LAST_SHUFFLE),\n        [controls, item, internalState, itemType],\n    );\n\n    const favoriteHandler = useMemo(\n        () => createFavoriteHandler(controls, item, internalState, itemType),\n        [controls, item, internalState, itemType],\n    );\n\n    const ratingChangeHandler = useMemo(\n        () => createRatingChangeHandler(controls, item, internalState, itemType),\n        [controls, item, internalState, itemType],\n    );\n\n    const moreHandler = useMemo(\n        () => createMoreHandler(controls, item, internalState, itemType),\n        [controls, item, internalState, itemType],\n    );\n\n    const expandHandler = useMemo(\n        () => createExpandHandler(controls, item, internalState, itemType),\n        [controls, item, internalState, itemType],\n    );\n\n    const isFavorite = (item as { userFavorite?: boolean })?.userFavorite ?? false;\n\n    return (\n        <motion.div className={clsx(styles.container)} {...containerProps[type]}>\n            {controls?.onPlay && (\n                <Tooltip.Group>\n                    <PlayTooltip type={Play.NOW}>\n                        <PlayButton\n                            classNames={clsx(styles.playButton, styles.primary)}\n                            onClick={playNowHandler}\n                            onLongPress={playShuffleHandler}\n                        />\n                    </PlayTooltip>\n                    <PlayTooltip type={Play.NEXT}>\n                        <PlayButton\n                            classNames={clsx(styles.playButton, styles.secondary, styles.left)}\n                            icon=\"mediaPlayNext\"\n                            onClick={playNextHandler}\n                            onLongPress={playNextShuffleHandler}\n                        />\n                    </PlayTooltip>\n                    <PlayTooltip type={Play.LAST}>\n                        <PlayButton\n                            classNames={clsx(styles.playButton, styles.secondary, styles.right)}\n                            icon=\"mediaPlayLast\"\n                            onClick={playLastHandler}\n                            onLongPress={playLastShuffleHandler}\n                        />\n                    </PlayTooltip>\n                </Tooltip.Group>\n            )}\n            {controls?.onFavorite && (\n                <FavoriteButton isFavorite={isFavorite} onClick={favoriteHandler} />\n            )}\n            {controls?.onRating &&\n                showRating &&\n                (item?._serverType === ServerType.NAVIDROME ||\n                    item?._serverType === ServerType.SUBSONIC) && (\n                    <RatingButton\n                        onChange={ratingChangeHandler}\n                        rating={(item as { userRating: number }).userRating}\n                    />\n                )}\n            {controls?.onMore && (\n                <SecondaryButton\n                    className={styles.options}\n                    icon=\"ellipsisHorizontal\"\n                    onClick={moreHandler}\n                    onDoubleClick={moreDoubleClickHandler}\n                />\n            )}\n            {controls?.onExpand && enableExpansion && (\n                <SecondaryButton\n                    className={styles.expand}\n                    icon=\"arrowDownS\"\n                    onClick={expandHandler}\n                />\n            )}\n        </motion.div>\n    );\n};\n\nconst FavoriteButton = memo(\n    ({\n        isFavorite,\n        onClick,\n    }: {\n        isFavorite: boolean;\n        onClick?: (e: MouseEvent<HTMLButtonElement>) => void;\n    }) => {\n        const isMutatingCreate = useIsMutatingCreateFavorite();\n        const isMutatingDelete = useIsMutatingDeleteFavorite();\n        const isMutating = isMutatingCreate || isMutatingDelete;\n\n        const favoriteIconProps = useMemo<Partial<IconProps>>(\n            () => ({\n                color: isFavorite ? ('primary' as const) : ('default' as const),\n                fill: isFavorite ? ('primary' as const) : undefined,\n            }),\n            [isFavorite],\n        );\n\n        return (\n            <SecondaryButton\n                className={styles.favorite}\n                disabled={isMutating}\n                icon=\"favorite\"\n                iconProps={favoriteIconProps}\n                onClick={onClick}\n            />\n        );\n    },\n    (prev, next) => prev.isFavorite === next.isFavorite,\n);\n\nconst RatingButton = memo(\n    ({ onChange, rating }: { onChange: (rating: number) => void; rating: number }) => {\n        const ratingClickHandler = (e: MouseEvent<HTMLElement>) => {\n            e.stopPropagation();\n            e.preventDefault();\n        };\n\n        const ratingMouseDownHandler = (e: React.MouseEvent<HTMLElement>) => {\n            e.stopPropagation();\n            e.preventDefault();\n        };\n\n        const isMutatingRating = useIsMutatingRating();\n        return (\n            <Rating\n                className={styles.rating}\n                onChange={onChange}\n                onClick={ratingClickHandler}\n                onMouseDown={ratingMouseDownHandler}\n                readOnly={isMutatingRating}\n                size=\"sm\"\n                value={rating}\n            />\n        );\n    },\n    (prev, next) => prev.rating === next.rating,\n);\n\ninterface SecondaryButtonProps {\n    className?: string;\n    disabled?: boolean;\n    icon: keyof typeof AppIcon;\n    onClick?: (e: MouseEvent<HTMLButtonElement>) => void;\n}\n\nconst SecondaryButton = memo(\n    ({\n        className,\n        disabled,\n        icon,\n        iconProps,\n        onClick,\n        onDoubleClick,\n    }: SecondaryButtonProps & {\n        iconProps?: Partial<IconProps>;\n        onDoubleClick?: (e: MouseEvent<HTMLButtonElement>) => void;\n    }) => {\n        const handleClick = (e: MouseEvent<HTMLButtonElement>) => {\n            e.stopPropagation();\n            e.preventDefault();\n            onClick?.(e);\n        };\n\n        const handleDoubleClick = (e: MouseEvent<HTMLButtonElement>) => {\n            e.stopPropagation();\n            e.preventDefault();\n            onDoubleClick?.(e);\n        };\n\n        const handleMouseDown = (e: React.MouseEvent<HTMLButtonElement>) => {\n            e.stopPropagation();\n            e.preventDefault();\n        };\n\n        return (\n            <button\n                className={clsx(styles.secondaryButton, className)}\n                disabled={disabled}\n                onClick={handleClick}\n                onDoubleClick={handleDoubleClick}\n                onMouseDown={handleMouseDown}\n            >\n                <Icon icon={icon} size=\"lg\" {...iconProps} />\n            </button>\n        );\n    },\n);\n"
  },
  {
    "path": "src/renderer/components/item-card/item-card.module.css",
    "content": ".container {\n    position: relative;\n    display: flex;\n    flex-direction: column;\n    width: 100%;\n    height: 100%;\n    padding: var(--theme-spacing-md);\n    overflow: hidden;\n    user-select: none;\n    background-color: var(--theme-colors-surface);\n    border-radius: var(--theme-radius-md);\n}\n\n.container.selected {\n    outline: 2px solid var(--theme-colors-primary);\n    outline-offset: var(--card-gap, var(--theme-spacing-md));\n}\n\n.container.dragging {\n    opacity: 0.5;\n}\n\n.image-container {\n    position: relative;\n    display: block;\n    width: 100%;\n    aspect-ratio: 1;\n    overflow: hidden;\n    color: inherit;\n    text-decoration: none;\n    border-radius: var(--theme-radius-md);\n\n    &::before {\n        position: absolute;\n        top: 0;\n        left: 0;\n        z-index: 5;\n        width: 100%;\n        height: 100%;\n        pointer-events: none;\n        content: '';\n        background-color: rgb(0 0 0);\n        opacity: 0;\n        transition: all 0.2s ease-in-out;\n    }\n\n    &:hover {\n        @mixin dark {\n            &::before {\n                opacity: 0.7;\n            }\n        }\n\n        @mixin light {\n            &::before {\n                opacity: 0.5;\n            }\n        }\n    }\n}\n\n.image-container.is-round {\n    &::before {\n        border-radius: 50%;\n    }\n}\n\n.favorite-badge {\n    position: absolute;\n    top: -50px;\n    left: -50px;\n    width: 80px;\n    height: 80px;\n    pointer-events: none;\n    background-color: var(--theme-colors-primary);\n    box-shadow: 0 0 10px 8px rgb(0 0 0 / 80%);\n    opacity: 1;\n    transform: rotate(-45deg);\n    transition: opacity 0.2s ease-in-out;\n}\n\n.rating-badge {\n    position: absolute;\n    top: var(--theme-spacing-sm);\n    right: var(--theme-spacing-sm);\n    z-index: 1;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    padding: var(--theme-spacing-xs) var(--theme-spacing-sm);\n    font-size: var(--theme-font-size-md);\n    font-weight: 600;\n    color: var(--theme-colors-foreground);\n    text-shadow: 0 1px 2px rgb(0 0 0 / 80%);\n    pointer-events: none;\n    background-color: var(--theme-colors-primary);\n    border-radius: var(--theme-radius-md);\n    box-shadow: 0 2px 8px rgb(0 0 0 / 50%);\n    opacity: 1;\n    transition: opacity 0.2s ease-in-out;\n}\n\n.image-container:hover .favorite-badge,\n.image-container:hover .rating-badge {\n    opacity: 0;\n}\n\n.image {\n    width: 100%;\n    height: 100%;\n    object-fit: var(--theme-image-fit);\n}\n\n.image.is-round {\n    border-radius: 50%;\n}\n\n.genre-placeholder {\n    box-sizing: border-box;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    width: 100%;\n    height: 100%;\n    padding: var(--theme-spacing-sm);\n    text-align: center;\n}\n\n.genre-placeholder-text {\n    display: -webkit-box;\n    width: 100%;\n    min-width: 0;\n    overflow: hidden;\n    text-overflow: ellipsis;\n    -webkit-line-clamp: 2;\n    font-weight: 600;\n    line-height: 1.2;\n    -webkit-box-orient: vertical;\n}\n\n.detail-container {\n    display: flex;\n    flex-direction: column;\n    gap: var(--theme-spacing-xs);\n    width: 100%;\n    min-width: 0;\n    max-width: 100%;\n    padding-top: var(--theme-spacing-sm);\n    overflow: hidden;\n}\n\n.row {\n    display: block;\n    width: 100%;\n    min-width: 0;\n    max-width: 100%;\n    overflow: hidden;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n\n    a {\n        display: inline;\n        max-width: 100%;\n        color: inherit;\n        cursor: pointer;\n\n        &:hover {\n            text-decoration: underline;\n        }\n    }\n}\n\n.row.bold {\n    font-weight: 500;\n}\n\n.row.muted {\n    color: var(--theme-colors-foreground-muted);\n}\n\n.row.align-start {\n    text-align: left;\n}\n\n.row.align-center {\n    text-align: center;\n}\n\n.row.align-end {\n    text-align: right;\n}\n\n.container.poster {\n    padding: 0;\n    background-color: inherit;\n}\n\n.container.compact {\n    position: relative;\n    padding: 0;\n}\n\n.detail-container.compact {\n    position: absolute;\n    bottom: 0;\n    left: 0;\n    gap: 0;\n    width: 100%;\n    padding: var(--theme-spacing-xs);\n    background-color: alpha(var(--theme-colors-background), 0.5);\n    backdrop-filter: blur(2px);\n    transform: translateY(0);\n    transition:\n        transform 0.2s ease-in-out,\n        opacity 0.2s ease-in-out;\n}\n\n.image-container:hover .detail-container.compact {\n    opacity: 0;\n    transform: translateY(100%);\n}\n\n.row.muted.compact {\n    color: var(--theme-colors-foreground);\n}\n"
  },
  {
    "path": "src/renderer/components/item-card/item-card.tsx",
    "content": "import clsx from 'clsx';\nimport { AnimatePresence } from 'motion/react';\nimport { Fragment, memo, ReactNode, useCallback, useMemo, useState } from 'react';\nimport { generatePath, Link } from 'react-router';\n\nimport styles from './item-card.module.css';\n\nimport i18n from '/@/i18n/i18n';\nimport { ItemCardControls } from '/@/renderer/components/item-card/item-card-controls';\nimport { ItemImage } from '/@/renderer/components/item-image/item-image';\nimport { getDraggedItems } from '/@/renderer/components/item-list/helpers/get-dragged-items';\nimport { getTitlePath } from '/@/renderer/components/item-list/helpers/get-title-path';\nimport {\n    ItemListStateActions,\n    useItemDraggingState,\n    useItemSelectionState,\n} from '/@/renderer/components/item-list/helpers/item-list-state';\nimport { ItemControls } from '/@/renderer/components/item-list/types';\nimport { JoinedArtists } from '/@/renderer/features/albums/components/joined-artists';\nimport { useDragDrop } from '/@/renderer/hooks/use-drag-drop';\nimport { AppRoute } from '/@/renderer/router/routes';\nimport { useShowRatings } from '/@/renderer/store';\nimport {\n    formatDateAbsolute,\n    formatDateAbsoluteUTC,\n    formatDateRelative,\n    formatDurationString,\n    formatRating,\n} from '/@/renderer/utils/format';\nimport { SEPARATOR_STRING } from '/@/shared/api/utils';\nimport { ExplicitIndicator } from '/@/shared/components/explicit-indicator/explicit-indicator';\nimport { Group } from '/@/shared/components/group/group';\nimport { Icon } from '/@/shared/components/icon/icon';\nimport { Separator } from '/@/shared/components/separator/separator';\nimport { Skeleton } from '/@/shared/components/skeleton/skeleton';\nimport { Text } from '/@/shared/components/text/text';\nimport { useDoubleClick } from '/@/shared/hooks/use-double-click';\nimport {\n    Album,\n    AlbumArtist,\n    Artist,\n    Genre,\n    LibraryItem,\n    Playlist,\n    Song,\n} from '/@/shared/types/domain-types';\nimport { DragOperation, DragTarget } from '/@/shared/types/drag-and-drop';\nimport { stringToColor } from '/@/shared/utils/string-to-color';\n\nexport type DataRow = {\n    align?: 'center' | 'end' | 'start';\n    format: (\n        data: Album | AlbumArtist | Artist | Genre | Playlist | Song,\n    ) => null | ReactNode | string;\n    id: string;\n    isMuted?: boolean;\n};\n\nexport interface ItemCardProps {\n    controls?: ItemControls;\n    data: Album | AlbumArtist | Artist | Genre | Playlist | Song | undefined;\n    enableDrag?: boolean;\n    enableExpansion?: boolean;\n    enableMultiSelect?: boolean;\n    enableNavigation?: boolean;\n    imageAsLink?: boolean;\n    imageFetchPriority?: 'auto' | 'high' | 'low';\n    internalState?: ItemListStateActions;\n    isRound?: boolean;\n    itemType: LibraryItem;\n    rows?: DataRow[];\n    type?: 'compact' | 'default' | 'poster';\n    withControls?: boolean;\n}\n\nexport const ItemCard = ({\n    controls,\n    data,\n    enableDrag,\n    enableExpansion,\n    enableMultiSelect,\n    enableNavigation = true,\n    imageAsLink,\n    imageFetchPriority,\n    internalState,\n    isRound,\n    itemType,\n    rows: providedRows,\n    type = 'poster',\n    withControls,\n}: ItemCardProps) => {\n    const showRatings = useShowRatings();\n    const imageUrl = getImageUrl(data);\n    const rows = providedRows || [];\n\n    switch (type) {\n        case 'compact':\n            return (\n                <MemoizedCompactItemCard\n                    controls={controls}\n                    data={data}\n                    enableDrag={enableDrag}\n                    enableExpansion={enableExpansion}\n                    enableMultiSelect={enableMultiSelect}\n                    enableNavigation={enableNavigation}\n                    imageAsLink={imageAsLink}\n                    imageFetchPriority={imageFetchPriority}\n                    imageUrl={imageUrl}\n                    internalState={internalState}\n                    isRound={isRound}\n                    itemType={itemType}\n                    rows={rows}\n                    showRating={showRatings}\n                    withControls={withControls}\n                />\n            );\n        case 'poster':\n            return (\n                <MemoizedPosterItemCard\n                    controls={controls}\n                    data={data}\n                    enableDrag={enableDrag}\n                    enableExpansion={enableExpansion}\n                    enableMultiSelect={enableMultiSelect}\n                    enableNavigation={enableNavigation}\n                    imageAsLink={imageAsLink}\n                    imageFetchPriority={imageFetchPriority}\n                    imageUrl={imageUrl}\n                    internalState={internalState}\n                    isRound={isRound}\n                    itemType={itemType}\n                    rows={rows}\n                    showRating={showRatings}\n                    withControls={withControls}\n                />\n            );\n        case 'default':\n        default:\n            return (\n                <MemoizedDefaultItemCard\n                    controls={controls}\n                    data={data}\n                    enableDrag={enableDrag}\n                    enableExpansion={enableExpansion}\n                    enableNavigation={enableNavigation}\n                    imageAsLink={imageAsLink}\n                    imageFetchPriority={imageFetchPriority}\n                    imageUrl={imageUrl}\n                    internalState={internalState}\n                    isRound={isRound}\n                    itemType={itemType}\n                    rows={rows}\n                    showRating={showRatings}\n                    withControls={withControls}\n                />\n            );\n    }\n};\n\nexport interface ItemCardDerivativeProps extends Omit<ItemCardProps, 'type'> {\n    controls?: ItemControls;\n    enableExpansion?: boolean;\n    enableNavigation?: boolean;\n    imageAsLink?: boolean;\n    imageFetchPriority?: 'auto' | 'high' | 'low';\n    imageUrl: string | undefined;\n    internalState?: ItemListStateActions;\n    rows: DataRow[];\n    showRating: boolean;\n}\n\nconst CompactItemCard = ({\n    controls,\n    data,\n    enableDrag,\n    enableExpansion,\n    enableMultiSelect,\n    enableNavigation,\n    imageAsLink,\n    imageFetchPriority,\n    internalState,\n    isRound,\n    itemType,\n    rows,\n    showRating,\n    withControls,\n}: ItemCardDerivativeProps) => {\n    const [showControls, setShowControls] = useState(false);\n    const itemRowId =\n        data && internalState && typeof data === 'object' && 'id' in data\n            ? internalState.extractRowId(data)\n            : undefined;\n    const isSelected = useItemSelectionState(internalState, itemRowId || undefined);\n\n    const getId = useCallback(() => {\n        if (!data) {\n            return [];\n        }\n\n        const draggedItems = getDraggedItems(data, internalState, enableMultiSelect !== false);\n        return draggedItems.map((item) => item.id);\n    }, [data, internalState, enableMultiSelect]);\n\n    const getItem = useCallback(() => {\n        if (!data) {\n            return [];\n        }\n\n        const draggedItems = getDraggedItems(data, internalState, enableMultiSelect !== false);\n        return draggedItems;\n    }, [data, internalState, enableMultiSelect]);\n\n    const onDragStart = useCallback(() => {\n        if (!data) {\n            return;\n        }\n\n        const draggedItems = getDraggedItems(data, internalState, enableMultiSelect !== false);\n        if (internalState) {\n            internalState.setDragging(draggedItems);\n        }\n    }, [data, internalState, enableMultiSelect]);\n\n    const onDrop = useCallback(() => {\n        if (internalState) {\n            internalState.setDragging([]);\n        }\n    }, [internalState]);\n\n    const dragOperation = useMemo(\n        () =>\n            itemType === LibraryItem.QUEUE_SONG\n                ? [DragOperation.REORDER, DragOperation.ADD]\n                : [DragOperation.ADD],\n        [itemType],\n    );\n\n    const drag = useMemo(\n        () => ({\n            getId,\n            getItem,\n            itemType,\n            onDragStart,\n            onDrop,\n            operation: dragOperation,\n            target: DragTarget.ALBUM,\n        }),\n        [getId, getItem, itemType, onDragStart, onDrop, dragOperation],\n    );\n\n    const { isDragging: isDraggingLocal, ref } = useDragDrop<HTMLDivElement>({\n        drag,\n        isEnabled: !!enableDrag && !!data,\n    });\n\n    const itemId = data && internalState ? data.id : undefined;\n    const isDraggingState = useItemDraggingState(internalState, itemId);\n    const isDragging = isDraggingState || isDraggingLocal;\n\n    const handleClick = useDoubleClick({\n        onDoubleClick: (e: React.MouseEvent<HTMLDivElement>) => {\n            if (!data || !controls || !internalState) {\n                return;\n            }\n\n            controls.onDoubleClick?.({\n                event: e,\n                internalState,\n                item: data as any,\n                itemType,\n            });\n        },\n        onSingleClick: (e: React.MouseEvent<HTMLDivElement>) => {\n            if (!data || !controls || !internalState) {\n                return;\n            }\n\n            // Don't trigger selection if clicking on interactive elements\n            const target = e.target as HTMLElement;\n            const isInteractiveElement = target.closest(\n                'button, a, input, select, textarea, [role=\"button\"]',\n            );\n\n            if (isInteractiveElement) {\n                return;\n            }\n\n            controls.onClick?.({\n                event: e,\n                internalState,\n                item: data as any,\n                itemType,\n            });\n        },\n    });\n\n    if (data) {\n        const navigationPath = getItemNavigationPath(data, itemType);\n\n        const handleMouseEnter = () => {\n            if (withControls) {\n                setShowControls(true);\n            }\n        };\n\n        const handleMouseLeave = () => {\n            if (withControls) {\n                setShowControls(false);\n            }\n        };\n\n        const handleContextMenu = (e: React.MouseEvent<HTMLElement>) => {\n            if (!data || !controls) {\n                return;\n            }\n\n            e.preventDefault();\n\n            controls.onMore?.({\n                event: e,\n                internalState,\n                item: data as any,\n                itemType,\n            });\n        };\n\n        const handleImageClick = (e: React.MouseEvent<HTMLElement>) => {\n            // Prevent navigation on double-click, let the double-click handler work\n            if (e.detail === 2 && navigationPath) {\n                e.preventDefault();\n            }\n            handleClick(e as any);\n        };\n\n        const handleLinkDragStart = (e: React.DragEvent<HTMLAnchorElement>) => {\n            // Prevent default browser link drag behavior to allow custom drag and drop\n            e.preventDefault();\n            e.stopPropagation();\n        };\n\n        const isFavorite =\n            'userFavorite' in data && (data as { userFavorite: boolean }).userFavorite;\n        const userRating =\n            'userRating' in data &&\n            typeof (data as { userRating: null | number }).userRating === 'number'\n                ? (data as { userRating: null | number }).userRating\n                : null;\n        const hasRating = showRating && userRating !== null && userRating > 0;\n\n        const imageContainerClassName = clsx(styles.imageContainer, {\n            [styles.isRound]: isRound,\n        });\n\n        const imageContainerContent = (\n            <>\n                {itemType === LibraryItem.GENRE &&\n                data &&\n                'name' in data &&\n                typeof (data as Genre).name === 'string' ? (\n                    <GenreImagePlaceholder\n                        className={clsx(styles.image, styles.genrePlaceholder, {\n                            [styles.isRound]: isRound,\n                        })}\n                        name={(data as Genre).name}\n                    />\n                ) : (\n                    <ItemImage\n                        className={clsx(styles.image, {\n                            [styles.isRound]: isRound,\n                        })}\n                        enableDebounce={false}\n                        explicitStatus={\n                            'explicitStatus' in data && data ? data.explicitStatus : null\n                        }\n                        fetchPriority={imageFetchPriority}\n                        id={data?.imageId}\n                        itemType={itemType}\n                        src={(data as Album | AlbumArtist | Playlist | Song)?.imageUrl}\n                        type=\"itemCard\"\n                    />\n                )}\n                {isFavorite && <div className={styles.favoriteBadge} />}\n                {hasRating && <div className={styles.ratingBadge}>{userRating}</div>}\n                <AnimatePresence>\n                    {withControls && showControls && data && (\n                        <ItemCardControls\n                            controls={controls}\n                            enableExpansion={enableExpansion}\n                            internalState={internalState}\n                            item={data}\n                            itemType={itemType}\n                            showRating={showRating}\n                            type=\"compact\"\n                        />\n                    )}\n                </AnimatePresence>\n                <div className={clsx(styles.detailContainer, styles.compact)}>\n                    {rows\n                        .filter(\n                            (row): row is NonNullable<typeof row> =>\n                                row !== null && row !== undefined,\n                        )\n                        .map((row, index) => (\n                            <ItemCardRow\n                                data={data!}\n                                index={index}\n                                key={row.id}\n                                row={row}\n                                type=\"compact\"\n                            />\n                        ))}\n                </div>\n            </>\n        );\n\n        return (\n            <div\n                className={clsx(styles.container, styles.compact, {\n                    [styles.dragging]: isDragging,\n                    [styles.selected]: isSelected,\n                })}\n                ref={ref}\n            >\n                {enableNavigation && navigationPath && (imageAsLink ?? !internalState) ? (\n                    <Link\n                        className={imageContainerClassName}\n                        draggable={false}\n                        onClick={handleImageClick}\n                        onContextMenu={handleContextMenu}\n                        onDragStart={handleLinkDragStart}\n                        onMouseEnter={handleMouseEnter}\n                        onMouseLeave={handleMouseLeave}\n                        state={{ item: data }}\n                        to={navigationPath}\n                    >\n                        {imageContainerContent}\n                    </Link>\n                ) : (\n                    <div\n                        className={imageContainerClassName}\n                        onClick={handleImageClick}\n                        onContextMenu={handleContextMenu}\n                        onMouseEnter={handleMouseEnter}\n                        onMouseLeave={handleMouseLeave}\n                    >\n                        {imageContainerContent}\n                    </div>\n                )}\n            </div>\n        );\n    }\n\n    return (\n        <div className={clsx(styles.container, styles.compact)}>\n            <div className={clsx(styles.imageContainer, { [styles.isRound]: isRound })}>\n                <Skeleton className={styles.image} />\n                <div className={clsx(styles.detailContainer, styles.compact)}>\n                    {rows\n                        .filter(\n                            (row): row is NonNullable<typeof row> =>\n                                row !== null && row !== undefined,\n                        )\n                        .map((row, index) => (\n                            <Text\n                                className={clsx(styles.row, {\n                                    [styles.muted]: index > 0,\n                                })}\n                                key={row.id}\n                                size={index > 0 ? 'sm' : 'md'}\n                            >\n                                &nbsp;\n                            </Text>\n                        ))}\n                </div>\n            </div>\n        </div>\n    );\n};\n\nconst DefaultItemCard = ({\n    controls,\n    data,\n    enableExpansion,\n    enableNavigation,\n    imageAsLink,\n    imageFetchPriority,\n    internalState,\n    isRound,\n    itemType,\n    rows,\n    showRating,\n    withControls,\n}: ItemCardDerivativeProps) => {\n    const [showControls, setShowControls] = useState(false);\n    const itemRowId =\n        data && internalState && typeof data === 'object' && 'id' in data\n            ? internalState.extractRowId(data)\n            : undefined;\n    const isSelected = useItemSelectionState(internalState, itemRowId || undefined);\n\n    const handleClick = useDoubleClick({\n        onDoubleClick: (e: React.MouseEvent<HTMLDivElement>) => {\n            if (!data || !controls || !internalState) {\n                return;\n            }\n\n            controls.onDoubleClick?.({\n                event: e,\n                internalState,\n                item: data as any,\n                itemType,\n            });\n        },\n        onSingleClick: (e: React.MouseEvent<HTMLDivElement>) => {\n            if (!data || !controls || !internalState) {\n                return;\n            }\n\n            // Don't trigger selection if clicking on interactive elements\n            const target = e.target as HTMLElement;\n            const isInteractiveElement = target.closest(\n                'button, a, input, select, textarea, [role=\"button\"]',\n            );\n\n            if (isInteractiveElement) {\n                return;\n            }\n\n            controls.onClick?.({\n                event: e,\n                internalState,\n                item: data as any,\n                itemType,\n            });\n        },\n    });\n\n    if (data) {\n        const navigationPath = getItemNavigationPath(data, itemType);\n\n        const handleMouseEnter = () => {\n            if (withControls) {\n                setShowControls(true);\n            }\n        };\n\n        const handleMouseLeave = () => {\n            if (withControls) {\n                setShowControls(false);\n            }\n        };\n\n        const handleContextMenu = (e: React.MouseEvent<HTMLElement>) => {\n            if (!data || !controls) {\n                return;\n            }\n\n            e.preventDefault();\n\n            controls.onMore?.({\n                event: e,\n                internalState,\n                item: data as any,\n                itemType,\n            });\n        };\n\n        const handleImageClick = (e: React.MouseEvent<HTMLElement>) => {\n            // Prevent navigation on double-click, let the double-click handler work\n            if (e.detail === 2 && navigationPath) {\n                e.preventDefault();\n            }\n            handleClick(e as any);\n        };\n\n        const handleLinkDragStart = (e: React.DragEvent<HTMLAnchorElement>) => {\n            // Prevent default browser link drag behavior to allow custom drag and drop\n            e.preventDefault();\n            e.stopPropagation();\n        };\n\n        const imageContainerClassName = clsx(styles.imageContainer, {\n            [styles.isRound]: isRound,\n        });\n\n        const isFavorite =\n            'userFavorite' in data && (data as { userFavorite: boolean }).userFavorite;\n        const userRating =\n            'userRating' in data &&\n            typeof (data as { userRating: null | number }).userRating === 'number'\n                ? (data as { userRating: null | number }).userRating\n                : null;\n        const hasRating = showRating && userRating !== null && userRating > 0;\n\n        const imageContainerContent = (\n            <>\n                {itemType === LibraryItem.GENRE &&\n                data &&\n                'name' in data &&\n                typeof (data as Genre).name === 'string' ? (\n                    <GenreImagePlaceholder\n                        className={clsx(styles.image, styles.genrePlaceholder, {\n                            [styles.isRound]: isRound,\n                        })}\n                        name={(data as Genre).name}\n                    />\n                ) : (\n                    <ItemImage\n                        className={clsx(styles.image, { [styles.isRound]: isRound })}\n                        enableDebounce={false}\n                        explicitStatus={\n                            'explicitStatus' in data && data ? data.explicitStatus : null\n                        }\n                        fetchPriority={imageFetchPriority}\n                        id={data?.imageId}\n                        itemType={itemType}\n                        src={(data as Album | AlbumArtist | Playlist | Song)?.imageUrl}\n                        type=\"itemCard\"\n                    />\n                )}\n                {isFavorite && <div className={styles.favoriteBadge} />}\n                {hasRating && <div className={styles.ratingBadge}>{userRating}</div>}\n                <AnimatePresence>\n                    {withControls && showControls && (\n                        <ItemCardControls\n                            controls={controls}\n                            enableExpansion={enableExpansion}\n                            item={data}\n                            itemType={itemType}\n                            showRating={showRating}\n                            type=\"default\"\n                        />\n                    )}\n                </AnimatePresence>\n            </>\n        );\n\n        return (\n            <div\n                className={clsx(styles.container, {\n                    [styles.selected]: isSelected,\n                })}\n            >\n                {enableNavigation && navigationPath && (imageAsLink ?? !internalState) ? (\n                    <Link\n                        className={imageContainerClassName}\n                        draggable={false}\n                        onClick={handleImageClick}\n                        onContextMenu={handleContextMenu}\n                        onDragStart={handleLinkDragStart}\n                        onMouseEnter={handleMouseEnter}\n                        onMouseLeave={handleMouseLeave}\n                        state={{ item: data }}\n                        to={navigationPath}\n                    >\n                        {imageContainerContent}\n                    </Link>\n                ) : (\n                    <div\n                        className={imageContainerClassName}\n                        onClick={handleImageClick}\n                        onContextMenu={handleContextMenu}\n                        onMouseEnter={handleMouseEnter}\n                        onMouseLeave={handleMouseLeave}\n                    >\n                        {imageContainerContent}\n                    </div>\n                )}\n                <div className={styles.detailContainer}>\n                    {rows\n                        .filter(\n                            (row): row is NonNullable<typeof row> =>\n                                row !== null && row !== undefined,\n                        )\n                        .map((row, index) => (\n                            <ItemCardRow\n                                data={data!}\n                                index={index}\n                                key={row.id}\n                                row={row}\n                                type=\"default\"\n                            />\n                        ))}\n                </div>\n            </div>\n        );\n    }\n\n    return (\n        <div className={clsx(styles.container)}>\n            <div className={clsx(styles.imageContainer, { [styles.isRound]: isRound })}>\n                <Skeleton className={styles.image} />\n            </div>\n            <div className={styles.detailContainer}>\n                {rows\n                    .filter(\n                        (row): row is NonNullable<typeof row> => row !== null && row !== undefined,\n                    )\n                    .map((row, index) => (\n                        <Text\n                            className={clsx(styles.row, {\n                                [styles.muted]: index > 0,\n                            })}\n                            key={row.id}\n                            size={index > 0 ? 'sm' : 'md'}\n                        >\n                            &nbsp;\n                        </Text>\n                    ))}\n            </div>\n        </div>\n    );\n};\n\nconst PosterItemCard = ({\n    controls,\n    data,\n    enableDrag,\n    enableExpansion,\n    enableMultiSelect,\n    enableNavigation,\n    imageAsLink,\n    imageFetchPriority,\n    internalState,\n    isRound,\n    itemType,\n    rows,\n    showRating,\n    withControls,\n}: ItemCardDerivativeProps) => {\n    const [showControls, setShowControls] = useState(false);\n    const itemRowId =\n        data && internalState && typeof data === 'object' && 'id' in data\n            ? internalState.extractRowId(data)\n            : undefined;\n    const isSelected = useItemSelectionState(internalState, itemRowId || undefined);\n\n    const getId = useCallback(() => {\n        if (!data) {\n            return [];\n        }\n\n        const draggedItems = getDraggedItems(data, internalState, enableMultiSelect !== false);\n        return draggedItems.map((item) => item.id);\n    }, [data, internalState, enableMultiSelect]);\n\n    const getItem = useCallback(() => {\n        if (!data) {\n            return [];\n        }\n\n        const draggedItems = getDraggedItems(data, internalState, enableMultiSelect !== false);\n        return draggedItems;\n    }, [data, internalState, enableMultiSelect]);\n\n    const onDragStart = useCallback(() => {\n        if (!data) {\n            return;\n        }\n\n        const draggedItems = getDraggedItems(data, internalState, enableMultiSelect !== false);\n        if (internalState) {\n            internalState.setDragging(draggedItems);\n        }\n    }, [data, internalState, enableMultiSelect]);\n\n    const onDrop = useCallback(() => {\n        if (internalState) {\n            internalState.setDragging([]);\n        }\n    }, [internalState]);\n\n    const dragOperation = useMemo(\n        () =>\n            itemType === LibraryItem.QUEUE_SONG\n                ? [DragOperation.REORDER, DragOperation.ADD]\n                : [DragOperation.ADD],\n        [itemType],\n    );\n\n    const drag = useMemo(\n        () => ({\n            getId,\n            getItem,\n            itemType,\n            onDragStart,\n            onDrop,\n            operation: dragOperation,\n            target: DragTarget.ALBUM,\n        }),\n        [getId, getItem, itemType, onDragStart, onDrop, dragOperation],\n    );\n\n    const { isDragging: isDraggingLocal, ref } = useDragDrop<HTMLDivElement>({\n        drag,\n        isEnabled: !!enableDrag && !!data,\n    });\n\n    const itemId = data && internalState ? data.id : undefined;\n    const isDraggingState = useItemDraggingState(internalState, itemId);\n    const isDragging = isDraggingState || isDraggingLocal;\n\n    const handleClick = useDoubleClick({\n        onDoubleClick: (e: React.MouseEvent<HTMLDivElement>) => {\n            if (!data || !controls || !internalState) {\n                return;\n            }\n\n            controls.onDoubleClick?.({\n                event: e,\n                internalState,\n                item: data as any,\n                itemType,\n            });\n        },\n        onSingleClick: (e: React.MouseEvent<HTMLDivElement>) => {\n            if (!data || !controls || !internalState) {\n                return;\n            }\n\n            // Don't trigger selection if clicking on interactive elements\n            const target = e.target as HTMLElement;\n            const isInteractiveElement = target.closest(\n                'button, a, input, select, textarea, [role=\"button\"]',\n            );\n\n            if (isInteractiveElement) {\n                return;\n            }\n\n            controls.onClick?.({\n                event: e,\n                internalState,\n                item: data as any,\n                itemType,\n            });\n        },\n    });\n\n    if (data) {\n        const navigationPath = getItemNavigationPath(data, itemType);\n\n        const handleMouseEnter = () => {\n            if (withControls) {\n                setShowControls(true);\n            }\n        };\n\n        const handleMouseLeave = () => {\n            if (withControls) {\n                setShowControls(false);\n            }\n        };\n\n        const handleContextMenu = (e: React.MouseEvent<HTMLElement>) => {\n            if (!data || !controls) {\n                return;\n            }\n\n            e.preventDefault();\n\n            controls.onMore?.({\n                event: e,\n                internalState,\n                item: data as any,\n                itemType,\n            });\n        };\n\n        const handleImageClick = (e: React.MouseEvent<HTMLElement>) => {\n            // Prevent navigation on double-click, let the double-click handler work\n            if (e.detail === 2 && navigationPath) {\n                e.preventDefault();\n            }\n            handleClick(e as any);\n        };\n\n        const handleLinkDragStart = (e: React.DragEvent<HTMLAnchorElement>) => {\n            // Prevent default browser link drag behavior to allow custom drag and drop\n            e.preventDefault();\n            e.stopPropagation();\n        };\n\n        const imageContainerClassName = clsx(styles.imageContainer, {\n            [styles.isRound]: isRound,\n        });\n\n        const isFavorite =\n            'userFavorite' in data && (data as { userFavorite: boolean }).userFavorite;\n        const userRating =\n            'userRating' in data &&\n            typeof (data as { userRating: null | number }).userRating === 'number'\n                ? (data as { userRating: null | number }).userRating\n                : null;\n        const hasRating = showRating && userRating !== null && userRating > 0;\n\n        const imageContainerContent = (\n            <>\n                {itemType === LibraryItem.GENRE &&\n                data &&\n                'name' in data &&\n                typeof (data as Genre).name === 'string' ? (\n                    <GenreImagePlaceholder\n                        className={clsx(styles.image, styles.genrePlaceholder, {\n                            [styles.isRound]: isRound,\n                        })}\n                        name={(data as Genre).name}\n                    />\n                ) : (\n                    <ItemImage\n                        className={clsx(styles.image, { [styles.isRound]: isRound })}\n                        enableDebounce={false}\n                        explicitStatus={\n                            'explicitStatus' in data && data ? data.explicitStatus : null\n                        }\n                        fetchPriority={imageFetchPriority}\n                        id={(data as { imageId: string })?.imageId}\n                        itemType={itemType}\n                        src={(data as { imageUrl: string })?.imageUrl}\n                        type=\"itemCard\"\n                    />\n                )}\n                {isFavorite && <div className={styles.favoriteBadge} />}\n                {hasRating && <div className={styles.ratingBadge}>{userRating}</div>}\n                <AnimatePresence>\n                    {withControls && showControls && data && (\n                        <ItemCardControls\n                            controls={controls}\n                            enableExpansion={enableExpansion}\n                            internalState={internalState}\n                            item={data}\n                            itemType={itemType}\n                            showRating={showRating}\n                            type=\"poster\"\n                        />\n                    )}\n                </AnimatePresence>\n            </>\n        );\n\n        return (\n            <div\n                className={clsx(styles.container, styles.poster, {\n                    [styles.dragging]: isDragging,\n                    [styles.selected]: isSelected,\n                })}\n                ref={ref}\n            >\n                {enableNavigation && navigationPath && (imageAsLink ?? !internalState) ? (\n                    <Link\n                        className={imageContainerClassName}\n                        draggable={false}\n                        onClick={handleImageClick}\n                        onContextMenu={handleContextMenu}\n                        onDragStart={handleLinkDragStart}\n                        onMouseEnter={handleMouseEnter}\n                        onMouseLeave={handleMouseLeave}\n                        state={{ item: data }}\n                        to={navigationPath}\n                    >\n                        {imageContainerContent}\n                    </Link>\n                ) : (\n                    <div\n                        className={imageContainerClassName}\n                        onClick={handleImageClick}\n                        onContextMenu={handleContextMenu}\n                        onMouseEnter={handleMouseEnter}\n                        onMouseLeave={handleMouseLeave}\n                    >\n                        {imageContainerContent}\n                    </div>\n                )}\n                {data && (\n                    <div className={styles.detailContainer}>\n                        {rows\n                            .filter(\n                                (row): row is NonNullable<typeof row> =>\n                                    row !== null && row !== undefined,\n                            )\n                            .map((row, index) => (\n                                <ItemCardRow\n                                    data={data}\n                                    index={index}\n                                    key={row.id}\n                                    row={row}\n                                    type=\"poster\"\n                                />\n                            ))}\n                    </div>\n                )}\n            </div>\n        );\n    }\n\n    return (\n        <div className={clsx(styles.container, styles.poster)}>\n            <div className={clsx(styles.imageContainer, { [styles.isRound]: isRound })}>\n                <Skeleton className={clsx(styles.image, { [styles.isRound]: isRound })} />\n            </div>\n            <div className={styles.detailContainer}>\n                {rows\n                    .filter(\n                        (row): row is NonNullable<typeof row> => row !== null && row !== undefined,\n                    )\n                    .map((row, index) => (\n                        <Text\n                            className={clsx(styles.row, {\n                                [styles.muted]: index > 0,\n                            })}\n                            key={row.id}\n                            size={index > 0 ? 'sm' : 'md'}\n                        >\n                            &nbsp;\n                        </Text>\n                    ))}\n            </div>\n        </div>\n    );\n};\n\nconst MemoizedPosterItemCard = memo(PosterItemCard);\nMemoizedPosterItemCard.displayName = 'MemoizedPosterItemCard';\n\nconst MemoizedCompactItemCard = memo(CompactItemCard);\nMemoizedCompactItemCard.displayName = 'MemoizedCompactItemCard';\n\nconst MemoizedDefaultItemCard = memo(DefaultItemCard);\nMemoizedDefaultItemCard.displayName = 'MemoizedDefaultItemCard';\n\nexport const getDataRows = (type?: 'compact' | 'default' | 'poster'): DataRow[] => {\n    return [\n        {\n            format: (data) => {\n                const explicitStatus = 'explicitStatus' in data ? data.explicitStatus : null;\n                if ('name' in data && data.name) {\n                    if ('id' in data && data.id) {\n                        if ('_itemType' in data) {\n                            switch (data._itemType) {\n                                case LibraryItem.ALBUM:\n                                    return (\n                                        <Link\n                                            state={{ item: data }}\n                                            to={generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {\n                                                albumId: data.id,\n                                            })}\n                                        >\n                                            <ExplicitIndicator explicitStatus={explicitStatus} />\n                                            {data.name}\n                                        </Link>\n                                    );\n                                case LibraryItem.ALBUM_ARTIST:\n                                    return (\n                                        <Link\n                                            state={{ item: data }}\n                                            to={generatePath(\n                                                AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,\n                                                {\n                                                    albumArtistId: data.id,\n                                                },\n                                            )}\n                                        >\n                                            <ExplicitIndicator explicitStatus={explicitStatus} />\n                                            {data.name}\n                                        </Link>\n                                    );\n                                case LibraryItem.GENRE:\n                                    return (\n                                        <Link\n                                            state={{ item: data }}\n                                            to={generatePath(AppRoute.LIBRARY_GENRES_DETAIL, {\n                                                genreId: data.id,\n                                            })}\n                                        >\n                                            {data.name}\n                                        </Link>\n                                    );\n                                case LibraryItem.PLAYLIST:\n                                    return (\n                                        <Link\n                                            state={{ item: data }}\n                                            to={generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, {\n                                                playlistId: data.id,\n                                            })}\n                                        >\n                                            {data.name}\n                                        </Link>\n                                    );\n                                default:\n                                    return (\n                                        <>\n                                            <ExplicitIndicator explicitStatus={explicitStatus} />\n                                            {data.name}\n                                        </>\n                                    );\n                            }\n                        }\n                    }\n                    return (\n                        <>\n                            <ExplicitIndicator explicitStatus={explicitStatus} />\n                            {data.name}\n                        </>\n                    );\n                }\n                return '';\n            },\n            id: 'name',\n        },\n        {\n            format: (data) => {\n                if ('albumArtists' in data && Array.isArray(data.albumArtists)) {\n                    return (\n                        <JoinedArtists\n                            artistName={data.albumArtistName}\n                            artists={data.albumArtists}\n                            linkProps={{ fw: 400, isMuted: true }}\n                            rootTextProps={{\n                                fw: 400,\n                                isMuted: type === 'compact' ? false : true,\n                                size: 'sm',\n                            }}\n                        />\n                    );\n                }\n                return '';\n            },\n            id: 'albumArtists',\n            isMuted: true,\n        },\n        {\n            format: (data) => {\n                if ('artists' in data && Array.isArray(data.artists)) {\n                    return (data as Album | Song).artists.map((artist, index) => (\n                        <Fragment key={artist.id}>\n                            <Link\n                                state={{ item: artist }}\n                                to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {\n                                    albumArtistId: artist.id,\n                                })}\n                            >\n                                {artist.name}\n                            </Link>\n                            {index < (data as Album | Song).artists.length - 1 && <Separator />}\n                        </Fragment>\n                    ));\n                }\n                return '';\n            },\n            id: 'artists',\n            isMuted: true,\n        },\n        {\n            format: (data) => {\n                if ('duration' in data && data.duration !== null) {\n                    return formatDurationString(data.duration);\n                }\n                return '';\n            },\n            id: 'duration',\n        },\n        {\n            format: (data) => {\n                if ('releaseYear' in data && data.releaseYear !== null) {\n                    const releaseYear = data.releaseYear;\n                    const originalYear =\n                        'originalYear' in data && data.originalYear !== null\n                            ? data.originalYear\n                            : null;\n\n                    if (originalYear !== null && originalYear !== releaseYear) {\n                        return `${originalYear}${SEPARATOR_STRING}${releaseYear}`;\n                    }\n\n                    return String(releaseYear);\n                }\n                return '';\n            },\n            id: 'releaseYear',\n        },\n        {\n            format: (data) => {\n                if ('releaseDate' in data && data.releaseDate) {\n                    if (\n                        'originalDate' in data &&\n                        data.originalDate &&\n                        data.originalDate !== data.releaseDate\n                    ) {\n                        return `${formatDateAbsoluteUTC(data.originalDate)}${SEPARATOR_STRING}${formatDateAbsoluteUTC(data.releaseDate)}`;\n                    }\n\n                    return `${formatDateAbsoluteUTC(data.releaseDate)}`;\n                }\n                return '';\n            },\n            id: 'releaseDate',\n        },\n        {\n            format: (data) => {\n                if ('createdAt' in data && data.createdAt) {\n                    return formatDateAbsolute(data.createdAt);\n                }\n                return '';\n            },\n            id: 'createdAt',\n        },\n        {\n            format: (data) => {\n                if ('lastPlayedAt' in data && data.lastPlayedAt) {\n                    return (\n                        <Group align=\"center\" gap=\"xs\">\n                            <Icon icon=\"lastPlayed\" size=\"sm\" />\n                            {formatDateRelative(data.lastPlayedAt)}\n                        </Group>\n                    );\n                }\n                return '';\n            },\n            id: 'lastPlayedAt',\n        },\n        {\n            format: (data) => {\n                if ('playCount' in data && data.playCount !== null) {\n                    return i18n.t('entity.play', { count: data.playCount });\n                }\n                return '';\n            },\n            id: 'playCount',\n        },\n        {\n            format: (data) => {\n                if ('genres' in data && Array.isArray(data.genres)) {\n                    return (data as Album | AlbumArtist | Song).genres\n                        .map((genre) => genre.name)\n                        .join(', ');\n                }\n                return '';\n            },\n            id: 'genres',\n            isMuted: true,\n        },\n        {\n            format: (data) => {\n                if ('album' in data && data.album) {\n                    const song = data as Song;\n                    if ('albumId' in song && song.albumId) {\n                        const albumData = {\n                            id: song.albumId,\n                            imageUrl: song.imageUrl,\n                            name: song.album,\n                        };\n                        return (\n                            <Link\n                                state={{ item: albumData }}\n                                to={generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {\n                                    albumId: song.albumId,\n                                })}\n                            >\n                                {song.album}\n                            </Link>\n                        );\n                    }\n                    return song.album;\n                }\n                return '';\n            },\n            id: 'album',\n            isMuted: true,\n        },\n        {\n            format: (data) => {\n                if ('songCount' in data && data.songCount !== null) {\n                    return i18n.t('entity.trackWithCount', { count: data.songCount });\n                }\n                return '';\n            },\n            id: 'songCount',\n        },\n        {\n            format: (data) => {\n                if ('albumCount' in data && data.albumCount !== null) {\n                    return i18n.t('entity.albumWithCount', { count: data.albumCount });\n                }\n                return '';\n            },\n            id: 'albumCount',\n        },\n        {\n            format: (data) => {\n                if (\n                    'userRating' in data &&\n                    (data as Album | AlbumArtist | Song).userRating !== null\n                ) {\n                    return formatRating(data as Album | AlbumArtist | Song);\n                }\n                return null;\n            },\n            id: 'rating',\n        },\n        {\n            format: (data) => {\n                if ('userFavorite' in data) {\n                    return (data as Album | AlbumArtist | Song).userFavorite ? '★' : '';\n                }\n                return '';\n            },\n            id: 'userFavorite',\n        },\n    ];\n};\n\nexport const getDataRowsCount = () => {\n    return getDataRows().length;\n};\n\nconst getImageUrl = (data: Album | AlbumArtist | Artist | Genre | Playlist | Song | undefined) => {\n    if (data && 'imageUrl' in data) {\n        return data.imageUrl || undefined;\n    }\n\n    return undefined;\n};\n\nconst GenreImagePlaceholder = ({ className, name }: { className?: string; name: string }) => {\n    const { color, isLight } = useMemo(() => stringToColor(name), [name]);\n    return (\n        <div\n            className={className}\n            style={{\n                backgroundColor: color,\n                color: isLight ? '#000' : '#fff',\n            }}\n        >\n            <span className={styles.genrePlaceholderText}>{name}</span>\n        </div>\n    );\n};\n\nconst getItemNavigationPath = (\n    data: Album | AlbumArtist | Artist | Genre | Playlist | Song | undefined,\n    itemType: LibraryItem,\n): null | string => {\n    if (!data || !('id' in data) || !data.id) {\n        return null;\n    }\n\n    const effectiveItemType = '_itemType' in data && data._itemType ? data._itemType : itemType;\n\n    return getTitlePath(effectiveItemType, data.id);\n};\n\nconst ItemCardRow = memo(\n    ({\n        data,\n        index,\n        row,\n        type,\n    }: {\n        data: Album | AlbumArtist | Artist | Genre | Playlist | Song | undefined;\n        index: number;\n        row: DataRow;\n        type?: 'compact' | 'default' | 'poster';\n    }) => {\n        const alignmentClass =\n            row.align === 'center'\n                ? styles['align-center']\n                : row.align === 'end'\n                  ? styles['align-end']\n                  : styles['align-start'];\n\n        // All rows except the first one (index 0) should be muted\n        const isMuted = index > 0 || row.isMuted;\n\n        const formattedContent = useMemo(() => {\n            if (!data) {\n                return null;\n            }\n            return row.format(data);\n        }, [data, row]);\n\n        if (!data) {\n            return (\n                <div\n                    className={clsx(styles.row, alignmentClass, {\n                        [styles.compact]: type === 'compact',\n                        [styles.default]: type === 'default',\n                        [styles.muted]: isMuted,\n                        [styles.poster]: type === 'poster',\n                    })}\n                >\n                    &nbsp;\n                </div>\n            );\n        }\n\n        return (\n            <Text\n                className={clsx(styles.row, alignmentClass, {\n                    [styles.bold]: index === 0,\n                    [styles.compact]: type === 'compact',\n                    [styles.default]: type === 'default',\n                    [styles.muted]: isMuted,\n                    [styles.poster]: type === 'poster',\n                })}\n                size={index > 0 ? 'sm' : 'md'}\n            >\n                {formattedContent}\n            </Text>\n        );\n    },\n);\n\nItemCardRow.displayName = 'ItemCardRow';\n\nexport const MemoizedItemCard = memo(ItemCard);\n"
  },
  {
    "path": "src/renderer/components/item-image/item-image.tsx",
    "content": "import { memo, useMemo } from 'react';\nimport z from 'zod';\n\nimport { api } from '/@/renderer/api';\nimport {\n    GeneralSettingsSchema,\n    getServerById,\n    useAuthStore,\n    useCurrentServerId,\n    useGeneralSettings,\n    useImageRes,\n    useSettingsStore,\n} from '/@/renderer/store';\nimport { BaseImage, ImageProps } from '/@/shared/components/image/image';\nimport { ExplicitStatus, ImageRequest, LibraryItem } from '/@/shared/types/domain-types';\n\nconst getUnloaderIcon = (itemType: LibraryItem) => {\n    switch (itemType) {\n        case LibraryItem.ALBUM:\n            return 'emptyAlbumImage';\n        case LibraryItem.ALBUM_ARTIST:\n            return 'emptyArtistImage';\n        case LibraryItem.ARTIST:\n            return 'emptyArtistImage';\n        case LibraryItem.GENRE:\n            return 'emptyGenreImage';\n        case LibraryItem.PLAYLIST:\n            return 'emptyPlaylistImage';\n        case LibraryItem.SONG:\n            return 'emptySongImage';\n        default:\n            return 'emptyImage';\n    }\n};\n\nconst BaseItemImage = (\n    props: Omit<ImageProps, 'id' | 'src'> & {\n        explicitStatus?: ExplicitStatus | null;\n        id?: null | string;\n        itemType: LibraryItem;\n        serverId?: null | string;\n        src?: null | string;\n        type?: keyof z.infer<typeof GeneralSettingsSchema>['imageRes'];\n    },\n) => {\n    const { explicitStatus, serverId, src, ...rest } = props;\n    const { blurExplicitImages } = useGeneralSettings();\n\n    const imageUrl = useItemImageUrl({\n        id: props.id,\n        imageUrl: src,\n        itemType: props.itemType,\n        serverId: serverId || undefined,\n        type: props.type,\n    });\n\n    const imageRequest = useItemImageRequest({\n        id: props.id,\n        imageUrl: src,\n        itemType: props.itemType,\n        serverId: serverId || undefined,\n        type: props.type,\n    });\n\n    const isExplicit = blurExplicitImages && explicitStatus === ExplicitStatus.EXPLICIT;\n\n    return (\n        <BaseImage\n            imageRequest={imageRequest}\n            isExplicit={isExplicit}\n            src={imageUrl}\n            unloaderIcon={getUnloaderIcon(props.itemType)}\n            {...rest}\n            id={props.id || undefined}\n        />\n    );\n};\n\nexport const ItemImage = memo(BaseItemImage);\n\ninterface UseItemImageUrlProps {\n    id?: null | string;\n    imageUrl?: null | string;\n    itemType: LibraryItem;\n    serverId?: string;\n    size?: number;\n    type?: keyof z.infer<typeof GeneralSettingsSchema>['imageRes'];\n    useRemoteUrl?: boolean;\n}\n\nexport const useItemImageUrl = (args: UseItemImageUrlProps) => {\n    const { id, imageUrl, itemType, size, type, useRemoteUrl } = args;\n    const serverId = useCurrentServerId();\n\n    const imageRes = useImageRes();\n    const sizeByType: number | undefined = type ? imageRes[type] : undefined;\n\n    return useMemo(() => {\n        if (imageUrl) {\n            return imageUrl;\n        }\n\n        if (!id) {\n            return undefined;\n        }\n\n        const targetServerId = args.serverId || serverId;\n        let baseUrl: string | undefined;\n\n        if (useRemoteUrl) {\n            const server = getServerById(targetServerId);\n            baseUrl = server?.remoteUrl || server?.url;\n        }\n\n        return (\n            api.controller.getImageUrl({\n                apiClientProps: { serverId: targetServerId },\n                baseUrl,\n                query: { id, itemType, size: size ?? sizeByType },\n            }) || undefined\n        );\n    }, [args.serverId, id, imageUrl, itemType, serverId, size, sizeByType, useRemoteUrl]);\n};\n\nexport const useItemImageRequest = (args: UseItemImageUrlProps) => {\n    const { id, imageUrl, itemType, size, type, useRemoteUrl } = args;\n    const serverId = useCurrentServerId();\n\n    const imageRes = useImageRes();\n    const sizeByType: number | undefined = type ? imageRes[type] : undefined;\n\n    return useMemo(() => {\n        if (imageUrl) {\n            return {\n                cacheKey: imageUrl,\n                url: imageUrl,\n            } satisfies ImageRequest;\n        }\n\n        if (!id) {\n            return undefined;\n        }\n\n        const targetServerId = args.serverId || serverId;\n        let baseUrl: string | undefined;\n\n        if (useRemoteUrl) {\n            const server = getServerById(targetServerId);\n            baseUrl = server?.remoteUrl || server?.url;\n        }\n\n        return (\n            api.controller.getImageRequest({\n                apiClientProps: { serverId: targetServerId },\n                baseUrl,\n                query: { id, itemType, size: size ?? sizeByType },\n            }) || undefined\n        );\n    }, [args.serverId, id, imageUrl, itemType, serverId, size, sizeByType, useRemoteUrl]);\n};\n\nexport function getItemImageRequest(args: UseItemImageUrlProps) {\n    const { id, imageUrl, itemType, size, type, useRemoteUrl } = args;\n    const authStore = useAuthStore.getState();\n    const currentServerId = authStore.currentServer?.id;\n    const serverId = (args.serverId || currentServerId) as string;\n\n    const imageRes = useSettingsStore.getState().general.imageRes;\n    const sizeByType: number | undefined = type ? imageRes[type] : undefined;\n\n    if (imageUrl) {\n        return {\n            cacheKey: imageUrl,\n            url: imageUrl,\n        } satisfies ImageRequest;\n    }\n\n    if (!id) {\n        return undefined;\n    }\n\n    let baseUrl: string | undefined;\n\n    if (useRemoteUrl) {\n        const server = getServerById(serverId);\n        baseUrl = server?.remoteUrl || server?.url;\n    }\n\n    return (\n        api.controller.getImageRequest({\n            apiClientProps: { serverId },\n            baseUrl,\n            query: { id, itemType, size: size ?? sizeByType },\n        }) || undefined\n    );\n}\n\nexport function getItemImageUrl(args: UseItemImageUrlProps) {\n    const { id, imageUrl, itemType, size, type, useRemoteUrl } = args;\n    const authStore = useAuthStore.getState();\n    const currentServerId = authStore.currentServer?.id;\n    const serverId = (args.serverId || currentServerId) as string;\n\n    const imageRes = useSettingsStore.getState().general.imageRes;\n    const sizeByType: number | undefined = type ? imageRes[type] : undefined;\n\n    if (imageUrl) {\n        return imageUrl;\n    }\n\n    if (!id) {\n        return undefined;\n    }\n\n    let baseUrl: string | undefined;\n\n    if (useRemoteUrl) {\n        const server = getServerById(serverId);\n        baseUrl = server?.remoteUrl || server?.url;\n    }\n\n    return (\n        api.controller.getImageUrl({\n            apiClientProps: { serverId },\n            baseUrl,\n            query: { id, itemType, size: size ?? sizeByType },\n        }) || undefined\n    );\n}\n"
  },
  {
    "path": "src/renderer/components/item-list/expanded-list-container.module.css",
    "content": ".list-expanded-container {\n    overflow: auto;\n}\n"
  },
  {
    "path": "src/renderer/components/item-list/expanded-list-container.tsx",
    "content": "import { ReactNode } from 'react';\n\nimport styles from './expanded-list-container.module.css';\n\nconst EXPANDED_HEIGHT = 300;\n\nexport interface ExpandedListContainerProps {\n    children: ReactNode;\n}\n\nexport const ExpandedListContainer = ({ children }: ExpandedListContainerProps) => {\n    return (\n        <div\n            className={styles.listExpandedContainer}\n            style={{\n                height: EXPANDED_HEIGHT,\n                overflow: 'auto',\n            }}\n        >\n            {children}\n        </div>\n    );\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/expanded-list-item.module.css",
    "content": ".container {\n    width: 100%;\n    height: 100%;\n    padding: var(--theme-spacing-sm);\n}\n\n.inner {\n    width: 100%;\n    height: 100%;\n    background-color: var(--theme-colors-surface);\n    border-radius: var(--theme-radius-md);\n}\n"
  },
  {
    "path": "src/renderer/components/item-list/expanded-list-item.tsx",
    "content": "import { Suspense } from 'react';\n\nimport styles from './expanded-list-item.module.css';\n\nimport { ItemListStateItem } from '/@/renderer/components/item-list/helpers/item-list-state';\nimport { ExpandedAlbumListItem } from '/@/renderer/features/albums/components/expanded-album-list-item';\nimport { Spinner } from '/@/shared/components/spinner/spinner';\nimport { LibraryItem } from '/@/shared/types/domain-types';\n\ninterface ExpandedListItemProps {\n    item?: ItemListStateItem;\n    itemType: LibraryItem;\n}\n\nexport const ExpandedListItem = ({ item, itemType }: ExpandedListItemProps) => {\n    if (!item) {\n        return null;\n    }\n\n    return (\n        <div className={styles.container}>\n            <div className={styles.inner}>\n                <Suspense fallback={<Spinner container />}>\n                    <SelectedItem item={item} itemType={itemType} />\n                </Suspense>\n            </div>\n        </div>\n    );\n};\n\ninterface SelectedItemProps {\n    item: ItemListStateItem;\n    itemType: LibraryItem;\n}\n\nconst SelectedItem = ({ item, itemType }: SelectedItemProps) => {\n    switch (itemType) {\n        case LibraryItem.ALBUM:\n            return <ExpandedAlbumListItem item={item} />;\n        default:\n            return null;\n    }\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/helpers/extract-row-id.ts",
    "content": "/**\n * Creates a function to extract row ID from an item based on the getRowId configuration.\n *\n * @param getRowId - Either a string property name, a function that extracts the ID, or undefined to use default 'id' property\n * @returns A function that extracts the row ID from an item\n */\nexport const createExtractRowId = (\n    getRowId?: ((item: unknown) => string) | string,\n): ((item: unknown) => string | undefined) => {\n    return (item: unknown): string | undefined => {\n        if (!item || typeof item !== 'object') {\n            return undefined;\n        }\n\n        if (getRowId === undefined) {\n            // Default behavior: use 'id' property\n            return (item as any).id;\n        }\n\n        if (typeof getRowId === 'string') {\n            // getRowId is a property name\n            return (item as any)[getRowId];\n        }\n\n        // getRowId is a function\n        return getRowId(item);\n    };\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/helpers/get-dragged-items.ts",
    "content": "import {\n    ItemListStateActions,\n    ItemListStateItemWithRequiredProperties,\n} from '/@/renderer/components/item-list/helpers/item-list-state';\nimport {\n    Album,\n    AlbumArtist,\n    Artist,\n    Folder,\n    Genre,\n    Playlist,\n    Song,\n} from '/@/shared/types/domain-types';\n\n/**\n * Type guard to assert that an item has the required properties for dragging\n */\nconst hasRequiredDragProperties = (\n    item: unknown,\n): item is ItemListStateItemWithRequiredProperties => {\n    return (\n        typeof item === 'object' &&\n        item !== null &&\n        'id' in item &&\n        typeof (item as any).id === 'string' &&\n        '_itemType' in item &&\n        typeof (item as any)._itemType === 'string' &&\n        '_serverId' in item &&\n        typeof (item as any)._serverId === 'string'\n    );\n};\n\n/**\n * Gets the items that should be dragged based on the current data and selection state.\n * If the current item is already selected, drag all selected items.\n * Otherwise, select and drag only the current item.\n * If internalState is not provided, returns the single item wrapped in an array.\n *\n * @param data - The item data to drag (Album, AlbumArtist, Artist, Folder, Playlist, or Song)\n * @param internalState - The item list state actions (optional)\n * @param updateSelection - Whether to update the selection state (default: true)\n * @returns Array of items that should be dragged (with original values, asserting id, itemType, and _serverId)\n */\nexport const getDraggedItems = (\n    data: Album | AlbumArtist | Artist | Folder | Genre | Playlist | Song | undefined,\n    internalState?: ItemListStateActions,\n    updateSelection: boolean = true,\n): ItemListStateItemWithRequiredProperties[] => {\n    if (!data) {\n        return [];\n    }\n\n    if (!hasRequiredDragProperties(data)) {\n        return [];\n    }\n\n    const draggedItem = data as ItemListStateItemWithRequiredProperties;\n\n    if (!internalState) {\n        return [draggedItem];\n    }\n\n    const rowId = internalState.extractRowId(data);\n\n    if (!rowId) {\n        return [draggedItem];\n    }\n\n    const previouslySelected = internalState.getSelected();\n    const isDraggingSelectedItem = previouslySelected.some((selected) => {\n        if (hasRequiredDragProperties(selected)) {\n            return internalState.extractRowId(selected) === rowId;\n        }\n        return false;\n    });\n\n    const draggedItems: ItemListStateItemWithRequiredProperties[] = [];\n\n    if (isDraggingSelectedItem) {\n        const selectedItems = previouslySelected.filter(\n            (item): item is ItemListStateItemWithRequiredProperties =>\n                hasRequiredDragProperties(item),\n        );\n        draggedItems.push(...selectedItems);\n    } else {\n        if (updateSelection) {\n            internalState.setSelected([draggedItem]);\n        }\n        draggedItems.push(draggedItem);\n    }\n\n    return draggedItems;\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/helpers/get-title-path.ts",
    "content": "import { generatePath } from 'react-router';\n\nimport { AppRoute } from '/@/renderer/router/routes';\nimport { LibraryItem } from '/@/shared/types/domain-types';\n\nexport const getTitlePath = (itemType: LibraryItem, id: string) => {\n    switch (itemType) {\n        case LibraryItem.ALBUM:\n            return generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, { albumId: id });\n        case LibraryItem.ALBUM_ARTIST:\n            return generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, { albumArtistId: id });\n        case LibraryItem.ARTIST:\n            return generatePath(AppRoute.LIBRARY_ARTISTS_DETAIL, { artistId: id });\n        case LibraryItem.GENRE:\n            return generatePath(AppRoute.LIBRARY_GENRES_DETAIL, { genreId: id });\n        case LibraryItem.PLAYLIST:\n            return generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId: id });\n        default:\n            return null;\n    }\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/helpers/item-list-controls.ts",
    "content": "import { useEffect, useMemo, useRef } from 'react';\nimport { useNavigate } from 'react-router';\n\nimport { getTitlePath } from '/@/renderer/components/item-list/helpers/get-title-path';\nimport { ItemListStateItemWithRequiredProperties } from '/@/renderer/components/item-list/helpers/item-list-state';\nimport { DefaultItemControlProps, ItemControls } from '/@/renderer/components/item-list/types';\nimport { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';\nimport { usePlayer } from '/@/renderer/features/player/context/player-context';\nimport { useSetFavorite } from '/@/renderer/features/shared/hooks/use-set-favorite';\nimport { useSetRating } from '/@/renderer/features/shared/hooks/use-set-rating';\nimport { useAppStore } from '/@/renderer/store';\nimport { LibraryItem, QueueSong, Song } from '/@/shared/types/domain-types';\nimport { Play, TableColumn } from '/@/shared/types/types';\n\ninterface UseDefaultItemListControlsArgs {\n    enableMultiSelect?: boolean;\n    onColumnReordered?: (\n        columnIdFrom: TableColumn,\n        columnIdTo: TableColumn,\n        edge: 'bottom' | 'left' | 'right' | 'top' | null,\n    ) => void;\n    onColumnResized?: (columnId: TableColumn, width: number) => void;\n    overrides?: Partial<ItemControls>;\n}\n\nconst itemTypeMapping = {\n    [LibraryItem.ALBUM]: LibraryItem.ALBUM,\n    [LibraryItem.ALBUM_ARTIST]: LibraryItem.ALBUM_ARTIST,\n    [LibraryItem.ARTIST]: LibraryItem.ARTIST,\n    [LibraryItem.GENRE]: LibraryItem.GENRE,\n    [LibraryItem.PLAYLIST]: LibraryItem.PLAYLIST,\n    [LibraryItem.PLAYLIST_SONG]: LibraryItem.SONG,\n    [LibraryItem.QUEUE_SONG]: LibraryItem.SONG,\n};\n\nexport const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs) => {\n    const player = usePlayer();\n    const navigate = useNavigate();\n    const navigateRef = useRef(navigate);\n    const setFavorite = useSetFavorite();\n    const setRating = useSetRating();\n\n    useEffect(() => {\n        navigateRef.current = navigate;\n    }, [navigate]);\n\n    const { enableMultiSelect = true, onColumnReordered, onColumnResized, overrides } = args || {};\n\n    const controls: ItemControls = useMemo(() => {\n        return {\n            onClick: ({ event, internalState, item }: DefaultItemControlProps) => {\n                if (!item || !internalState || !event) {\n                    return;\n                }\n\n                // Extract rowId from the item\n                const rowId = internalState.extractRowId(item);\n                if (!rowId) return;\n\n                // Use the item directly (rowId is separate, used only as key in state)\n                const itemListItem = item as ItemListStateItemWithRequiredProperties;\n\n                // Check if ctrl/cmd key is held for multi-selection\n                if (event.ctrlKey || event.metaKey) {\n                    const isCurrentlySelected = internalState.isSelected(rowId);\n\n                    if (isCurrentlySelected) {\n                        // Remove this item from selection\n                        const currentSelected = internalState.getSelected();\n                        const filteredSelected = currentSelected.filter(\n                            (\n                                selectedItem,\n                            ): selectedItem is ItemListStateItemWithRequiredProperties =>\n                                typeof selectedItem === 'object' &&\n                                selectedItem !== null &&\n                                internalState.extractRowId(selectedItem) !== rowId,\n                        );\n                        internalState.setSelected(filteredSelected);\n                    } else {\n                        // Add this item to selection\n                        const currentSelected = internalState.getSelected();\n                        const newSelected = [\n                            ...currentSelected.filter(\n                                (\n                                    selectedItem,\n                                ): selectedItem is ItemListStateItemWithRequiredProperties =>\n                                    typeof selectedItem === 'object' && selectedItem !== null,\n                            ),\n                            itemListItem,\n                        ];\n                        internalState.setSelected(newSelected);\n                    }\n                }\n                // Check if shift key is held for range selection\n                else if (event.shiftKey) {\n                    const selectedItems = internalState.getSelected();\n                    const lastSelectedItem = selectedItems[selectedItems.length - 1];\n\n                    if (\n                        lastSelectedItem &&\n                        typeof lastSelectedItem === 'object' &&\n                        lastSelectedItem !== null\n                    ) {\n                        // Get the data array from internalState\n                        const data = internalState.getData();\n                        // Filter out null/undefined values (e.g., header row)\n                        const validData = data.filter((d) => d && typeof d === 'object');\n\n                        // Find the indices of the last selected item and current item\n                        const lastRowId = internalState.extractRowId(lastSelectedItem);\n                        if (!lastRowId) return;\n                        const lastIndex = internalState.findItemIndex(lastRowId);\n                        const currentIndex = internalState.findItemIndex(rowId);\n\n                        if (lastIndex !== -1 && currentIndex !== -1) {\n                            // Create range selection - select ALL items in the range\n                            const startIndex = Math.min(lastIndex, currentIndex);\n                            const stopIndex = Math.max(lastIndex, currentIndex);\n\n                            const rangeItems: ItemListStateItemWithRequiredProperties[] = [];\n                            for (let i = startIndex; i <= stopIndex; i++) {\n                                const rangeItem = validData[i];\n                                if (\n                                    rangeItem &&\n                                    typeof rangeItem === 'object' &&\n                                    '_serverId' in rangeItem &&\n                                    '_itemType' in rangeItem\n                                ) {\n                                    const rangeRowId = internalState.extractRowId(rangeItem);\n                                    if (rangeRowId) {\n                                        rangeItems.push(\n                                            rangeItem as ItemListStateItemWithRequiredProperties,\n                                        );\n                                    }\n                                }\n                            }\n\n                            // Merge with existing selection, avoiding duplicates\n                            const currentSelected = internalState.getSelected();\n                            const newSelected = [\n                                ...currentSelected.filter(\n                                    (\n                                        selectedItem,\n                                    ): selectedItem is ItemListStateItemWithRequiredProperties =>\n                                        typeof selectedItem === 'object' && selectedItem !== null,\n                                ),\n                            ];\n                            rangeItems.forEach((rangeItem) => {\n                                const rangeRowId = internalState.extractRowId(rangeItem);\n                                if (\n                                    rangeRowId &&\n                                    !newSelected.some(\n                                        (selected) =>\n                                            internalState.extractRowId(selected) === rangeRowId,\n                                    )\n                                ) {\n                                    newSelected.push(rangeItem);\n                                }\n                            });\n                            internalState.setSelected(newSelected);\n                        }\n                    } else {\n                        // No previous selection, just select this item\n                        internalState.setSelected([itemListItem]);\n                    }\n                } else {\n                    // Regular click - deselect all others and select only this item\n                    // If this item is already the only selected item, deselect it\n                    const selectedItems = internalState.getSelected();\n                    const isOnlySelected =\n                        selectedItems.length === 1 &&\n                        typeof selectedItems[0] === 'object' &&\n                        selectedItems[0] !== null &&\n                        internalState.extractRowId(selectedItems[0]) === rowId;\n\n                    if (isOnlySelected) {\n                        internalState.clearSelected();\n                    } else {\n                        internalState.setSelected([itemListItem]);\n                    }\n                }\n            },\n\n            onColumnReordered: ({\n                columnIdFrom,\n                columnIdTo,\n                edge,\n            }: {\n                columnIdFrom: TableColumn;\n                columnIdTo: TableColumn;\n                edge: 'bottom' | 'left' | 'right' | 'top' | null;\n            }) => {\n                onColumnReordered?.(columnIdFrom, columnIdTo, edge);\n            },\n\n            onColumnResized: onColumnResized\n                ? ({ columnId, width }: { columnId: TableColumn; width: number }) =>\n                      onColumnResized(columnId, width)\n                : undefined,\n\n            onDoubleClick: ({ internalState, item, itemType, meta }: DefaultItemControlProps) => {\n                if (!item || !internalState) {\n                    return;\n                }\n\n                internalState.setSelected([item]);\n\n                if (\n                    itemType === LibraryItem.ALBUM ||\n                    itemType === LibraryItem.ALBUM_ARTIST ||\n                    itemType === LibraryItem.ARTIST ||\n                    itemType === LibraryItem.GENRE ||\n                    itemType === LibraryItem.PLAYLIST\n                ) {\n                    const path = getTitlePath(itemType, item.id);\n                    if (path) {\n                        navigateRef.current(path, { state: { item } });\n                        return;\n                    }\n                }\n\n                if (itemType === LibraryItem.SONG || itemType === LibraryItem.PLAYLIST_SONG) {\n                    const data = internalState.getData();\n                    const validSongs = data.filter((d): d is Song => {\n                        if (!d || typeof d !== 'object') {\n                            return false;\n                        }\n                        if (!('_itemType' in d)) {\n                            return false;\n                        }\n                        return (d as { _itemType: LibraryItem })._itemType === LibraryItem.SONG;\n                    });\n\n                    if (validSongs.length === 0) {\n                        return;\n                    }\n\n                    const clickedSongId = item.id;\n                    const clickedIndex = validSongs.findIndex((song) => song.id === clickedSongId);\n\n                    if (clickedIndex === -1) {\n                        return;\n                    }\n\n                    const playType = (meta?.playType as Play) || Play.NOW;\n                    const singleSongOnly = meta?.singleSongOnly === true;\n\n                    let songsToAdd: Song[];\n                    if (\n                        singleSongOnly ||\n                        playType === Play.NEXT ||\n                        playType === Play.LAST ||\n                        playType === Play.NEXT_SHUFFLE ||\n                        playType === Play.LAST_SHUFFLE\n                    ) {\n                        songsToAdd = [item as Song];\n                    } else {\n                        const songsBefore = 50;\n                        const songsAfter = 50;\n                        const startIndex = Math.max(0, clickedIndex - songsBefore);\n                        const endIndex = Math.min(validSongs.length, clickedIndex + songsAfter + 1);\n                        songsToAdd = validSongs.slice(startIndex, endIndex);\n                    }\n\n                    if (songsToAdd.length === 0) {\n                        return;\n                    }\n\n                    player.addToQueueByData(songsToAdd, playType, item.id);\n                    return;\n                }\n\n                if (itemType === LibraryItem.QUEUE_SONG) {\n                    const queueSong = item as QueueSong;\n                    if (queueSong._uniqueId) {\n                        player.mediaPlay(queueSong._uniqueId);\n                    }\n                }\n            },\n\n            onExpand: ({ item, itemType }: DefaultItemControlProps) => {\n                if (!item) return;\n\n                const itemListItem = item as ItemListStateItemWithRequiredProperties;\n                const setGlobalExpanded = useAppStore.getState().actions.setGlobalExpanded;\n                const globalExpanded = useAppStore.getState().globalExpanded;\n\n                if (globalExpanded?.item?.id === item.id) {\n                    setGlobalExpanded(null);\n                } else {\n                    const itemForStore: ItemListStateItemWithRequiredProperties & {\n                        imageId: null | string;\n                    } = {\n                        ...itemListItem,\n                        imageId: (itemListItem as { imageId?: null | string }).imageId ?? null,\n                    };\n                    setGlobalExpanded({\n                        item: itemForStore,\n                        itemType,\n                    });\n                }\n            },\n\n            onFavorite: ({\n                favorite,\n                item,\n                itemType,\n            }: DefaultItemControlProps & { favorite: boolean }) => {\n                if (!item) {\n                    return;\n                }\n\n                const apiItemType = itemTypeMapping[itemType] || itemType;\n\n                if (!item.id || !item._serverId) {\n                    return;\n                }\n\n                setFavorite(item._serverId, [item.id], apiItemType, favorite);\n            },\n\n            onMore: ({ event, internalState, item, itemType }: DefaultItemControlProps) => {\n                if (!item || !event) {\n                    return;\n                }\n\n                // For context menus, prioritize the itemType prop when it's PLAYLIST_SONG or QUEUE_SONG\n                // This is because playlist/queue songs are Song objects (_itemType: SONG) but need special context menus\n                // Otherwise, use the item's _itemType if available, or fall back to the mapped itemType\n                const actualItemType =\n                    itemType === LibraryItem.PLAYLIST_SONG || itemType === LibraryItem.QUEUE_SONG\n                        ? itemType\n                        : (item as any)?._itemType || itemTypeMapping[itemType] || itemType;\n\n                // If no internalState, call ContextMenuController directly\n                if (!internalState) {\n                    return ContextMenuController.call({\n                        cmd: { items: [item] as any[], type: actualItemType as any },\n                        event,\n                    });\n                }\n\n                const rowId = internalState.extractRowId(item);\n\n                if (!rowId) return;\n\n                if (!enableMultiSelect) {\n                    return ContextMenuController.call({\n                        cmd: { items: [item] as any[], type: actualItemType as any },\n                        event,\n                    });\n                }\n\n                // If none selected, select this item\n                if (internalState.getSelected().length === 0) {\n                    internalState.setSelected([item]);\n                    return ContextMenuController.call({\n                        cmd: { items: [item] as any[], type: actualItemType as any },\n                        event,\n                    });\n                }\n                // If this item is not already selected, replace the selection with this item\n                else if (!internalState.isSelected(rowId)) {\n                    internalState.setSelected([item]);\n                    return ContextMenuController.call({\n                        cmd: { items: [item] as any[], type: actualItemType as any },\n                        event,\n                    });\n                }\n\n                const selectedItems = internalState.getSelected();\n\n                // For multiple selected items, prioritize the itemType prop for PLAYLIST_SONG/QUEUE_SONG\n                // Otherwise use the first item's _itemType or the mapped type\n                const selectedItemType =\n                    itemType === LibraryItem.PLAYLIST_SONG || itemType === LibraryItem.QUEUE_SONG\n                        ? itemType\n                        : selectedItems.length > 0 && (selectedItems[0] as any)?._itemType\n                          ? (selectedItems[0] as any)._itemType\n                          : itemTypeMapping[itemType] || itemType;\n\n                return ContextMenuController.call({\n                    cmd: { items: selectedItems as any[], type: selectedItemType as any },\n                    event,\n                });\n            },\n\n            onPlay: ({\n                item,\n                itemType,\n                playType,\n            }: DefaultItemControlProps & { playType: Play }) => {\n                if (!item) {\n                    return;\n                }\n\n                player.addToQueueByFetch(item._serverId, [item.id], itemType, playType);\n            },\n\n            onRating: ({\n                item,\n                itemType,\n                rating,\n            }: DefaultItemControlProps & { rating: number }) => {\n                if (!item) {\n                    return;\n                }\n\n                const apiItemType = itemTypeMapping[itemType] || itemType;\n\n                if (!item.id || !item._serverId) {\n                    return;\n                }\n\n                const previousRating = (item as { userRating: number }).userRating || 0;\n\n                let newRating = rating;\n\n                if (previousRating === rating) {\n                    newRating = 0;\n                }\n\n                setRating(item._serverId, [item.id], apiItemType, newRating);\n            },\n\n            ...overrides,\n        };\n    }, [\n        enableMultiSelect,\n        overrides,\n        onColumnReordered,\n        onColumnResized,\n        player,\n        setFavorite,\n        setRating,\n    ]);\n\n    return controls;\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/helpers/item-list-infinite-loader.ts",
    "content": "import {\n    useMutation,\n    useQuery,\n    useQueryClient,\n    useSuspenseQuery,\n    UseSuspenseQueryOptions,\n} from '@tanstack/react-query';\nimport throttle from 'lodash/throttle';\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react';\n\nimport { queryKeys } from '/@/renderer/api/query-keys';\nimport { useListContext } from '/@/renderer/context/list-context';\nimport { eventEmitter } from '/@/renderer/events/event-emitter';\nimport { UserFavoriteEventPayload, UserRatingEventPayload } from '/@/renderer/events/events';\nimport { getListRefreshMutationKey } from '/@/renderer/features/shared/components/list-refresh-button';\nimport { LibraryItem } from '/@/shared/types/domain-types';\n\nexport const getListQueryKeyName = (itemType: LibraryItem): string => {\n    switch (itemType) {\n        case LibraryItem.ALBUM:\n            return 'albums';\n        case LibraryItem.ALBUM_ARTIST:\n            return 'albumArtists';\n        case LibraryItem.ARTIST:\n            return 'artists';\n        case LibraryItem.GENRE:\n            return 'genres';\n        case LibraryItem.PLAYLIST:\n            return 'playlists';\n        case LibraryItem.SONG:\n            return 'songs';\n        default:\n            return 'albums';\n    }\n};\n\ntype InfiniteLoaderCacheData = {\n    dataMap: Map<number, unknown>;\n    idToIndexMap: Map<string, number>;\n    pagesLoaded: Record<string, boolean>;\n    version: number;\n};\n\ninterface UseItemListInfiniteLoaderProps {\n    eventKey: string;\n    fetchThreshold?: number;\n    itemsPerPage: number;\n    itemType: LibraryItem;\n    listCountQuery: UseSuspenseQueryOptions<number, Error, number, readonly unknown[]>;\n    listQueryFn: (args: { apiClientProps: any; query: any }) => Promise<{ items: unknown[] }>;\n    query: Record<string, any>;\n    serverId: string;\n}\n\nfunction getInitialData(): InfiniteLoaderCacheData {\n    return {\n        dataMap: new Map(),\n        idToIndexMap: new Map(),\n        pagesLoaded: {},\n        version: 0,\n    };\n}\n\nexport const infiniteLoaderDataQueryKey = (\n    serverId: string,\n    itemType: LibraryItem,\n    query?: Record<string, any>,\n) => {\n    if (query) {\n        return [serverId, 'item-list-infinite-loader', itemType, query];\n    }\n\n    return [serverId, 'item-list-infinite-loader', itemType];\n};\n\nexport const useItemListInfiniteLoader = ({\n    eventKey,\n    fetchThreshold = 0.5,\n    itemsPerPage = 100,\n    itemType,\n    listCountQuery,\n    listQueryFn,\n    query = {},\n    serverId,\n}: UseItemListInfiniteLoaderProps) => {\n    const queryClient = useQueryClient();\n    const lastFetchedPageRef = useRef<number>(-1);\n    const currentVisibleRangeRef = useRef<null | { startIndex: number; stopIndex: number }>(null);\n    const [isRefetching, setIsRefetching] = useState(false);\n    const refetchPromiseRef = useRef<null | Promise<void>>(null);\n    const previousDataQueryKeyRef = useRef<string>('');\n    const isRefetchingRef = useRef<boolean>(false);\n\n    const { data: totalItemCount } = useSuspenseQuery<number, any, number, any>(listCountQuery);\n\n    const { setItemCount } = useListContext();\n\n    useEffect(() => {\n        if (totalItemCount == null || !setItemCount) {\n            return;\n        }\n\n        setItemCount(totalItemCount);\n    }, [setItemCount, totalItemCount]);\n\n    const dataQueryKey = useMemo(\n        () => [serverId, 'item-list-infinite-loader', itemType, query],\n        [serverId, itemType, query],\n    );\n\n    const fetchPage = useCallback(\n        async (pageNumber: number) => {\n            const startIndex = pageNumber * itemsPerPage;\n            const queryParams = {\n                limit: itemsPerPage,\n                startIndex,\n                ...query,\n            };\n\n            const result = await queryClient.fetchQuery({\n                queryFn: async ({ signal }) => {\n                    const result = await listQueryFn({\n                        apiClientProps: { serverId, signal },\n                        query: queryParams,\n                    });\n\n                    return result;\n                },\n                queryKey: queryKeys[getListQueryKeyName(itemType)].list(serverId, queryParams),\n            });\n\n            // Update the query data with the fetched page\n            queryClient.setQueryData(dataQueryKey, (oldData: InfiniteLoaderCacheData) => {\n                const nextDataMap = new Map(oldData.dataMap);\n                const nextIdToIndexMap = new Map(oldData.idToIndexMap);\n\n                result.items.forEach((item, offset) => {\n                    const index = startIndex + offset;\n                    nextDataMap.set(index, item);\n                    if (item && typeof item === 'object' && 'id' in (item as any)) {\n                        const id = String((item as any).id);\n                        nextIdToIndexMap.set(id, index);\n                    }\n                });\n\n                return {\n                    dataMap: nextDataMap,\n                    idToIndexMap: nextIdToIndexMap,\n                    pagesLoaded: { ...oldData.pagesLoaded, [pageNumber]: true },\n                    version: oldData.version + 1,\n                };\n            });\n\n            // Track the last fetched page\n            lastFetchedPageRef.current = Math.max(lastFetchedPageRef.current, pageNumber);\n        },\n        [itemsPerPage, query, queryClient, serverId, dataQueryKey, listQueryFn, itemType],\n    );\n\n    // Reset the loaded pages and refetch current page when the query changes\n    useEffect(() => {\n        const currentDataQueryKey = JSON.stringify(dataQueryKey);\n\n        if (previousDataQueryKeyRef.current === currentDataQueryKey || isRefetchingRef.current) {\n            return;\n        }\n\n        previousDataQueryKeyRef.current = currentDataQueryKey;\n        isRefetchingRef.current = true;\n\n        // Capture the current visible range before resetting\n        const visibleRange = currentVisibleRangeRef.current;\n\n        // Determine which page to fetch based on current visible range\n        let pageToFetch = 0;\n        if (visibleRange) {\n            pageToFetch = Math.floor(visibleRange.startIndex / itemsPerPage);\n        }\n\n        // Invalidate and refetch the count query to trigger Suspense\n        const countQueryKey = listCountQuery.queryKey;\n\n        // Set refetching state and create a promise to suspend\n        setIsRefetching(true);\n        const refetchPromise = (async () => {\n            try {\n                // Reset the loaded pages\n                queryClient.setQueryData(dataQueryKey, (oldData: any) => {\n                    if (!oldData) return oldData;\n                    return {\n                        ...oldData,\n                        dataMap: new Map(),\n                        idToIndexMap: new Map(),\n                        pagesLoaded: {},\n                        version: (oldData?.version ?? 0) + 1,\n                    };\n                });\n\n                lastFetchedPageRef.current = -1;\n                currentVisibleRangeRef.current = null;\n\n                // Invalidate and wait for count query to refetch\n                await queryClient.ensureQueryData({\n                    queryKey: countQueryKey,\n                });\n\n                // Fetch the first page after count is refetched\n                await fetchPage(pageToFetch);\n            } finally {\n                setIsRefetching(false);\n                isRefetchingRef.current = false;\n                refetchPromiseRef.current = null;\n            }\n        })();\n\n        refetchPromiseRef.current = refetchPromise;\n\n        refetchPromise.catch(() => {\n            setIsRefetching(false);\n            isRefetchingRef.current = false;\n            refetchPromiseRef.current = null;\n        });\n\n        // eslint-disable-next-line react-hooks/exhaustive-deps\n    }, [dataQueryKey, queryClient, fetchPage, itemsPerPage]);\n\n    const { data } = useQuery<InfiniteLoaderCacheData>({\n        enabled: false,\n        initialData: getInitialData(),\n        queryFn: () => {\n            return getInitialData();\n        },\n        queryKey: dataQueryKey,\n    });\n\n    // Suspend if refetching\n    if (isRefetching && refetchPromiseRef.current) {\n        throw refetchPromiseRef.current;\n    }\n\n    const onRangeChangedBase = useCallback(\n        async (range: { startIndex: number; stopIndex: number }) => {\n            // Track the current visible range\n            currentVisibleRangeRef.current = range;\n\n            const pageNumber = Math.floor(range.startIndex / itemsPerPage);\n\n            const currentData = queryClient.getQueryData<{\n                dataMap: Map<number, unknown>;\n                pagesLoaded: Record<string, boolean>;\n            }>(dataQueryKey);\n\n            const startPageBoundary = pageNumber * itemsPerPage;\n            const endPageBoundary = (pageNumber + 1) * itemsPerPage;\n\n            const distanceFromStartBoundary = range.startIndex - startPageBoundary;\n            const distanceToEndBoundary = endPageBoundary - range.stopIndex;\n\n            const thresholdDistance = Math.floor(itemsPerPage * fetchThreshold);\n\n            const isCurrentPageLoaded = currentData?.pagesLoaded[pageNumber] ?? false;\n\n            // Fetch current page if not loaded\n            if (!isCurrentPageLoaded) {\n                await fetchPage(pageNumber);\n            }\n\n            // If current page is loaded, check if we should prefetch adjacent pages\n            if (isCurrentPageLoaded) {\n                if (\n                    distanceFromStartBoundary <= thresholdDistance &&\n                    pageNumber > 0 &&\n                    !currentData?.pagesLoaded[pageNumber - 1]\n                ) {\n                    await fetchPage(pageNumber - 1);\n                }\n\n                if (\n                    distanceToEndBoundary <= thresholdDistance &&\n                    !currentData?.pagesLoaded[pageNumber + 1]\n                ) {\n                    await fetchPage(pageNumber + 1);\n                }\n            }\n        },\n        [itemsPerPage, fetchThreshold, queryClient, dataQueryKey, fetchPage],\n    );\n\n    const onRangeChanged = useMemo(\n        () =>\n            throttle(onRangeChangedBase, 150, {\n                leading: true,\n                trailing: true,\n            }),\n        [onRangeChangedBase],\n    );\n\n    const refreshMutation = useMutation({\n        mutationFn: async (force?: boolean) => {\n            // Invalidate all queries to ensure fresh data\n            queryClient.invalidateQueries();\n\n            // Reset the infinite list data\n            const currentData = queryClient.getQueryData<{\n                dataMap: Map<number, unknown>;\n                pagesLoaded: Record<string, boolean>;\n            }>(dataQueryKey);\n\n            if (force || currentData) {\n                // Reset data to initial state and clear all loaded pages\n                await queryClient.setQueryData(dataQueryKey, (oldData: any) => {\n                    if (!oldData) return getInitialData();\n                    return {\n                        ...oldData,\n                        dataMap: new Map(),\n                        idToIndexMap: new Map(),\n                        pagesLoaded: {},\n                        version: (oldData?.version ?? 0) + 1,\n                    };\n                });\n                lastFetchedPageRef.current = -1;\n            }\n\n            // Add a delay to make the refresh visually clear\n            // await new Promise((resolve) => setTimeout(resolve, 150));\n\n            // Determine which page to refetch based on current visible range\n            let pageToFetch = 0;\n            if (currentVisibleRangeRef.current) {\n                // Calculate the page from the current visible range\n                pageToFetch = Math.floor(currentVisibleRangeRef.current.startIndex / itemsPerPage);\n            } else if (lastFetchedPageRef.current >= 0) {\n                // Fallback to last fetched page if no visible range is tracked\n                pageToFetch = lastFetchedPageRef.current;\n            }\n\n            // Refetch the current page\n            await fetchPage(pageToFetch);\n\n            // Trigger range changed to ensure adjacent pages are prefetched if needed\n            const startIndex = pageToFetch * itemsPerPage;\n            const stopIndex = Math.min((pageToFetch + 1) * itemsPerPage, totalItemCount);\n\n            await onRangeChangedBase({\n                startIndex,\n                stopIndex,\n            });\n        },\n        mutationKey: getListRefreshMutationKey(eventKey),\n    });\n\n    const refresh = useCallback(\n        async (force?: boolean) => refreshMutation.mutateAsync(force),\n        [refreshMutation],\n    );\n\n    const updateItems = useCallback(\n        (indexes: number[], value: object) => {\n            queryClient.setQueryData(dataQueryKey, (prev: InfiniteLoaderCacheData) => {\n                const nextDataMap = new Map(prev.dataMap);\n\n                indexes.forEach((index) => {\n                    const existing = nextDataMap.get(index);\n                    if (!existing || typeof existing !== 'object') {\n                        return;\n                    }\n                    nextDataMap.set(index, { ...(existing as any), ...(value as any) });\n                });\n\n                return {\n                    ...prev,\n                    dataMap: nextDataMap,\n                    version: prev.version + 1,\n                };\n            });\n        },\n        [queryClient, dataQueryKey],\n    );\n\n    useEffect(() => {\n        const handleRefresh = (payload: { key: string }) => {\n            if (!eventKey || eventKey !== payload.key) {\n                return;\n            }\n\n            refreshMutation.mutate(true);\n        };\n\n        eventEmitter.on('ITEM_LIST_REFRESH', handleRefresh);\n\n        return () => {\n            eventEmitter.off('ITEM_LIST_REFRESH', handleRefresh);\n        };\n    }, [eventKey, refreshMutation]);\n\n    useEffect(() => {\n        const handleFavorite = (payload: UserFavoriteEventPayload) => {\n            if (payload.itemType !== itemType || payload.serverId !== serverId) {\n                return;\n            }\n\n            const dataIndexes = payload.id\n                .map((id: string) => (data as any).idToIndexMap?.get(id))\n                .filter((idx): idx is number => typeof idx === 'number');\n\n            if (dataIndexes.length === 0) {\n                return;\n            }\n\n            return updateItems(dataIndexes, { userFavorite: payload.favorite });\n        };\n\n        const handleRating = (payload: UserRatingEventPayload) => {\n            if (payload.itemType !== itemType || payload.serverId !== serverId) {\n                return;\n            }\n\n            const dataIndexes = payload.id\n                .map((id: string) => (data as any).idToIndexMap?.get(id))\n                .filter((idx): idx is number => typeof idx === 'number');\n\n            if (dataIndexes.length === 0) {\n                return;\n            }\n\n            return updateItems(dataIndexes, { userRating: payload.rating });\n        };\n\n        eventEmitter.on('USER_FAVORITE', handleFavorite);\n        eventEmitter.on('USER_RATING', handleRating);\n\n        return () => {\n            eventEmitter.off('USER_FAVORITE', handleFavorite);\n            eventEmitter.off('USER_RATING', handleRating);\n        };\n    }, [data, eventKey, itemType, serverId, updateItems]);\n\n    const itemCount = totalItemCount ?? 0;\n\n    const getItem = useCallback(\n        (index: number) => {\n            return (data as any).dataMap?.get(index);\n        },\n        [data],\n    );\n\n    const getItemIndex = useCallback(\n        (id: string) => {\n            return (data as any).idToIndexMap?.get(id);\n        },\n        [data],\n    );\n\n    const loadedItems = useMemo(() => {\n        const map: Map<number, unknown> | undefined = (data as any).dataMap;\n        if (!map || map.size === 0) return [];\n        return Array.from(map.entries())\n            .sort(([a], [b]) => a - b)\n            .map(([, v]) => v);\n    }, [data]);\n\n    return {\n        dataVersion: (data as any).version ?? 0,\n        getItem,\n        getItemIndex,\n        itemCount,\n        loadedItems,\n        onRangeChanged,\n        refresh,\n        updateItems,\n    };\n};\n\nexport const parseListCountQuery = (query: any) => {\n    return {\n        ...query,\n        limit: 1,\n        startIndex: 0,\n    };\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/helpers/item-list-paginated-loader.ts",
    "content": "import {\n    useMutation,\n    useQuery,\n    useQueryClient,\n    useSuspenseQuery,\n    UseSuspenseQueryOptions,\n} from '@tanstack/react-query';\nimport { useCallback, useEffect, useMemo } from 'react';\n\nimport { queryKeys } from '/@/renderer/api/query-keys';\nimport { useListContext } from '/@/renderer/context/list-context';\nimport { eventEmitter } from '/@/renderer/events/event-emitter';\nimport { UserFavoriteEventPayload, UserRatingEventPayload } from '/@/renderer/events/events';\nimport { getListRefreshMutationKey } from '/@/renderer/features/shared/components/list-refresh-button';\nimport { LibraryItem } from '/@/shared/types/domain-types';\n\nconst getQueryKeyName = (itemType: LibraryItem): string => {\n    switch (itemType) {\n        case LibraryItem.ALBUM:\n            return 'albums';\n        case LibraryItem.ALBUM_ARTIST:\n            return 'albumArtists';\n        case LibraryItem.ARTIST:\n            return 'artists';\n        case LibraryItem.GENRE:\n            return 'genres';\n        case LibraryItem.PLAYLIST:\n            return 'playlists';\n        case LibraryItem.SONG:\n            return 'songs';\n        default:\n            return 'albums'; // fallback\n    }\n};\n\ninterface UseItemListPaginatedLoaderProps {\n    currentPage: number;\n    eventKey?: string;\n    itemsPerPage: number;\n    itemType: LibraryItem;\n    listCountQuery: UseSuspenseQueryOptions<number, Error, number, readonly unknown[]>;\n    listQueryFn: (args: { apiClientProps: any; query: any }) => Promise<{ items: unknown[] }>;\n    query: Record<string, any>;\n    serverId: string;\n}\n\nfunction getInitialData(itemCount: number) {\n    return Array.from({ length: itemCount }, () => undefined);\n}\n\nexport const useItemListPaginatedLoader = ({\n    currentPage,\n    eventKey,\n    itemsPerPage = 100,\n    itemType,\n    listCountQuery,\n    listQueryFn,\n    query = {},\n    serverId,\n}: UseItemListPaginatedLoaderProps) => {\n    const queryClient = useQueryClient();\n    const { data: totalItemCount } = useSuspenseQuery<number, any, number, any>(listCountQuery);\n\n    const { setItemCount } = useListContext();\n\n    useEffect(() => {\n        if (totalItemCount == null || !setItemCount) {\n            return;\n        }\n\n        setItemCount(totalItemCount);\n    }, [setItemCount, totalItemCount]);\n\n    const pageCount = Math.ceil(totalItemCount / itemsPerPage);\n\n    const fetchRange = getFetchRange(currentPage, itemsPerPage);\n    const startIndex = fetchRange.startIndex;\n\n    const queryParams = useMemo(\n        () => ({\n            limit: itemsPerPage,\n            startIndex: startIndex,\n            ...query,\n        }),\n        [itemsPerPage, startIndex, query],\n    );\n\n    const { data } = useQuery({\n        gcTime: 1000 * 15,\n        placeholderData: { items: getInitialData(itemsPerPage) },\n        queryFn: async ({ signal }) => {\n            const result = await listQueryFn({\n                apiClientProps: { serverId, signal },\n                query: queryParams,\n            });\n\n            return result;\n        },\n        queryKey: queryKeys[getQueryKeyName(itemType)].list(serverId, queryParams),\n        staleTime: 1000 * 15,\n    });\n\n    const refreshMutation = useMutation({\n        mutationFn: async (force?: boolean) => {\n            const queryKey = queryKeys[getQueryKeyName(itemType)].list(serverId, queryParams);\n\n            if (force) {\n                queryClient.setQueryData(queryKey, {\n                    items: getInitialData(itemsPerPage),\n                });\n            }\n\n            await queryClient.invalidateQueries();\n        },\n        mutationKey: getListRefreshMutationKey(eventKey ?? 'paginated'),\n    });\n\n    const updateItems = useCallback(\n        (indexes: number[], value: object) => {\n            return queryClient.setQueryData(\n                queryKeys[getQueryKeyName(itemType)].list(serverId, queryParams),\n                (prev: undefined | { items: unknown[] }) => {\n                    if (!prev) {\n                        return prev;\n                    }\n\n                    return {\n                        ...prev,\n                        items: prev.items.map((item: any, index) => {\n                            if (!item) {\n                                return item;\n                            }\n\n                            if (!indexes.includes(index)) {\n                                return item;\n                            }\n\n                            return {\n                                ...item,\n                                ...value,\n                            };\n                        }),\n                    };\n                },\n            );\n        },\n        [queryClient, queryParams, serverId, itemType],\n    );\n\n    useEffect(() => {\n        const handleRefresh = (payload: { key: string }) => {\n            if (!eventKey || eventKey !== payload.key) {\n                return;\n            }\n\n            refreshMutation.mutate(true);\n        };\n\n        const handleFavorite = (payload: UserFavoriteEventPayload) => {\n            if (!data || !data.items) {\n                return;\n            }\n\n            if (payload.itemType !== itemType || payload.serverId !== serverId) {\n                return;\n            }\n\n            const idToIndexMap = data.items\n                .filter(Boolean)\n                .reduce((acc: Record<string, number>, item: any, index: number) => {\n                    acc[item.id] = index;\n                    return acc;\n                }, {});\n\n            const dataIndexes = payload.id\n                .map((id: string) => idToIndexMap[id])\n                .filter((idx) => idx !== undefined);\n\n            if (dataIndexes.length === 0) {\n                return;\n            }\n\n            return updateItems(dataIndexes, { userFavorite: payload.favorite });\n        };\n\n        const handleRating = (payload: UserRatingEventPayload) => {\n            if (!data || !data.items) {\n                return;\n            }\n\n            if (payload.itemType !== itemType || payload.serverId !== serverId) {\n                return;\n            }\n\n            const idToIndexMap = data.items.reduce(\n                (acc: Record<string, number>, item: any, index: number) => {\n                    acc[item.id] = index;\n                    return acc;\n                },\n                {},\n            );\n\n            const dataIndexes = payload.id\n                .map((id: string) => idToIndexMap[id])\n                .filter((idx) => idx !== undefined);\n\n            if (dataIndexes.length === 0) {\n                return;\n            }\n\n            return updateItems(dataIndexes, { userRating: payload.rating });\n        };\n\n        eventEmitter.on('ITEM_LIST_REFRESH', handleRefresh);\n        eventEmitter.on('USER_FAVORITE', handleFavorite);\n        eventEmitter.on('USER_RATING', handleRating);\n\n        return () => {\n            eventEmitter.off('ITEM_LIST_REFRESH', handleRefresh);\n            eventEmitter.off('USER_FAVORITE', handleFavorite);\n            eventEmitter.off('USER_RATING', handleRating);\n        };\n    }, [data, eventKey, itemType, refreshMutation, serverId, updateItems]);\n\n    return { data: data?.items || [], pageCount, totalItemCount };\n};\n\nconst getFetchRange = (pageIndex: number, itemsPerPage: number) => {\n    const startIndex = pageIndex * itemsPerPage;\n\n    return {\n        limit: itemsPerPage,\n        startIndex,\n    };\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/helpers/item-list-reducer-utils.ts",
    "content": "import {\n    ItemListAction,\n    ItemListState,\n    ItemListStateItemWithRequiredProperties,\n} from './item-list-state';\n\n/**\n * Action creators for item grid state management\n * These can be reused across different components and contexts\n */\nexport const itemListActions = {\n    clearAll: (): ItemListAction => ({\n        type: 'CLEAR_ALL',\n    }),\n\n    clearExpanded: (): ItemListAction => ({\n        type: 'CLEAR_EXPANDED',\n    }),\n\n    clearSelected: (): ItemListAction => ({\n        type: 'CLEAR_SELECTED',\n    }),\n\n    setDragging: (\n        items: ItemListStateItemWithRequiredProperties[],\n        extractRowId: (item: unknown) => string | undefined,\n    ): ItemListAction => ({\n        extractRowId,\n        payload: items,\n        type: 'SET_DRAGGING',\n    }),\n\n    setExpanded: (\n        items: ItemListStateItemWithRequiredProperties[],\n        extractRowId: (item: unknown) => string | undefined,\n    ): ItemListAction => ({\n        extractRowId,\n        payload: items,\n        type: 'SET_EXPANDED',\n    }),\n\n    setSelected: (\n        items: ItemListStateItemWithRequiredProperties[],\n        extractRowId: (item: unknown) => string | undefined,\n    ): ItemListAction => ({\n        extractRowId,\n        payload: items,\n        type: 'SET_SELECTED',\n    }),\n\n    toggleExpanded: (\n        item: ItemListStateItemWithRequiredProperties,\n        extractRowId: (item: unknown) => string | undefined,\n    ): ItemListAction => ({\n        extractRowId,\n        payload: item,\n        type: 'TOGGLE_EXPANDED',\n    }),\n\n    toggleSelected: (\n        item: ItemListStateItemWithRequiredProperties,\n        extractRowId: (item: unknown) => string | undefined,\n    ): ItemListAction => ({\n        extractRowId,\n        payload: item,\n        type: 'TOGGLE_SELECTED',\n    }),\n};\n\n/**\n * Selector functions for item grid state\n * These can be reused to extract specific data from state\n */\nexport const itemListSelectors = {\n    getDragging: (state: ItemListState): unknown[] => {\n        return Array.from(state.draggingItems.values());\n    },\n\n    getDraggingCount: (state: ItemListState): number => {\n        return state.dragging.size;\n    },\n\n    getDraggingIds: (state: ItemListState): string[] => {\n        return Array.from(state.dragging);\n    },\n\n    getExpanded: (state: ItemListState): unknown[] => {\n        return Array.from(state.expandedItems.values());\n    },\n\n    getExpandedCount: (state: ItemListState): number => {\n        return state.expanded.size;\n    },\n\n    getExpandedIds: (state: ItemListState): string[] => {\n        return Array.from(state.expanded);\n    },\n\n    getSelected: (state: ItemListState): unknown[] => {\n        return Array.from(state.selectedItems.values());\n    },\n\n    getSelectedCount: (state: ItemListState): number => {\n        return state.selected.size;\n    },\n\n    getSelectedIds: (state: ItemListState): string[] => {\n        return Array.from(state.selected);\n    },\n\n    getVersion: (state: ItemListState): number => {\n        return state.version;\n    },\n\n    hasAnyDragging: (state: ItemListState): boolean => {\n        return state.dragging.size > 0;\n    },\n\n    hasAnyExpanded: (state: ItemListState): boolean => {\n        return state.expanded.size > 0;\n    },\n\n    hasAnySelected: (state: ItemListState): boolean => {\n        return state.selected.size > 0;\n    },\n\n    isDragging: (state: ItemListState, rowId: string): boolean => {\n        return state.dragging.has(rowId);\n    },\n\n    isExpanded: (state: ItemListState, rowId: string): boolean => {\n        return state.expanded.has(rowId);\n    },\n\n    isSelected: (state: ItemListState, rowId: string): boolean => {\n        return state.selected.has(rowId);\n    },\n};\n\nexport const itemListUtils = {\n    /**\n     * Check if all items in a list are selected\n     */\n    areAllSelected: (state: ItemListState, rowIds: string[]): boolean => {\n        return rowIds.every((id) => state.selected.has(id));\n    },\n\n    /**\n     * Check if any items in a list are selected\n     */\n    areAnySelected: (state: ItemListState, rowIds: string[]): boolean => {\n        return rowIds.some((id) => state.selected.has(id));\n    },\n\n    /**\n     * Check if multiple items are expanded\n     */\n    isMultiExpand: (state: ItemListState): boolean => {\n        return state.expanded.size > 1;\n    },\n\n    /**\n     * Check if multiple items are selected\n     */\n    isMultiSelect: (state: ItemListState): boolean => {\n        return state.selected.size > 1;\n    },\n\n    /**\n     * Toggle expansion of all items in a list\n     */\n    toggleAllExpanded: (\n        items: ItemListStateItemWithRequiredProperties[],\n        currentState: ItemListState,\n        extractRowId: (item: unknown) => string | undefined,\n    ): ItemListAction => {\n        const allExpanded = items.every((item) => {\n            const rowId = extractRowId(item);\n            return rowId ? currentState.expanded.has(rowId) : false;\n        });\n        return allExpanded\n            ? itemListActions.clearExpanded()\n            : itemListActions.setExpanded(items, extractRowId);\n    },\n\n    /**\n     * Toggle selection of all items in a list\n     */\n    toggleAllSelected: (\n        items: ItemListStateItemWithRequiredProperties[],\n        currentState: ItemListState,\n        extractRowId: (item: unknown) => string | undefined,\n    ): ItemListAction => {\n        const allSelected = items.every((item) => {\n            const rowId = extractRowId(item);\n            return rowId ? currentState.selected.has(rowId) : false;\n        });\n        return allSelected\n            ? itemListActions.clearSelected()\n            : itemListActions.setSelected(items, extractRowId);\n    },\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/helpers/item-list-state.ts",
    "content": "import { useCallback, useMemo, useRef, useSyncExternalStore } from 'react';\n\nimport { itemListSelectors } from '/@/renderer/components/item-list/helpers/item-list-reducer-utils';\nimport { LibraryItem } from '/@/shared/types/domain-types';\n\nconst sortByDataOrder = <T>(\n    items: T[],\n    data: unknown[],\n    extractRowId: (item: unknown) => string | undefined,\n    isIdArray: boolean,\n): T[] => {\n    const rowIdToIndex = new Map<string, number>();\n\n    // Create a map of rowId to index in the data array\n    data.forEach((item, index) => {\n        if (item && typeof item === 'object') {\n            const itemRowId = extractRowId(item);\n            if (itemRowId) {\n                rowIdToIndex.set(itemRowId, index);\n            }\n        }\n    });\n\n    // Sort items by their index in the data array (create new array to avoid mutation)\n    return [...items].sort((a, b) => {\n        const rowIdA = isIdArray ? (a as string) : extractRowId(a as unknown);\n        const rowIdB = isIdArray ? (b as string) : extractRowId(b as unknown);\n        const indexA = rowIdA ? (rowIdToIndex.get(rowIdA) ?? Infinity) : Infinity;\n        const indexB = rowIdB ? (rowIdToIndex.get(rowIdB) ?? Infinity) : Infinity;\n        return indexA - indexB;\n    });\n};\n\nexport type ItemListAction =\n    | {\n          extractRowId: (item: unknown) => string | undefined;\n          payload: ItemListStateItemWithRequiredProperties;\n          type: 'TOGGLE_EXPANDED';\n      }\n    | {\n          extractRowId: (item: unknown) => string | undefined;\n          payload: ItemListStateItemWithRequiredProperties;\n          type: 'TOGGLE_SELECTED';\n      }\n    | {\n          extractRowId: (item: unknown) => string | undefined;\n          payload: ItemListStateItemWithRequiredProperties[];\n          type: 'SET_DRAGGING';\n      }\n    | {\n          extractRowId: (item: unknown) => string | undefined;\n          payload: ItemListStateItemWithRequiredProperties[];\n          type: 'SET_EXPANDED';\n      }\n    | {\n          extractRowId: (item: unknown) => string | undefined;\n          payload: ItemListStateItemWithRequiredProperties[];\n          type: 'SET_SELECTED';\n      }\n    | { type: 'CLEAR_ALL' }\n    | { type: 'CLEAR_DRAGGING' }\n    | { type: 'CLEAR_EXPANDED' }\n    | { type: 'CLEAR_SELECTED' };\n\nexport interface ItemListState {\n    dragging: Set<string>;\n    draggingItems: Map<string, unknown>;\n    expanded: Set<string>;\n    expandedItems: Map<string, unknown>;\n    selected: Set<string>;\n    selectedItems: Map<string, unknown>;\n    version: number;\n}\n\nexport interface ItemListStateActions {\n    clearAll: () => void;\n    clearDragging: () => void;\n    clearExpanded: () => void;\n    clearSelected: () => void;\n    deselectAll: () => void;\n    extractRowId: (item: unknown) => string | undefined;\n    findItemIndex: (rowId: string) => number;\n    getData: () => unknown[];\n    getDragging: () => unknown[];\n    getDraggingIds: () => string[];\n    getExpanded: () => unknown[];\n    getExpandedIds: () => string[];\n    getExpandedItemsCached: () => unknown[];\n    getSelected: () => unknown[];\n    getSelectedIds: () => string[];\n    getVersion: () => number;\n    hasDragging: () => boolean;\n    hasExpanded: () => boolean;\n    hasSelected: () => boolean;\n    isAllSelected: () => boolean;\n    isDragging: (rowId: string) => boolean;\n    isExpanded: (rowId: string) => boolean;\n    isSelected: (rowId: string) => boolean;\n    isSomeSelected: () => boolean;\n    selectAll: () => void;\n    setDragging: (items: ItemListStateItemWithRequiredProperties[]) => void;\n    setExpanded: (items: ItemListStateItemWithRequiredProperties[]) => void;\n    setSelected: (items: ItemListStateItemWithRequiredProperties[]) => void;\n    toggleExpanded: (item: ItemListStateItemWithRequiredProperties) => void;\n    toggleSelected: (item: ItemListStateItemWithRequiredProperties) => void;\n}\n\nexport interface ItemListStateItem {\n    _itemType: LibraryItem;\n    _serverId: string;\n    id: string;\n    imageId: null | string;\n}\n\nexport type ItemListStateItemWithRequiredProperties = Record<string, unknown> & {\n    _itemType: LibraryItem;\n    _serverId: string;\n    id: string;\n};\n\n/**\n * Reusable reducer for item grid state management\n * Can be used in different components or contexts\n */\nexport const itemListReducer = (state: ItemListState, action: ItemListAction): ItemListState => {\n    switch (action.type) {\n        case 'CLEAR_ALL':\n            return {\n                ...state,\n                dragging: new Set(),\n                draggingItems: new Map(),\n                expanded: new Set(),\n                expandedItems: new Map(),\n                selected: new Set(),\n                selectedItems: new Map(),\n                version: state.version + 1,\n            };\n\n        case 'CLEAR_DRAGGING':\n            return {\n                ...state,\n                dragging: new Set(),\n                draggingItems: new Map(),\n                version: state.version + 1,\n            };\n\n        case 'CLEAR_EXPANDED':\n            return {\n                ...state,\n                expanded: new Set(),\n                expandedItems: new Map(),\n                version: state.version + 1,\n            };\n\n        case 'CLEAR_SELECTED':\n            return {\n                ...state,\n                selected: new Set(),\n                selectedItems: new Map(),\n                version: state.version + 1,\n            };\n\n        case 'SET_DRAGGING': {\n            const newDragging = new Set<string>();\n            const newDraggingItems = new Map<string, unknown>();\n\n            action.payload.forEach((item) => {\n                const rowId = action.extractRowId(item);\n                if (rowId) {\n                    newDragging.add(rowId);\n                    newDraggingItems.set(rowId, item);\n                }\n            });\n\n            return {\n                ...state,\n                dragging: newDragging,\n                draggingItems: newDraggingItems,\n                version: state.version + 1,\n            };\n        }\n\n        case 'SET_EXPANDED': {\n            const newExpanded = new Set<string>();\n            const newExpandedItems = new Map<string, unknown>();\n\n            if (action.payload.length > 0) {\n                const firstItem = action.payload[0];\n                const rowId = action.extractRowId(firstItem);\n                if (rowId) {\n                    newExpanded.add(rowId);\n                    newExpandedItems.set(rowId, firstItem);\n                }\n            }\n\n            return {\n                ...state,\n                expanded: newExpanded,\n                expandedItems: newExpandedItems,\n                version: state.version + 1,\n            };\n        }\n\n        case 'SET_SELECTED': {\n            const newSelected = new Set<string>();\n            const newSelectedItems = new Map<string, unknown>();\n\n            action.payload.forEach((item) => {\n                const rowId = action.extractRowId(item);\n                if (rowId) {\n                    newSelected.add(rowId);\n                    newSelectedItems.set(rowId, item);\n                }\n            });\n\n            return {\n                ...state,\n                selected: newSelected,\n                selectedItems: newSelectedItems,\n                version: state.version + 1,\n            };\n        }\n\n        case 'TOGGLE_EXPANDED': {\n            const newExpanded = new Set<string>();\n            const newExpandedItems = new Map<string, unknown>();\n\n            const rowId = action.extractRowId(action.payload);\n            if (!rowId) {\n                return state;\n            }\n\n            // If the item is already expanded, collapse it\n            if (state.expanded.has(rowId)) {\n                // Item is expanded, so collapse it (leave sets empty)\n            } else {\n                // Item is not expanded, so expand it (clear others first for single expansion)\n                newExpanded.add(rowId);\n                newExpandedItems.set(rowId, action.payload);\n            }\n\n            return {\n                ...state,\n                expanded: newExpanded,\n                expandedItems: newExpandedItems,\n                version: state.version + 1,\n            };\n        }\n\n        case 'TOGGLE_SELECTED': {\n            const newSelected = new Set(state.selected);\n            const newSelectedItems = new Map(state.selectedItems);\n\n            const rowId = action.extractRowId(action.payload);\n            if (!rowId) {\n                return state;\n            }\n\n            if (newSelected.has(rowId)) {\n                newSelected.delete(rowId);\n                newSelectedItems.delete(rowId);\n            } else {\n                newSelected.add(rowId);\n                newSelectedItems.set(rowId, action.payload);\n            }\n\n            return {\n                ...state,\n                selected: newSelected,\n                selectedItems: newSelectedItems,\n                version: state.version + 1,\n            };\n        }\n\n        default:\n            return state;\n    }\n};\n\nexport const initialItemListState: ItemListState = {\n    dragging: new Set(),\n    draggingItems: new Map(),\n    expanded: new Set(),\n    expandedItems: new Map(),\n    selected: new Set(),\n    selectedItems: new Map(),\n    version: 0,\n};\n\n/**\n * External store for item list state that doesn't cause React rerenders\n * Components can subscribe to specific state slices using useSyncExternalStore\n */\nclass ItemListStateStore {\n    // Cache for derived values to prevent unnecessary rerenders\n    private expandedItemsCache: null | unknown[] = null;\n    private expandedItemsCacheVersion: number = -1;\n    private listeners = new Set<() => void>();\n    private state: ItemListState = { ...initialItemListState };\n\n    dispatch(action: ItemListAction): void {\n        this.state = itemListReducer(this.state, action);\n        // Invalidate caches when state changes\n        this.expandedItemsCache = null;\n        // Notify all subscribers\n        this.listeners.forEach((listener) => listener());\n    }\n\n    getExpandedItems(): unknown[] {\n        // Return cached array if state version hasn't changed\n        if (\n            this.expandedItemsCache !== null &&\n            this.expandedItemsCacheVersion === this.state.version\n        ) {\n            return this.expandedItemsCache;\n        }\n        // Create new array and cache it\n        this.expandedItemsCache = Array.from(this.state.expandedItems.values());\n        this.expandedItemsCacheVersion = this.state.version;\n        return this.expandedItemsCache;\n    }\n\n    getState(): ItemListState {\n        return this.state;\n    }\n\n    subscribe(listener: () => void): () => void {\n        this.listeners.add(listener);\n        return () => {\n            this.listeners.delete(listener);\n        };\n    }\n}\n\n/**\n * Hook to subscribe to specific state changes in the item list state\n * Use this in components that need to rerender when state changes\n */\nexport const useItemListStateSubscription = <T>(\n    internalState: ItemListStateActions | undefined,\n    selector: (state: ItemListState | null) => T,\n): T => {\n    const store = internalState ? ((internalState as any).__store as ItemListStateStore) : null;\n\n    return useSyncExternalStore(\n        store?.subscribe.bind(store) || (() => () => {}), // Return no-op unsubscribe if no store\n        () => selector(store?.getState() || null),\n    );\n};\n\n/**\n * Hook to subscribe to selection state for a specific item\n * Use this in components that need to rerender when a specific item's selection changes\n */\nexport const useItemSelectionState = (\n    internalState: ItemListStateActions | undefined,\n    rowId: string | undefined,\n): boolean => {\n    return useItemListStateSubscription(internalState, (state) =>\n        state && rowId ? state.selected.has(rowId) : false,\n    );\n};\n\n/**\n * Hook to subscribe to expansion state for a specific item\n * Use this in components that need to rerender when a specific item's expansion changes\n */\nexport const useItemExpansionState = (\n    internalState: ItemListStateActions | undefined,\n    rowId: string | undefined,\n): boolean => {\n    return useItemListStateSubscription(internalState, (state) =>\n        state && rowId ? state.expanded.has(rowId) : false,\n    );\n};\n\n/**\n * Hook to subscribe to dragging state for a specific item\n * Use this in components that need to rerender when a specific item's dragging state changes\n */\nexport const useItemDraggingState = (\n    internalState: ItemListStateActions | undefined,\n    rowId: string | undefined,\n): boolean => {\n    return useItemListStateSubscription(internalState, (state) =>\n        state && rowId ? state.dragging.has(rowId) : false,\n    );\n};\n\nexport const useItemListState = (\n    getDataFn?: () => unknown[],\n    extractRowId?: (item: unknown) => string | undefined,\n): ItemListStateActions => {\n    // Create store instance (stable across rerenders)\n    const storeRef = useRef<ItemListStateStore | null>(null);\n    if (!storeRef.current) {\n        storeRef.current = new ItemListStateStore();\n    }\n    const store = storeRef.current;\n\n    // DON'T subscribe here - this prevents rerenders when state changes\n    // Components that need to react should use useItemListStateSubscription\n\n    // Get current state (this doesn't cause rerenders, it's just reading from the store)\n    const getCurrentState = useCallback(() => store.getState(), [store]);\n\n    const extractRowIdFn = useCallback(\n        (item: unknown) => {\n            if (extractRowId) {\n                return extractRowId(item);\n            }\n            // Fallback to id if extractRowId is not provided\n            if (item && typeof item === 'object' && 'id' in item) {\n                return (item as any).id;\n            }\n            return undefined;\n        },\n        [extractRowId],\n    );\n\n    const setExpanded = useCallback(\n        (items: ItemListStateItemWithRequiredProperties[]) => {\n            store.dispatch({\n                extractRowId: extractRowIdFn,\n                payload: items,\n                type: 'SET_EXPANDED',\n            });\n        },\n        [store, extractRowIdFn],\n    );\n\n    const setDragging = useCallback(\n        (items: ItemListStateItemWithRequiredProperties[]) => {\n            store.dispatch({\n                extractRowId: extractRowIdFn,\n                payload: items,\n                type: 'SET_DRAGGING',\n            });\n        },\n        [store, extractRowIdFn],\n    );\n\n    const setSelected = useCallback(\n        (items: ItemListStateItemWithRequiredProperties[]) => {\n            store.dispatch({\n                extractRowId: extractRowIdFn,\n                payload: items,\n                type: 'SET_SELECTED',\n            });\n        },\n        [store, extractRowIdFn],\n    );\n\n    const toggleExpanded = useCallback(\n        (item: ItemListStateItemWithRequiredProperties) => {\n            store.dispatch({\n                extractRowId: extractRowIdFn,\n                payload: item,\n                type: 'TOGGLE_EXPANDED',\n            });\n        },\n        [store, extractRowIdFn],\n    );\n\n    const toggleSelected = useCallback(\n        (item: ItemListStateItemWithRequiredProperties) => {\n            store.dispatch({\n                extractRowId: extractRowIdFn,\n                payload: item,\n                type: 'TOGGLE_SELECTED',\n            });\n        },\n        [store, extractRowIdFn],\n    );\n\n    // These methods read from the store without subscribing, so they don't cause rerenders\n    const isExpanded = useCallback(\n        (rowId: string) => {\n            const state = getCurrentState();\n            return itemListSelectors.isExpanded(state, rowId);\n        },\n        [getCurrentState],\n    );\n\n    const isSelected = useCallback(\n        (rowId: string) => {\n            const state = getCurrentState();\n            return itemListSelectors.isSelected(state, rowId);\n        },\n        [getCurrentState],\n    );\n\n    const getExpanded = useCallback(() => {\n        const state = getCurrentState();\n        return itemListSelectors.getExpanded(state);\n    }, [getCurrentState]);\n\n    const getExpandedItemsCached = useCallback(() => {\n        return store.getExpandedItems();\n    }, [store]);\n\n    const getDragging = useCallback(() => {\n        const state = getCurrentState();\n        return itemListSelectors.getDragging(state);\n    }, [getCurrentState]);\n\n    const getSelected = useCallback(() => {\n        const state = getCurrentState();\n        const selectedItems = itemListSelectors.getSelected(state);\n        const data = getDataFn ? getDataFn() : [];\n        return sortByDataOrder(selectedItems, data, extractRowIdFn, false);\n    }, [getCurrentState, getDataFn, extractRowIdFn]);\n\n    const getDraggingIds = useCallback(() => {\n        const state = getCurrentState();\n        return Array.from(state.dragging);\n    }, [getCurrentState]);\n\n    const getExpandedIds = useCallback(() => {\n        const state = getCurrentState();\n        return Array.from(state.expanded);\n    }, [getCurrentState]);\n\n    const getSelectedIds = useCallback(() => {\n        const state = getCurrentState();\n        const selectedIds = Array.from(state.selected);\n        const data = getDataFn ? getDataFn() : [];\n        return sortByDataOrder(selectedIds, data, extractRowIdFn, true);\n    }, [getCurrentState, getDataFn, extractRowIdFn]);\n\n    const clearExpanded = useCallback(() => {\n        store.dispatch({ type: 'CLEAR_EXPANDED' });\n    }, [store]);\n\n    const clearDragging = useCallback(() => {\n        store.dispatch({ type: 'CLEAR_DRAGGING' });\n    }, [store]);\n\n    const clearSelected = useCallback(() => {\n        store.dispatch({ type: 'CLEAR_SELECTED' });\n    }, [store]);\n\n    const clearAll = useCallback(() => {\n        store.dispatch({ type: 'CLEAR_ALL' });\n    }, [store]);\n\n    const getVersion = useCallback(() => {\n        const state = getCurrentState();\n        return itemListSelectors.getVersion(state);\n    }, [getCurrentState]);\n\n    const hasExpanded = useCallback(() => {\n        const state = getCurrentState();\n        return itemListSelectors.hasAnyExpanded(state);\n    }, [getCurrentState]);\n\n    const hasDragging = useCallback(() => {\n        const state = getCurrentState();\n        return itemListSelectors.hasAnyDragging(state);\n    }, [getCurrentState]);\n\n    const hasSelected = useCallback(() => {\n        const state = getCurrentState();\n        return itemListSelectors.hasAnySelected(state);\n    }, [getCurrentState]);\n\n    const isDragging = useCallback(\n        (rowId: string) => {\n            const state = getCurrentState();\n            return itemListSelectors.isDragging(state, rowId);\n        },\n        [getCurrentState],\n    );\n\n    const getData = useCallback(() => {\n        const data = getDataFn ? getDataFn() : [];\n        // Filter out null/undefined values (e.g., group header rows)\n        return data.filter((d) => d != null);\n    }, [getDataFn]);\n\n    const findItemIndex = useCallback(\n        (rowId: string) => {\n            const data = getDataFn ? getDataFn() : [];\n            // Filter out null/undefined values (e.g., header row)\n            const validData = data.filter((d) => d && typeof d === 'object');\n            if (!extractRowId) {\n                // Fallback to id if extractRowId is not provided\n                return validData.findIndex((d) => (d as any).id === rowId);\n            }\n            return validData.findIndex((d) => extractRowId(d) === rowId);\n        },\n        [getDataFn, extractRowId],\n    );\n\n    const selectAll = useCallback(() => {\n        const data = getDataFn ? getDataFn() : [];\n        const items = data\n            .filter((d) => d && typeof d === 'object')\n            .map((d) => d as ItemListStateItemWithRequiredProperties);\n        store.dispatch({ extractRowId: extractRowIdFn, payload: items, type: 'SET_SELECTED' });\n    }, [extractRowIdFn, getDataFn, store]);\n\n    const deselectAll = useCallback(() => {\n        store.dispatch({ type: 'CLEAR_SELECTED' });\n    }, [store]);\n\n    const isAllSelected = useCallback(() => {\n        const state = getCurrentState();\n        const data = getDataFn ? getDataFn() : [];\n        return state.selected.size === data.filter((d) => d && typeof d === 'object').length;\n    }, [getCurrentState, getDataFn]);\n\n    const isSomeSelected = useCallback(() => {\n        const state = getCurrentState();\n        return state.selected.size > 0;\n    }, [getCurrentState]);\n\n    // Expose the store so components can subscribe if needed\n    // Store it in the actions object for access\n    const actions = useMemo(() => {\n        const actionsObj = {\n            __getState: getCurrentState,\n            __store: store,\n            clearAll,\n            clearDragging,\n            clearExpanded,\n            clearSelected,\n            deselectAll,\n            extractRowId: extractRowIdFn,\n            findItemIndex,\n            getData,\n            getDragging,\n            getDraggingIds,\n            getExpanded,\n            getExpandedIds,\n            getExpandedItemsCached,\n            getSelected,\n            getSelectedIds,\n            getVersion,\n            hasDragging,\n            hasExpanded,\n            hasSelected,\n            isAllSelected,\n            isDragging,\n            isExpanded,\n            isSelected,\n            isSomeSelected,\n            selectAll,\n            setDragging,\n            setExpanded,\n            setSelected,\n            toggleExpanded,\n            toggleSelected,\n        } as ItemListStateActions & {\n            __getState: () => ItemListState;\n            __store: ItemListStateStore;\n        };\n        return actionsObj;\n    }, [\n        getCurrentState,\n        store,\n        clearAll,\n        clearDragging,\n        clearExpanded,\n        clearSelected,\n        isAllSelected,\n        isSomeSelected,\n        deselectAll,\n        extractRowIdFn,\n        findItemIndex,\n        getData,\n        getDragging,\n        getDraggingIds,\n        getExpanded,\n        getExpandedIds,\n        getExpandedItemsCached,\n        getSelected,\n        getSelectedIds,\n        getVersion,\n        hasDragging,\n        hasExpanded,\n        hasSelected,\n        isDragging,\n        isExpanded,\n        isSelected,\n        selectAll,\n        setDragging,\n        setExpanded,\n        setSelected,\n        toggleExpanded,\n        toggleSelected,\n    ]);\n\n    return actions;\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/helpers/parse-table-columns.ts",
    "content": "import { ItemTableListColumnConfig } from '/@/renderer/components/item-list/types';\n\n/**\n * Sorts table columns by their pinned position and filters out disabled columns:\n * - Left pinned columns come first (maintaining their original order)\n * - Unpinned columns come next (maintaining their original order)\n * - Right pinned columns come last (maintaining their original order)\n * - Columns with isEnabled: false are removed\n */\nexport const parseTableColumns = (\n    columns: ItemTableListColumnConfig[],\n): ItemTableListColumnConfig[] => {\n    const leftPinned: ItemTableListColumnConfig[] = [];\n    const unpinned: ItemTableListColumnConfig[] = [];\n    const rightPinned: ItemTableListColumnConfig[] = [];\n\n    // Separate columns by pinned position while maintaining original order\n    // Only include columns that are enabled (isEnabled !== false)\n    columns.forEach((column) => {\n        if (column.isEnabled === false) {\n            return;\n        }\n\n        switch (column.pinned) {\n            case 'left':\n                leftPinned.push(column);\n                break;\n            case 'right':\n                rightPinned.push(column);\n                break;\n            case null:\n            default:\n                unpinned.push(column);\n                break;\n        }\n    });\n\n    // Combine in the desired order: left pinned, unpinned, right pinned\n    return [...leftPinned, ...unpinned, ...rightPinned];\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/helpers/use-grid-rows.ts",
    "content": "import { useMemo } from 'react';\n\nimport { type DataRow, getDataRows } from '/@/renderer/components/item-card/item-card';\nimport { useSettingsStore } from '/@/renderer/store';\nimport { LibraryItem } from '/@/shared/types/domain-types';\nimport { TableColumn } from '/@/shared/types/types';\nimport { ItemListKey } from '/@/shared/types/types';\n\nconst getDefaultRowsForItemType = (\n    itemType: LibraryItem,\n    type?: 'compact' | 'default' | 'poster',\n): DataRow[] => {\n    const allRows = getDataRows(type);\n    const rowMap = new Map(allRows.map((row) => [row.id, row]));\n\n    switch (itemType) {\n        case LibraryItem.ALBUM:\n            return [rowMap.get('name'), rowMap.get('albumArtists')].filter(\n                (row): row is NonNullable<typeof row> => row !== undefined,\n            );\n        case LibraryItem.ALBUM_ARTIST:\n            return [rowMap.get('name')].filter(\n                (row): row is NonNullable<typeof row> => row !== undefined,\n            );\n        case LibraryItem.ARTIST:\n            return [rowMap.get('name')].filter(\n                (row): row is NonNullable<typeof row> => row !== undefined,\n            );\n        case LibraryItem.GENRE:\n            return [rowMap.get('name')].filter(\n                (row): row is NonNullable<typeof row> => row !== undefined,\n            );\n        case LibraryItem.PLAYLIST:\n            return [rowMap.get('name')].filter(\n                (row): row is NonNullable<typeof row> => row !== undefined,\n            );\n        case LibraryItem.SONG:\n            return [rowMap.get('name')].filter(\n                (row): row is NonNullable<typeof row> => row !== undefined,\n            );\n        default:\n            return [];\n    }\n};\n\nconst getRowIdFromTableColumn = (tableColumn: TableColumn): null | string => {\n    const columnToRowIdMap: Record<TableColumn, null | string> = {\n        [TableColumn.ACTIONS]: null,\n        [TableColumn.ALBUM]: 'album',\n        [TableColumn.ALBUM_ARTIST]: 'albumArtists',\n        [TableColumn.ALBUM_COUNT]: 'albumCount',\n        [TableColumn.ALBUM_GROUP]: null,\n        [TableColumn.ARTIST]: 'artists',\n        [TableColumn.BIOGRAPHY]: null,\n        [TableColumn.BIT_DEPTH]: 'bitDepth',\n        [TableColumn.BIT_RATE]: null,\n        [TableColumn.BPM]: null,\n        [TableColumn.CHANNELS]: null,\n        [TableColumn.CODEC]: null,\n        [TableColumn.COMMENT]: null,\n        [TableColumn.COMPOSER]: null,\n        [TableColumn.DATE_ADDED]: 'createdAt',\n        [TableColumn.DISC_NUMBER]: null,\n        [TableColumn.DURATION]: 'duration',\n        [TableColumn.GENRE]: 'genres',\n        [TableColumn.GENRE_BADGE]: null,\n        [TableColumn.ID]: null,\n        [TableColumn.IMAGE]: null,\n        [TableColumn.LAST_PLAYED]: 'lastPlayedAt',\n        [TableColumn.OWNER]: null,\n        [TableColumn.PATH]: null,\n        [TableColumn.PLAY_COUNT]: 'playCount',\n        [TableColumn.PLAYLIST_REORDER]: null,\n        [TableColumn.RELEASE_DATE]: 'releaseDate',\n        [TableColumn.ROW_INDEX]: null,\n        [TableColumn.SAMPLE_RATE]: 'sampleRate',\n        [TableColumn.SIZE]: null,\n        [TableColumn.SKIP]: null,\n        [TableColumn.SONG_COUNT]: 'songCount',\n        [TableColumn.TITLE]: 'name',\n        [TableColumn.TITLE_ARTIST]: null,\n        [TableColumn.TITLE_COMBINED]: null,\n        [TableColumn.TRACK_NUMBER]: null,\n        [TableColumn.USER_FAVORITE]: 'userFavorite',\n        [TableColumn.USER_RATING]: 'rating',\n        [TableColumn.YEAR]: 'releaseYear',\n    };\n    return columnToRowIdMap[tableColumn] || null;\n};\n\nexport const useGridRows = (\n    itemType: LibraryItem,\n    listKey?: ItemListKey,\n    size?: 'compact' | 'default' | 'large',\n) => {\n    const gridRowsConfig = useSettingsStore((state) =>\n        listKey ? state.lists[listKey]?.grid?.rows : undefined,\n    );\n\n    const type: 'compact' | 'default' | 'poster' = size === 'compact' ? 'compact' : 'poster';\n\n    return useMemo(() => {\n        const allRows = getDataRows(type);\n\n        if (!listKey || !gridRowsConfig || gridRowsConfig.length === 0) {\n            const defaultRows = getDefaultRowsForItemType(itemType, type);\n            return defaultRows.length > 0 ? defaultRows : allRows;\n        }\n\n        const rowMap = new Map(allRows.map((row) => [row.id, row]));\n\n        const configuredRows = gridRowsConfig\n            .filter((config) => config.isEnabled)\n            .map((config) => {\n                const rowId = getRowIdFromTableColumn(config.id);\n                const baseRow = rowId ? rowMap.get(rowId) : null;\n                if (!baseRow) return null;\n\n                return {\n                    ...baseRow,\n                    align: config.align,\n                };\n            })\n            .filter((row): row is NonNullable<typeof row> => row !== null && row !== undefined);\n\n        return configuredRows.length > 0 ? configuredRows : allRows;\n    }, [itemType, listKey, gridRowsConfig, type]);\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/helpers/use-is-fetching-item-list.ts",
    "content": "import { useIsFetching } from '@tanstack/react-query';\n\nimport { queryKeys } from '/@/renderer/api/query-keys';\nimport { getListQueryKeyName } from '/@/renderer/components/item-list/helpers/item-list-infinite-loader';\nimport { useCurrentServerId } from '/@/renderer/store';\nimport { LibraryItem } from '/@/shared/types/domain-types';\n\nexport const useIsFetchingItemListCount = ({ itemType }: { itemType: LibraryItem }) => {\n    const serverId = useCurrentServerId();\n\n    const isFetching = useIsFetching({\n        queryKey: queryKeys[getListQueryKeyName(itemType)].count(serverId),\n    });\n\n    return isFetching > 0;\n};\n\nexport const useIsFetchingItemList = ({ itemType }: { itemType: LibraryItem }) => {\n    const serverId = useCurrentServerId();\n\n    const isFetching = useIsFetching({\n        queryKey: queryKeys[getListQueryKeyName(itemType)].list(serverId),\n    });\n\n    return isFetching > 0;\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/helpers/use-item-list-column-reorder.ts",
    "content": "import type { Edge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge';\n\nimport { useCallback } from 'react';\n\nimport { useSettingsStore, useSettingsStoreActions } from '/@/renderer/store';\nimport { ItemListKey, TableColumn } from '/@/shared/types/types';\n\ninterface UseItemListColumnReorderProps {\n    itemListKey: ItemListKey;\n    tableKey?: 'detail' | 'main';\n}\n\nexport const useItemListColumnReorder = ({\n    itemListKey,\n    tableKey = 'main',\n}: UseItemListColumnReorderProps) => {\n    const { setList } = useSettingsStoreActions();\n\n    const handleColumnReordered = useCallback(\n        (columnIdFrom: TableColumn, columnIdTo: TableColumn, edge: Edge | null) => {\n            const list = useSettingsStore.getState().lists[itemListKey];\n            const columns = tableKey === 'detail' ? list?.detail?.columns : list?.table?.columns;\n\n            if (!columns) {\n                return;\n            }\n\n            const indexFrom = columns.findIndex((column) => column.id === columnIdFrom);\n            const indexTo = columns.findIndex((column) => column.id === columnIdTo);\n\n            // If either column not found or dragging to the same position, do nothing\n            if (indexFrom === -1 || indexTo === -1 || indexFrom === indexTo) {\n                return;\n            }\n\n            const targetColumn = columns[indexTo];\n\n            // Create a new array to avoid mutating the original\n            const newColumns = [...columns];\n\n            // Remove the column from its current position\n            const [movedColumn] = newColumns.splice(indexFrom, 1);\n\n            // Update pinned status based on target column\n            // If dragging onto a pinned left column, pin the moved column to left\n            // If dragging onto a pinned right column, pin the moved column to right\n            // If dragging onto an unpinned column, unpin the moved column\n            const updatedMovedColumn =\n                targetColumn.pinned === 'left'\n                    ? { ...movedColumn, pinned: 'left' as const }\n                    : targetColumn.pinned === 'right'\n                      ? { ...movedColumn, pinned: 'right' as const }\n                      : { ...movedColumn, pinned: null };\n\n            // Calculate the new insertion index based on edge\n            // After removing the item, indices shift:\n            // - If removing from before the target, target index decreases by 1\n            // - If removing from after the target, target index stays the same\n            let newIndex: number;\n\n            if (edge === 'left') {\n                // Insert before the target column\n                if (indexFrom < indexTo) {\n                    // Removed item was before target, so target shifted left by 1\n                    newIndex = indexTo - 1;\n                } else {\n                    // Removed item was after target, target index unchanged\n                    newIndex = indexTo;\n                }\n            } else if (edge === 'right') {\n                // Insert after the target column\n                if (indexFrom < indexTo) {\n                    // Removed item was before target, so target shifted left by 1\n                    newIndex = indexTo;\n                } else {\n                    // Removed item was after target, target index unchanged\n                    newIndex = indexTo + 1;\n                }\n            } else {\n                // No edge specified, default to inserting after the target position\n                if (indexFrom < indexTo) {\n                    newIndex = indexTo;\n                } else {\n                    newIndex = indexTo + 1;\n                }\n            }\n\n            // Insert the column at the new position\n            newColumns.splice(newIndex, 0, updatedMovedColumn);\n\n            if (tableKey === 'detail') {\n                type SetListData = Parameters<\n                    ReturnType<typeof useSettingsStoreActions>['setList']\n                >[1];\n                setList(itemListKey, { detail: { columns: newColumns } } as SetListData);\n            } else {\n                setList(itemListKey, {\n                    table: {\n                        columns: newColumns,\n                    },\n                });\n            }\n        },\n        [itemListKey, setList, tableKey],\n    );\n\n    return { handleColumnReordered };\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/helpers/use-item-list-column-resize.ts",
    "content": "import { useCallback } from 'react';\n\nimport { useSettingsStore, useSettingsStoreActions } from '/@/renderer/store';\nimport { ItemListKey, TableColumn } from '/@/shared/types/types';\n\ninterface UseItemListColumnResizeProps {\n    itemListKey: ItemListKey;\n    tableKey?: 'detail' | 'main';\n}\n\nexport const useItemListColumnResize = ({\n    itemListKey,\n    tableKey = 'main',\n}: UseItemListColumnResizeProps) => {\n    const { setList } = useSettingsStoreActions();\n    const columns = useSettingsStore((state) => {\n        const list = state.lists[itemListKey];\n        return tableKey === 'detail' ? list?.detail?.columns : list?.table?.columns;\n    });\n\n    const handleColumnResized = useCallback(\n        (columnId: TableColumn, width: number) => {\n            if (!columns) return;\n\n            const updatedColumns = columns.map((column) =>\n                column.id === columnId ? { ...column, width } : column,\n            );\n\n            if (tableKey === 'detail') {\n                type SetListData = Parameters<\n                    ReturnType<typeof useSettingsStoreActions>['setList']\n                >[1];\n                setList(itemListKey, { detail: { columns: updatedColumns } } as SetListData);\n            } else {\n                setList(itemListKey, {\n                    table: {\n                        columns: updatedColumns,\n                    },\n                });\n            }\n        },\n        [columns, itemListKey, setList, tableKey],\n    );\n\n    return { handleColumnResized };\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/helpers/use-item-list-scroll-persist.ts",
    "content": "import { useCallback, useMemo } from 'react';\nimport { useLocation, useNavigationType } from 'react-router';\n\nimport { useScrollStore } from '/@/renderer/store/scroll.store';\n\ninterface UseItemListScrollPersistProps {\n    enabled: boolean;\n}\n\nexport const useItemListScrollPersist = ({ enabled }: UseItemListScrollPersistProps) => {\n    const location = useLocation();\n    const navigationType = useNavigationType();\n    const setOffset = useScrollStore((s) => s.setOffset);\n    const getOffset = useScrollStore((s) => s.getOffset);\n\n    const scrollOffset = useMemo(() => {\n        if (navigationType !== 'POP') return undefined;\n        return getOffset(location.key);\n    }, [getOffset, location.key, navigationType]);\n\n    const handleOnScrollEnd = useCallback(\n        (offset: number) => {\n            if (!enabled) return;\n            setOffset(location.key, offset);\n        },\n        [enabled, location.key, setOffset],\n    );\n\n    return { handleOnScrollEnd, scrollOffset };\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/helpers/use-list-hotkeys.ts",
    "content": "import { useNavigate } from 'react-router';\n\nimport { getTitlePath } from '/@/renderer/components/item-list/helpers/get-title-path';\nimport {\n    ItemListStateActions,\n    ItemListStateItemWithRequiredProperties,\n} from '/@/renderer/components/item-list/helpers/item-list-state';\nimport { ItemControls } from '/@/renderer/components/item-list/types';\nimport { useHotkeySettings, usePlayButtonBehavior } from '/@/renderer/store';\nimport { useHotkeys } from '/@/shared/hooks/use-hotkeys';\nimport { LibraryItem } from '/@/shared/types/domain-types';\nimport { Play } from '/@/shared/types/types';\n\nexport const useListHotkeys = ({\n    controls,\n    focused,\n    internalState,\n    itemType,\n}: {\n    controls: ItemControls;\n    focused: boolean;\n    internalState: ItemListStateActions;\n    itemType: LibraryItem;\n}) => {\n    const { bindings } = useHotkeySettings();\n    const playButtonBehavior = usePlayButtonBehavior();\n    const navigate = useNavigate();\n\n    // Helper to check if item has required properties\n    const hasRequiredStateItemProperties = (\n        item: unknown,\n    ): item is ItemListStateItemWithRequiredProperties => {\n        return (\n            typeof item === 'object' &&\n            item !== null &&\n            'id' in item &&\n            typeof (item as any).id === 'string' &&\n            '_serverId' in item &&\n            typeof (item as any)._serverId === 'string' &&\n            '_itemType' in item &&\n            typeof (item as any)._itemType === 'string'\n        );\n    };\n\n    useHotkeys([\n        [\n            'mod+a',\n            () => {\n                if (focused) {\n                    if (internalState.isAllSelected()) {\n                        internalState.deselectAll();\n                    } else {\n                        internalState.selectAll();\n                    }\n                }\n            },\n        ],\n        [\n            bindings.listPlayDefault.hotkey,\n            () => {\n                if (!focused) return;\n                const selected = internalState.getSelected();\n                const validSelected = selected.filter(hasRequiredStateItemProperties);\n                if (validSelected.length === 0) return;\n\n                const item = validSelected[0];\n                const playType = playButtonBehavior;\n                controls.onPlay?.({ item, itemType, playType } as any);\n            },\n        ],\n        [\n            bindings.listPlayNow.hotkey,\n            () => {\n                if (!focused) return;\n                const selected = internalState.getSelected();\n                const validSelected = selected.filter(hasRequiredStateItemProperties);\n                if (validSelected.length === 0) return;\n\n                const item = validSelected[0];\n                controls.onPlay?.({ item, itemType, playType: Play.NOW } as any);\n            },\n        ],\n        [\n            bindings.listPlayNext.hotkey,\n            () => {\n                if (!focused) return;\n                const selected = internalState.getSelected();\n                const validSelected = selected.filter(hasRequiredStateItemProperties);\n                if (validSelected.length === 0) return;\n\n                const item = validSelected[0];\n                controls.onPlay?.({ item, itemType, playType: Play.NEXT } as any);\n            },\n        ],\n        [\n            bindings.listPlayLast.hotkey,\n            () => {\n                if (!focused) return;\n                const selected = internalState.getSelected();\n                const validSelected = selected.filter(hasRequiredStateItemProperties);\n                if (validSelected.length === 0) return;\n\n                const item = validSelected[0];\n                controls.onPlay?.({ item, itemType, playType: Play.LAST } as any);\n            },\n        ],\n        [\n            bindings.listNavigateToPage.hotkey,\n            () => {\n                if (!focused) return;\n                const selected = internalState.getSelected();\n                const validSelected = selected.filter(hasRequiredStateItemProperties);\n                if (validSelected.length === 0) return;\n\n                const item = validSelected[0];\n                const path = getTitlePath(itemType, item.id);\n                if (path) {\n                    navigate(path, { state: { item } });\n                }\n            },\n        ],\n    ]);\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/item-detail-list/columns/actions-column.tsx",
    "content": "import { ItemDetailListCellProps } from './types';\n\nimport { ActionIcon } from '/@/shared/components/action-icon/action-icon';\nimport { LibraryItem } from '/@/shared/types/domain-types';\n\nexport const ActionsColumn = ({ controls, internalState, song }: ItemDetailListCellProps) => {\n    const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {\n        event.stopPropagation();\n        event.preventDefault();\n        const index = internalState?.findItemIndex(song.id) ?? -1;\n        controls?.onMore?.({\n            event,\n            index,\n            internalState: internalState ?? undefined,\n            item: song,\n            itemType: LibraryItem.SONG,\n        });\n    };\n\n    const handleDoubleClick = (event: React.MouseEvent<HTMLButtonElement>) => {\n        event.stopPropagation();\n        event.preventDefault();\n    };\n\n    return (\n        <ActionIcon\n            icon=\"ellipsisHorizontal\"\n            iconProps={{\n                color: 'muted',\n                size: 'xs',\n            }}\n            onClick={handleClick}\n            onDoubleClick={handleDoubleClick}\n            size=\"xs\"\n            variant=\"subtle\"\n        />\n    );\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/item-detail-list/columns/album-artist-column.tsx",
    "content": "import { ItemDetailListCellProps } from './types';\n\nimport {\n    JOINED_ARTISTS_MUTED_PROPS,\n    JoinedArtists,\n} from '/@/renderer/features/albums/components/joined-artists';\n\nexport const AlbumArtistColumn = ({ isRowHovered, song }: ItemDetailListCellProps) => {\n    const name = song.albumArtistName?.trim() ?? '';\n    const hasArtists = name.length > 0 || (song.albumArtists?.length ?? 0) > 0;\n\n    if (!hasArtists) return <>&nbsp;</>;\n\n    return (\n        <JoinedArtists\n            artistName={song.albumArtistName ?? ''}\n            artists={song.albumArtists ?? []}\n            linkProps={JOINED_ARTISTS_MUTED_PROPS.linkProps}\n            readOnly={!isRowHovered}\n            rootTextProps={JOINED_ARTISTS_MUTED_PROPS.rootTextProps}\n        />\n    );\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/item-detail-list/columns/album-column.tsx",
    "content": "import { ItemDetailListCellProps } from './types';\n\nexport const AlbumColumn = ({ song }: ItemDetailListCellProps) => song.album ?? <>&nbsp;</>;\n"
  },
  {
    "path": "src/renderer/components/item-list/item-detail-list/columns/artist-column.tsx",
    "content": "import { ItemDetailListCellProps } from './types';\n\nimport {\n    JOINED_ARTISTS_MUTED_PROPS,\n    JoinedArtists,\n} from '/@/renderer/features/albums/components/joined-artists';\n\nexport const ArtistColumn = ({ isRowHovered, song }: ItemDetailListCellProps) => {\n    const name = song.artistName?.trim() ?? '';\n    const hasArtists = name.length > 0 || (song.artists?.length ?? 0) > 0;\n\n    if (!hasArtists) return <>&nbsp;</>;\n\n    return (\n        <JoinedArtists\n            artistName={song.artistName ?? ''}\n            artists={song.artists ?? []}\n            linkProps={JOINED_ARTISTS_MUTED_PROPS.linkProps}\n            readOnly={!isRowHovered}\n            rootTextProps={JOINED_ARTISTS_MUTED_PROPS.rootTextProps}\n        />\n    );\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/item-detail-list/columns/bit-depth-column.tsx",
    "content": "import { ItemDetailListCellProps } from './types';\n\nexport const BitDepthColumn = ({ song }: ItemDetailListCellProps) => song.bitDepth;\n"
  },
  {
    "path": "src/renderer/components/item-list/item-detail-list/columns/bit-rate-column.tsx",
    "content": "import { ItemDetailListCellProps } from './types';\n\nexport const BitRateColumn = ({ song }: ItemDetailListCellProps) =>\n    song.bitRate != null ? `${song.bitRate} kbps` : <>&nbsp;</>;\n"
  },
  {
    "path": "src/renderer/components/item-list/item-detail-list/columns/bpm-column.tsx",
    "content": "import { ItemDetailListCellProps } from './types';\n\nexport const BpmColumn = ({ song }: ItemDetailListCellProps) => song.bpm ?? <>&nbsp;</>;\n"
  },
  {
    "path": "src/renderer/components/item-list/item-detail-list/columns/channels-column.tsx",
    "content": "import { ItemDetailListCellProps } from './types';\n\nexport const ChannelsColumn = ({ song }: ItemDetailListCellProps) =>\n    song.channels != null ? String(song.channels) : <>&nbsp;</>;\n"
  },
  {
    "path": "src/renderer/components/item-list/item-detail-list/columns/codec-column.tsx",
    "content": "import { ItemDetailListCellProps } from './types';\n\nexport const CodecColumn = ({ song }: ItemDetailListCellProps) => song.container ?? <>&nbsp;</>;\n"
  },
  {
    "path": "src/renderer/components/item-list/item-detail-list/columns/comment-column.tsx",
    "content": "import { ItemDetailListCellProps } from './types';\n\nexport const CommentColumn = ({ song }: ItemDetailListCellProps) => song.comment ?? <>&nbsp;</>;\n"
  },
  {
    "path": "src/renderer/components/item-list/item-detail-list/columns/composer-column.tsx",
    "content": "import { ItemDetailListCellProps } from './types';\n\nexport const ComposerColumn = ({ song }: ItemDetailListCellProps) => {\n    const composers = song.participants?.composer;\n    if (!composers?.length) return <>&nbsp;</>;\n    return composers.map((a) => a.name).join(', ');\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/item-detail-list/columns/date-added-column.tsx",
    "content": "import { ItemDetailListCellProps } from './types';\n\nimport { formatDateAbsolute } from '/@/renderer/utils/format';\n\nexport const DateAddedColumn = ({ song }: ItemDetailListCellProps) =>\n    song.createdAt ? formatDateAbsolute(song.createdAt) : <>&nbsp;</>;\n"
  },
  {
    "path": "src/renderer/components/item-list/item-detail-list/columns/default-column.tsx",
    "content": "import { ItemDetailListCellProps } from './types';\n\ninterface DefaultColumnProps extends ItemDetailListCellProps {\n    columnId: string;\n}\n\nexport const DefaultColumn = ({ columnId, song }: DefaultColumnProps) => {\n    const raw = (song as Record<string, unknown>)[columnId];\n    if (raw === undefined || raw === null || typeof raw === 'object') return <>&nbsp;</>;\n    return String(raw);\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/item-detail-list/columns/disc-number-column.tsx",
    "content": "import { ItemDetailListCellProps } from './types';\n\nexport const DiscNumberColumn = ({ song }: ItemDetailListCellProps) => String(song.discNumber ?? 1);\n"
  },
  {
    "path": "src/renderer/components/item-list/item-detail-list/columns/duration-column.tsx",
    "content": "import formatDuration from 'format-duration';\n\nimport { ItemDetailListCellProps } from './types';\n\nexport const DurationColumn = ({ song }: ItemDetailListCellProps) => formatDuration(song.duration);\n"
  },
  {
    "path": "src/renderer/components/item-list/item-detail-list/columns/favorite-column.tsx",
    "content": "import { ItemDetailListCellProps } from './types';\n\nimport { useIsMutatingCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation';\nimport { useIsMutatingDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation';\nimport { ActionIcon } from '/@/shared/components/action-icon/action-icon';\nimport { LibraryItem } from '/@/shared/types/domain-types';\n\nexport const FavoriteColumn = ({\n    controls,\n    internalState,\n    isMutatingFavorite,\n    onFavoriteClick,\n    song,\n}: ItemDetailListCellProps) => {\n    const isMutatingCreateFavorite = useIsMutatingCreateFavorite();\n    const isMutatingDeleteFavorite = useIsMutatingDeleteFavorite();\n    const isMutating = isMutatingFavorite ?? (isMutatingCreateFavorite || isMutatingDeleteFavorite);\n    const isFavorite = song.userFavorite ?? false;\n\n    return (\n        <ActionIcon\n            disabled={isMutating}\n            icon=\"favorite\"\n            iconProps={{\n                color: isFavorite ? 'primary' : 'muted',\n                fill: isFavorite ? 'primary' : undefined,\n                size: 'xs',\n            }}\n            onClick={(event) => {\n                event.stopPropagation();\n                event.preventDefault();\n                const index = internalState?.findItemIndex(song.id) ?? -1;\n                if (controls?.onFavorite) {\n                    controls.onFavorite({\n                        event,\n                        favorite: !isFavorite,\n                        index,\n                        internalState: internalState ?? undefined,\n                        item: song,\n                        itemType: LibraryItem.SONG,\n                    });\n                } else {\n                    onFavoriteClick?.(song);\n                }\n            }}\n            onDoubleClick={(event) => {\n                event.stopPropagation();\n                event.preventDefault();\n            }}\n            size=\"xs\"\n            variant=\"subtle\"\n        />\n    );\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/item-detail-list/columns/genre-badge-column.module.css",
    "content": ".group {\n    flex-wrap: nowrap;\n    gap: var(--theme-spacing-sm) var(--theme-spacing-xs);\n    min-width: 0;\n    padding: var(--theme-spacing-xs) 0;\n    overflow: hidden;\n}\n\n.group a {\n    cursor: pointer;\n    user-select: none;\n}\n"
  },
  {
    "path": "src/renderer/components/item-list/item-detail-list/columns/genre-badge-column.tsx",
    "content": "import { useMemo } from 'react';\nimport { generatePath, Link } from 'react-router';\n\nimport styles from './genre-badge-column.module.css';\nimport { ItemDetailListCellProps } from './types';\n\nimport { AppRoute } from '/@/renderer/router/routes';\nimport { Badge } from '/@/shared/components/badge/badge';\nimport { Group } from '/@/shared/components/group/group';\nimport { stringToColor } from '/@/shared/utils/string-to-color';\n\nconst MAX_GENRES = 4;\n\nexport const GenreBadgeColumn = ({ song }: ItemDetailListCellProps) => {\n    const genres = song.genres;\n\n    const genresWithStyle = useMemo(() => {\n        if (!genres) return [];\n        return genres.slice(0, MAX_GENRES).map((genre) => {\n            const { color, isLight } = stringToColor(genre.name);\n            const path = generatePath(AppRoute.LIBRARY_GENRES_DETAIL, { genreId: genre.id });\n            return { ...genre, color, isLight, path };\n        });\n    }, [genres]);\n\n    if (!genresWithStyle.length) return <>&nbsp;</>;\n\n    return (\n        <Group className={styles.group} wrap=\"nowrap\">\n            {genresWithStyle.map((genre) => (\n                <Badge\n                    component={Link}\n                    key={genre.id}\n                    state={{ item: genre }}\n                    style={{\n                        backgroundColor: genre.color,\n                        color: genre.isLight ? 'black' : 'white',\n                    }}\n                    to={genre.path}\n                >\n                    {genre.name}\n                </Badge>\n            ))}\n        </Group>\n    );\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/item-detail-list/columns/genre-column.tsx",
    "content": "import { Fragment } from 'react';\nimport { generatePath, Link } from 'react-router';\n\nimport { ItemDetailListCellProps } from '/@/renderer/components/item-list/item-detail-list/columns/types';\nimport { AppRoute } from '/@/renderer/router/routes';\nimport { Text } from '/@/shared/components/text/text';\n\nconst TEXT_PROPS = { isMuted: true, isNoSelect: true, size: 'sm' as const } as const;\n\nexport const GenreColumn = ({ isRowHovered, song }: ItemDetailListCellProps) => {\n    const genres = song.genres ?? [];\n    if (!genres.length) return <>&nbsp;</>;\n\n    return (\n        <>\n            {genres.map((genre, index) => (\n                <Fragment key={genre.id}>\n                    {isRowHovered ? (\n                        <Text\n                            component={Link}\n                            isLink\n                            state={{ item: genre }}\n                            to={generatePath(AppRoute.LIBRARY_GENRES_DETAIL, {\n                                genreId: genre.id,\n                            })}\n                            {...TEXT_PROPS}\n                        >\n                            {genre.name}\n                        </Text>\n                    ) : (\n                        <Text component=\"span\" {...TEXT_PROPS}>\n                            {genre.name}\n                        </Text>\n                    )}\n                    {index < genres.length - 1 && ', '}\n                </Fragment>\n            ))}\n        </>\n    );\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/item-detail-list/columns/image-column.module.css",
    "content": ".image-container {\n    position: relative;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    width: 100%;\n    height: 100%;\n}\n\n.compact-container {\n    flex: 1 1 0;\n    width: 100%;\n    min-width: 0;\n    height: 100%;\n    min-height: 0;\n    max-height: 100%;\n    aspect-ratio: unset;\n    padding-top: var(--theme-spacing-xs);\n    padding-bottom: var(--theme-spacing-xs);\n    overflow: hidden;\n    border-radius: var(--theme-radius-md);\n}\n\n.play-button-overlay {\n    position: absolute;\n    top: 50%;\n    left: 50%;\n    z-index: 10;\n    opacity: 0.6;\n    transform: translate(-50%, -50%);\n    transition: opacity 0.2s ease-in-out;\n}\n\n.play-button-overlay:hover {\n    opacity: 1;\n}\n\n.play-button-overlay button {\n    width: 24px;\n    height: 24px;\n}\n\n.compact-image {\n    display: block;\n    width: 100%;\n    height: 100%;\n    object-fit: cover;\n    object-position: center;\n    border-radius: var(--theme-radius-md);\n}\n"
  },
  {
    "path": "src/renderer/components/item-list/item-detail-list/columns/image-column.tsx",
    "content": "import clsx from 'clsx';\nimport { useState } from 'react';\n\nimport styles from './image-column.module.css';\nimport { ItemDetailListCellProps } from './types';\n\nimport { ItemImage } from '/@/renderer/components/item-image/item-image';\nimport { PlayButton } from '/@/renderer/features/shared/components/play-button';\nimport {\n    LONG_PRESS_PLAY_BEHAVIOR,\n    PlayTooltip,\n} from '/@/renderer/features/shared/components/play-button-group';\nimport { usePlayButtonBehavior } from '/@/renderer/store';\nimport { LibraryItem } from '/@/shared/types/domain-types';\nimport { Play } from '/@/shared/types/types';\n\nexport const ImageColumn = ({\n    controls,\n    internalState,\n    rowIndex = 0,\n    song,\n}: ItemDetailListCellProps) => {\n    const playButtonBehavior = usePlayButtonBehavior();\n    const [isHovered, setIsHovered] = useState(false);\n\n    const handlePlay = (playType: Play) => {\n        if (!song || !controls?.onDoubleClick) {\n            return;\n        }\n\n        controls.onDoubleClick({\n            event: null,\n            index: rowIndex,\n            internalState,\n            item: song,\n            itemType: LibraryItem.SONG,\n            meta: { playType, singleSongOnly: true },\n        });\n    };\n\n    return (\n        <div\n            className={styles.imageContainer}\n            onMouseEnter={() => setIsHovered(true)}\n            onMouseLeave={() => setIsHovered(false)}\n        >\n            <ItemImage\n                className={styles.compactImage}\n                containerClassName={styles.compactContainer}\n                explicitStatus={song.explicitStatus}\n                id={song.imageId}\n                itemType={LibraryItem.SONG}\n                serverId={song._serverId}\n                type=\"table\"\n            />\n            {isHovered && (\n                <div className={clsx(styles.playButtonOverlay)}>\n                    <PlayTooltip disabled={false} type={playButtonBehavior}>\n                        <PlayButton\n                            fill\n                            onClick={() => handlePlay(playButtonBehavior)}\n                            onLongPress={() =>\n                                handlePlay(LONG_PRESS_PLAY_BEHAVIOR[playButtonBehavior])\n                            }\n                        />\n                    </PlayTooltip>\n                </div>\n            )}\n        </div>\n    );\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/item-detail-list/columns/index.ts",
    "content": "import React, { type ReactNode } from 'react';\n\nimport type { ItemDetailListCellProps } from './types';\n\nimport { ActionsColumn } from './actions-column';\nimport { AlbumArtistColumn } from './album-artist-column';\nimport { AlbumColumn } from './album-column';\nimport { ArtistColumn } from './artist-column';\nimport { BitDepthColumn } from './bit-depth-column';\nimport { BitRateColumn } from './bit-rate-column';\nimport { BpmColumn } from './bpm-column';\nimport { ChannelsColumn } from './channels-column';\nimport { CodecColumn } from './codec-column';\nimport { CommentColumn } from './comment-column';\nimport { ComposerColumn } from './composer-column';\nimport { DateAddedColumn } from './date-added-column';\nimport { DefaultColumn } from './default-column';\nimport { DiscNumberColumn } from './disc-number-column';\nimport { DurationColumn } from './duration-column';\nimport { FavoriteColumn } from './favorite-column';\nimport { GenreBadgeColumn } from './genre-badge-column';\nimport { GenreColumn } from './genre-column';\nimport { ImageColumn } from './image-column';\nimport { LastPlayedColumn } from './last-played-column';\nimport { PathColumn } from './path-column';\nimport { PlayCountColumn } from './play-count-column';\nimport { RatingColumn } from './rating-column';\nimport { ReleaseDateColumn } from './release-date-column';\nimport { RowIndexColumn } from './row-index-column';\nimport { SampleRateColumn } from './sample-rate-column';\nimport { SizeColumn } from './size-column';\nimport { TitleArtistColumn } from './title-artist-column';\nimport { TitleColumn } from './title-column';\nimport { TitleCombinedColumn } from './title-combined-column';\nimport { TrackNumberColumn } from './track-number-column';\nimport { YearColumn } from './year-column';\n\nimport { TableColumn } from '/@/shared/types/types';\n\ntype CellComponent = (props: ItemDetailListCellProps) => ReactNode;\n\nconst COLUMN_MAP: Partial<Record<TableColumn, CellComponent>> = {\n    [TableColumn.ACTIONS]: ActionsColumn,\n    [TableColumn.ALBUM]: AlbumColumn,\n    [TableColumn.ALBUM_ARTIST]: AlbumArtistColumn,\n    [TableColumn.ARTIST]: ArtistColumn,\n    [TableColumn.BIT_DEPTH]: BitDepthColumn,\n    [TableColumn.BIT_RATE]: BitRateColumn,\n    [TableColumn.BPM]: BpmColumn,\n    [TableColumn.CHANNELS]: ChannelsColumn,\n    [TableColumn.CODEC]: CodecColumn,\n    [TableColumn.COMMENT]: CommentColumn,\n    [TableColumn.COMPOSER]: ComposerColumn,\n    [TableColumn.DATE_ADDED]: DateAddedColumn,\n    [TableColumn.DISC_NUMBER]: DiscNumberColumn,\n    [TableColumn.DURATION]: DurationColumn,\n    [TableColumn.GENRE]: GenreColumn,\n    [TableColumn.GENRE_BADGE]: GenreBadgeColumn,\n    [TableColumn.IMAGE]: ImageColumn,\n    [TableColumn.LAST_PLAYED]: LastPlayedColumn,\n    [TableColumn.PATH]: PathColumn,\n    [TableColumn.PLAY_COUNT]: PlayCountColumn,\n    [TableColumn.RELEASE_DATE]: ReleaseDateColumn,\n    [TableColumn.ROW_INDEX]: RowIndexColumn,\n    [TableColumn.SAMPLE_RATE]: SampleRateColumn,\n    [TableColumn.SIZE]: SizeColumn,\n    [TableColumn.TITLE]: TitleColumn,\n    [TableColumn.TITLE_ARTIST]: TitleArtistColumn,\n    [TableColumn.TITLE_COMBINED]: TitleCombinedColumn,\n    [TableColumn.TRACK_NUMBER]: TrackNumberColumn,\n    [TableColumn.USER_FAVORITE]: FavoriteColumn,\n    [TableColumn.USER_RATING]: RatingColumn,\n    [TableColumn.YEAR]: YearColumn,\n};\n\nexport type DetailListCellComponentProps = ItemDetailListCellProps & { columnId?: string };\n\nexport function getDetailListCellComponent(\n    columnId: string | TableColumn,\n): (props: DetailListCellComponentProps) => ReactNode {\n    const Component = COLUMN_MAP[columnId as TableColumn];\n    if (Component) {\n        return Component as (props: DetailListCellComponentProps) => ReactNode;\n    }\n    return (props: DetailListCellComponentProps) =>\n        React.createElement(DefaultColumn, {\n            columnId: props.columnId ?? (columnId as string),\n            song: props.song,\n        });\n}\n\nexport type { ItemDetailListCellProps } from './types';\n\nexport {\n    ActionsColumn,\n    AlbumArtistColumn,\n    AlbumColumn,\n    ArtistColumn,\n    BitDepthColumn,\n    BitRateColumn,\n    BpmColumn,\n    ChannelsColumn,\n    CodecColumn,\n    CommentColumn,\n    ComposerColumn,\n    DateAddedColumn,\n    DefaultColumn,\n    DiscNumberColumn,\n    DurationColumn,\n    FavoriteColumn,\n    GenreBadgeColumn,\n    GenreColumn,\n    ImageColumn,\n    LastPlayedColumn,\n    PathColumn,\n    PlayCountColumn,\n    RatingColumn,\n    ReleaseDateColumn,\n    RowIndexColumn,\n    SampleRateColumn,\n    SizeColumn,\n    TitleArtistColumn,\n    TitleColumn,\n    TitleCombinedColumn,\n    TrackNumberColumn,\n    YearColumn,\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/item-detail-list/columns/last-played-column.tsx",
    "content": "import { ItemDetailListCellProps } from './types';\n\nimport { formatDateRelative } from '/@/renderer/utils/format';\n\nexport const LastPlayedColumn = ({ song }: ItemDetailListCellProps) =>\n    song.lastPlayedAt ? formatDateRelative(song.lastPlayedAt) : <>&nbsp;</>;\n"
  },
  {
    "path": "src/renderer/components/item-list/item-detail-list/columns/path-column.tsx",
    "content": "import { ItemDetailListCellProps } from './types';\n\nexport const PathColumn = ({ song }: ItemDetailListCellProps) => song.path ?? <>&nbsp;</>;\n"
  },
  {
    "path": "src/renderer/components/item-list/item-detail-list/columns/play-count-column.tsx",
    "content": "import { ItemDetailListCellProps } from './types';\n\nexport const PlayCountColumn = ({ song }: ItemDetailListCellProps) =>\n    song.playCount ? String(song.playCount) : <>&nbsp;</>;\n"
  },
  {
    "path": "src/renderer/components/item-list/item-detail-list/columns/rating-column.tsx",
    "content": "import { ItemDetailListCellProps } from './types';\n\nimport { useIsMutatingRating } from '/@/renderer/features/shared/mutations/set-rating-mutation';\nimport { Rating } from '/@/shared/components/rating/rating';\nimport { LibraryItem } from '/@/shared/types/domain-types';\n\nexport const RatingColumn = ({ controls, internalState, song }: ItemDetailListCellProps) => {\n    const isMutatingRating = useIsMutatingRating();\n    const value = song.userRating ?? 0;\n\n    return (\n        <Rating\n            onChange={(rating) => {\n                const index = internalState?.findItemIndex(song.id) ?? -1;\n                controls?.onRating?.({\n                    event: null,\n                    index,\n                    internalState: internalState ?? undefined,\n                    item: song,\n                    itemType: LibraryItem.SONG,\n                    rating,\n                });\n            }}\n            readOnly={isMutatingRating}\n            size=\"xs\"\n            value={value}\n        />\n    );\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/item-detail-list/columns/release-date-column.tsx",
    "content": "import { ItemDetailListCellProps } from './types';\n\nimport { formatDateAbsoluteUTC } from '/@/renderer/utils/format';\n\nexport const ReleaseDateColumn = ({ song }: ItemDetailListCellProps) =>\n    song.releaseDate ? formatDateAbsoluteUTC(song.releaseDate) : <>&nbsp;</>;\n"
  },
  {
    "path": "src/renderer/components/item-list/item-detail-list/columns/row-index-column.module.css",
    "content": ".icon-wrapper {\n    display: flex;\n    align-items: center;\n    justify-content: center;\n}\n"
  },
  {
    "path": "src/renderer/components/item-list/item-detail-list/columns/row-index-column.tsx",
    "content": "import styles from './row-index-column.module.css';\nimport { ItemDetailListCellProps } from './types';\n\nimport { useIsCurrentSong } from '/@/renderer/features/player/hooks/use-is-current-song';\nimport { usePlayerStatus } from '/@/renderer/store';\nimport { Icon } from '/@/shared/components/icon/icon';\nimport { PlayerStatus } from '/@/shared/types/types';\n\nexport const RowIndexColumn = ({ rowIndex, song }: ItemDetailListCellProps) => {\n    const status = usePlayerStatus();\n    const { isActive } = useIsCurrentSong(song);\n    const isPlaying = isActive && status === PlayerStatus.PLAYING;\n\n    if (isActive) {\n        return (\n            <div className={styles.iconWrapper}>\n                <Icon fill=\"primary\" icon={isPlaying ? 'mediaPlay' : 'mediaPause'} />\n            </div>\n        );\n    }\n\n    return <>{String((rowIndex ?? 0) + 1)}</>;\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/item-detail-list/columns/sample-rate-column.tsx",
    "content": "import { ItemDetailListCellProps } from './types';\n\nexport const SampleRateColumn = ({ song }: ItemDetailListCellProps) =>\n    song.sampleRate ? `${song.sampleRate} Hz` : <>&nbsp;</>;\n"
  },
  {
    "path": "src/renderer/components/item-list/item-detail-list/columns/size-column.tsx",
    "content": "import { ItemDetailListCellProps } from './types';\n\nimport { formatSizeString } from '/@/renderer/utils/format';\n\nexport const SizeColumn = ({ song }: ItemDetailListCellProps) =>\n    song.size ? formatSizeString(song.size) : <>&nbsp;</>;\n"
  },
  {
    "path": "src/renderer/components/item-list/item-detail-list/columns/title-artist-column.tsx",
    "content": "import clsx from 'clsx';\n\nimport styles from './title-column.module.css';\n\nimport { ItemDetailListCellProps } from '/@/renderer/components/item-list/item-detail-list/columns/types';\nimport { useIsCurrentSong } from '/@/renderer/features/player/hooks/use-is-current-song';\nimport { ExplicitIndicator } from '/@/shared/components/explicit-indicator/explicit-indicator';\n\nexport const TitleArtistColumn = ({ song }: ItemDetailListCellProps) => {\n    const { isActive } = useIsCurrentSong(song);\n\n    return (\n        <span className={clsx({ [styles.active]: isActive })}>\n            <ExplicitIndicator explicitStatus={song.explicitStatus} />\n            {[song.name, song.artistName].filter(Boolean).join(' — ') ?? <>&nbsp;</>}\n        </span>\n    );\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/item-detail-list/columns/title-column.module.css",
    "content": ".active {\n    color: var(--theme-colors-primary);\n}\n"
  },
  {
    "path": "src/renderer/components/item-list/item-detail-list/columns/title-column.tsx",
    "content": "import clsx from 'clsx';\n\nimport styles from './title-column.module.css';\n\nimport { ItemDetailListCellProps } from '/@/renderer/components/item-list/item-detail-list/columns/types';\nimport { useIsCurrentSong } from '/@/renderer/features/player/hooks/use-is-current-song';\nimport { ExplicitIndicator } from '/@/shared/components/explicit-indicator/explicit-indicator';\n\nexport const TitleColumn = ({ song }: ItemDetailListCellProps) => {\n    const { isActive } = useIsCurrentSong(song);\n\n    return (\n        <span className={clsx({ [styles.active]: isActive })}>\n            <ExplicitIndicator explicitStatus={song.explicitStatus} />\n            {song.name ?? <>&nbsp;</>}\n        </span>\n    );\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/item-detail-list/columns/title-combined-column.tsx",
    "content": "import clsx from 'clsx';\n\nimport styles from './title-column.module.css';\n\nimport { ItemDetailListCellProps } from '/@/renderer/components/item-list/item-detail-list/columns/types';\nimport { useIsCurrentSong } from '/@/renderer/features/player/hooks/use-is-current-song';\nimport { ExplicitIndicator } from '/@/shared/components/explicit-indicator/explicit-indicator';\n\nexport const TitleCombinedColumn = ({ song }: ItemDetailListCellProps) => {\n    const { isActive } = useIsCurrentSong(song);\n\n    return (\n        <span className={clsx({ [styles.active]: isActive })}>\n            <ExplicitIndicator explicitStatus={song.explicitStatus} />\n            {[song.name, song.artistName].filter(Boolean).join(' — ') ?? <>&nbsp;</>}\n        </span>\n    );\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/item-detail-list/columns/track-number-column.tsx",
    "content": "import { ItemDetailListCellProps } from './types';\n\nexport const TrackNumberColumn = ({ song }: ItemDetailListCellProps) => {\n    const disc = song.discNumber ?? 1;\n    const track = song.trackNumber.toString().padStart(2, '0');\n    return `${disc}-${track}`;\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/item-detail-list/columns/types.ts",
    "content": "import { ItemListStateActions } from '/@/renderer/components/item-list/helpers/item-list-state';\nimport { ItemControls } from '/@/renderer/components/item-list/types';\nimport { Song } from '/@/shared/types/domain-types';\n\nexport interface ItemDetailListCellProps {\n    controls?: ItemControls;\n    internalState?: ItemListStateActions;\n    isMutatingFavorite?: boolean;\n    isRowHovered?: boolean;\n    onFavoriteClick?: (song: Song) => void;\n    rowIndex?: number;\n    size?: 'compact' | 'default' | 'large';\n    song: Song;\n}\n"
  },
  {
    "path": "src/renderer/components/item-list/item-detail-list/columns/year-column.tsx",
    "content": "import { ItemDetailListCellProps } from './types';\n\nexport const YearColumn = ({ song }: ItemDetailListCellProps) =>\n    song.releaseYear ? String(song.releaseYear) : <>&nbsp;</>;\n"
  },
  {
    "path": "src/renderer/components/item-list/item-detail-list/item-detail-list.module.css",
    "content": ".container {\n    position: relative;\n    flex: 1 1 auto;\n    width: 100%;\n    min-height: 0;\n    overflow: auto;\n}\n\n.placeholder {\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    width: 100%;\n}\n\n.wrapper {\n    display: flex;\n    flex-direction: column;\n    width: 100%;\n    height: 100%;\n    min-height: 0;\n}\n\n.detail-list-header {\n    display: grid;\n    flex-shrink: 0;\n    grid-template-columns: 240px 1fr;\n    gap: var(--theme-spacing-md);\n    padding: 0 var(--theme-spacing-md);\n    font-size: var(--theme-font-size-sm);\n    user-select: none;\n    background-color: var(--theme-colors-background);\n    border-bottom: 1px solid var(--theme-colors-border);\n}\n\n.header-left {\n    display: flex;\n    align-items: center;\n    min-width: 0;\n    overflow: hidden;\n}\n\n.header-left-album-name {\n    overflow: hidden;\n    text-overflow: ellipsis;\n    font-size: var(--theme-font-size-sm);\n    font-weight: 500;\n    color: var(--theme-colors-foreground);\n    white-space: nowrap;\n}\n\n.header-right {\n    min-width: 0;\n    overflow: hidden;\n}\n\n.tracks-table-header {\n    display: flex;\n    flex-shrink: 0;\n    flex-wrap: nowrap;\n    align-items: center;\n    width: 100%;\n    min-width: 0;\n}\n\n.tracks-table-header-size-compact {\n    height: 32px;\n    min-height: 32px;\n}\n\n.tracks-table-header-size-default {\n    height: 40px;\n    min-height: 40px;\n}\n\n.tracks-table-header-size-large {\n    height: 48px;\n    min-height: 48px;\n}\n\n.track-header-cell {\n    position: relative;\n    display: flex;\n    flex-wrap: nowrap;\n    align-items: center;\n    min-width: 0;\n    min-height: 60%;\n    padding-right: var(--theme-spacing-sm);\n    padding-left: var(--theme-spacing-sm);\n    overflow: visible;\n    white-space: nowrap;\n}\n\n.track-header-cell-no-h-padding {\n    padding-right: 0;\n    padding-left: 0;\n}\n\n.track-header-cell-with-vertical-border {\n    border-right: 1px solid var(--theme-colors-border);\n}\n\n.track-header-cell-dragging {\n    cursor: grabbing;\n    opacity: 0.5;\n}\n\n.track-header-cell-dragged-over-left::before {\n    position: absolute;\n    top: 0;\n    bottom: 0;\n    left: 0;\n    z-index: 10;\n    width: 3px;\n    content: '';\n    background-color: var(--theme-colors-primary);\n}\n\n.track-header-cell-dragged-over-right::after {\n    position: absolute;\n    top: 0;\n    right: 0;\n    bottom: 0;\n    z-index: 10;\n    width: 3px;\n    content: '';\n    background-color: var(--theme-colors-primary);\n}\n\n.track-header-cell:hover .resize-handle {\n    opacity: 1;\n}\n\n.track-header-cell:hover .resize-handle::before {\n    background-color: var(--theme-colors-border);\n}\n\n.resize-handle {\n    position: absolute;\n    top: 0;\n    bottom: 0;\n    z-index: 10;\n    width: 2px;\n    margin-right: -4px;\n    cursor: col-resize;\n    background: var(--theme-colors-border);\n    opacity: 0;\n    transition: opacity 0.3s ease;\n}\n\n/* .resize-handle::before {\n    position: absolute;\n    top: 0;\n    right: 0;\n    bottom: 0;\n    width: 2px;\n    content: '';\n    background-color: transparent;\n    transition: background-color 0.15s ease;\n} */\n\n.resize-handle-left {\n    left: 0;\n    margin-right: 0;\n    margin-left: -4px;\n}\n\n.resize-handle-left::before {\n    right: auto;\n    left: 0;\n}\n\n.resize-handle-right {\n    right: 0;\n    margin-right: 0;\n}\n\n.resize-handle-dragging {\n    opacity: 1;\n}\n\n.resize-handle:hover {\n    opacity: 1;\n}\n\n.row {\n    display: grid;\n    grid-template-columns: 240px 1fr;\n    gap: var(--theme-spacing-md);\n    padding: var(--theme-spacing-md);\n    border-bottom: 1px solid var(--theme-colors-border);\n}\n\n.skeleton-column-wrapper {\n    box-sizing: border-box;\n    min-width: 0;\n}\n\n.image-wrapper-outer {\n    position: relative;\n    display: block;\n    width: 100%;\n    aspect-ratio: 1;\n}\n\n.image-wrapper-outer.image-wrapper-dragging {\n    opacity: 0.5;\n}\n\n.image-wrapper {\n    position: relative;\n    display: block;\n    width: 100%;\n    aspect-ratio: 1;\n    overflow: hidden;\n    color: inherit;\n    text-decoration: none;\n    border-radius: var(--theme-radius-md);\n\n    &::before {\n        position: absolute;\n        top: 0;\n        left: 0;\n        z-index: 5;\n        width: 100%;\n        height: 100%;\n        pointer-events: none;\n        content: '';\n        background-color: rgb(0 0 0);\n        opacity: 0;\n        transition: opacity 0.2s ease-in-out;\n    }\n\n    &:hover {\n        @mixin dark {\n            &::before {\n                opacity: 0.7;\n            }\n        }\n\n        @mixin light {\n            &::before {\n                opacity: 0.5;\n            }\n        }\n    }\n\n    &:hover .favorite-badge,\n    &:hover .rating-badge {\n        opacity: 0;\n    }\n}\n\n.favorite-badge {\n    position: absolute;\n    top: -50px;\n    left: -50px;\n    z-index: 1;\n    width: 80px;\n    height: 80px;\n    pointer-events: none;\n    background-color: var(--theme-colors-primary);\n    box-shadow: 0 0 10px 8px rgb(0 0 0 / 80%);\n    opacity: 1;\n    transform: rotate(-45deg);\n    transition: opacity 0.2s ease-in-out;\n}\n\n.rating-badge {\n    position: absolute;\n    top: var(--theme-spacing-sm);\n    right: var(--theme-spacing-sm);\n    z-index: 1;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    padding: var(--theme-spacing-xs) var(--theme-spacing-sm);\n    font-size: var(--theme-font-size-md);\n    font-weight: 600;\n    color: var(--theme-colors-foreground);\n    text-shadow: 0 1px 2px rgb(0 0 0 / 80%);\n    pointer-events: none;\n    background-color: var(--theme-colors-primary);\n    border-radius: var(--theme-radius-md);\n    box-shadow: 0 2px 8px rgb(0 0 0 / 50%);\n    opacity: 1;\n    transition: opacity 0.2s ease-in-out;\n}\n\n.row .image {\n    object-fit: var(--theme-image-fit);\n    border-radius: var(--theme-radius-md);\n}\n\n.row .metadata {\n    display: flex;\n    flex-direction: column;\n    gap: var(--theme-spacing-xs);\n    align-items: center;\n    overflow: hidden;\n    text-overflow: ellipsis;\n    font-size: var(--theme-font-size-md);\n    text-align: center;\n}\n\n.row .title {\n    font-weight: 500;\n    color: inherit;\n    text-decoration: none;\n}\n\n.row .title:hover {\n    text-decoration: underline;\n}\n\n.row .artist {\n    font-size: var(--theme-font-size-sm);\n    color: var(--theme-colors-foreground-muted);\n    text-decoration: none;\n}\n\n.row .artist-plain-text:hover {\n    text-decoration: underline;\n}\n\n.row .metadata-link {\n    color: inherit;\n    text-decoration: none;\n}\n\n.row .metadata-link:hover {\n    color: var(--theme-colors-foreground);\n    text-decoration: underline;\n}\n\n.row .metadata-extra {\n    display: flex;\n    flex-direction: column;\n    gap: var(--theme-spacing-xs);\n    align-items: center;\n    width: 100%;\n    font-size: var(--theme-font-size-sm);\n    color: var(--theme-colors-foreground-muted);\n    text-align: center;\n}\n\n.row .metadata-line {\n    max-width: 100%;\n    overflow: hidden;\n    text-overflow: ellipsis;\n    text-wrap-style: balance;\n    white-space: nowrap;\n}\n\n.row .metadata-line-clamp-2 {\n    display: -webkit-box;\n    -webkit-box-orient: vertical;\n    -webkit-line-clamp: 2;\n    white-space: normal;\n}\n\n.row .right {\n    min-width: 0;\n    overflow: hidden;\n}\n\n.row .tracks-table {\n    display: flex;\n    flex-direction: column;\n    width: 100%;\n    font-size: var(--theme-font-size-sm);\n}\n\n.row .track-row {\n    display: flex;\n    flex-wrap: nowrap;\n    align-items: center;\n    min-width: 0;\n    overflow: hidden;\n}\n\n.row .track-header-cell {\n    overflow: hidden;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n}\n\n.row .track-cell {\n    min-width: 0;\n    padding-right: var(--theme-spacing-sm);\n    padding-left: var(--theme-spacing-sm);\n    overflow: hidden;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n}\n\n.row .track-row-size-compact {\n    height: 32px;\n    min-height: 32px;\n    max-height: 32px;\n}\n\n.row .track-row-size-default {\n    height: 40px;\n    min-height: 40px;\n    max-height: 40px;\n}\n\n.row .track-row-size-large {\n    height: 48px;\n    min-height: 48px;\n    max-height: 48px;\n}\n\n.row .track-cell-muted {\n    color: var(--theme-colors-foreground-muted);\n}\n\n.row .track-cell-with-vertical-border {\n    border-right: 1px solid transparent;\n}\n\n.row .track-cell-vertical-border-visible {\n    border-right-color: var(--theme-colors-border);\n}\n\n.row .track-cell-image {\n    display: flex;\n    align-self: stretch;\n    min-height: 0;\n    max-height: 100%;\n    padding-right: var(--theme-spacing-sm);\n    padding-left: var(--theme-spacing-sm);\n    overflow: hidden;\n}\n\n.row .track-cell-no-h-padding {\n    padding-right: 0;\n    padding-left: 0;\n}\n\n.track-row-dragging {\n    opacity: 0.5;\n}\n\n.track-row.track-row-alternate-even {\n    background-color: var(--theme-colors-background);\n}\n\n.track-row.track-row-alternate-odd {\n    @mixin dark {\n        background-color: darken(var(--theme-colors-background), 30%);\n    }\n\n    @mixin light {\n        background-color: darken(var(--theme-colors-background), 2%);\n    }\n}\n\n.track-row.track-row-selected {\n    @mixin dark {\n        background-color: lighten(var(--theme-colors-surface), 5%);\n    }\n\n    @mixin light {\n        background-color: darken(var(--theme-colors-surface), 5%);\n    }\n}\n\n.track-row.track-row-with-horizontal-border {\n    border-top: 1px solid transparent;\n}\n\n.track-row.track-row-horizontal-border-visible {\n    border-top-color: var(--theme-colors-border);\n}\n\n.track-row.track-row-hover-highlight-enabled {\n    position: relative;\n}\n\n.track-row.track-row-hover-highlight-enabled .track-cell {\n    position: relative;\n    z-index: 2;\n}\n\n.track-row.track-row-hover-highlight-enabled:hover::before {\n    position: absolute;\n    top: 0;\n    right: 0;\n    bottom: 0;\n    left: 0;\n    z-index: 1;\n    pointer-events: none;\n    content: '';\n    background-color: var(--theme-colors-surface);\n    opacity: 0.7;\n}\n\n.skeleton-image-container {\n    justify-content: center;\n}\n\n.skeleton-image {\n    width: 100%;\n    aspect-ratio: 1;\n    border-radius: var(--theme-radius-md);\n}\n\n.skeleton-title-container {\n    justify-content: center;\n}\n\n.skeleton-title {\n    width: 75%;\n    height: 1.25rem;\n}\n\n.skeleton-artist-container {\n    justify-content: center;\n}\n\n.skeleton-artist {\n    width: 50%;\n    height: 1rem;\n}\n\n.skeleton-tracks {\n    display: flex;\n    flex-direction: column;\n    gap: 0;\n}\n\n.skeleton-track-row {\n    display: grid;\n    grid-template-columns: 40px 1fr 8rem;\n    gap: var(--theme-spacing-sm);\n    align-items: center;\n    padding-right: var(--theme-spacing-sm);\n    padding-left: var(--theme-spacing-sm);\n}\n\n.skeleton-tracks-size-compact .skeleton-track-row {\n    height: 32px;\n    padding-top: 0;\n    padding-bottom: 0;\n}\n\n.skeleton-tracks-size-default .skeleton-track-row {\n    height: 40px;\n    padding-top: 0;\n    padding-bottom: 0;\n}\n\n.skeleton-tracks-size-large .skeleton-track-row {\n    height: 48px;\n    padding-top: 0;\n    padding-bottom: 0;\n}\n\n.skeleton-track-cell {\n    width: 100%;\n    height: 1rem;\n}\n\n.skeleton-track-cell-title {\n    width: 100%;\n    min-width: 0;\n    height: 1rem;\n}\n"
  },
  {
    "path": "src/renderer/components/item-list/item-detail-list/item-detail-list.tsx",
    "content": "import {\n    attachClosestEdge,\n    type Edge,\n    extractClosestEdge,\n} from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge';\nimport { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';\nimport {\n    draggable,\n    dropTargetForElements,\n} from '@atlaskit/pragmatic-drag-and-drop/element/adapter';\nimport { disableNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/disable-native-drag-preview';\nimport { useQuery, useQueryClient } from '@tanstack/react-query';\nimport clsx from 'clsx';\nimport throttle from 'lodash/throttle';\nimport { AnimatePresence } from 'motion/react';\nimport { useOverlayScrollbars } from 'overlayscrollbars-react';\nimport {\n    Fragment,\n    memo,\n    type ReactElement,\n    useCallback,\n    useEffect,\n    useMemo,\n    useRef,\n    useState,\n} from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { generatePath, Link } from 'react-router';\nimport { List, RowComponentProps, useDynamicRowHeight, useListRef } from 'react-window-v2';\n\nimport styles from './item-detail-list.module.css';\n\nimport { ItemCardControls } from '/@/renderer/components/item-card/item-card-controls';\nimport { ItemImage } from '/@/renderer/components/item-image/item-image';\nimport { getDraggedItems } from '/@/renderer/components/item-list/helpers/get-dragged-items';\nimport { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls';\nimport {\n    ItemListStateActions,\n    ItemListStateItemWithRequiredProperties,\n    useItemDraggingState,\n    useItemListState,\n    useItemSelectionState,\n} from '/@/renderer/components/item-list/helpers/item-list-state';\nimport { parseTableColumns } from '/@/renderer/components/item-list/helpers/parse-table-columns';\nimport { useListHotkeys } from '/@/renderer/components/item-list/helpers/use-list-hotkeys';\nimport { getDetailListCellComponent } from '/@/renderer/components/item-list/item-detail-list/columns';\nimport {\n    getTrackColumnFixed,\n    isNoHorizontalPaddingColumn,\n    shouldShowHoverOnlyColumnContent,\n} from '/@/renderer/components/item-list/item-detail-list/utils';\nimport {\n    pickTableColumns,\n    SONG_TABLE_COLUMNS,\n} from '/@/renderer/components/item-list/item-table-list/default-columns';\nimport { useItemDragDropState } from '/@/renderer/components/item-list/item-table-list/hooks/use-item-drag-drop-state';\nimport { columnLabelMap } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';\nimport { ItemControls, ItemTableListColumnConfig } from '/@/renderer/components/item-list/types';\nimport {\n    JOINED_ARTISTS_MUTED_PROPS,\n    JoinedArtists,\n} from '/@/renderer/features/albums/components/joined-artists';\nimport { usePlayer } from '/@/renderer/features/player/context/player-context';\nimport { useIsMutatingCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation';\nimport { useIsMutatingDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation';\nimport { songsQueries } from '/@/renderer/features/songs/api/songs-api';\nimport { useDragDrop } from '/@/renderer/hooks/use-drag-drop';\nimport { AppRoute } from '/@/renderer/router/routes';\nimport { useSettingsStore, useShowRatings } from '/@/renderer/store';\nimport { formatDateAbsoluteUTC, formatDurationString } from '/@/renderer/utils';\nimport { SEPARATOR_STRING } from '/@/shared/api/utils';\nimport { ExplicitIndicator } from '/@/shared/components/explicit-indicator/explicit-indicator';\nimport { Skeleton } from '/@/shared/components/skeleton/skeleton';\nimport { useDoubleClick } from '/@/shared/hooks/use-double-click';\nimport { useFocusWithin } from '/@/shared/hooks/use-focus-within';\nimport { useMergedRef } from '/@/shared/hooks/use-merged-ref';\nimport { Album, LibraryItem, Song, SongListSort, SortOrder } from '/@/shared/types/domain-types';\nimport { dndUtils, DragData, DragOperation, DragTarget } from '/@/shared/types/drag-and-drop';\nimport { ItemListKey, Play, TableColumn } from '/@/shared/types/types';\n\nconst DEFAULT_ROW_HEIGHT = 300;\n\nconst SKELETON_TRACK_ROW_COUNT = 6;\n\ninterface ItemDetailListProps {\n    currentPage?: number;\n    data?: unknown[];\n    enableHeader?: boolean;\n    getItem?: (index: number) => unknown;\n    internalState?: ItemListStateActions;\n    itemCount?: number;\n    items?: unknown[];\n    listKey?: ItemListKey;\n    onColumnReordered?: (\n        columnIdFrom: TableColumn,\n        columnIdTo: TableColumn,\n        edge: 'bottom' | 'left' | 'right' | 'top' | null,\n    ) => void;\n    onColumnResized?: (columnId: TableColumn, width: number) => void;\n    onRangeChanged?: (range: { startIndex: number; stopIndex: number }) => Promise<void> | void;\n    onScrollEnd?: (rowIndex: number) => void;\n    onSongRowDoubleClick?: (params: {\n        index: number;\n        internalState: ItemListStateActions;\n        item: Song;\n    }) => void;\n    overrideControls?: Partial<ItemControls>;\n    rowHeight?: number;\n    scrollOffset?: number;\n    songsByAlbumId?: Record<string, Song[]>;\n    tableId?: string;\n}\n\ninterface RowData {\n    columnWidthPercents: number[];\n    controls?: ItemControls;\n    data: unknown[];\n    defaultRowHeight: number;\n    enableAlternateRowColors: boolean;\n    enableHorizontalBorders: boolean;\n    enableRowHoverHighlight: boolean;\n    enableVerticalBorders: boolean;\n    getItem?: (index: number) => unknown;\n    internalState: ItemListStateActions;\n    isMutatingFavorite: boolean;\n    onSongRowDoubleClick?: (params: {\n        index: number;\n        internalState: ItemListStateActions;\n        item: Song;\n    }) => void;\n    registerSongs: (albumId: string, songs: Song[]) => void;\n    songsByAlbumId?: Record<string, Song[]>;\n    trackColumns: ItemTableListColumnConfig[];\n    trackTableSize: 'compact' | 'default' | 'large';\n}\n\ninterface TrackRowProps {\n    albumSongs: Song[];\n    columns: ItemTableListColumnConfig[];\n    columnWidthPercents: number[];\n    controls?: ItemControls;\n    enableAlternateRowColors: boolean;\n    enableHorizontalBorders: boolean;\n    enableRowHoverHighlight: boolean;\n    enableVerticalBorders: boolean;\n    internalState: ItemListStateActions;\n    isMutatingFavorite: boolean;\n    isSongsLoading?: boolean;\n    onSongRowDoubleClick?: (params: {\n        index: number;\n        internalState: ItemListStateActions;\n        item: Song;\n    }) => void;\n    rowIndex: number;\n    size: 'compact' | 'default' | 'large';\n    song: Song;\n}\n\nconst textAlignFromAlign = (align: ItemTableListColumnConfig['align']) =>\n    align === 'start' ? 'left' : align === 'end' ? 'right' : 'center';\n\nconst TrackRow = memo(\n    ({\n        albumSongs,\n        columns,\n        columnWidthPercents,\n        controls,\n        enableAlternateRowColors,\n        enableHorizontalBorders,\n        enableRowHoverHighlight,\n        enableVerticalBorders,\n        internalState,\n        isMutatingFavorite,\n        isSongsLoading,\n        onSongRowDoubleClick,\n        rowIndex,\n        size,\n        song,\n    }: TrackRowProps) => {\n        const playerContext = usePlayer();\n        const { dragRef, isDragging } = useItemDragDropState<HTMLDivElement>({\n            enableDrag: true,\n            internalState,\n            isDataRow: true,\n            item: song,\n            itemType: LibraryItem.SONG,\n            playerContext,\n        });\n        const [isRowHovered, setIsRowHovered] = useState(false);\n        const isSelected = useItemSelectionState(internalState, song.id);\n\n        const handleDoubleClick = useCallback(\n            (e: React.MouseEvent) => {\n                e.preventDefault();\n                e.stopPropagation();\n                if (onSongRowDoubleClick) {\n                    onSongRowDoubleClick({\n                        index: internalState.findItemIndex(song.id),\n                        internalState,\n                        item: song,\n                    });\n                    return;\n                }\n                if (controls?.onDoubleClick) {\n                    controls.onDoubleClick({\n                        event: e,\n                        index: internalState.findItemIndex(song.id),\n                        internalState,\n                        item: song,\n                        itemType: LibraryItem.SONG,\n                    });\n                    return;\n                }\n                if (isSongsLoading || albumSongs.length === 0) return;\n                internalState.setSelected([song]);\n                playerContext.addToQueueByData(albumSongs, Play.NOW, song.id);\n            },\n            [\n                albumSongs,\n                controls,\n                internalState,\n                isSongsLoading,\n                onSongRowDoubleClick,\n                playerContext,\n                song,\n            ],\n        );\n\n        const handleRowClick = useCallback(\n            (e: React.MouseEvent) => {\n                e.preventDefault();\n                e.stopPropagation();\n                if (e.ctrlKey || e.metaKey) {\n                    internalState.toggleSelected(song);\n                } else if (e.shiftKey) {\n                    const selectedItems = internalState.getSelected();\n                    const lastSelectedItem = selectedItems[selectedItems.length - 1];\n\n                    if (\n                        lastSelectedItem &&\n                        typeof lastSelectedItem === 'object' &&\n                        lastSelectedItem !== null\n                    ) {\n                        const data = internalState.getData();\n                        const validData = data.filter((d) => d && typeof d === 'object');\n                        const lastRowId = internalState.extractRowId(lastSelectedItem);\n                        if (!lastRowId) {\n                            internalState.setSelected([song]);\n                            return;\n                        }\n                        const lastIndex = internalState.findItemIndex(lastRowId);\n                        const currentIndex = internalState.findItemIndex(song.id);\n\n                        if (lastIndex !== -1 && currentIndex !== -1) {\n                            const startIndex = Math.min(lastIndex, currentIndex);\n                            const stopIndex = Math.max(lastIndex, currentIndex);\n                            const rangeItems: ItemListStateItemWithRequiredProperties[] = [];\n                            for (let i = startIndex; i <= stopIndex; i++) {\n                                const rangeItem = validData[i];\n                                if (\n                                    rangeItem &&\n                                    typeof rangeItem === 'object' &&\n                                    '_serverId' in rangeItem &&\n                                    '_itemType' in rangeItem\n                                ) {\n                                    const rangeRowId = internalState.extractRowId(rangeItem);\n                                    if (rangeRowId) {\n                                        rangeItems.push(\n                                            rangeItem as ItemListStateItemWithRequiredProperties,\n                                        );\n                                    }\n                                }\n                            }\n                            const currentSelected = internalState.getSelected();\n                            const newSelected = [\n                                ...currentSelected.filter(\n                                    (\n                                        selectedItem,\n                                    ): selectedItem is ItemListStateItemWithRequiredProperties =>\n                                        typeof selectedItem === 'object' && selectedItem !== null,\n                                ),\n                            ];\n                            rangeItems.forEach((rangeItem) => {\n                                const rangeRowId = internalState.extractRowId(rangeItem);\n                                if (\n                                    rangeRowId &&\n                                    !newSelected.some(\n                                        (selected) =>\n                                            internalState.extractRowId(selected) === rangeRowId,\n                                    )\n                                ) {\n                                    newSelected.push(rangeItem);\n                                }\n                            });\n                            internalState.setSelected(newSelected);\n                        } else {\n                            internalState.setSelected([song]);\n                        }\n                    } else {\n                        internalState.setSelected([song]);\n                    }\n                } else {\n                    const selected = internalState.getSelected();\n                    const onlyThisSelected =\n                        selected.length === 1 &&\n                        internalState.extractRowId(selected[0]) === song.id;\n                    internalState.setSelected(onlyThisSelected ? [] : [song]);\n                }\n            },\n            [internalState, song],\n        );\n\n        const handleClick = useDoubleClick({\n            onDoubleClick: handleDoubleClick,\n            onSingleClick: handleRowClick,\n        });\n\n        const handleContextMenu = useCallback(\n            (event: React.MouseEvent<HTMLDivElement>) => {\n                if (isSongsLoading || !controls?.onMore) return;\n                event.preventDefault();\n                const index = internalState.findItemIndex(song.id);\n                controls.onMore({\n                    event,\n                    index,\n                    internalState,\n                    item: song,\n                    itemType: LibraryItem.SONG,\n                });\n            },\n            [controls, internalState, isSongsLoading, song],\n        );\n\n        return (\n            <div\n                className={clsx(styles.trackRow, {\n                    [styles.trackRowAlternateEven]: enableAlternateRowColors && rowIndex % 2 === 0,\n                    [styles.trackRowAlternateOdd]: enableAlternateRowColors && rowIndex % 2 === 1,\n                    [styles.trackRowDragging]: isDragging,\n                    [styles.trackRowHorizontalBorderVisible]:\n                        enableHorizontalBorders && rowIndex > 0,\n                    [styles.trackRowHoverHighlightEnabled]: enableRowHoverHighlight,\n                    [styles.trackRowSelected]: isSelected,\n                    [styles.trackRowSizeCompact]: size === 'compact',\n                    [styles.trackRowSizeDefault]: size === 'default',\n                    [styles.trackRowSizeLarge]: size === 'large',\n                    [styles.trackRowWithHorizontalBorder]: rowIndex > 0,\n                })}\n                onClick={handleClick}\n                onContextMenu={handleContextMenu}\n                onMouseEnter={() => setIsRowHovered(true)}\n                onMouseLeave={() => setIsRowHovered(false)}\n                ref={dragRef ?? undefined}\n                role=\"row\"\n            >\n                {columns.map((col, colIndex) => {\n                    const percent = columnWidthPercents[colIndex] ?? 0;\n                    const { fixedWidth, isFixedColumn } = getTrackColumnFixed(col.id);\n                    const style: React.CSSProperties = {\n                        flex: isFixedColumn ? `0 0 ${fixedWidth}px` : `${percent} 1 0`,\n                        minWidth: isFixedColumn ? fixedWidth : 0,\n                        textAlign: textAlignFromAlign(col.align),\n                    };\n                    const CellComponent = getDetailListCellComponent(col.id);\n                    const isTitleColumn = col.id === TableColumn.TITLE;\n                    const isImageColumn = col.id === TableColumn.IMAGE;\n                    const isIconActionColumn = isNoHorizontalPaddingColumn(col.id);\n                    const showHoverContent = shouldShowHoverOnlyColumnContent(\n                        col.id,\n                        isRowHovered,\n                        song,\n                    );\n\n                    const content = isSongsLoading ? null : showHoverContent ? (\n                        <CellComponent\n                            columnId={col.id}\n                            controls={controls}\n                            internalState={internalState}\n                            isMutatingFavorite={isMutatingFavorite}\n                            isRowHovered={isRowHovered}\n                            rowIndex={rowIndex}\n                            size={size}\n                            song={song}\n                        />\n                    ) : (\n                        '\\u00A0'\n                    );\n\n                    const isLastColumn = colIndex === columns.length - 1;\n                    return (\n                        <div\n                            className={clsx(styles.trackCell, {\n                                [styles.trackCellImage]: isImageColumn,\n                                [styles.trackCellMuted]: !isTitleColumn,\n                                [styles.trackCellNoHPadding]: isIconActionColumn,\n                                [styles.trackCellVerticalBorderVisible]:\n                                    enableVerticalBorders && !isLastColumn,\n                                [styles.trackCellWithVerticalBorder]: !isLastColumn,\n                            })}\n                            key={col.id}\n                            role=\"cell\"\n                            style={style}\n                        >\n                            {content}\n                        </div>\n                    );\n                })}\n            </div>\n        );\n    },\n);\n\nTrackRow.displayName = 'TrackRow';\n\ninterface MetadataSectionProps {\n    controls?: ItemControls;\n    internalState: ItemListStateActions;\n    item: Album;\n}\n\nconst MetadataSection = memo(\n    ({ controls, internalState, item }: MetadataSectionProps) => {\n        const { t } = useTranslation();\n        const showRatings = useShowRatings();\n        const [isImageHovered, setIsImageHovered] = useState(false);\n        const [isMetadataHovered, setIsMetadataHovered] = useState(false);\n\n        const getId = useCallback(() => {\n            const draggedItems = getDraggedItems(item, internalState, false);\n            return draggedItems.map((i) => i.id);\n        }, [item, internalState]);\n\n        const getItem = useCallback(() => {\n            return getDraggedItems(item, internalState, false);\n        }, [item, internalState]);\n\n        const onDragStart = useCallback(() => {\n            const draggedItems = getDraggedItems(item, internalState, false);\n            internalState?.setDragging(draggedItems);\n        }, [item, internalState]);\n\n        const onDrop = useCallback(() => {\n            internalState?.setDragging([]);\n        }, [internalState]);\n\n        const drag = useMemo(() => {\n            const playlistSongs = (item as { _playlistSongs?: Song[] })._playlistSongs;\n            if (playlistSongs && playlistSongs.length > 0) {\n                return {\n                    getId,\n                    getItem: () => playlistSongs,\n                    itemType: LibraryItem.SONG,\n                    onDragStart,\n                    onDrop,\n                    operation: [DragOperation.ADD],\n                    target: DragTarget.SONG,\n                };\n            }\n\n            return {\n                getId,\n                getItem,\n                itemType: item._itemType,\n                onDragStart,\n                onDrop,\n                operation: [DragOperation.ADD],\n                target: DragTarget.ALBUM,\n            };\n        }, [getId, getItem, item, onDragStart, onDrop]);\n\n        const { isDragging: isDraggingLocal, ref: dragRef } = useDragDrop<HTMLDivElement>({\n            drag,\n            isEnabled: !!item,\n        });\n        const isDraggingState = useItemDraggingState(internalState, item.id);\n        const isDragging = isDraggingState || isDraggingLocal;\n\n        const handleLinkDragStart = useCallback((e: React.DragEvent<HTMLAnchorElement>) => {\n            e.preventDefault();\n            e.stopPropagation();\n        }, []);\n\n        const isFavorite = item.userFavorite ?? false;\n        const userRating = item.userRating ?? null;\n        const hasRating = showRatings && userRating !== null && userRating > 0;\n\n        const metadataExtra = useMemo(() => {\n            const parts: Array<{ content: React.ReactNode; key: string }> = [];\n            let releaseStr = '';\n            if (item.releaseDate) {\n                if (item.originalDate && item.originalDate !== item.releaseDate) {\n                    releaseStr = `${formatDateAbsoluteUTC(item.originalDate)}${SEPARATOR_STRING}${formatDateAbsoluteUTC(item.releaseDate)}`;\n                } else {\n                    releaseStr = formatDateAbsoluteUTC(item.releaseDate);\n                }\n            } else if (item.releaseYear != null) {\n                releaseStr = String(item.releaseYear);\n            }\n            if (releaseStr) parts.push({ content: releaseStr, key: 'release' });\n            const songCount = item.songCount ?? 0;\n            const duration = item.duration ?? 0;\n            const tracksAndDurationParts: string[] = [];\n            if (songCount > 0) {\n                tracksAndDurationParts.push(t('entity.trackWithCount', { count: songCount }));\n            }\n            if (duration > 0) {\n                tracksAndDurationParts.push(formatDurationString(duration));\n            }\n            const tracksAndDuration = tracksAndDurationParts.join(SEPARATOR_STRING);\n            if (tracksAndDuration) {\n                parts.push({ content: tracksAndDuration, key: 'tracks' });\n            }\n            const genres = item.genres?.filter((g) => g.name) ?? [];\n            if (genres.length > 0) {\n                parts.push({\n                    content: genres.map((genre, i) => (\n                        <Fragment key={genre.id}>\n                            {i > 0 && ', '}\n                            <Link\n                                className={styles.metadataLink}\n                                to={generatePath(AppRoute.LIBRARY_GENRES_DETAIL, {\n                                    genreId: genre.id,\n                                })}\n                            >\n                                {genre.name}\n                            </Link>\n                        </Fragment>\n                    )),\n                    key: 'genres',\n                });\n            }\n            return parts.length > 0 ? parts : null;\n        }, [item, t]);\n\n        const hasArtist =\n            (item.albumArtistName?.trim()?.length ?? 0) > 0 || (item.albumArtists?.length ?? 0) > 0;\n\n        return (\n            <div\n                className={styles.metadata}\n                onMouseEnter={() => setIsMetadataHovered(true)}\n                onMouseLeave={() => setIsMetadataHovered(false)}\n            >\n                <div\n                    className={clsx(styles.imageWrapperOuter, {\n                        [styles.imageWrapperDragging]: isDragging,\n                    })}\n                    ref={dragRef ?? undefined}\n                >\n                    <Link\n                        className={styles.imageWrapper}\n                        draggable={false}\n                        onDragStart={handleLinkDragStart}\n                        onMouseEnter={() => setIsImageHovered(true)}\n                        onMouseLeave={() => setIsImageHovered(false)}\n                        state={{ item }}\n                        to={generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {\n                            albumId: item.id,\n                        })}\n                    >\n                        <ItemImage\n                            className={styles.image}\n                            explicitStatus={item.explicitStatus}\n                            id={item.imageId}\n                            itemType={item._itemType}\n                            serverId={item._serverId}\n                            type=\"itemCard\"\n                        />\n                        {isFavorite && <div className={styles.favoriteBadge} />}\n                        {hasRating && <div className={styles.ratingBadge}>{userRating}</div>}\n                        <AnimatePresence>\n                            {controls && isImageHovered && (\n                                <ItemCardControls\n                                    controls={controls}\n                                    enableExpansion={false}\n                                    internalState={internalState}\n                                    item={item}\n                                    itemType={item._itemType}\n                                    showRating={true}\n                                    type=\"compact\"\n                                />\n                            )}\n                        </AnimatePresence>\n                    </Link>\n                </div>\n                <Link\n                    className={styles.title}\n                    state={{ item }}\n                    to={generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {\n                        albumId: item.id,\n                    })}\n                >\n                    <ExplicitIndicator explicitStatus={item.explicitStatus} />\n                    {item.name}\n                </Link>\n                <div className={styles.artist}>\n                    {!hasArtist ? (\n                        <>&nbsp;</>\n                    ) : (\n                        <JoinedArtists\n                            artistName={item.albumArtistName ?? ''}\n                            artists={item.albumArtists ?? []}\n                            linkProps={JOINED_ARTISTS_MUTED_PROPS.linkProps}\n                            readOnly={!isMetadataHovered}\n                            rootTextProps={JOINED_ARTISTS_MUTED_PROPS.rootTextProps}\n                        />\n                    )}\n                </div>\n                {metadataExtra && metadataExtra.length > 0 && (\n                    <div className={styles.metadataExtra}>\n                        {metadataExtra.map((part) => (\n                            <div\n                                className={clsx(styles.metadataLine, {\n                                    [styles.metadataLineClamp2]: part.key === 'genres',\n                                })}\n                                key={part.key}\n                            >\n                                {part.content}\n                            </div>\n                        ))}\n                    </div>\n                )}\n            </div>\n        );\n    },\n    (prev, next) => prev.item === next.item,\n);\n\nMetadataSection.displayName = 'MetadataSection';\n\ninterface ItemDetailSkeletonRowProps {\n    defaultRowHeight: number;\n    enableAlternateRowColors: boolean;\n    enableHorizontalBorders: boolean;\n    enableVerticalBorders: boolean;\n    trackTableSize: 'compact' | 'default' | 'large';\n}\n\nconst ItemDetailSkeletonRow = memo(\n    ({\n        defaultRowHeight,\n        enableAlternateRowColors,\n        enableHorizontalBorders,\n        enableVerticalBorders,\n        trackTableSize,\n    }: ItemDetailSkeletonRowProps) => {\n        const heightStyle = {\n            height: defaultRowHeight,\n            minHeight: defaultRowHeight,\n            overflow: 'hidden' as const,\n        };\n        return (\n            <>\n                <div className={styles.skeletonColumnWrapper} style={heightStyle}>\n                    <div className={styles.left}>\n                        <div className={styles.metadata}>\n                            <Skeleton\n                                className={styles.skeletonImage}\n                                containerClassName={styles.skeletonImageContainer}\n                            />\n                            <Skeleton\n                                className={styles.skeletonTitle}\n                                containerClassName={styles.skeletonTitleContainer}\n                            />\n                            <Skeleton\n                                className={styles.skeletonArtist}\n                                containerClassName={styles.skeletonArtistContainer}\n                            />\n                        </div>\n                    </div>\n                </div>\n                <div className={styles.skeletonColumnWrapper} style={heightStyle}>\n                    <div className={styles.right}>\n                        <div className={styles.tracksTable} role=\"table\">\n                            {Array.from({ length: SKELETON_TRACK_ROW_COUNT }).map((_, i) => (\n                                <div\n                                    className={clsx(styles.trackRow, {\n                                        [styles.trackRowAlternateEven]:\n                                            enableAlternateRowColors && i % 2 === 0,\n                                        [styles.trackRowAlternateOdd]:\n                                            enableAlternateRowColors && i % 2 === 1,\n                                        [styles.trackRowHorizontalBorderVisible]:\n                                            enableHorizontalBorders && i > 0,\n                                        [styles.trackRowSizeCompact]: trackTableSize === 'compact',\n                                        [styles.trackRowSizeDefault]: trackTableSize === 'default',\n                                        [styles.trackRowSizeLarge]: trackTableSize === 'large',\n                                        [styles.trackRowWithHorizontalBorder]: i > 0,\n                                    })}\n                                    key={i}\n                                    role=\"row\"\n                                >\n                                    <div\n                                        className={clsx(styles.trackCell, {\n                                            [styles.trackCellVerticalBorderVisible]:\n                                                enableVerticalBorders,\n                                            [styles.trackCellWithVerticalBorder]: true,\n                                        })}\n                                        role=\"cell\"\n                                        style={{ flex: 1, minWidth: 0 }}\n                                    />\n                                </div>\n                            ))}\n                        </div>\n                    </div>\n                </div>\n            </>\n        );\n    },\n);\n\nItemDetailSkeletonRow.displayName = 'ItemDetailSkeletonRow';\n\ntype RowContentProps = Omit<RowComponentProps<RowData>, 'style'>;\n\nconst RowContent = memo(\n    ({\n        columnWidthPercents,\n        controls,\n        data,\n        defaultRowHeight,\n        enableAlternateRowColors,\n        enableHorizontalBorders,\n        enableRowHoverHighlight,\n        enableVerticalBorders,\n        getItem,\n        index,\n        internalState,\n        isMutatingFavorite,\n        onSongRowDoubleClick,\n        registerSongs,\n        songsByAlbumId,\n        trackColumns,\n        trackTableSize,\n    }: RowContentProps) => {\n        const item = useMemo(() => {\n            if (getItem) {\n                return getItem(index) as Album | undefined;\n            }\n\n            return (data?.[index] as Album | undefined) || undefined;\n        }, [data, getItem, index]);\n\n        const useClientSideSongs = Boolean(songsByAlbumId);\n\n        const songListQuery = useMemo(() => {\n            if (useClientSideSongs || !item?.id || !item?._serverId) return null;\n            return {\n                query: {\n                    albumIds: [item.id],\n                    limit: -1,\n                    sortBy: SongListSort.ALBUM,\n                    sortOrder: SortOrder.ASC,\n                    startIndex: 0,\n                },\n                serverId: item?._serverId || '',\n            };\n        }, [item, useClientSideSongs]);\n\n        const { data: songListData, isLoading: isSongsQueryLoading } = useQuery({\n            enabled: !!songListQuery,\n            ...(songListQuery\n                ? songsQueries.list(songListQuery)\n                : {\n                      queryFn: async () => ({ items: [], startIndex: 0, totalRecordCount: 0 }),\n                      queryKey: ['item-detail', 'list', 'disabled'],\n                  }),\n        });\n\n        const songItemsFromQuery = songListData?.items;\n        const songItemsFromClient = useMemo(() => {\n            const rowSongs = (item as { _playlistSongs?: Song[] })?._playlistSongs;\n            if (rowSongs?.length) return rowSongs;\n            if (!songsByAlbumId || !item?.id) return undefined;\n            return songsByAlbumId[item.id];\n        }, [item, songsByAlbumId]);\n\n        const songItems = useClientSideSongs ? songItemsFromClient : songItemsFromQuery;\n        const isSongsLoading =\n            !useClientSideSongs && !!item && isSongsQueryLoading && !songItemsFromQuery?.length;\n\n        const songs = useMemo(() => {\n            return (\n                songItems ||\n                Array.from({ length: item?.songCount || 0 }, (_, i) => ({\n                    duration: 0,\n                    id: `${item?.id}-${i}`,\n                    name: '',\n                    trackNumber: i + 1,\n                }))\n            );\n        }, [songItems, item?.id, item?.songCount]);\n\n        useEffect(() => {\n            if (item?.id && songItems?.length) {\n                registerSongs(item.id, songItems as Song[]);\n            }\n        }, [item?.id, registerSongs, songItems]);\n\n        if (!item) {\n            return (\n                <ItemDetailSkeletonRow\n                    defaultRowHeight={defaultRowHeight}\n                    enableAlternateRowColors={enableAlternateRowColors}\n                    enableHorizontalBorders={enableHorizontalBorders}\n                    enableVerticalBorders={enableVerticalBorders}\n                    trackTableSize={trackTableSize}\n                />\n            );\n        }\n\n        return (\n            <>\n                <div className={styles.left}>\n                    <MetadataSection\n                        controls={controls}\n                        internalState={internalState}\n                        item={item}\n                    />\n                </div>\n\n                <div className={styles.right}>\n                    <div className={styles.tracksTable} role=\"table\">\n                        {songs.map((song, rowIndex) => (\n                            <TrackRow\n                                albumSongs={songItems ? (songItems as Song[]) : []}\n                                columns={trackColumns}\n                                columnWidthPercents={columnWidthPercents}\n                                controls={controls}\n                                enableAlternateRowColors={enableAlternateRowColors}\n                                enableHorizontalBorders={enableHorizontalBorders}\n                                enableRowHoverHighlight={enableRowHoverHighlight}\n                                enableVerticalBorders={enableVerticalBorders}\n                                internalState={internalState}\n                                isMutatingFavorite={isMutatingFavorite}\n                                isSongsLoading={isSongsLoading}\n                                key={song.id}\n                                onSongRowDoubleClick={onSongRowDoubleClick}\n                                rowIndex={rowIndex}\n                                size={trackTableSize}\n                                song={song as Song}\n                            />\n                        ))}\n                    </div>\n                </div>\n            </>\n        );\n    },\n    (prev, next) =>\n        prev.index === next.index &&\n        prev.data === next.data &&\n        prev.columnWidthPercents === next.columnWidthPercents &&\n        prev.defaultRowHeight === next.defaultRowHeight &&\n        prev.enableAlternateRowColors === next.enableAlternateRowColors &&\n        prev.enableHorizontalBorders === next.enableHorizontalBorders &&\n        prev.enableRowHoverHighlight === next.enableRowHoverHighlight &&\n        prev.enableVerticalBorders === next.enableVerticalBorders &&\n        prev.getItem === next.getItem &&\n        prev.internalState === next.internalState &&\n        prev.isMutatingFavorite === next.isMutatingFavorite &&\n        prev.controls === next.controls &&\n        prev.registerSongs === next.registerSongs &&\n        prev.songsByAlbumId === next.songsByAlbumId &&\n        prev.trackColumns === next.trackColumns &&\n        prev.trackTableSize === next.trackTableSize,\n);\n\nRowContent.displayName = 'RowContent';\n\nconst RowComponent = memo((props: RowComponentProps<RowData>): ReactElement => {\n    const { style, ...rowContentProps } = props;\n    return (\n        <div className={styles.row} style={style}>\n            <RowContent {...rowContentProps} />\n        </div>\n    );\n});\n\nRowComponent.displayName = 'ItemDetailRow';\n\ninterface DetailListHeaderCellProps {\n    columnId: TableColumn;\n    columnWidthPercents: number[];\n    enableColumnResize?: boolean;\n    enableVerticalBorders: boolean;\n    isLastColumn: boolean;\n    onColumnReordered?: (args: {\n        columnIdFrom: TableColumn;\n        columnIdTo: TableColumn;\n        edge: Edge | null;\n    }) => void;\n    onColumnResized?: (columnId: TableColumn, width: number) => void;\n    tableId: string;\n    trackColumns: ItemTableListColumnConfig[];\n}\n\nconst DetailListHeaderCell = memo(\n    ({\n        columnId,\n        columnWidthPercents,\n        enableColumnResize,\n        onColumnReordered,\n        onColumnResized,\n        tableId,\n        trackColumns,\n    }: DetailListHeaderCellProps) => {\n        const containerRef = useRef<HTMLDivElement>(null);\n        const [isDragging, setIsDragging] = useState(false);\n        const [isDraggedOver, setIsDraggedOver] = useState<Edge | null>(null);\n        const colIndex = trackColumns.findIndex((c) => c.id === columnId);\n        const col = colIndex >= 0 ? trackColumns[colIndex] : null;\n        const percent = col ? (columnWidthPercents[colIndex] ?? 0) : 0;\n        const { fixedWidth, isFixedColumn } = getTrackColumnFixed(columnId);\n        const currentWidth = col?.width ?? (fixedWidth || 100);\n        const showResizeHandle =\n            enableColumnResize && !isFixedColumn && !col?.autoSize && onColumnResized;\n\n        useEffect(() => {\n            if (!containerRef.current || !onColumnReordered) {\n                return;\n            }\n\n            const handleReorder = (\n                columnIdFrom: TableColumn,\n                columnIdTo: TableColumn,\n                edge: Edge | null,\n            ) => {\n                onColumnReordered({ columnIdFrom, columnIdTo, edge });\n            };\n\n            return combine(\n                draggable({\n                    element: containerRef.current,\n                    getInitialData: () => {\n                        const data = dndUtils.generateDragData(\n                            {\n                                id: [columnId],\n                                operation: [DragOperation.REORDER],\n                                type: DragTarget.TABLE_COLUMN,\n                            },\n                            { tableId },\n                        );\n                        return data;\n                    },\n                    onDragStart: () => setIsDragging(true),\n                    onDrop: () => setIsDragging(false),\n                    onGenerateDragPreview: (data) => {\n                        disableNativeDragPreview({ nativeSetDragImage: data.nativeSetDragImage });\n                    },\n                }),\n                dropTargetForElements({\n                    canDrop: (args) => {\n                        const data = args.source.data as unknown as DragData;\n                        const sourceTableId = (data.metadata as { tableId?: string })?.tableId;\n                        const isSelf = (args.source.data.id as string[])[0] === columnId;\n                        const isSameTable = sourceTableId === tableId;\n                        return (\n                            dndUtils.isDropTarget(data.type, [DragTarget.TABLE_COLUMN]) &&\n                            !isSelf &&\n                            isSameTable\n                        );\n                    },\n                    element: containerRef.current,\n                    getData: ({ element, input }) => {\n                        const data = dndUtils.generateDragData(\n                            {\n                                id: [columnId],\n                                operation: [DragOperation.REORDER],\n                                type: DragTarget.TABLE_COLUMN,\n                            },\n                            { tableId },\n                        );\n                        return attachClosestEdge(data, {\n                            allowedEdges: ['left', 'right'],\n                            element,\n                            input,\n                        });\n                    },\n                    onDrag: (args) => {\n                        const closestEdgeOfTarget = extractClosestEdge(args.self.data);\n                        setIsDraggedOver(closestEdgeOfTarget);\n                    },\n                    onDragLeave: () => setIsDraggedOver(null),\n                    onDrop: (args) => {\n                        const closestEdgeOfTarget = extractClosestEdge(args.self.data);\n                        const from = args.source.data.id as string[];\n                        const to = args.self.data.id as string[];\n\n                        handleReorder(\n                            from[0] as TableColumn,\n                            to[0] as TableColumn,\n                            closestEdgeOfTarget,\n                        );\n                        setIsDraggedOver(null);\n                    },\n                }),\n            );\n        }, [columnId, onColumnReordered, tableId]);\n\n        const style: React.CSSProperties = {\n            flex: isFixedColumn ? `0 0 ${fixedWidth}px` : `${percent} 1 0`,\n            justifyContent: colTypeToJustifyContentMap[col?.align ?? 'start'],\n            minWidth: isFixedColumn ? fixedWidth : 0,\n            textAlign: colTypeToAlignMap[col?.align ?? 'start'] as 'center' | 'left' | 'right',\n        };\n\n        const handleResize = useCallback(\n            (id: TableColumn, width: number) => {\n                onColumnResized?.(id, width);\n            },\n            [onColumnResized],\n        );\n\n        return (\n            <div\n                className={clsx(styles.trackHeaderCell, {\n                    [styles.trackHeaderCellDraggedOverLeft]: isDraggedOver === 'left',\n                    [styles.trackHeaderCellDraggedOverRight]: isDraggedOver === 'right',\n                    [styles.trackHeaderCellDragging]: isDragging,\n                    [styles.trackHeaderCellNoHPadding]: isNoHorizontalPaddingColumn(columnId),\n                })}\n                ref={containerRef}\n                role=\"columnheader\"\n                style={style}\n            >\n                {columnLabelMap[columnId] ?? ''}\n                {showResizeHandle && (\n                    <DetailListColumnResizeHandle\n                        columnId={columnId}\n                        initialWidth={currentWidth}\n                        onResize={handleResize}\n                        side=\"right\"\n                    />\n                )}\n            </div>\n        );\n    },\n);\n\nDetailListHeaderCell.displayName = 'DetailListHeaderCell';\n\ninterface DetailListColumnResizeHandleProps {\n    columnId: TableColumn;\n    initialWidth: number;\n    onResize: (columnId: TableColumn, width: number) => void;\n    side: 'left' | 'right';\n}\n\nconst DetailListColumnResizeHandle = ({\n    columnId,\n    initialWidth,\n    onResize,\n    side,\n}: DetailListColumnResizeHandleProps) => {\n    const [isDragging, setIsDragging] = useState(false);\n    const handleRef = useRef<HTMLDivElement>(null);\n    const startWidthRef = useRef<number>(initialWidth);\n    const startXRef = useRef<number>(0);\n    const finalWidthRef = useRef<number>(initialWidth);\n\n    useEffect(() => {\n        if (!isDragging) {\n            startWidthRef.current = initialWidth;\n        }\n    }, [initialWidth, isDragging]);\n\n    useEffect(() => {\n        if (!isDragging) return;\n\n        const handleMouseMove = (event: MouseEvent) => {\n            const deltaX = event.clientX - startXRef.current;\n            const newWidth = Math.min(Math.max(10, startWidthRef.current + deltaX), 1000);\n            finalWidthRef.current = newWidth;\n        };\n\n        const handleMouseUp = () => {\n            setIsDragging(false);\n            document.body.style.cursor = '';\n            document.body.style.userSelect = '';\n            document.removeEventListener('mousemove', handleMouseMove);\n            document.removeEventListener('mouseup', handleMouseUp);\n            onResize(columnId, finalWidthRef.current);\n        };\n\n        document.addEventListener('mousemove', handleMouseMove);\n        document.addEventListener('mouseup', handleMouseUp);\n\n        return () => {\n            document.removeEventListener('mousemove', handleMouseMove);\n            document.removeEventListener('mouseup', handleMouseUp);\n        };\n    }, [isDragging, columnId, onResize]);\n\n    const handleMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {\n        event.preventDefault();\n        event.stopPropagation();\n        setIsDragging(true);\n        startWidthRef.current = initialWidth;\n        startXRef.current = event.clientX;\n        document.body.style.cursor = 'col-resize';\n        document.body.style.userSelect = 'none';\n    };\n\n    return (\n        <div\n            className={clsx(styles.resizeHandle, {\n                [styles.resizeHandleDragging]: isDragging,\n                [styles.resizeHandleLeft]: side === 'left',\n                [styles.resizeHandleRight]: side === 'right',\n            })}\n            onMouseDown={handleMouseDown}\n            ref={handleRef}\n        />\n    );\n};\n\ninterface DetailListHeaderProps {\n    columnWidthPercents: number[];\n    enableColumnReorder?: boolean;\n    enableColumnResize?: boolean;\n    enableVerticalBorders: boolean;\n    headerLeftRef: React.RefObject<HTMLSpanElement | null>;\n    onColumnReordered?: (args: {\n        columnIdFrom: TableColumn;\n        columnIdTo: TableColumn;\n        edge: Edge | null;\n    }) => void;\n    onColumnResized?: (columnId: TableColumn, width: number) => void;\n    tableId: string;\n    trackColumns: ItemTableListColumnConfig[];\n    trackTableSize: 'compact' | 'default' | 'large';\n}\n\nconst colTypeToAlignMap = {\n    center: 'center',\n    end: 'right',\n    start: 'left',\n};\n\nconst colTypeToJustifyContentMap = {\n    center: 'center',\n    end: 'flex-end',\n    start: 'flex-start',\n};\n\nconst DetailListHeader = memo(\n    ({\n        columnWidthPercents,\n        enableColumnReorder,\n        enableColumnResize,\n        enableVerticalBorders,\n        headerLeftRef,\n        onColumnReordered,\n        onColumnResized,\n        tableId,\n        trackColumns,\n        trackTableSize,\n    }: DetailListHeaderProps) => {\n        return (\n            <header className={styles.detailListHeader} role=\"rowgroup\">\n                <div className={styles.headerLeft}>\n                    <span\n                        className={styles.headerLeftAlbumName}\n                        data-title=\"\"\n                        ref={headerLeftRef}\n                    />\n                </div>\n                <div className={styles.headerRight}>\n                    <div\n                        className={clsx(styles.tracksTableHeader, {\n                            [styles.tracksTableHeaderSizeCompact]: trackTableSize === 'compact',\n                            [styles.tracksTableHeaderSizeDefault]: trackTableSize === 'default',\n                            [styles.tracksTableHeaderSizeLarge]: trackTableSize === 'large',\n                        })}\n                        role=\"row\"\n                    >\n                        {trackColumns.map((col, colIndex) => {\n                            const isLastColumn = colIndex === trackColumns.length - 1;\n\n                            if (\n                                (enableColumnResize && onColumnResized) ||\n                                (enableColumnReorder && onColumnReordered)\n                            ) {\n                                return (\n                                    <DetailListHeaderCell\n                                        columnId={col.id}\n                                        columnWidthPercents={columnWidthPercents}\n                                        enableColumnResize={enableColumnResize}\n                                        enableVerticalBorders={enableVerticalBorders}\n                                        isLastColumn={isLastColumn}\n                                        key={col.id}\n                                        onColumnReordered={onColumnReordered}\n                                        onColumnResized={onColumnResized}\n                                        tableId={tableId}\n                                        trackColumns={trackColumns}\n                                    />\n                                );\n                            }\n\n                            const percent = columnWidthPercents[colIndex] ?? 0;\n                            const { fixedWidth, isFixedColumn } = getTrackColumnFixed(col.id);\n                            const style: React.CSSProperties = {\n                                flex: isFixedColumn ? `0 0 ${fixedWidth}px` : `${percent} 1 0`,\n                                justifyContent: colTypeToJustifyContentMap[col.align],\n                                minWidth: isFixedColumn ? fixedWidth : 0,\n                                textAlign: colTypeToAlignMap[col.align] as\n                                    | 'center'\n                                    | 'left'\n                                    | 'right',\n                            };\n\n                            return (\n                                <div\n                                    className={clsx(styles.trackHeaderCell, {\n                                        [styles.trackHeaderCellNoHPadding]:\n                                            isNoHorizontalPaddingColumn(col.id),\n                                    })}\n                                    key={col.id}\n                                    role=\"columnheader\"\n                                    style={style}\n                                >\n                                    <span className={styles.trackHeaderCellContent}>\n                                        {columnLabelMap[col.id] ?? ''}\n                                    </span>\n                                </div>\n                            );\n                        })}\n                    </div>\n                </div>\n            </header>\n        );\n    },\n);\n\nDetailListHeader.displayName = 'DetailListHeader';\n\nconst SCROLL_END_DEBOUNCE_MS = 150;\n\nconst DEFAULT_DETAIL_TABLE_ID = 'album-detail';\n\nexport const ItemDetailList = ({\n    currentPage,\n    data,\n    enableHeader = true,\n    getItem,\n    itemCount: externalItemCount,\n    items,\n    listKey = ItemListKey.ALBUM,\n    onColumnReordered,\n    onColumnResized,\n    onRangeChanged,\n    onScrollEnd,\n    onSongRowDoubleClick,\n    overrideControls,\n    songsByAlbumId,\n    tableId = DEFAULT_DETAIL_TABLE_ID,\n}: ItemDetailListProps) => {\n    const containerRef = useRef<HTMLDivElement>(null);\n    const listRef = useListRef(null);\n    const { focused, ref: focusRef } = useFocusWithin();\n    const mergedContainerRef = useMergedRef(containerRef, focusRef);\n    const lastVisibleStartIndexRef = useRef(0);\n    const queryClient = useQueryClient();\n\n    const controls = useDefaultItemListControls({\n        onColumnReordered,\n        onColumnResized,\n        overrides: overrideControls,\n    });\n    const isMutatingCreateFavorite = useIsMutatingCreateFavorite();\n    const isMutatingDeleteFavorite = useIsMutatingDeleteFavorite();\n    const isMutatingFavorite = isMutatingCreateFavorite || isMutatingDeleteFavorite;\n\n    const rowHeight = useDynamicRowHeight({\n        defaultRowHeight: DEFAULT_ROW_HEIGHT,\n    });\n\n    const isInfinite = data !== undefined || getItem !== undefined;\n    const isPaginated = items !== undefined || currentPage !== undefined;\n\n    const dataSource = useMemo(() => {\n        if (isInfinite && data) {\n            return data;\n        }\n        if (isPaginated && items) {\n            return items;\n        }\n        return [];\n    }, [data, isInfinite, isPaginated, items]);\n\n    const itemCount = useMemo(() => {\n        if (externalItemCount !== undefined) {\n            return externalItemCount;\n        }\n        return dataSource.length;\n    }, [dataSource.length, externalItemCount]);\n\n    // Accumulate songs from each row for selection/drag state (keyed by album id)\n    const songsByAlbumRef = useRef<Map<string, Song[]>>(new Map());\n    const registerSongs = useCallback((albumId: string, songs: Song[]) => {\n        songsByAlbumRef.current.set(albumId, songs);\n    }, []);\n\n    // Flattened songs in album order for ItemListState (selection/drag are per-song)\n    const getDataFn = useCallback(() => {\n        const map = songsByAlbumRef.current;\n        return dataSource.flatMap((album) => map.get((album as Album).id) ?? []);\n    }, [dataSource]);\n\n    const extractRowIdSong = useCallback((item: unknown) => (item as Song).id, []);\n\n    const internalState = useItemListState(getDataFn, extractRowIdSong);\n\n    const tableConfig = useSettingsStore((state) => state.lists[listKey]?.detail);\n    const trackColumns = useMemo((): ItemTableListColumnConfig[] => {\n        const raw = tableConfig?.columns;\n        if (raw && raw.length > 0) {\n            return parseTableColumns(raw);\n        }\n        return pickTableColumns({\n            columns: SONG_TABLE_COLUMNS,\n            enabledColumns: [\n                TableColumn.TRACK_NUMBER,\n                TableColumn.TITLE,\n                TableColumn.DURATION,\n                TableColumn.USER_FAVORITE,\n                TableColumn.USER_RATING,\n            ],\n        });\n    }, [tableConfig?.columns]);\n    const trackTableSize = tableConfig?.size ?? 'default';\n    const enableRowHoverHighlight = tableConfig?.enableRowHoverHighlight ?? true;\n    const enableAlternateRowColors = tableConfig?.enableAlternateRowColors ?? false;\n    const enableHorizontalBorders = tableConfig?.enableHorizontalBorders ?? false;\n    const enableVerticalBorders = tableConfig?.enableVerticalBorders ?? false;\n\n    const columnWidthPercents = useMemo(() => {\n        const total = trackColumns.reduce((sum, c) => sum + c.width, 0);\n        if (total <= 0) {\n            return trackColumns.map(() => 100 / Math.max(1, trackColumns.length));\n        }\n        return trackColumns.map((c) => (c.width / total) * 100);\n    }, [trackColumns]);\n\n    const headerLeftRef = useRef<HTMLSpanElement>(null);\n    const dataSourceRef = useRef(dataSource);\n    dataSourceRef.current = dataSource;\n    const lastHeaderNameRef = useRef('');\n\n    const handleRowsRendered = useCallback(\n        (range: { startIndex: number; stopIndex: number }) => {\n            lastVisibleStartIndexRef.current = range.startIndex;\n            const el = headerLeftRef.current;\n            if (el) {\n                const album = (\n                    getItem ? getItem(range.startIndex) : dataSourceRef.current[range.startIndex]\n                ) as Album | undefined;\n                const name = album?.name ?? '';\n                if (name) {\n                    lastHeaderNameRef.current = name;\n                    el.textContent = name;\n                    el.setAttribute('data-title', name);\n                    el.title = name;\n                } else {\n                    el.textContent = lastHeaderNameRef.current;\n                    el.setAttribute('data-title', lastHeaderNameRef.current);\n                    el.title = lastHeaderNameRef.current;\n                }\n            }\n            if (onRangeChanged) {\n                onRangeChanged(range);\n            }\n        },\n        [getItem, onRangeChanged],\n    );\n\n    const throttledHandleRowsRendered = useMemo(\n        () =>\n            throttle(handleRowsRendered, 150, {\n                leading: true,\n                trailing: true,\n            }),\n        [handleRowsRendered],\n    );\n\n    useEffect(() => {\n        return () => {\n            throttledHandleRowsRendered.cancel();\n        };\n    }, [throttledHandleRowsRendered]);\n\n    const rowProps = useMemo<RowData>(\n        () => ({\n            columnWidthPercents,\n            controls,\n            data: dataSource,\n            defaultRowHeight: DEFAULT_ROW_HEIGHT,\n            enableAlternateRowColors,\n            enableHorizontalBorders,\n            enableRowHoverHighlight,\n            enableVerticalBorders,\n            getItem,\n            internalState,\n            isMutatingFavorite,\n            onSongRowDoubleClick,\n            queryClient,\n            registerSongs,\n            songsByAlbumId,\n            trackColumns,\n            trackTableSize,\n        }),\n        [\n            columnWidthPercents,\n            controls,\n            dataSource,\n            enableAlternateRowColors,\n            enableHorizontalBorders,\n            enableRowHoverHighlight,\n            enableVerticalBorders,\n            getItem,\n            internalState,\n            isMutatingFavorite,\n            onSongRowDoubleClick,\n            queryClient,\n            registerSongs,\n            songsByAlbumId,\n            trackColumns,\n            trackTableSize,\n        ],\n    );\n\n    const [initialize, osInstance] = useOverlayScrollbars({\n        defer: false,\n        events: {\n            initialized(osInstance) {\n                const { viewport } = osInstance.elements();\n                viewport.style.overflowX = `var(--os-viewport-overflow-x)`;\n            },\n        },\n        options: {\n            overflow: { x: 'hidden', y: 'scroll' },\n            paddingAbsolute: true,\n            scrollbars: {\n                autoHide: 'leave',\n                autoHideDelay: 500,\n                pointers: ['mouse', 'pen', 'touch'],\n                theme: 'feishin-os-scrollbar',\n                visibility: 'visible',\n            },\n        },\n    });\n\n    useListHotkeys({\n        controls,\n        focused,\n        internalState,\n        itemType: LibraryItem.SONG,\n    });\n\n    useEffect(() => {\n        const { current: container } = containerRef;\n\n        if (!container || !container.firstElementChild) {\n            return;\n        }\n\n        const viewport = container.firstElementChild as HTMLElement;\n\n        initialize({\n            elements: { viewport },\n            target: container,\n        });\n\n        let scrollEndTimeoutId: null | ReturnType<typeof setTimeout> = null;\n        const handleScroll = () => {\n            if (scrollEndTimeoutId) clearTimeout(scrollEndTimeoutId);\n            scrollEndTimeoutId = setTimeout(() => {\n                scrollEndTimeoutId = null;\n                onScrollEnd?.(lastVisibleStartIndexRef.current);\n            }, SCROLL_END_DEBOUNCE_MS);\n        };\n\n        if (onScrollEnd) {\n            viewport.addEventListener('scroll', handleScroll, { passive: true });\n        }\n\n        return () => {\n            if (onScrollEnd) {\n                viewport.removeEventListener('scroll', handleScroll);\n                if (scrollEndTimeoutId) clearTimeout(scrollEndTimeoutId);\n            }\n            osInstance()?.destroy();\n        };\n    }, [initialize, onScrollEnd, osInstance]);\n\n    return (\n        <div className={styles.wrapper}>\n            {enableHeader && (\n                <DetailListHeader\n                    columnWidthPercents={columnWidthPercents}\n                    enableColumnReorder={!!onColumnReordered}\n                    enableColumnResize={!!controls.onColumnResized}\n                    enableVerticalBorders={enableVerticalBorders}\n                    headerLeftRef={headerLeftRef}\n                    onColumnReordered={controls.onColumnReordered}\n                    onColumnResized={\n                        controls.onColumnResized\n                            ? (columnId, width) => controls.onColumnResized?.({ columnId, width })\n                            : undefined\n                    }\n                    tableId={tableId}\n                    trackColumns={trackColumns}\n                    trackTableSize={trackTableSize}\n                />\n            )}\n            <div className={styles.container} ref={mergedContainerRef}>\n                <List\n                    listRef={listRef}\n                    onRowsRendered={throttledHandleRowsRendered}\n                    rowComponent={\n                        RowComponent as (props: RowComponentProps<RowData>) => ReactElement\n                    }\n                    rowCount={itemCount}\n                    rowHeight={rowHeight}\n                    rowProps={rowProps}\n                />\n            </div>\n        </div>\n    );\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/item-detail-list/utils.ts",
    "content": "import { TableColumn } from '/@/shared/types/types';\n\nconst FIXED_TRACK_COLUMN_WIDTHS: Partial<Record<TableColumn, number>> = {\n    [TableColumn.ACTIONS]: 32,\n    [TableColumn.BIT_DEPTH]: 88,\n    [TableColumn.BIT_RATE]: 88,\n    [TableColumn.BPM]: 56,\n    [TableColumn.CHANNELS]: 80,\n    [TableColumn.CODEC]: 80,\n    [TableColumn.DATE_ADDED]: 128,\n    [TableColumn.DISC_NUMBER]: 36,\n    [TableColumn.DURATION]: 72,\n    [TableColumn.RELEASE_DATE]: 128,\n    [TableColumn.SAMPLE_RATE]: 112,\n    [TableColumn.TRACK_NUMBER]: 64,\n    [TableColumn.USER_FAVORITE]: 32,\n    [TableColumn.USER_RATING]: 64,\n    [TableColumn.YEAR]: 56,\n};\n\nconst HOVER_ONLY_COLUMNS: TableColumn[] = [\n    TableColumn.ACTIONS,\n    TableColumn.USER_FAVORITE,\n    TableColumn.USER_RATING,\n];\n\nconst NO_HORIZONTAL_PADDING_COLUMNS: TableColumn[] = [\n    TableColumn.ACTIONS,\n    TableColumn.USER_FAVORITE,\n    TableColumn.USER_RATING,\n];\n\nexport function getTrackColumnFixed(columnId: TableColumn): {\n    fixedWidth: number;\n    isFixedColumn: boolean;\n} {\n    const width = FIXED_TRACK_COLUMN_WIDTHS[columnId];\n    return width !== undefined\n        ? { fixedWidth: width, isFixedColumn: true }\n        : { fixedWidth: 0, isFixedColumn: false };\n}\n\nexport function isNoHorizontalPaddingColumn(columnId: TableColumn): boolean {\n    return NO_HORIZONTAL_PADDING_COLUMNS.includes(columnId);\n}\n\nexport function isTrackColumnHoverOnly(columnId: TableColumn): boolean {\n    return HOVER_ONLY_COLUMNS.includes(columnId);\n}\n\nexport function shouldShowHoverOnlyColumnContent(\n    columnId: TableColumn,\n    isRowHovered: boolean,\n    song: { userFavorite?: boolean | null; userRating?: null | number },\n): boolean {\n    if (!HOVER_ONLY_COLUMNS.includes(columnId)) {\n        return true;\n    }\n\n    return (\n        isRowHovered ||\n        (columnId === TableColumn.USER_FAVORITE && song.userFavorite !== false) ||\n        (columnId === TableColumn.USER_RATING && song.userRating !== null && song.userRating !== 0)\n    );\n}\n"
  },
  {
    "path": "src/renderer/components/item-list/item-grid-list/item-grid-list.module.css",
    "content": ".item-grid-container {\n    position: relative;\n    display: flex;\n    flex-direction: column !important;\n    width: 100%;\n    height: 100%;\n    padding-right: var(--theme-spacing-md);\n    outline: none;\n    border: none;\n}\n\n.auto-sizer-container {\n    flex: 1;\n}\n\n.grid-list-container {\n    width: 100%;\n    padding: 0 var(--theme-spacing-md);\n}\n\n.item-list {\n    display: flex;\n}\n\n.item-row {\n    flex: 1 1 calc(100% / var(--columns));\n    width: 100%;\n    max-width: calc(100% / var(--columns));\n    height: 100%;\n    overflow: hidden;\n}\n\n.item-row.gap-xs {\n    padding: var(--theme-spacing-xs);\n\n    --card-gap: 2px;\n}\n\n.item-row.gap-sm {\n    padding: var(--theme-spacing-sm);\n\n    --card-gap: var(--theme-spacing-xs);\n}\n\n.item-row.gap-md {\n    padding: var(--theme-spacing-md);\n\n    --card-gap: var(--theme-spacing-sm);\n}\n\n.item-row.gap-lg {\n    padding: var(--theme-spacing-lg);\n\n    --card-gap: var(--theme-spacing-sm);\n}\n\n.item-row.gap-xl {\n    padding: var(--theme-spacing-xl);\n\n    --card-gap: var(--theme-spacing-sm);\n}\n"
  },
  {
    "path": "src/renderer/components/item-list/item-grid-list/item-grid-list.tsx",
    "content": "import clsx from 'clsx';\nimport throttle from 'lodash/throttle';\nimport { motion } from 'motion/react';\nimport { useOverlayScrollbars } from 'overlayscrollbars-react';\nimport React, {\n    CSSProperties,\n    memo,\n    ReactNode,\n    Ref,\n    RefObject,\n    useCallback,\n    useEffect,\n    useImperativeHandle,\n    useLayoutEffect,\n    useMemo,\n    useRef,\n    useState,\n} from 'react';\nimport AutoSizer from 'react-virtualized-auto-sizer';\nimport {\n    FixedSizeList,\n    ListChildComponentProps,\n    ListOnItemsRenderedProps,\n    ListOnScrollProps,\n} from 'react-window';\n\nimport styles from './item-grid-list.module.css';\n\nimport {\n    getDataRowsCount,\n    ItemCard,\n    ItemCardProps,\n} from '/@/renderer/components/item-card/item-card';\nimport { createExtractRowId } from '/@/renderer/components/item-list/helpers/extract-row-id';\nimport { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls';\nimport {\n    ItemListStateActions,\n    ItemListStateItemWithRequiredProperties,\n    useItemListState,\n} from '/@/renderer/components/item-list/helpers/item-list-state';\nimport { useListHotkeys } from '/@/renderer/components/item-list/helpers/use-list-hotkeys';\nimport { ItemControls, ItemListHandle } from '/@/renderer/components/item-list/types';\nimport { animationProps } from '/@/shared/components/animations/animation-props';\nimport { useElementSize } from '/@/shared/hooks/use-element-size';\nimport { useFocusWithin } from '/@/shared/hooks/use-focus-within';\nimport { useMergedRef } from '/@/shared/hooks/use-merged-ref';\nimport { LibraryItem } from '/@/shared/types/domain-types';\n\ninterface VirtualizedGridListProps {\n    _tableMetaVersion: number; // Used to trigger rerenders via React.memo comparison\n    controls: ItemControls;\n    currentPage?: number;\n    dataVersion?: number;\n    enableDrag?: boolean;\n    enableExpansion: boolean;\n    enableMultiSelect: boolean;\n    enableSelection: boolean;\n    gap: 'lg' | 'md' | 'sm' | 'xl' | 'xs';\n    getItem?: (index: number) => ItemCardProps['data'];\n    height: number;\n    initialTop?: ItemGridListProps['initialTop'];\n    internalState: ItemListStateActions;\n    itemCount: number;\n    itemType: LibraryItem;\n    onRangeChanged?: ItemGridListProps['onRangeChanged'];\n    onScroll?: ItemGridListProps['onScroll'];\n    onScrollEnd?: ItemGridListProps['onScrollEnd'];\n    outerRef: RefObject<any>;\n    ref: RefObject<FixedSizeList<GridItemProps> | null>;\n    rows?: ItemCardProps['rows'];\n    size?: 'compact' | 'default' | 'large';\n    tableMetaRef: RefObject<null | {\n        columnCount: number;\n        itemHeight: number;\n        rowCount: number;\n    }>;\n    width: number;\n}\n\nconst VirtualizedGridList = React.memo(\n    ({\n        controls,\n        currentPage,\n        dataVersion,\n        enableDrag,\n        enableExpansion,\n        enableMultiSelect,\n        enableSelection,\n        gap,\n        getItem,\n        height,\n        initialTop,\n        internalState,\n        itemCount,\n        itemType,\n        onRangeChanged,\n        onScroll,\n        onScrollEnd,\n        outerRef,\n        ref,\n        rows,\n        size,\n        tableMetaRef,\n        width,\n    }: VirtualizedGridListProps) => {\n        const tableMeta = tableMetaRef.current;\n        const scrollEndTimeoutRef = useRef<NodeJS.Timeout | null>(null);\n        const isInitialScrollRef = useRef(true);\n        const initialScrollOffsetRef = useRef<null | number>(null);\n\n        const itemData: GridItemProps = useMemo(() => {\n            return {\n                columns: tableMeta?.columnCount || 0,\n                controls,\n                dataVersion,\n                enableDrag,\n                enableExpansion,\n                enableMultiSelect,\n                enableSelection,\n                gap,\n                getItem,\n                internalState,\n                itemCount,\n                itemType,\n                rows,\n                size,\n                tableMeta,\n            };\n        }, [\n            tableMeta,\n            controls,\n            rows,\n            getItem,\n            itemCount,\n            dataVersion,\n            enableDrag,\n            enableExpansion,\n            enableMultiSelect,\n            enableSelection,\n            gap,\n            internalState,\n            itemType,\n            size,\n        ]);\n\n        const handleOnScroll = useCallback(\n            ({ scrollDirection, scrollOffset }: ListOnScrollProps) => {\n                onScroll?.(scrollOffset, scrollDirection === 'forward' ? 'down' : 'up');\n\n                if (isInitialScrollRef.current) {\n                    if (initialScrollOffsetRef.current === null) {\n                        initialScrollOffsetRef.current = scrollOffset;\n                        return;\n                    } else if (Math.abs(initialScrollOffsetRef.current - scrollOffset) < 1) {\n                        return;\n                    }\n                    isInitialScrollRef.current = false;\n                }\n\n                if (scrollEndTimeoutRef.current) {\n                    clearTimeout(scrollEndTimeoutRef.current);\n                }\n\n                scrollEndTimeoutRef.current = setTimeout(() => {\n                    onScrollEnd?.(scrollOffset, scrollDirection === 'forward' ? 'down' : 'up');\n                    scrollEndTimeoutRef.current = null;\n                }, 150);\n            },\n            [onScroll, onScrollEnd],\n        );\n\n        useEffect(() => {\n            return () => {\n                if (scrollEndTimeoutRef.current) {\n                    clearTimeout(scrollEndTimeoutRef.current);\n                }\n            };\n        }, []);\n\n        const handleOnItemsRendered = useCallback(\n            (items: ListOnItemsRenderedProps) => {\n                const columnCount = tableMetaRef.current?.columnCount || 0;\n                onRangeChanged?.({\n                    startIndex: items.visibleStartIndex * columnCount,\n                    stopIndex: items.visibleStopIndex * columnCount,\n                });\n            },\n            [onRangeChanged, tableMetaRef],\n        );\n\n        useEffect(() => {\n            isInitialScrollRef.current = true;\n            initialScrollOffsetRef.current = null;\n        }, [initialTop]);\n\n        if (!tableMeta) {\n            return null;\n        }\n\n        const calculateInitialScrollOffset = (): number => {\n            // When page changes, always start at top (ignore initialTop)\n            if (currentPage !== undefined) {\n                if (currentPage === 0 && initialTop) {\n                    if (initialTop.type === 'offset') {\n                        return initialTop.to;\n                    }\n                    const columnCount = tableMeta?.columnCount || 1;\n                    const itemHeight = tableMeta?.itemHeight || 0;\n                    const rowIndex = Math.floor(initialTop.to / columnCount);\n                    return rowIndex * itemHeight;\n                }\n                return 0;\n            }\n\n            if (!initialTop) return 0;\n\n            if (initialTop.type === 'offset') {\n                return initialTop.to;\n            }\n\n            const columnCount = tableMeta?.columnCount || 1;\n            const itemHeight = tableMeta?.itemHeight || 0;\n            const rowIndex = Math.floor(initialTop.to / columnCount);\n            return rowIndex * itemHeight;\n        };\n\n        return (\n            <FixedSizeList\n                height={height}\n                initialScrollOffset={calculateInitialScrollOffset()}\n                itemCount={tableMeta.rowCount || 0}\n                itemData={itemData}\n                itemSize={tableMeta.itemHeight || 0}\n                onItemsRendered={handleOnItemsRendered}\n                onScroll={handleOnScroll}\n                outerRef={outerRef}\n                ref={ref}\n                width={width}\n            >\n                {ListComponent}\n            </FixedSizeList>\n        );\n    },\n);\n\nVirtualizedGridList.displayName = 'VirtualizedGridList';\n\nconst createThrottledSetTableMeta = (\n    itemsPerRow?: number,\n    rowsCount?: number,\n    size?: 'compact' | 'default' | 'large',\n) => {\n    return throttle((width: number, dataLength: number, setTableMeta: (meta: any) => void) => {\n        const isSm = width >= 600;\n        const isMd = width >= 768;\n        const isLg = width >= 960;\n        const isXl = width >= 1200;\n        const is2xl = width >= 1440;\n        const is3xl = width >= 1920;\n        const is4xl = width >= 2560;\n\n        let dynamicItemsPerRow = 2;\n\n        if (is4xl) {\n            dynamicItemsPerRow = 10;\n        } else if (is3xl) {\n            dynamicItemsPerRow = 8;\n        } else if (is2xl) {\n            dynamicItemsPerRow = 7;\n        } else if (isXl) {\n            dynamicItemsPerRow = 6;\n        } else if (isLg) {\n            dynamicItemsPerRow = 5;\n        } else if (isMd) {\n            dynamicItemsPerRow = 4;\n        } else if (isSm) {\n            dynamicItemsPerRow = 3;\n        } else {\n            dynamicItemsPerRow = 2;\n        }\n\n        if (size === 'large') {\n            dynamicItemsPerRow = Math.round(dynamicItemsPerRow * 0.75);\n            if (dynamicItemsPerRow < 1) {\n                dynamicItemsPerRow = 1;\n            }\n        }\n\n        const setItemsPerRow = itemsPerRow || dynamicItemsPerRow;\n\n        const widthPerItem = Number(width) / setItemsPerRow;\n        // For compact size, don't include text lines in height calculation\n        // CompactItemCard has a different layout that doesn't need the extra space\n        const itemHeight =\n            size === 'compact'\n                ? widthPerItem\n                : widthPerItem + (rowsCount || getDataRowsCount()) * 26;\n\n        if (widthPerItem === 0) {\n            return;\n        }\n\n        setTableMeta({\n            columnCount: setItemsPerRow,\n            itemHeight,\n            rowCount: Math.ceil(dataLength / setItemsPerRow),\n        });\n    }, 200);\n};\n\nexport interface GridItemProps {\n    columns: number;\n    controls: ItemCardProps['controls'];\n    dataVersion?: number;\n    enableDrag?: boolean;\n    enableExpansion?: boolean;\n    enableMultiSelect: boolean;\n    enableSelection?: boolean;\n    gap: 'lg' | 'md' | 'sm' | 'xl' | 'xs';\n    getItem?: (index: number) => ItemCardProps['data'];\n    internalState: ItemListStateActions;\n    itemCount: number;\n    itemType: LibraryItem;\n    rows?: ItemCardProps['rows'];\n    size?: 'compact' | 'default' | 'large';\n    tableMeta: null | {\n        columnCount: number;\n        itemHeight: number;\n        rowCount: number;\n    };\n}\n\nexport interface ItemGridListProps {\n    currentPage?: number;\n    data: unknown[];\n    dataVersion?: number;\n    enableDrag?: boolean;\n    enableEntranceAnimation?: boolean;\n    enableExpansion?: boolean;\n    enableMultiSelect?: boolean;\n    enableSelection?: boolean;\n    enableSelectionDialog?: boolean;\n    gap?: 'lg' | 'md' | 'sm' | 'xl' | 'xs';\n    getItem?: (index: number) => ItemCardProps['data'];\n    getItemIndex?: (rowId: string) => number | undefined;\n    getRowId?: ((item: unknown) => string) | string;\n    initialTop?: {\n        to: number;\n        type: 'index' | 'offset';\n    };\n    itemCount?: number;\n    itemsPerRow?: number;\n    itemType: LibraryItem;\n    onRangeChanged?: (range: { startIndex: number; stopIndex: number }) => void;\n    onScroll?: (offset: number, direction: 'down' | 'up') => void;\n    onScrollEnd?: (offset: number, direction: 'down' | 'up') => void;\n    overrideControls?: Partial<ItemControls>;\n    ref?: Ref<ItemListHandle>;\n    rows?: ItemCardProps['rows'];\n    size?: 'compact' | 'default' | 'large';\n}\n\nconst BaseItemGridList = ({\n    currentPage,\n    data,\n    dataVersion,\n    enableDrag = true,\n    enableEntranceAnimation = true,\n    enableExpansion = false,\n    enableMultiSelect = false,\n    enableSelection = true,\n    gap = 'sm',\n    getItem,\n    getItemIndex,\n    getRowId,\n    initialTop,\n    itemCount,\n    itemsPerRow,\n    itemType,\n    onRangeChanged,\n    onScroll,\n    onScrollEnd,\n    overrideControls,\n    ref,\n    rows,\n    size = 'default',\n}: ItemGridListProps) => {\n    const rootRef = useRef(null);\n    const outerRef = useRef(null);\n    const listRef = useRef<FixedSizeList<GridItemProps>>(null);\n    const { ref: containerRef, width: containerWidth } = useElementSize();\n    const { focused, ref: containerFocusRef } = useFocusWithin();\n    const handleRef = useRef<ItemListHandle | null>(null);\n    const mergedContainerRef = useMergedRef(containerRef, rootRef, containerFocusRef);\n\n    const resolvedItemCount = itemCount ?? data.length;\n    const resolvedGetItem = useCallback<(index: number) => ItemCardProps['data']>(\n        (index: number) => {\n            return (getItem ? getItem(index) : (data as any[])[index]) as ItemCardProps['data'];\n        },\n        [data, getItem],\n    );\n\n    const getDataFn = useCallback(() => {\n        return data;\n    }, [data]);\n\n    const extractRowId = useMemo(() => createExtractRowId(getRowId), [getRowId]);\n\n    const internalState = useItemListState(getDataFn, extractRowId);\n\n    const [initialize, osInstance] = useOverlayScrollbars({\n        defer: false,\n        events: {\n            initialized(osInstance) {\n                const { viewport } = osInstance.elements();\n                viewport.style.overflowX = `var(--os-viewport-overflow-x)`;\n                viewport.style.overflowY = `var(--os-viewport-overflow-y)`;\n            },\n        },\n        options: {\n            overflow: { x: 'hidden', y: 'scroll' },\n            paddingAbsolute: true,\n            scrollbars: {\n                autoHide: 'leave',\n                autoHideDelay: 500,\n                pointers: ['mouse', 'pen', 'touch'],\n                theme: 'feishin-os-scrollbar',\n            },\n        },\n    });\n\n    const tableMetaRef = useRef<null | {\n        columnCount: number;\n        itemHeight: number;\n        rowCount: number;\n    }>(null);\n\n    const [tableMetaVersion, setTableMetaVersion] = useState(0);\n    const isOverlayScrollbarsInitialized = useRef(false);\n\n    useEffect(() => {\n        const { current: root } = rootRef;\n        const { current: outer } = outerRef;\n\n        if (!tableMetaRef.current || !root || !outer || isOverlayScrollbarsInitialized.current) {\n            return;\n        }\n\n        initialize({\n            elements: {\n                viewport: outer,\n            },\n            target: root,\n        });\n\n        isOverlayScrollbarsInitialized.current = true;\n    }, [initialize, tableMetaVersion]);\n\n    useEffect(() => {\n        return () => {\n            try {\n                const instance = osInstance();\n                const { current: root } = rootRef;\n                const { current: outer } = outerRef;\n\n                // Check if instance exists and elements are still connected to the DOM\n                if (instance) {\n                    // Check if elements are still in the document\n                    const rootInDocument = root && document.contains(root);\n                    const outerInDocument = outer && document.contains(outer);\n\n                    // Only destroy if elements are still in the document\n                    if (rootInDocument && outerInDocument) {\n                        instance.destroy();\n                    }\n                }\n            } catch {\n                // Ignore error\n            }\n        };\n    }, [osInstance]);\n\n    const throttledSetTableMeta = useMemo(() => {\n        return createThrottledSetTableMeta(itemsPerRow, rows?.length, size);\n    }, [itemsPerRow, rows?.length, size]);\n\n    useLayoutEffect(() => {\n        const { current: container } = containerRef;\n        if (!container) return;\n\n        throttledSetTableMeta(containerWidth, resolvedItemCount, (meta) => {\n            if (!meta) return;\n\n            const current = tableMetaRef.current;\n            if (\n                !current ||\n                current.columnCount !== meta.columnCount ||\n                current.itemHeight !== meta.itemHeight ||\n                current.rowCount !== meta.rowCount\n            ) {\n                tableMetaRef.current = meta;\n                container.style.setProperty('--grid-column-count', String(meta.columnCount));\n                container.style.setProperty('--grid-item-height', `${meta.itemHeight}px`);\n                container.style.setProperty('--grid-row-count', String(meta.rowCount));\n                setTableMetaVersion((v) => v + 1);\n            }\n        });\n    }, [containerWidth, resolvedItemCount, throttledSetTableMeta, containerRef]);\n\n    const controls = useDefaultItemListControls({\n        enableMultiSelect,\n        overrides: overrideControls,\n    });\n\n    const scrollToIndex = useCallback(\n        (\n            index: number,\n            options?: { align?: 'bottom' | 'center' | 'top'; behavior?: 'auto' | 'smooth' },\n        ) => {\n            if (!listRef.current || !tableMetaRef.current) return;\n            const row = Math.floor(index / tableMetaRef.current.columnCount);\n\n            // Map alignment options to react-window's alignment\n            let alignment: 'auto' | 'center' | 'end' | 'smart' | 'start' = 'smart';\n            if (options?.align === 'top') {\n                alignment = 'start';\n            } else if (options?.align === 'center') {\n                alignment = 'center';\n            } else if (options?.align === 'bottom') {\n                alignment = 'end';\n            }\n\n            listRef.current.scrollToItem(row, alignment);\n        },\n        [],\n    );\n\n    const scrollToOffset = useCallback((offset: number) => {\n        if (!listRef.current) return;\n        listRef.current.scrollTo(offset);\n    }, []);\n\n    // Handle keyboard navigation\n    const handleKeyDown = useCallback(\n        (e: React.KeyboardEvent<HTMLDivElement>) => {\n            if (!enableSelection || !tableMetaRef.current) return;\n            if (\n                e.key !== 'ArrowDown' &&\n                e.key !== 'ArrowUp' &&\n                e.key !== 'ArrowLeft' &&\n                e.key !== 'ArrowRight'\n            )\n                return;\n            e.preventDefault();\n            e.stopPropagation();\n\n            const selected = internalState.getSelected();\n            let currentIndex = -1;\n\n            if (selected.length > 0) {\n                const lastSelected = selected[selected.length - 1];\n                const lastRowId = internalState.extractRowId(lastSelected);\n                if (lastRowId) {\n                    currentIndex =\n                        getItemIndex?.(lastRowId) ??\n                        data.findIndex((d: any) => {\n                            const rowId = internalState.extractRowId(d);\n                            return rowId === lastRowId;\n                        });\n                }\n            }\n\n            // Calculate grid position\n            const currentRow =\n                currentIndex !== -1\n                    ? Math.floor(currentIndex / tableMetaRef.current.columnCount)\n                    : 0;\n            const currentCol =\n                currentIndex !== -1 ? currentIndex % tableMetaRef.current.columnCount : 0;\n            const totalRows = Math.ceil(resolvedItemCount / tableMetaRef.current.columnCount);\n\n            let newIndex = 0;\n            if (currentIndex !== -1) {\n                switch (e.key) {\n                    case 'ArrowDown': {\n                        // Move down one row\n                        const nextRow = currentRow + 1;\n                        if (nextRow < totalRows) {\n                            const nextRowStart = nextRow * tableMetaRef.current.columnCount;\n                            const nextRowEnd = Math.min(\n                                nextRowStart + tableMetaRef.current.columnCount - 1,\n                                resolvedItemCount - 1,\n                            );\n                            // Keep same column position, or use last item in row if column doesn't exist\n                            newIndex = Math.min(nextRowStart + currentCol, nextRowEnd);\n                        } else {\n                            newIndex = currentIndex;\n                        }\n                        break;\n                    }\n                    case 'ArrowLeft': {\n                        // Move left, wrap to previous row if at start of row\n                        if (currentCol > 0) {\n                            newIndex = currentIndex - 1;\n                        } else if (currentRow > 0) {\n                            // Wrap to end of previous row\n                            newIndex = Math.max(\n                                (currentRow - 1) * tableMetaRef.current.columnCount +\n                                    tableMetaRef.current.columnCount -\n                                    1,\n                                0,\n                            );\n                            newIndex = Math.min(newIndex, resolvedItemCount - 1);\n                        } else {\n                            newIndex = currentIndex;\n                        }\n                        break;\n                    }\n                    case 'ArrowRight': {\n                        // Move right, wrap to next row if at end of row\n                        if (\n                            currentCol < tableMetaRef.current.columnCount - 1 &&\n                            currentIndex < resolvedItemCount - 1\n                        ) {\n                            newIndex = currentIndex + 1;\n                        } else if (currentRow < totalRows - 1) {\n                            // Wrap to start of next row\n                            newIndex = Math.min(\n                                (currentRow + 1) * tableMetaRef.current.columnCount,\n                                resolvedItemCount - 1,\n                            );\n                        } else {\n                            newIndex = currentIndex;\n                        }\n                        break;\n                    }\n                    case 'ArrowUp': {\n                        // Move up one row\n                        const prevRow = currentRow - 1;\n                        if (prevRow >= 0) {\n                            const prevRowStart = prevRow * tableMetaRef.current.columnCount;\n                            const prevRowEnd = Math.min(\n                                prevRowStart + tableMetaRef.current.columnCount - 1,\n                                resolvedItemCount - 1,\n                            );\n                            // Keep same column position, or use last item in row if column doesn't exist\n                            newIndex = Math.min(prevRowStart + currentCol, prevRowEnd);\n                        } else {\n                            newIndex = currentIndex;\n                        }\n                        break;\n                    }\n                }\n            } else {\n                // No selection, start at first item\n                newIndex = 0;\n            }\n\n            const newItem: any = resolvedGetItem(newIndex);\n            if (!newItem) return;\n\n            // Handle Shift + Arrow for incremental range selection (matches shift+click behavior)\n            if (e.shiftKey) {\n                const selectedItems = internalState.getSelected();\n                const lastSelectedItem = selectedItems[selectedItems.length - 1];\n\n                if (lastSelectedItem) {\n                    // Find the indices of the last selected item and new item\n                    const lastRowId = internalState.extractRowId(lastSelectedItem);\n                    if (!lastRowId) return;\n\n                    const lastIndex =\n                        getItemIndex?.(lastRowId) ??\n                        data.findIndex((d: any) => {\n                            const rowId = internalState.extractRowId(d);\n                            return rowId === lastRowId;\n                        });\n\n                    if (lastIndex !== -1 && newIndex !== -1) {\n                        // Create range selection from last selected to new position\n                        const startIndex = Math.min(lastIndex, newIndex);\n                        const stopIndex = Math.max(lastIndex, newIndex);\n\n                        const rangeItems: ItemListStateItemWithRequiredProperties[] = [];\n                        for (let i = startIndex; i <= stopIndex; i++) {\n                            const rangeItem = resolvedGetItem(i);\n                            if (\n                                rangeItem &&\n                                typeof rangeItem === 'object' &&\n                                '_serverId' in rangeItem &&\n                                'itemType' in rangeItem &&\n                                internalState.extractRowId(rangeItem)\n                            ) {\n                                rangeItems.push(\n                                    rangeItem as unknown as ItemListStateItemWithRequiredProperties,\n                                );\n                            }\n                        }\n\n                        // Add range items to selection (matching shift+click behavior)\n                        const currentSelected = internalState.getSelected();\n                        const newSelected: ItemListStateItemWithRequiredProperties[] = [\n                            ...currentSelected.filter(\n                                (item): item is ItemListStateItemWithRequiredProperties =>\n                                    typeof item === 'object' && item !== null,\n                            ),\n                        ];\n                        rangeItems.forEach((rangeItem) => {\n                            const rangeRowId = internalState.extractRowId(rangeItem);\n                            if (\n                                rangeRowId &&\n                                !newSelected.some(\n                                    (selected) =>\n                                        internalState.extractRowId(selected) === rangeRowId,\n                                )\n                            ) {\n                                newSelected.push(rangeItem);\n                            }\n                        });\n\n                        // Ensure the last item in selection is the item at newIndex for incremental extension\n                        const newItemListItem = newItem as ItemListStateItemWithRequiredProperties;\n                        const newItemRowId = internalState.extractRowId(newItemListItem);\n                        if (newItemRowId) {\n                            // Remove the new item from its current position if it exists\n                            const filteredSelected = newSelected.filter(\n                                (item) => internalState.extractRowId(item) !== newItemRowId,\n                            );\n                            // Add it at the end so it becomes the last selected item\n                            filteredSelected.push(newItemListItem);\n                            internalState.setSelected(filteredSelected);\n                        }\n                    }\n                } else {\n                    // No previous selection, just select the new item\n                    const newItemListItem = newItem as ItemListStateItemWithRequiredProperties;\n                    if (internalState.extractRowId(newItemListItem)) {\n                        internalState.setSelected([newItemListItem]);\n                    }\n                }\n            } else {\n                // Without Shift: select only the new item\n                const newItemListItem = newItem as ItemListStateItemWithRequiredProperties;\n                if (internalState.extractRowId(newItemListItem)) {\n                    internalState.setSelected([newItemListItem]);\n                }\n            }\n\n            scrollToIndex(newIndex);\n        },\n        [\n            data,\n            enableSelection,\n            getItemIndex,\n            internalState,\n            resolvedGetItem,\n            resolvedItemCount,\n            scrollToIndex,\n        ],\n    );\n\n    const imperativeHandle: ItemListHandle = useMemo(() => {\n        return {\n            internalState,\n            scrollToIndex: (index: number, options?: { align?: 'bottom' | 'center' | 'top' }) => {\n                scrollToIndex(index, options);\n            },\n            scrollToOffset: (offset: number) => {\n                scrollToOffset(offset);\n            },\n        };\n    }, [internalState, scrollToIndex, scrollToOffset]);\n\n    useEffect(() => {\n        handleRef.current = imperativeHandle;\n    }, [imperativeHandle]);\n\n    useImperativeHandle(ref, () => imperativeHandle, [imperativeHandle]);\n\n    useListHotkeys({\n        controls,\n        focused,\n        internalState,\n        itemType,\n    });\n\n    return (\n        <motion.div\n            className={styles.itemGridContainer}\n            data-overlayscrollbars-initialize=\"\"\n            onKeyDown={handleKeyDown}\n            onMouseDown={(e) => (e.currentTarget as HTMLDivElement).focus()}\n            ref={mergedContainerRef}\n            tabIndex={0}\n            {...animationProps.fadeIn}\n            transition={{ duration: enableEntranceAnimation ? 0.5 : 0, ease: 'anticipate' }}\n        >\n            <AutoSizer>\n                {({ height, width }) => (\n                    <VirtualizedGridList\n                        _tableMetaVersion={tableMetaVersion}\n                        controls={controls}\n                        currentPage={currentPage}\n                        dataVersion={dataVersion}\n                        enableDrag={enableDrag}\n                        enableExpansion={enableExpansion}\n                        enableMultiSelect={enableMultiSelect}\n                        enableSelection={enableSelection}\n                        gap={gap}\n                        getItem={resolvedGetItem}\n                        height={height}\n                        initialTop={initialTop}\n                        internalState={internalState}\n                        itemCount={resolvedItemCount}\n                        itemType={itemType}\n                        onRangeChanged={onRangeChanged}\n                        onScroll={onScroll ?? (() => {})}\n                        onScrollEnd={onScrollEnd ?? (() => {})}\n                        outerRef={outerRef}\n                        ref={listRef}\n                        rows={rows}\n                        size={size}\n                        tableMetaRef={tableMetaRef}\n                        width={width}\n                    />\n                )}\n            </AutoSizer>\n        </motion.div>\n    );\n};\n\nconst ListComponent = memo((props: ListChildComponentProps<GridItemProps>) => {\n    const { index, style } = props;\n    const {\n        columns,\n        controls,\n        enableDrag,\n        enableMultiSelect,\n        gap,\n        getItem,\n        itemCount,\n        itemType,\n        rows,\n        size,\n    } = props.data;\n\n    const items: ReactNode[] = [];\n    const startIndex = index * columns;\n    const stopIndex = Math.min(itemCount - 1, startIndex + columns - 1);\n\n    const columnCountInRow = stopIndex - startIndex + 1;\n\n    let columnCountToAdd = 0;\n\n    if (columnCountInRow !== columns) {\n        columnCountToAdd = columns - columnCountInRow;\n    }\n\n    for (let i = startIndex; i <= stopIndex + columnCountToAdd; i += 1) {\n        if (i < itemCount) {\n            const item = getItem ? getItem(i) : undefined;\n            items.push(\n                <div\n                    className={clsx(styles.itemRow, styles[`gap-${gap}`])}\n                    key={`card-${i}-${index}`}\n                    style={{ '--columns': columns } as CSSProperties}\n                >\n                    <ItemCard\n                        controls={controls}\n                        data={item}\n                        enableDrag={enableDrag}\n                        enableExpansion={props.data.enableExpansion}\n                        enableMultiSelect={enableMultiSelect}\n                        imageAsLink={!enableMultiSelect}\n                        internalState={props.data.internalState}\n                        itemType={itemType}\n                        rows={rows}\n                        type={size === 'compact' ? 'compact' : 'poster'}\n                        withControls\n                    />\n                </div>,\n            );\n        } else {\n            items.push(null);\n        }\n    }\n\n    return (\n        <div className={styles.itemList} style={style}>\n            {items}\n        </div>\n    );\n});\n\nexport const ItemGridList = memo(BaseItemGridList);\n\nItemGridList.displayName = 'ItemGridList';\n"
  },
  {
    "path": "src/renderer/components/item-list/item-list-pagination/item-list-pagination.module.css",
    "content": ".container {\n    display: flex;\n    flex-direction: column;\n    gap: var(--theme-spacing-md);\n    width: 100%;\n    height: 100%;\n    min-height: 0;\n    overflow: hidden;\n}\n\n.pagination-container {\n    padding: var(--theme-spacing-sm) var(--theme-spacing-sm);\n    overflow: hidden;\n    border-top: 1px solid var(--theme-colors-border);\n}\n"
  },
  {
    "path": "src/renderer/components/item-list/item-list-pagination/item-list-pagination.tsx",
    "content": "import { Fragment, ReactNode } from 'react';\n\nimport styles from './item-list-pagination.module.css';\n\nimport { Pagination } from '/@/shared/components/pagination/pagination';\n\ninterface ItemListWithPaginationProps {\n    children: ReactNode;\n    currentPage: number;\n    itemsPerPage: number;\n    onChange: (e: number) => void;\n    pageCount: number;\n    totalItemCount: number;\n}\n\nexport const ItemListWithPagination = ({\n    children,\n    currentPage,\n    itemsPerPage,\n    onChange,\n    pageCount,\n    totalItemCount,\n}: ItemListWithPaginationProps) => {\n    return (\n        <div className={styles.container}>\n            <Fragment key={currentPage}>{children}</Fragment>\n            <div className={styles.paginationContainer}>\n                <Pagination\n                    itemsPerPage={itemsPerPage}\n                    onChange={onChange}\n                    total={pageCount}\n                    totalItemCount={totalItemCount}\n                    value={currentPage}\n                />\n            </div>\n        </div>\n    );\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/item-list-pagination/use-item-list-pagination.ts",
    "content": "import { useMemo } from 'react';\nimport { useSearchParams } from 'react-router';\n\nimport { parseIntParam, setSearchParam } from '/@/renderer/utils/query-params';\n\nexport const useItemListPagination = () => {\n    const [searchParams, setSearchParams] = useSearchParams();\n\n    const currentPage = useMemo(() => {\n        const value = parseIntParam(searchParams, 'currentPage');\n        return value ?? 0;\n    }, [searchParams]);\n\n    const onChange = (index: number) => {\n        setSearchParams((prev) => setSearchParam(prev, 'currentPage', index), { replace: true });\n    };\n\n    return { currentPage, onChange };\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/item-table-list/album-group-header.module.css",
    "content": ".container {\n    display: flex;\n    gap: var(--theme-spacing-sm);\n    width: 100%;\n    height: 100%;\n    padding: 0 var(--theme-spacing-xs);\n}\n\n.image-container {\n    position: relative;\n    box-sizing: border-box;\n    flex-shrink: 0;\n    height: 100%;\n    aspect-ratio: 1;\n    padding-top: calc(var(--theme-spacing-xs) * 0.5);\n}\n\n.info {\n    display: flex;\n    flex-direction: column;\n    min-width: 0;\n    padding: 0;\n    overflow: hidden;\n}\n\n.album-name {\n    font-size: var(--theme-font-size-sm);\n}\n\n.artist-name {\n    font-size: var(--theme-font-size-xs);\n    opacity: 0.7;\n}\n"
  },
  {
    "path": "src/renderer/components/item-list/item-table-list/album-group-header.tsx",
    "content": "import { ReactElement, useState } from 'react';\n\nimport imageColumnStyles from '../item-detail-list/columns/image-column.module.css';\nimport styles from './album-group-header.module.css';\nimport { TableItemSize } from './item-table-list';\n\nimport { ItemImage } from '/@/renderer/components/item-image/item-image';\nimport { PlayButton } from '/@/renderer/features/shared/components/play-button';\nimport {\n    LONG_PRESS_PLAY_BEHAVIOR,\n    PlayTooltip,\n} from '/@/renderer/features/shared/components/play-button-group';\nimport { usePlayButtonBehavior } from '/@/renderer/store';\nimport { LibraryItem, Song } from '/@/shared/types/domain-types';\nimport { Play } from '/@/shared/types/types';\n\ninterface AlbumGroupHeaderProps {\n    groupRowCount?: number;\n    onPlay?: (playType: Play) => void;\n    size?: 'compact' | 'large' | 'normal';\n    song: Song | undefined;\n}\n\nexport const AlbumGroupHeader = ({\n    groupRowCount,\n    onPlay,\n    size = 'normal',\n    song,\n}: AlbumGroupHeaderProps): ReactElement => {\n    const [isHovered, setIsHovered] = useState(false);\n    const playButtonBehavior = usePlayButtonBehavior();\n    const rowHeight = {\n        compact: TableItemSize.COMPACT,\n        large: TableItemSize.LARGE,\n        normal: TableItemSize.DEFAULT,\n    }[size];\n    const infoHeight = groupRowCount !== undefined ? groupRowCount * rowHeight : undefined;\n\n    return (\n        <div className={styles.container}>\n            <div\n                className={styles.imageContainer}\n                onMouseEnter={() => setIsHovered(true)}\n                onMouseLeave={() => setIsHovered(false)}\n            >\n                <ItemImage\n                    className={imageColumnStyles.compactImage}\n                    enableDebounce\n                    enableViewport={false}\n                    id={song?.imageId}\n                    itemType={LibraryItem.SONG}\n                    src={song?.imageUrl}\n                    type=\"table\"\n                />\n                {isHovered && onPlay && (\n                    <div className={imageColumnStyles.playButtonOverlay}>\n                        <PlayTooltip type={playButtonBehavior}>\n                            <PlayButton\n                                fill\n                                onClick={(e) => {\n                                    e.stopPropagation();\n                                    onPlay(playButtonBehavior);\n                                }}\n                                onLongPress={(e) => {\n                                    e.stopPropagation();\n                                    onPlay(LONG_PRESS_PLAY_BEHAVIOR[playButtonBehavior]);\n                                }}\n                            />\n                        </PlayTooltip>\n                    </div>\n                )}\n            </div>\n            <div className={styles.info} style={{ height: infoHeight }}>\n                <div className={styles.albumName}>{song?.album ?? ''}</div>\n                <div className={styles.artistName}>{song?.albumArtistName ?? ''}</div>\n            </div>\n        </div>\n    );\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/item-table-list/cell-component-factory.tsx",
    "content": "import React from 'react';\nimport { CellComponentProps } from 'react-window-v2';\n\nimport { TableItemProps } from '/@/renderer/components/item-list/item-table-list/item-table-list';\nimport { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';\nimport { LibraryItem } from '/@/shared/types/domain-types';\nimport { TableColumn } from '/@/shared/types/types';\n\nexport const createColumnCellComponent = (\n    columnType: TableColumn,\n    itemType: LibraryItem,\n): React.ComponentType<CellComponentProps<TableItemProps>> => {\n    return React.memo(\n        (props: CellComponentProps<TableItemProps>) => {\n            return <ItemTableListColumn {...props} columnType={columnType} itemType={itemType} />;\n        },\n        (prevProps, nextProps) => {\n            return (\n                prevProps.rowIndex === nextProps.rowIndex &&\n                prevProps.columnIndex === nextProps.columnIndex &&\n                prevProps.data === nextProps.data &&\n                prevProps.style === nextProps.style &&\n                prevProps.columns === nextProps.columns\n            );\n        },\n    );\n};\n\nexport const createColumnCellComponents = (\n    columns: TableColumn[],\n    itemType: LibraryItem,\n): Map<TableColumn, React.ComponentType<CellComponentProps<TableItemProps>>> => {\n    const componentMap = new Map<\n        TableColumn,\n        React.ComponentType<CellComponentProps<TableItemProps>>\n    >();\n\n    columns.forEach((columnType) => {\n        componentMap.set(columnType, createColumnCellComponent(columnType, itemType));\n    });\n\n    return componentMap;\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/item-table-list/columns/actions-column.tsx",
    "content": "import {\n    ItemTableListInnerColumn,\n    TableColumnContainer,\n} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';\nimport { ItemListItem } from '/@/renderer/components/item-list/types';\nimport { ActionIcon } from '/@/shared/components/action-icon/action-icon';\n\nexport const ActionsColumn = (props: ItemTableListInnerColumn) => {\n    const row: any = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];\n\n    const handleActionClick = (event: React.MouseEvent<HTMLButtonElement>) => {\n        event.stopPropagation();\n        event.preventDefault();\n        if (row !== undefined) {\n            const item = row as ItemListItem;\n            const rowId = props.internalState.extractRowId(item);\n            const index = rowId ? props.internalState.findItemIndex(rowId) : -1;\n            props.controls.onMore?.({\n                event,\n                index,\n                internalState: props.internalState,\n                item,\n                itemType: props.itemType,\n            });\n        }\n    };\n\n    const handleActionDoubleClick = (event: React.MouseEvent<HTMLButtonElement>) => {\n        event.stopPropagation();\n        event.preventDefault();\n    };\n\n    if (row !== undefined) {\n        return (\n            <TableColumnContainer {...props}>\n                <ActionIcon\n                    className=\"hover-only\"\n                    icon=\"ellipsisHorizontal\"\n                    iconProps={{\n                        color: 'muted',\n                        size: 'md',\n                    }}\n                    onClick={handleActionClick}\n                    onDoubleClick={handleActionDoubleClick}\n                    size=\"xs\"\n                    variant=\"subtle\"\n                />\n            </TableColumnContainer>\n        );\n    }\n\n    return <TableColumnContainer {...props}>&nbsp;</TableColumnContainer>;\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/item-table-list/columns/album-artists-column.module.css",
    "content": ".group {\n    gap: var(--theme-spacing-sm) var(--theme-spacing-xs);\n    overflow: hidden;\n}\n\n.group a {\n    cursor: pointer;\n}\n\n.artists-container {\n    display: -webkit-box;\n    overflow: hidden;\n    -webkit-line-clamp: 2;\n    -webkit-box-orient: vertical;\n    color: var(--theme-colors-foreground-muted);\n    user-select: none;\n}\n\n.artists-container.compact {\n    -webkit-line-clamp: 1;\n}\n\n.artists-container.large {\n    -webkit-line-clamp: 3;\n}\n"
  },
  {
    "path": "src/renderer/components/item-list/item-table-list/columns/album-artists-column.tsx",
    "content": "import clsx from 'clsx';\n\nimport styles from './album-artists-column.module.css';\n\nimport {\n    ColumnNullFallback,\n    ColumnSkeletonVariable,\n    ItemTableListInnerColumn,\n    TableColumnContainer,\n} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';\nimport { JoinedArtists } from '/@/renderer/features/albums/components/joined-artists';\nimport { Album, RelatedAlbumArtist, Song } from '/@/shared/types/domain-types';\n\nconst AlbumArtistsColumn = (props: ItemTableListInnerColumn) => {\n    const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];\n    const row: RelatedAlbumArtist[] | undefined = rowItem?.[props.columns[props.columnIndex].id];\n\n    const item = rowItem as Album | Song | undefined;\n    const albumArtistString = item && 'albumArtistName' in item ? item.albumArtistName : '';\n\n    if (Array.isArray(row)) {\n        return (\n            <TableColumnContainer {...props}>\n                <div\n                    className={clsx(styles.artistsContainer, {\n                        [styles.compact]: props.size === 'compact',\n                        [styles.large]: props.size === 'large',\n                    })}\n                >\n                    <JoinedArtists\n                        artistName={albumArtistString}\n                        artists={row}\n                        linkProps={{ fw: 400, isMuted: true }}\n                        rootTextProps={{\n                            className: clsx(styles.artistsContainer, {\n                                [styles.compact]: props.size === 'compact',\n                                [styles.large]: props.size === 'large',\n                            }),\n                            fw: 400,\n                            isMuted: true,\n                            size: 'sm',\n                        }}\n                    />\n                </div>\n            </TableColumnContainer>\n        );\n    }\n\n    if (row === null) {\n        return <ColumnNullFallback {...props} />;\n    }\n\n    return <ColumnSkeletonVariable {...props} />;\n};\n\nexport { AlbumArtistsColumn };\n"
  },
  {
    "path": "src/renderer/components/item-list/item-table-list/columns/album-column.module.css",
    "content": ".album-container {\n    display: -webkit-box;\n    overflow: hidden;\n    -webkit-line-clamp: 2;\n    -webkit-box-orient: vertical;\n    color: var(--theme-colors-foreground-muted);\n    user-select: none;\n}\n\n.album-link {\n    display: inline;\n    width: max-content;\n    max-width: 100%;\n}\n\n.album-container.compact {\n    -webkit-line-clamp: 1;\n}\n\n.album-container.large {\n    -webkit-line-clamp: 3;\n}\n"
  },
  {
    "path": "src/renderer/components/item-list/item-table-list/columns/album-column.tsx",
    "content": "import clsx from 'clsx';\nimport { useMemo } from 'react';\nimport { generatePath, Link } from 'react-router';\n\nimport styles from './album-column.module.css';\n\nimport {\n    ColumnNullFallback,\n    ColumnSkeletonVariable,\n    ItemTableListInnerColumn,\n    TableColumnContainer,\n} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';\nimport { AppRoute } from '/@/renderer/router/routes';\nimport { Text } from '/@/shared/components/text/text';\nimport { Song } from '/@/shared/types/domain-types';\n\nconst AlbumColumn = (props: ItemTableListInnerColumn) => {\n    const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];\n    const row: null | string | undefined = rowItem?.[props.columns[props.columnIndex].id];\n\n    const song = rowItem as Song | undefined;\n    const albumId = song?.albumId;\n\n    const albumPath = useMemo(() => {\n        if (!albumId) return null;\n        return generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, { albumId });\n    }, [albumId]);\n\n    if (typeof row === 'string') {\n        if (albumId && albumPath) {\n            return (\n                <TableColumnContainer {...props}>\n                    <div\n                        className={clsx(styles.albumContainer, {\n                            [styles.compact]: props.size === 'compact',\n                            [styles.large]: props.size === 'large',\n                        })}\n                    >\n                        <Text\n                            className={styles.albumLink}\n                            component={Link}\n                            isLink\n                            isMuted\n                            isNoSelect\n                            state={{ item: song }}\n                            to={albumPath}\n                        >\n                            {row}\n                        </Text>\n                    </div>\n                </TableColumnContainer>\n            );\n        }\n\n        return (\n            <TableColumnContainer {...props}>\n                <Text\n                    className={clsx(styles.albumContainer, {\n                        [styles.compact]: props.size === 'compact',\n                        [styles.large]: props.size === 'large',\n                    })}\n                    isMuted\n                    isNoSelect\n                >\n                    {row}\n                </Text>\n            </TableColumnContainer>\n        );\n    }\n\n    if (row === null) {\n        return <ColumnNullFallback {...props} />;\n    }\n\n    return <ColumnSkeletonVariable {...props} />;\n};\n\nexport { AlbumColumn };\n"
  },
  {
    "path": "src/renderer/components/item-list/item-table-list/columns/album-group-column.tsx",
    "content": "import { useCallback } from 'react';\n\nimport { AlbumGroupHeader } from '/@/renderer/components/item-list/item-table-list/album-group-header';\nimport {\n    isLastInAlbumGroup,\n    ItemTableListInnerColumn,\n    TableColumnContainer,\n} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';\nimport { Song } from '/@/shared/types/domain-types';\nimport { Play } from '/@/shared/types/types';\n\nexport const AlbumGroupColumn = (props: ItemTableListInnerColumn) => {\n    const firstDataRow = props.enableHeader ? 1 : 0;\n    const item = props.getRowItem?.(props.rowIndex) as null | Song | undefined;\n\n    const handlePlay = useCallback(\n        (playType: Play) => {\n            if (!item || !props.controls?.onDoubleClick) return;\n\n            const isHeaderEnabled = !!props.enableHeader;\n            const index = isHeaderEnabled ? props.rowIndex - 1 : props.rowIndex;\n\n            props.controls.onDoubleClick({\n                event: null,\n                index,\n                internalState: (props as any).internalState,\n                item,\n                itemType: props.itemType,\n                meta: { playType },\n            });\n        },\n        [item, props],\n    );\n\n    if (!item?.album) {\n        return <div style={props.style} />;\n    }\n\n    // Check if this is the first row of a new album group (by album name)\n    let isFirstInGroup = true;\n    if (props.rowIndex > firstDataRow) {\n        const prevItem = props.getRowItem?.(props.rowIndex - 1) as null | Song | undefined;\n        // If prevItem is undefined (not loaded yet), assume same group to avoid duplicates\n        if (prevItem === undefined || prevItem?.album === item.album) {\n            isFirstInGroup = false;\n        }\n    }\n\n    if (!isFirstInGroup) {\n        // For non-first rows, add border-bottom on the last row of the group\n        const needsBorder =\n            props.enableHorizontalBorders &&\n            isLastInAlbumGroup(\n                props.rowIndex,\n                props.getRowItem,\n                !!props.enableHeader,\n                props.data.length,\n            );\n\n        return (\n            <div\n                style={{\n                    ...props.style,\n                    ...(needsBorder\n                        ? { borderBottom: '1px solid var(--theme-colors-border)' }\n                        : {}),\n                }}\n            />\n        );\n    }\n\n    let groupRowCount = 1;\n    const totalDataRows = props.data.length + firstDataRow;\n    for (let idx = props.rowIndex + 1; idx < totalDataRows; idx++) {\n        const nextItem = props.getRowItem?.(idx) as null | Song | undefined;\n        if (!nextItem || nextItem.album !== item.album) break;\n        groupRowCount++;\n    }\n\n    return (\n        <TableColumnContainer {...props} enableAlternateRowColors={false}>\n            <AlbumGroupHeader\n                groupRowCount={groupRowCount}\n                onPlay={handlePlay}\n                size={props.size === 'default' ? 'normal' : props.size}\n                song={item}\n            />\n        </TableColumnContainer>\n    );\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/item-table-list/columns/artists-column.module.css",
    "content": ".group {\n    gap: var(--theme-spacing-sm) var(--theme-spacing-xs);\n    overflow: hidden;\n}\n\n.group a {\n    cursor: pointer;\n}\n\n.artists-container {\n    display: -webkit-box;\n    overflow: hidden;\n    -webkit-line-clamp: 2;\n    -webkit-box-orient: vertical;\n    color: var(--theme-colors-foreground-muted);\n}\n\n.artists-container.compact {\n    -webkit-line-clamp: 1;\n}\n\n.artists-container.large {\n    -webkit-line-clamp: 3;\n}\n"
  },
  {
    "path": "src/renderer/components/item-list/item-table-list/columns/artists-column.tsx",
    "content": "import clsx from 'clsx';\nimport { Fragment, useMemo } from 'react';\nimport { generatePath, Link } from 'react-router';\n\nimport styles from './album-artists-column.module.css';\n\nimport {\n    ColumnNullFallback,\n    ColumnSkeletonVariable,\n    ItemTableListInnerColumn,\n    TableColumnContainer,\n} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';\nimport { JoinedArtists } from '/@/renderer/features/albums/components/joined-artists';\nimport { AppRoute } from '/@/renderer/router/routes';\nimport { Text } from '/@/shared/components/text/text';\nimport { LibraryItem, RelatedAlbumArtist, Song } from '/@/shared/types/domain-types';\n\nconst AlbumArtistsColumn = (props: ItemTableListInnerColumn) => {\n    const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];\n    const row: RelatedAlbumArtist[] | undefined = (rowItem as any)?.[\n        props.columns[props.columnIndex].id\n    ];\n\n    const artists = useMemo(() => {\n        if (!row) return [];\n        return row.map((artist) => {\n            const path = generatePath(AppRoute.LIBRARY_ARTISTS_DETAIL, {\n                artistId: artist.id,\n            });\n            return { ...artist, path };\n        });\n    }, [row]);\n\n    if (Array.isArray(row)) {\n        return (\n            <TableColumnContainer {...props}>\n                <div\n                    className={clsx(styles.artistsContainer, {\n                        [styles.compact]: props.size === 'compact',\n                        [styles.large]: props.size === 'large',\n                    })}\n                >\n                    {artists.map((artist, index) => (\n                        <Fragment key={artist.id}>\n                            <Text\n                                component={Link}\n                                isLink\n                                isMuted\n                                isNoSelect\n                                state={{ item: artist }}\n                                to={artist.path}\n                            >\n                                {artist.name}\n                            </Text>\n                            {index < artists.length - 1 && ', '}\n                        </Fragment>\n                    ))}\n                </div>\n            </TableColumnContainer>\n        );\n    }\n\n    if (row === null) {\n        return <ColumnNullFallback {...props} />;\n    }\n\n    return <ColumnSkeletonVariable {...props} />;\n};\n\nconst SongArtistsColumn = (props: ItemTableListInnerColumn) => {\n    const row: Song | undefined = (props.getRowItem?.(props.rowIndex) ??\n        (props.data as any[])[props.rowIndex]) as Song | undefined;\n\n    if (row) {\n        return (\n            <TableColumnContainer {...props}>\n                <div\n                    className={clsx(styles.artistsContainer, {\n                        [styles.compact]: props.size === 'compact',\n                        [styles.large]: props.size === 'large',\n                    })}\n                >\n                    <JoinedArtists\n                        artistName={row.artistName}\n                        artists={row.artists}\n                        linkProps={{ fw: 400, isMuted: true }}\n                        rootTextProps={{ fw: 400, isMuted: true, size: 'sm' }}\n                    />\n                </div>\n            </TableColumnContainer>\n        );\n    }\n\n    if (row === null) {\n        return <ColumnNullFallback {...props} />;\n    }\n\n    return <ColumnSkeletonVariable {...props} />;\n};\n\nconst BaseArtistsColumn = (props: ItemTableListInnerColumn) => {\n    const { itemType } = props;\n\n    switch (itemType) {\n        case LibraryItem.ALBUM:\n            return <AlbumArtistsColumn {...props} />;\n        default:\n            return <SongArtistsColumn {...props} />;\n    }\n};\n\nexport { BaseArtistsColumn as ArtistsColumn };\n"
  },
  {
    "path": "src/renderer/components/item-list/item-table-list/columns/composer-column.module.css",
    "content": ".composers-container {\n    display: -webkit-box;\n    overflow: hidden;\n    -webkit-line-clamp: 2;\n    -webkit-box-orient: vertical;\n    color: var(--theme-colors-foreground-muted);\n    user-select: none;\n}\n\n.composers-container.compact {\n    -webkit-line-clamp: 1;\n}\n\n.composers-container.large {\n    -webkit-line-clamp: 3;\n}\n"
  },
  {
    "path": "src/renderer/components/item-list/item-table-list/columns/composer-column.tsx",
    "content": "import clsx from 'clsx';\n\nimport styles from './composer-column.module.css';\n\nimport {\n    ColumnNullFallback,\n    ColumnSkeletonVariable,\n    ItemTableListInnerColumn,\n    TableColumnContainer,\n} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';\nimport { JoinedArtists } from '/@/renderer/features/albums/components/joined-artists';\nimport { Album, RelatedArtist, Song } from '/@/shared/types/domain-types';\n\nexport const ComposerColumn = (props: ItemTableListInnerColumn) => {\n    const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];\n    const item = rowItem as Album | Song | undefined;\n\n    const composers = item?.participants?.composer || [];\n\n    if (composers && Array.isArray(composers) && composers.length > 0) {\n        return (\n            <TableColumnContainer {...props}>\n                <div\n                    className={clsx(styles.composersContainer, {\n                        [styles.compact]: props.size === 'compact',\n                        [styles.large]: props.size === 'large',\n                    })}\n                >\n                    <JoinedArtists\n                        artistName=\"\"\n                        artists={composers as RelatedArtist[]}\n                        linkProps={{ fw: 400, isMuted: true }}\n                        rootTextProps={{ fw: 400, isMuted: true, size: 'sm' }}\n                    />\n                </div>\n            </TableColumnContainer>\n        );\n    }\n\n    if (composers?.length === 0 || item === null || item === undefined) {\n        return <ColumnNullFallback {...props} />;\n    }\n\n    return <ColumnSkeletonVariable {...props} />;\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/item-table-list/columns/count-column.tsx",
    "content": "import {\n    ColumnNullFallback,\n    ColumnSkeletonFixed,\n    ItemTableListInnerColumn,\n    TableColumnTextContainer,\n} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';\n\nexport const CountColumn = (props: ItemTableListInnerColumn) => {\n    const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];\n    const row: number | undefined = (rowItem as any)?.[props.columns[props.columnIndex].id];\n\n    if (typeof row === 'number') {\n        return (\n            <TableColumnTextContainer {...props}>{row.toLocaleString()}</TableColumnTextContainer>\n        );\n    }\n\n    if (row === null) {\n        return <ColumnNullFallback {...props} />;\n    }\n\n    return <ColumnSkeletonFixed {...props} />;\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/item-table-list/columns/date-column.tsx",
    "content": "import { useMemo } from 'react';\n\nimport {\n    ColumnNullFallback,\n    ColumnSkeletonFixed,\n    ItemTableListInnerColumn,\n    TableColumnTextContainer,\n} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';\nimport {\n    formatDateAbsolute,\n    formatDateAbsoluteUTC,\n    formatDateRelative,\n    formatHrDateTime,\n} from '/@/renderer/utils/format';\nimport { SEPARATOR_STRING } from '/@/shared/api/utils';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { Text } from '/@/shared/components/text/text';\nimport { Tooltip } from '/@/shared/components/tooltip/tooltip';\nimport { TableColumn } from '/@/shared/types/types';\n\nconst getDateTooltipLabel = (utcString: string) => {\n    return (\n        <Stack gap=\"xs\" justify=\"center\">\n            <Text size=\"md\" ta=\"center\">\n                {formatHrDateTime(utcString)}\n            </Text>\n            <Text isMuted size=\"sm\" ta=\"center\">\n                {utcString}\n            </Text>\n        </Stack>\n    );\n};\n\nconst DateColumnBase = (props: ItemTableListInnerColumn) => {\n    const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];\n    const row: string | undefined = (rowItem as any)?.[props.columns[props.columnIndex].id];\n\n    const { formattedDate, tooltipLabel } = useMemo(() => {\n        if (typeof row === 'string' && row) {\n            return {\n                formattedDate: formatDateAbsolute(row),\n                tooltipLabel: getDateTooltipLabel(row),\n            };\n        }\n        return { formattedDate: null, tooltipLabel: null };\n    }, [row]);\n\n    if (typeof row === 'string' && row) {\n        return (\n            <TableColumnTextContainer {...props}>\n                <Tooltip label={tooltipLabel} multiline={false}>\n                    <span>{formattedDate}</span>\n                </Tooltip>\n            </TableColumnTextContainer>\n        );\n    }\n\n    if (row === null) {\n        return <ColumnNullFallback {...props} />;\n    }\n\n    return <ColumnSkeletonFixed {...props} />;\n};\n\nexport const DateColumn = DateColumnBase;\n\nconst AbsoluteDateColumnBase = (props: ItemTableListInnerColumn) => {\n    const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];\n    const row: string | undefined = (rowItem as any)?.[props.columns[props.columnIndex].id];\n\n    const releaseDateContent = useMemo(() => {\n        if (props.type === TableColumn.RELEASE_DATE) {\n            const item = rowItem as any;\n            if (item && 'releaseDate' in item && item.releaseDate) {\n                const releaseDate = item.releaseDate;\n                const originalDate =\n                    'originalDate' in item && item.originalDate && item.originalDate !== releaseDate\n                        ? item.originalDate\n                        : null;\n\n                if (originalDate) {\n                    const formattedOriginalDate = formatDateAbsoluteUTC(originalDate);\n                    const formattedReleaseDate = formatDateAbsoluteUTC(releaseDate);\n                    const displayText = `${formattedOriginalDate}${SEPARATOR_STRING}${formattedReleaseDate}`;\n\n                    return {\n                        displayText,\n                        tooltipLabel: getDateTooltipLabel(releaseDate),\n                    };\n                }\n\n                if (typeof releaseDate === 'string' && releaseDate) {\n                    return {\n                        displayText: formatDateAbsoluteUTC(releaseDate),\n                        tooltipLabel: getDateTooltipLabel(releaseDate),\n                    };\n                }\n            }\n        }\n        return null;\n    }, [props.type, rowItem]);\n\n    const { formattedDate, tooltipLabel } = useMemo(() => {\n        if (typeof row === 'string' && row) {\n            return {\n                formattedDate: formatDateAbsoluteUTC(row),\n                tooltipLabel: getDateTooltipLabel(row),\n            };\n        }\n        return { formattedDate: null, tooltipLabel: null };\n    }, [row]);\n\n    if (props.type === TableColumn.RELEASE_DATE) {\n        if (releaseDateContent) {\n            return (\n                <TableColumnTextContainer {...props}>\n                    <Tooltip label={releaseDateContent.tooltipLabel} multiline={false}>\n                        <span>{releaseDateContent.displayText}</span>\n                    </Tooltip>\n                </TableColumnTextContainer>\n            );\n        }\n\n        if (row === null) {\n            return <ColumnNullFallback {...props} />;\n        }\n\n        return <ColumnSkeletonFixed {...props} />;\n    }\n\n    if (typeof row === 'string' && row) {\n        return (\n            <TableColumnTextContainer {...props}>\n                <Tooltip label={tooltipLabel} multiline={false}>\n                    <span>{formattedDate}</span>\n                </Tooltip>\n            </TableColumnTextContainer>\n        );\n    }\n\n    if (row === null) {\n        return <ColumnNullFallback {...props} />;\n    }\n\n    return <ColumnSkeletonFixed {...props} />;\n};\n\nexport const AbsoluteDateColumn = AbsoluteDateColumnBase;\n\nconst RelativeDateColumnBase = (props: ItemTableListInnerColumn) => {\n    const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];\n    const row: string | undefined = (rowItem as any)?.[props.columns[props.columnIndex].id];\n\n    const { formattedDate, tooltipLabel } = useMemo(() => {\n        if (typeof row === 'string') {\n            return {\n                formattedDate: formatDateRelative(row),\n                tooltipLabel: getDateTooltipLabel(row),\n            };\n        }\n        return { formattedDate: null, tooltipLabel: null };\n    }, [row]);\n\n    if (typeof row === 'string') {\n        return (\n            <TableColumnTextContainer {...props}>\n                <Tooltip label={tooltipLabel} multiline={false}>\n                    <span>{formattedDate}</span>\n                </Tooltip>\n            </TableColumnTextContainer>\n        );\n    }\n\n    if (row === null) {\n        return <ColumnNullFallback {...props} />;\n    }\n\n    return <ColumnSkeletonFixed {...props} />;\n};\n\nexport const RelativeDateColumn = RelativeDateColumnBase;\n"
  },
  {
    "path": "src/renderer/components/item-list/item-table-list/columns/default-column.tsx",
    "content": "import {\n    ColumnNullFallback,\n    ColumnSkeletonFixed,\n    ItemTableListInnerColumn,\n    TableColumnTextContainer,\n} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';\n\nexport const DefaultColumn = (props: ItemTableListInnerColumn) => {\n    const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];\n    const row: any | undefined = (rowItem as any)?.[props.columns[props.columnIndex].id];\n\n    if (typeof row === 'string') {\n        return <TableColumnTextContainer {...props}>{row}</TableColumnTextContainer>;\n    }\n\n    if (row === null) {\n        return <ColumnNullFallback {...props} />;\n    }\n\n    return <ColumnSkeletonFixed {...props} />;\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/item-table-list/columns/duration-column.tsx",
    "content": "import formatDuration from 'format-duration';\nimport { useMemo } from 'react';\n\nimport {\n    ColumnNullFallback,\n    ColumnSkeletonFixed,\n    ItemTableListInnerColumn,\n    TableColumnTextContainer,\n} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';\n\nconst DurationColumnBase = (props: ItemTableListInnerColumn) => {\n    const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];\n    const row: number | undefined = (rowItem as any)?.[props.columns[props.columnIndex].id];\n\n    const formattedDuration = useMemo(() => {\n        return typeof row === 'number' ? formatDuration(row) : null;\n    }, [row]);\n\n    if (typeof row === 'number') {\n        return <TableColumnTextContainer {...props}>{formattedDuration}</TableColumnTextContainer>;\n    }\n\n    if (row === null) {\n        return <ColumnNullFallback {...props} />;\n    }\n\n    return <ColumnSkeletonFixed {...props} />;\n};\n\nexport const DurationColumn = DurationColumnBase;\n"
  },
  {
    "path": "src/renderer/components/item-list/item-table-list/columns/favorite-column.tsx",
    "content": "import {\n    ItemTableListInnerColumn,\n    TableColumnContainer,\n} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';\nimport { ItemListItem } from '/@/renderer/components/item-list/types';\nimport { useIsMutatingCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation';\nimport { useIsMutatingDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation';\nimport { ActionIcon } from '/@/shared/components/action-icon/action-icon';\n\nexport const FavoriteColumn = (props: ItemTableListInnerColumn) => {\n    const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];\n    const row: boolean | undefined = rowItem?.[props.columns[props.columnIndex].id];\n\n    const isMutatingCreateFavorite = useIsMutatingCreateFavorite();\n    const isMutatingDeleteFavorite = useIsMutatingDeleteFavorite();\n    const isMutatingFavorite = isMutatingCreateFavorite || isMutatingDeleteFavorite;\n\n    if (typeof row === 'boolean') {\n        return (\n            <TableColumnContainer {...props}>\n                <ActionIcon\n                    className={row ? undefined : 'hover-only'}\n                    disabled={isMutatingFavorite}\n                    icon=\"favorite\"\n                    iconProps={{\n                        color: row ? 'primary' : 'muted',\n                        fill: row ? 'primary' : undefined,\n                        size: 'md',\n                    }}\n                    onClick={(event) => {\n                        event.stopPropagation();\n                        event.preventDefault();\n                        const item = rowItem as ItemListItem;\n                        const rowId = props.internalState.extractRowId(item);\n                        const index = rowId ? props.internalState.findItemIndex(rowId) : -1;\n                        props.controls.onFavorite?.({\n                            event,\n                            favorite: !row,\n                            index,\n                            internalState: props.internalState,\n                            item,\n                            itemType: props.itemType,\n                        });\n                    }}\n                    onDoubleClick={(event) => {\n                        event.stopPropagation();\n                        event.preventDefault();\n                    }}\n                    size=\"xs\"\n                    variant=\"subtle\"\n                />\n            </TableColumnContainer>\n        );\n    }\n\n    return <TableColumnContainer {...props}>&nbsp;</TableColumnContainer>;\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/item-table-list/columns/genre-badge-column.module.css",
    "content": ".group {\n    gap: var(--theme-spacing-sm) var(--theme-spacing-xs);\n    padding: var(--theme-spacing-xs) 0;\n    overflow: hidden;\n}\n\n.group a {\n    cursor: pointer;\n    user-select: none;\n}\n"
  },
  {
    "path": "src/renderer/components/item-list/item-table-list/columns/genre-badge-column.tsx",
    "content": "import { useMemo } from 'react';\nimport { generatePath, Link } from 'react-router';\n\nimport styles from './genre-badge-column.module.css';\n\nimport {\n    ColumnNullFallback,\n    ColumnSkeletonVariable,\n    ItemTableListInnerColumn,\n    TableColumnContainer,\n} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';\nimport { AppRoute } from '/@/renderer/router/routes';\nimport { Badge } from '/@/shared/components/badge/badge';\nimport { Group } from '/@/shared/components/group/group';\nimport { Genre } from '/@/shared/types/domain-types';\nimport { stringToColor } from '/@/shared/utils/string-to-color';\n\nconst MAX_GENRES = 4;\n\nconst GenreBadgeColumn = (props: ItemTableListInnerColumn) => {\n    const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];\n    const row: Genre[] | undefined = (rowItem as any)?.genres;\n\n    const genres = useMemo(() => {\n        if (!row) return [];\n        return row.map((genre) => {\n            const { color, isLight } = stringToColor(genre.name);\n            const path = generatePath(AppRoute.LIBRARY_GENRES_DETAIL, { genreId: genre.id });\n            return { ...genre, color, isLight, path };\n        });\n    }, [row]);\n\n    if (Array.isArray(row)) {\n        return (\n            <TableColumnContainer {...props}>\n                <Group className={styles.group} wrap=\"wrap\">\n                    {genres.slice(0, MAX_GENRES).map((genre) => (\n                        <Badge\n                            component={Link}\n                            key={genre.id}\n                            state={{ item: genre }}\n                            style={{\n                                backgroundColor: genre.color,\n                                color: genre.isLight ? 'black' : 'white',\n                            }}\n                            to={genre.path}\n                        >\n                            {genre.name}\n                        </Badge>\n                    ))}\n                </Group>\n            </TableColumnContainer>\n        );\n    }\n\n    if (row === null) {\n        return <ColumnNullFallback {...props} />;\n    }\n\n    return <ColumnSkeletonVariable {...props} />;\n};\n\nexport { GenreBadgeColumn };\n"
  },
  {
    "path": "src/renderer/components/item-list/item-table-list/columns/genre-column.module.css",
    "content": ".genres-container {\n    display: -webkit-box;\n    overflow: hidden;\n    -webkit-line-clamp: 2;\n    color: var(--theme-colors-foreground-muted);\n    -webkit-box-orient: vertical;\n    user-select: none;\n}\n\n.genres-container.compact {\n    -webkit-line-clamp: 1;\n}\n\n.genres-container.large {\n    -webkit-line-clamp: 3;\n}\n"
  },
  {
    "path": "src/renderer/components/item-list/item-table-list/columns/genre-column.tsx",
    "content": "import clsx from 'clsx';\nimport { Fragment, useMemo } from 'react';\nimport { generatePath, Link } from 'react-router';\n\nimport styles from './genre-column.module.css';\n\nimport {\n    ColumnNullFallback,\n    ColumnSkeletonVariable,\n    ItemTableListInnerColumn,\n    TableColumnContainer,\n} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';\nimport { AppRoute } from '/@/renderer/router/routes';\nimport { Text } from '/@/shared/components/text/text';\nimport { Genre } from '/@/shared/types/domain-types';\n\nconst GenreColumn = (props: ItemTableListInnerColumn) => {\n    const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];\n    const row: Genre[] | undefined = (rowItem as any)?.[props.columns[props.columnIndex].id];\n\n    const genres = useMemo(() => {\n        if (!row) return [];\n        return row.map((genre) => {\n            const path = generatePath(AppRoute.LIBRARY_GENRES_DETAIL, {\n                genreId: genre.id,\n            });\n            return { ...genre, path };\n        });\n    }, [row]);\n\n    if (Array.isArray(row)) {\n        return (\n            <TableColumnContainer {...props}>\n                <div\n                    className={clsx(styles.genresContainer, {\n                        [styles.compact]: props.size === 'compact',\n                        [styles.large]: props.size === 'large',\n                    })}\n                >\n                    {genres.map((genre, index) => (\n                        <Fragment key={genre.id}>\n                            <Text\n                                component={Link}\n                                isLink\n                                isMuted\n                                isNoSelect\n                                state={{ item: genre }}\n                                to={genre.path}\n                            >\n                                {genre.name}\n                            </Text>\n                            {index < genres.length - 1 && ', '}\n                        </Fragment>\n                    ))}\n                </div>\n            </TableColumnContainer>\n        );\n    }\n\n    if (row === null) {\n        return <ColumnNullFallback {...props} />;\n    }\n\n    return <ColumnSkeletonVariable {...props} />;\n};\n\nexport { GenreColumn };\n"
  },
  {
    "path": "src/renderer/components/item-list/item-table-list/columns/image-column.module.css",
    "content": ".skeleton {\n    width: 100%;\n    height: 100%;\n    border-radius: 0;\n}\n\n.image-container {\n    position: relative;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    width: 100%;\n    height: 100%;\n}\n\n.compact-image-container .skeleton {\n    border-radius: 0;\n}\n\n.compact-image-container {\n    width: 100%;\n    height: 100%;\n    overflow: hidden;\n    border-radius: var(--theme-radius-md);\n}\n\n.compact-image-container img {\n    width: 100%;\n    height: 100%;\n    object-fit: cover;\n    object-position: center;\n    border-radius: 0;\n}\n\n.image-container-with-aspect-ratio {\n    width: auto;\n    height: 100%;\n    aspect-ratio: 1 / 1;\n    border-radius: var(--theme-radius-md);\n}\n\n.image-container-with-aspect-ratio img {\n    width: auto;\n    height: 100%;\n    aspect-ratio: 1 / 1;\n    object-fit: var(--theme-image-fit);\n    object-position: center;\n}\n\n.skeleton-with-aspect-ratio {\n    width: auto;\n    height: 100%;\n    aspect-ratio: 1 / 1;\n}\n\n.play-button-overlay {\n    position: absolute;\n    top: 50%;\n    left: 50%;\n    z-index: 10;\n    opacity: 0.6;\n    transform: translate(-50%, -50%);\n    transition: opacity 0.2s ease-in-out;\n\n    &:hover {\n        opacity: 1;\n    }\n}\n\n.play-button-overlay button {\n    width: 32px;\n    height: 32px;\n}\n\n.compact-play-button-overlay button {\n    width: 24px;\n    height: 24px;\n}\n\n.folder-icon {\n    color: black;\n    fill: rgb(255 215 100);\n}\n"
  },
  {
    "path": "src/renderer/components/item-list/item-table-list/columns/image-column.tsx",
    "content": "import clsx from 'clsx';\nimport { useState } from 'react';\n\nimport styles from './image-column.module.css';\n\nimport { ItemImage } from '/@/renderer/components/item-image/item-image';\nimport {\n    ItemTableListInnerColumn,\n    TableColumnContainer,\n} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';\nimport { PlayButton } from '/@/renderer/features/shared/components/play-button';\nimport {\n    LONG_PRESS_PLAY_BEHAVIOR,\n    PlayTooltip,\n} from '/@/renderer/features/shared/components/play-button-group';\nimport { usePlayButtonBehavior } from '/@/renderer/store';\nimport { Icon } from '/@/shared/components/icon/icon';\nimport { Skeleton } from '/@/shared/components/skeleton/skeleton';\nimport { Folder, LibraryItem } from '/@/shared/types/domain-types';\nimport { Play } from '/@/shared/types/types';\n\nconst ImageColumnBase = (props: ItemTableListInnerColumn) => {\n    const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];\n    const row: string | undefined = rowItem?.id;\n    const item = rowItem as any;\n    const playButtonBehavior = usePlayButtonBehavior();\n    const internalState = (props as any).internalState;\n    const [isHovered, setIsHovered] = useState(false);\n\n    const isFolder = (rowItem as unknown as Folder)?._itemType === LibraryItem.FOLDER;\n    const shouldShowFolderIcon = isFolder && !item?.imageId && !item?.imageUrl;\n\n    const handlePlay = (playType: Play, event: React.MouseEvent<HTMLButtonElement>) => {\n        if (!item) {\n            return;\n        }\n\n        // For SONG items, use double click behavior\n        if (\n            (props.itemType === LibraryItem.SONG ||\n                props.itemType === LibraryItem.PLAYLIST_SONG ||\n                item._itemType === LibraryItem.SONG) &&\n            props.controls?.onDoubleClick\n        ) {\n            // Calculate the index based on rowIndex, accounting for header if enabled\n            const isHeaderEnabled = !!props.enableHeader;\n            const index = isHeaderEnabled ? props.rowIndex - 1 : props.rowIndex;\n\n            props.controls.onDoubleClick({\n                event: null,\n                index,\n                internalState,\n                item,\n                itemType: props.itemType,\n                meta: {\n                    playType,\n                    singleSongOnly: true,\n                },\n            });\n            return;\n        }\n\n        // For other item types, use regular onPlay\n        if (!props.controls?.onPlay) {\n            return;\n        }\n\n        props.controls.onPlay({\n            event,\n            item,\n            itemType: props.itemType,\n            playType,\n        });\n    };\n\n    if (typeof row === 'string') {\n        return (\n            <TableColumnContainer {...props}>\n                <div\n                    className={clsx(styles.imageContainer, {\n                        [styles.compactImageContainer]: props.size === 'compact',\n                    })}\n                    onMouseEnter={() => setIsHovered(true)}\n                    onMouseLeave={() => setIsHovered(false)}\n                >\n                    <ItemImage\n                        containerClassName={clsx({\n                            [styles.compactImageContainer]: props.size === 'compact',\n                            [styles.imageContainerWithAspectRatio]:\n                                props.size === 'default' || props.size === 'large',\n                        })}\n                        enableDebounce={true}\n                        enableViewport={false}\n                        explicitStatus={item?.explicitStatus}\n                        id={item?.imageId}\n                        itemType={item?._itemType}\n                        src={item?.imageUrl}\n                        type=\"table\"\n                    />\n                    {isHovered && (\n                        <div\n                            className={clsx(styles.playButtonOverlay, {\n                                [styles.compactPlayButtonOverlay]: props.size === 'compact',\n                            })}\n                        >\n                            <PlayTooltip\n                                disabled={props.itemType === LibraryItem.QUEUE_SONG}\n                                type={playButtonBehavior}\n                            >\n                                <PlayButton\n                                    fill\n                                    onClick={(e) => handlePlay(playButtonBehavior, e)}\n                                    onLongPress={(e) =>\n                                        handlePlay(LONG_PRESS_PLAY_BEHAVIOR[playButtonBehavior], e)\n                                    }\n                                />\n                            </PlayTooltip>\n                        </div>\n                    )}\n                </div>\n            </TableColumnContainer>\n        );\n    }\n\n    if (shouldShowFolderIcon) {\n        return (\n            <TableColumnContainer {...props}>\n                <Icon className={styles.folderIcon} icon=\"folder\" size=\"2xl\" />\n            </TableColumnContainer>\n        );\n    }\n\n    return (\n        <TableColumnContainer {...props}>\n            <div\n                className={clsx(styles.imageContainer, {\n                    [styles.compactImageContainer]: props.size === 'compact',\n                    [styles.skeletonWithAspectRatio]:\n                        props.size === 'default' || props.size === 'large',\n                })}\n            >\n                <Skeleton containerClassName={styles.skeleton} />\n            </div>\n        </TableColumnContainer>\n    );\n};\n\nexport const ImageColumn = ImageColumnBase;\n"
  },
  {
    "path": "src/renderer/components/item-list/item-table-list/columns/numeric-column.tsx",
    "content": "import {\n    ColumnNullFallback,\n    ColumnSkeletonFixed,\n    ItemTableListInnerColumn,\n    TableColumnTextContainer,\n} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';\n\nexport const NumericColumn = (props: ItemTableListInnerColumn) => {\n    const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];\n    const row: number | undefined = (rowItem as any)?.[props.columns[props.columnIndex].id];\n\n    if (typeof row === 'number') {\n        return <TableColumnTextContainer {...props}>{row}</TableColumnTextContainer>;\n    }\n\n    if (row === null) {\n        return <ColumnNullFallback {...props} />;\n    }\n\n    return <ColumnSkeletonFixed {...props} />;\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/item-table-list/columns/path-column.tsx",
    "content": "import {\n    ColumnNullFallback,\n    ColumnSkeletonVariable,\n    ItemTableListInnerColumn,\n    TableColumnTextContainer,\n} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';\n\nexport const PathColumn = (props: ItemTableListInnerColumn) => {\n    const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];\n    const row: string | undefined = (rowItem as any)?.[props.columns[props.columnIndex].id];\n\n    if (typeof row === 'string' && row) {\n        return (\n            <TableColumnTextContainer {...props}>\n                <span>{row}</span>\n            </TableColumnTextContainer>\n        );\n    }\n\n    if (row === null) {\n        return <ColumnNullFallback {...props} />;\n    }\n\n    return <ColumnSkeletonVariable {...props} />;\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/item-table-list/columns/playlist-reorder-column.module.css",
    "content": ".group {\n    justify-content: center;\n}\n"
  },
  {
    "path": "src/renderer/components/item-list/item-table-list/columns/playlist-reorder-column.tsx",
    "content": "import { useCallback } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { useParams } from 'react-router';\n\nimport styles from './playlist-reorder-column.module.css';\n\nimport { getDraggedItems } from '/@/renderer/components/item-list/helpers/get-dragged-items';\nimport { useItemDraggingState } from '/@/renderer/components/item-list/helpers/item-list-state';\nimport {\n    ItemTableListInnerColumn,\n    TableColumnContainer,\n} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';\nimport { eventEmitter } from '/@/renderer/events/event-emitter';\nimport { useDragDrop } from '/@/renderer/hooks/use-drag-drop';\nimport { ActionIcon, ActionIconGroup } from '/@/shared/components/action-icon/action-icon';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { Text } from '/@/shared/components/text/text';\nimport { useLongPress } from '/@/shared/hooks/use-long-press';\nimport { LibraryItem } from '/@/shared/types/domain-types';\nimport { DragOperation, DragTarget, DragTargetMap } from '/@/shared/types/drag-and-drop';\n\nconst PlaylistReorderColumnBase = (props: ItemTableListInnerColumn) => {\n    const { t } = useTranslation();\n    const { playlistId } = useParams() as { playlistId?: string };\n    const isHeaderEnabled = !!props.enableHeader;\n    const isDataRow = isHeaderEnabled ? props.rowIndex > 0 : true;\n    const item = isDataRow\n        ? (props.getRowItem?.(props.rowIndex) ?? props.data[props.rowIndex])\n        : null;\n\n    const isPlaylistSong = props.itemType === LibraryItem.PLAYLIST_SONG;\n\n    const {\n        isDraggedOver,\n        isDragging: isDraggingLocal,\n        ref: dragRef,\n    } = useDragDrop<HTMLButtonElement>({\n        drag: {\n            getId: () => {\n                if (!item || !isDataRow || !isPlaylistSong) {\n                    return [];\n                }\n\n                const draggedItems = getDraggedItems(item as any, props.internalState);\n                return draggedItems.map((draggedItem) => draggedItem.id);\n            },\n            getItem: () => {\n                if (!item || !isDataRow || !isPlaylistSong) {\n                    return [];\n                }\n\n                const draggedItems = getDraggedItems(item as any, props.internalState);\n                return draggedItems;\n            },\n            itemType: LibraryItem.PLAYLIST_SONG,\n            metadata: { fromReorderHandle: true },\n            onDragStart: () => {\n                if (!item || !isDataRow || !isPlaylistSong) {\n                    return;\n                }\n\n                const draggedItems = getDraggedItems(item as any, props.internalState);\n                if (props.internalState) {\n                    props.internalState.setDragging(draggedItems);\n                }\n            },\n            onDrop: () => {\n                if (props.internalState) {\n                    props.internalState.setDragging([]);\n                }\n            },\n            operation: [DragOperation.REORDER],\n            target: DragTargetMap[LibraryItem.PLAYLIST_SONG] || DragTarget.SONG,\n        },\n        drop: {\n            canDrop: (args) => {\n                // Only allow drops from PLAYLIST_SONG items\n                return (\n                    args.source.itemType === LibraryItem.PLAYLIST_SONG &&\n                    isPlaylistSong &&\n                    isDataRow\n                );\n            },\n            getData: () => {\n                if (!item || !isDataRow) {\n                    return {\n                        id: [],\n                        item: [],\n                        itemType: LibraryItem.PLAYLIST_SONG,\n                        type: DragTarget.SONG,\n                    };\n                }\n\n                return {\n                    id: [(item as unknown as { id: string }).id],\n                    item: [item as unknown as unknown[]],\n                    itemType: LibraryItem.PLAYLIST_SONG,\n                    type: DragTargetMap[LibraryItem.PLAYLIST_SONG] || DragTarget.SONG,\n                };\n            },\n            onDrag: () => {\n                // Visual feedback is handled by isDraggedOver state\n            },\n            onDragLeave: () => {\n                // Visual feedback is handled by isDraggedOver state\n            },\n            onDrop: (args) => {\n                if (!item || !isDataRow || !isPlaylistSong) {\n                    return;\n                }\n\n                // Only handle drops from PLAYLIST_SONG items\n                if (args.source.itemType !== LibraryItem.PLAYLIST_SONG) {\n                    return;\n                }\n\n                const sourceItems = (args.source.item || []) as any[];\n                const targetItem = item as any;\n\n                if (\n                    sourceItems.length > 0 &&\n                    args.edge &&\n                    (args.edge === 'top' || args.edge === 'bottom') &&\n                    playlistId\n                ) {\n                    // Emit event to reorder playlist songs\n                    eventEmitter.emit('PLAYLIST_REORDER', {\n                        edge: args.edge,\n                        playlistId,\n                        sourceIds: args.source.id,\n                        targetId: targetItem.id,\n                    });\n                }\n\n                if (props.internalState) {\n                    props.internalState.setDragging([]);\n                }\n            },\n        },\n        isEnabled: isPlaylistSong && isDataRow && !!item,\n    });\n\n    const draggedOverEdge: 'bottom' | 'top' | null =\n        isDraggedOver === 'top' || isDraggedOver === 'bottom' ? isDraggedOver : null;\n\n    const itemRowId =\n        item && typeof item === 'object' && 'id' in item && props.internalState\n            ? props.internalState.extractRowId(item)\n            : undefined;\n    const isDraggingState = useItemDraggingState(\n        props.internalState,\n        itemRowId ||\n            (item && typeof item === 'object' && 'id' in item ? (item as any).id : undefined),\n    );\n    const isDragging = props.internalState ? isDraggingState : isDraggingLocal;\n\n    const getValidDataItems = useCallback(() => {\n        return props.internalState.getData().filter((d) => d !== null && (d as any).id);\n    }, [props.internalState]);\n\n    const handleMoveUp = useCallback(() => {\n        if (!item || !isDataRow || !isPlaylistSong || !playlistId) {\n            return;\n        }\n\n        const validItems = getValidDataItems();\n        const selectedItems = getDraggedItems(item as any, props.internalState);\n        const sourceIds = selectedItems.map((draggedItem) => draggedItem.id);\n\n        if (sourceIds.length === 0) {\n            return;\n        }\n\n        let topmostIndex = validItems.length;\n        for (const selectedItem of selectedItems) {\n            const index = validItems.findIndex((d) => (d as any).id === selectedItem.id);\n            if (index !== -1 && index < topmostIndex) {\n                topmostIndex = index;\n            }\n        }\n\n        if (topmostIndex <= 0) {\n            return;\n        }\n\n        const targetItem = validItems[topmostIndex - 1];\n\n        eventEmitter.emit('PLAYLIST_REORDER', {\n            edge: 'top',\n            playlistId,\n            sourceIds,\n            targetId: (targetItem as any).id,\n        });\n    }, [item, isDataRow, isPlaylistSong, playlistId, getValidDataItems, props.internalState]);\n\n    const handleMoveToTop = useCallback(() => {\n        if (!item || !isDataRow || !isPlaylistSong || !playlistId) {\n            return;\n        }\n\n        const validItems = getValidDataItems();\n        const selectedItems = getDraggedItems(item as any, props.internalState);\n        const sourceIds = selectedItems.map((draggedItem) => draggedItem.id);\n\n        if (sourceIds.length === 0) {\n            return;\n        }\n\n        const firstItem = validItems[0];\n\n        const isAlreadyAtTop = selectedItems.some(\n            (selectedItem) => (selectedItem as any).id === (firstItem as any).id,\n        );\n\n        if (!firstItem || isAlreadyAtTop) {\n            return;\n        }\n\n        eventEmitter.emit('PLAYLIST_REORDER', {\n            edge: 'top',\n            playlistId,\n            sourceIds,\n            targetId: (firstItem as any).id,\n        });\n    }, [item, isDataRow, isPlaylistSong, playlistId, getValidDataItems, props.internalState]);\n\n    const handleMoveDown = useCallback(() => {\n        if (!item || !isDataRow || !isPlaylistSong || !playlistId) {\n            return;\n        }\n\n        const validItems = getValidDataItems();\n        const selectedItems = getDraggedItems(item as any, props.internalState);\n        const sourceIds = selectedItems.map((draggedItem) => draggedItem.id);\n\n        if (sourceIds.length === 0) {\n            return;\n        }\n\n        let bottommostIndex = -1;\n        for (const selectedItem of selectedItems) {\n            const index = validItems.findIndex((d) => (d as any).id === selectedItem.id);\n            if (index !== -1 && index > bottommostIndex) {\n                bottommostIndex = index;\n            }\n        }\n\n        if (bottommostIndex === -1 || bottommostIndex >= validItems.length - 1) {\n            return;\n        }\n\n        const targetItem = validItems[bottommostIndex + 1];\n\n        eventEmitter.emit('PLAYLIST_REORDER', {\n            edge: 'bottom',\n            playlistId,\n            sourceIds,\n            targetId: (targetItem as any).id,\n        });\n    }, [item, isDataRow, isPlaylistSong, playlistId, getValidDataItems, props.internalState]);\n\n    const handleMoveToBottom = useCallback(() => {\n        if (!item || !isDataRow || !isPlaylistSong || !playlistId) {\n            return;\n        }\n\n        const validItems = getValidDataItems();\n        const selectedItems = getDraggedItems(item as any, props.internalState);\n        const sourceIds = selectedItems.map((draggedItem) => draggedItem.id);\n\n        if (sourceIds.length === 0) {\n            return;\n        }\n\n        const lastItem = validItems[validItems.length - 1];\n\n        const isAlreadyAtBottom = selectedItems.some(\n            (selectedItem) => (selectedItem as any).id === (lastItem as any).id,\n        );\n\n        if (!lastItem || isAlreadyAtBottom) {\n            return;\n        }\n\n        eventEmitter.emit('PLAYLIST_REORDER', {\n            edge: 'bottom',\n            playlistId,\n            sourceIds,\n            targetId: (lastItem as any).id,\n        });\n    }, [item, isDataRow, isPlaylistSong, playlistId, getValidDataItems, props.internalState]);\n\n    const upButtonHandlers = useLongPress<HTMLButtonElement>({\n        onClick: handleMoveUp,\n        onLongPress: handleMoveToTop,\n    });\n\n    const downButtonHandlers = useLongPress<HTMLButtonElement>({\n        onClick: handleMoveDown,\n        onLongPress: handleMoveToBottom,\n    });\n\n    return (\n        <TableColumnContainer {...props} isDraggedOver={draggedOverEdge} isDragging={isDragging}>\n            <ActionIconGroup className={styles.group} w=\"100%\">\n                <ActionIcon\n                    {...upButtonHandlers}\n                    icon=\"arrowUp\"\n                    iconProps={{ size: 'md' }}\n                    size=\"xs\"\n                    tooltip={{\n                        label: (\n                            <>\n                                <Stack gap=\"xs\" justify=\"center\">\n                                    <Text fw={500} ta=\"center\">\n                                        {t('action.moveUp', { postProcess: 'sentenceCase' })}\n                                    </Text>\n                                    <Text fw={500} isMuted size=\"xs\" ta=\"center\">\n                                        {t('action.holdToMoveToTop', {\n                                            postProcess: 'sentenceCase',\n                                        })}\n                                    </Text>\n                                </Stack>\n                            </>\n                        ),\n                    }}\n                    variant=\"default\"\n                />\n                <ActionIcon\n                    {...downButtonHandlers}\n                    icon=\"arrowDown\"\n                    iconProps={{ size: 'md' }}\n                    size=\"xs\"\n                    tooltip={{\n                        label: (\n                            <>\n                                <Stack gap=\"xs\" justify=\"center\">\n                                    <Text fw={500} ta=\"center\">\n                                        {t('action.moveDown', { postProcess: 'sentenceCase' })}\n                                    </Text>\n                                    <Text fw={500} isMuted size=\"xs\" ta=\"center\">\n                                        {t('action.holdToMoveToBottom', {\n                                            postProcess: 'sentenceCase',\n                                        })}\n                                    </Text>\n                                </Stack>\n                            </>\n                        ),\n                    }}\n                    variant=\"default\"\n                />\n                <ActionIcon\n                    icon=\"dragVertical\"\n                    iconProps={{ size: 'md' }}\n                    ref={dragRef}\n                    size=\"xs\"\n                    style={{\n                        cursor: isPlaylistSong ? 'grab' : 'default',\n                    }}\n                    variant=\"default\"\n                />\n            </ActionIconGroup>\n        </TableColumnContainer>\n    );\n};\n\nexport const PlaylistReorderColumn = PlaylistReorderColumnBase;\n"
  },
  {
    "path": "src/renderer/components/item-list/item-table-list/columns/rating-column.tsx",
    "content": "import {\n    ItemTableListInnerColumn,\n    TableColumnContainer,\n} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';\nimport { ItemListItem } from '/@/renderer/components/item-list/types';\nimport { useIsMutatingRating } from '/@/renderer/features/shared/mutations/set-rating-mutation';\nimport { Rating } from '/@/shared/components/rating/rating';\n\nexport const RatingColumn = (props: ItemTableListInnerColumn) => {\n    const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];\n    const row: null | number | undefined = rowItem?.[props.columns[props.columnIndex].id];\n\n    const isMutatingRating = useIsMutatingRating();\n\n    if (typeof row === 'number' || row === null) {\n        return (\n            <TableColumnContainer {...props}>\n                <Rating\n                    className={row ? undefined : 'hover-only-flex'}\n                    onChange={(rating) => {\n                        const item = rowItem as ItemListItem;\n                        const rowId = props.internalState.extractRowId(item);\n                        const index = rowId ? props.internalState.findItemIndex(rowId) : -1;\n                        props.controls.onRating?.({\n                            event: null,\n                            index,\n                            internalState: props.internalState,\n                            item,\n                            itemType: props.itemType,\n                            rating,\n                        });\n                    }}\n                    readOnly={isMutatingRating}\n                    size=\"xs\"\n                    value={row || 0}\n                />\n            </TableColumnContainer>\n        );\n    }\n\n    return <TableColumnContainer {...props}>&nbsp;</TableColumnContainer>;\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/item-table-list/columns/row-index-column.module.css",
    "content": ".expand {\n    position: absolute;\n}\n"
  },
  {
    "path": "src/renderer/components/item-list/item-table-list/columns/row-index-column.tsx",
    "content": "import clsx from 'clsx';\n\nimport styles from './row-index-column.module.css';\n\nimport {\n    ItemTableListInnerColumn,\n    TableColumnContainer,\n    TableColumnTextContainer,\n} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';\nimport { useIsActiveRow } from '/@/renderer/components/item-list/item-table-list/item-table-list-context';\nimport { ItemListItem } from '/@/renderer/components/item-list/types';\nimport { usePlayerStatus } from '/@/renderer/store';\nimport { ActionIcon } from '/@/shared/components/action-icon/action-icon';\nimport { Flex } from '/@/shared/components/flex/flex';\nimport { Icon } from '/@/shared/components/icon/icon';\nimport { Text } from '/@/shared/components/text/text';\nimport { LibraryItem, QueueSong } from '/@/shared/types/domain-types';\nimport { PlayerStatus } from '/@/shared/types/types';\n\nconst RowIndexColumnBase = (props: ItemTableListInnerColumn) => {\n    const { itemType } = props;\n\n    switch (itemType) {\n        case LibraryItem.FOLDER:\n        case LibraryItem.PLAYLIST_SONG:\n        case LibraryItem.QUEUE_SONG:\n        case LibraryItem.SONG:\n            return <QueueSongRowIndexColumn {...props} />;\n        default:\n            return <DefaultRowIndexColumn {...props} />;\n    }\n};\n\nexport const RowIndexColumn = RowIndexColumnBase;\n\nconst DefaultRowIndexColumn = (props: ItemTableListInnerColumn) => {\n    const {\n        controls,\n        data,\n        enableExpansion,\n        enableHeader,\n        internalState,\n        itemType,\n        rowIndex,\n        startRowIndex,\n    } = props;\n\n    let adjustedRowIndex =\n        props.getAdjustedRowIndex?.(rowIndex) ??\n        props.adjustedRowIndexMap?.get(rowIndex) ??\n        (enableHeader ? rowIndex : rowIndex + 1);\n\n    if (startRowIndex !== undefined && adjustedRowIndex > 0) {\n        adjustedRowIndex = startRowIndex + adjustedRowIndex;\n    }\n\n    if (enableExpansion) {\n        return (\n            <TableColumnContainer {...props}>\n                <ActionIcon\n                    className={clsx(styles.expand, 'hover-only')}\n                    icon=\"arrowDownS\"\n                    iconProps={{ color: 'muted', size: 'md' }}\n                    onClick={(e) => {\n                        e.stopPropagation();\n                        const item = (props.getRowItem?.(rowIndex) ??\n                            data[rowIndex]) as ItemListItem;\n                        const rowId = internalState.extractRowId(item);\n                        const index = rowId ? internalState.findItemIndex(rowId) : -1;\n                        controls.onExpand?.({\n                            event: e,\n                            index,\n                            internalState,\n                            item,\n                            itemType,\n                        });\n                    }}\n                    size=\"xs\"\n                    variant=\"subtle\"\n                />\n                <Text className=\"hide-on-hover\" isMuted isNoSelect>\n                    {adjustedRowIndex}\n                </Text>\n            </TableColumnContainer>\n        );\n    }\n\n    return <TableColumnTextContainer {...props}>{adjustedRowIndex}</TableColumnTextContainer>;\n};\n\nconst QueueSongRowIndexColumn = (props: ItemTableListInnerColumn) => {\n    const status = usePlayerStatus();\n    const song = (props.getRowItem?.(props.rowIndex) ?? props.data[props.rowIndex]) as QueueSong;\n    const isActive = useIsActiveRow(song?.id, song?._uniqueId);\n\n    const isActiveAndPlaying = isActive && status === PlayerStatus.PLAYING;\n\n    let adjustedRowIndex =\n        props.getAdjustedRowIndex?.(props.rowIndex) ??\n        props.adjustedRowIndexMap?.get(props.rowIndex) ??\n        (props.enableHeader ? props.rowIndex : props.rowIndex + 1);\n\n    if (props.startRowIndex !== undefined && adjustedRowIndex > 0) {\n        adjustedRowIndex = props.startRowIndex + adjustedRowIndex;\n    }\n\n    return (\n        <InnerQueueSongRowIndexColumn\n            {...props}\n            adjustedRowIndex={adjustedRowIndex}\n            isActive={isActive}\n            isPlaying={isActiveAndPlaying}\n        />\n    );\n};\n\nconst InnerQueueSongRowIndexColumn = (\n    props: ItemTableListInnerColumn & {\n        adjustedRowIndex: number;\n        isActive: boolean;\n        isPlaying: boolean;\n    },\n) => {\n    return (\n        <TableColumnTextContainer {...props}>\n            {props.isActive ? (\n                props.isPlaying ? (\n                    <Flex>\n                        <Icon fill=\"primary\" icon=\"mediaPlay\" />\n                    </Flex>\n                ) : (\n                    <Flex>\n                        <Icon fill=\"primary\" icon=\"mediaPause\" />\n                    </Flex>\n                )\n            ) : (\n                props.adjustedRowIndex\n            )}\n        </TableColumnTextContainer>\n    );\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/item-table-list/columns/size-column.tsx",
    "content": "import { useMemo } from 'react';\n\nimport {\n    ColumnNullFallback,\n    ColumnSkeletonFixed,\n    ItemTableListInnerColumn,\n    TableColumnTextContainer,\n} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';\nimport { formatSizeString } from '/@/renderer/utils/format';\n\nconst SizeColumnBase = (props: ItemTableListInnerColumn) => {\n    const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];\n    const row: number | undefined = (rowItem as any)?.[props.columns[props.columnIndex].id];\n\n    const formattedSize = useMemo(() => {\n        return typeof row === 'number' ? formatSizeString(row) : null;\n    }, [row]);\n\n    if (typeof row === 'number') {\n        return <TableColumnTextContainer {...props}>{formattedSize}</TableColumnTextContainer>;\n    }\n\n    if (row === null) {\n        return <ColumnNullFallback {...props} />;\n    }\n\n    return <ColumnSkeletonFixed {...props} />;\n};\n\nexport const SizeColumn = SizeColumnBase;\n"
  },
  {
    "path": "src/renderer/components/item-list/item-table-list/columns/text-column.module.css",
    "content": ".text-container {\n    display: -webkit-box;\n    overflow: hidden;\n    -webkit-line-clamp: 2;\n    -webkit-box-orient: vertical;\n}\n\n.text-container.compact {\n    -webkit-line-clamp: 1;\n}\n\n.text-container.large {\n    -webkit-line-clamp: 3;\n}\n"
  },
  {
    "path": "src/renderer/components/item-list/item-table-list/columns/text-column.tsx",
    "content": "import clsx from 'clsx';\n\nimport styles from './text-column.module.css';\n\nimport {\n    ColumnNullFallback,\n    ColumnSkeletonVariable,\n    ItemTableListInnerColumn,\n    TableColumnTextContainer,\n} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';\n\nexport const TextColumn = (props: ItemTableListInnerColumn) => {\n    const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];\n    const row: string | undefined = (rowItem as any)?.[props.columns[props.columnIndex].id];\n\n    if (typeof row === 'string' && row) {\n        return (\n            <TableColumnTextContainer\n                className={clsx(styles.textContainer, {\n                    [styles.compact]: props.size === 'compact',\n                    [styles.large]: props.size === 'large',\n                })}\n                {...props}\n            >\n                {row}\n            </TableColumnTextContainer>\n        );\n    }\n\n    if (row === null) {\n        return <ColumnNullFallback {...props} />;\n    }\n\n    return <ColumnSkeletonVariable {...props} />;\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/item-table-list/columns/title-artist-column.module.css",
    "content": ".title-artist {\n    display: flex;\n    gap: var(--theme-spacing-sm);\n    width: 100%;\n    height: 100%;\n}\n\n\n\n.text-container {\n    display: grid;\n    grid-template-rows: 1fr 1fr;\n    gap: var(--theme-spacing-xs);\n    min-width: 0;\n}\n\n.text-container.align-left {\n    justify-items: start;\n}\n\n.text-container.align-center {\n    justify-items: center;\n}\n\n.text-container.align-right {\n    justify-items: end;\n}\n\n.text-container.compact {\n    gap: 0;\n}\n\n.title {\n    display: inline-block;\n    width: 100%;\n    max-width: 100%;\n    overflow: hidden;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n}\n\na.title {\n    width: auto;\n}\n\n\n.artists {\n    display: block;\n    width: 100%;\n    min-width: 0;\n    max-width: 100%;\n    overflow: hidden;\n    text-overflow: ellipsis;\n    font-size: var(--theme-font-size-xs) !important;\n    color: var(--theme-colors-foreground-muted);\n    white-space: nowrap;\n    user-select: none;\n}\n\n.folder-icon {\n    color: black;\n    fill: rgb(255 215 100);\n}\n\n.active {\n    color: var(--theme-colors-primary);\n}\n"
  },
  {
    "path": "src/renderer/components/item-list/item-table-list/columns/title-artist-column.tsx",
    "content": "import clsx from 'clsx';\nimport { CSSProperties } from 'react';\nimport { Link } from 'react-router';\n\nimport styles from './title-artist-column.module.css';\n\nimport { getTitlePath } from '/@/renderer/components/item-list/helpers/get-title-path';\nimport {\n    ColumnNullFallback,\n    ColumnSkeletonVariable,\n    ItemTableListInnerColumn,\n    TableColumnContainer,\n} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';\nimport { useIsActiveRow } from '/@/renderer/components/item-list/item-table-list/item-table-list-context';\nimport { JoinedArtists } from '/@/renderer/features/albums/components/joined-artists';\nimport { ExplicitIndicator } from '/@/shared/components/explicit-indicator/explicit-indicator';\nimport { Icon } from '/@/shared/components/icon/icon';\nimport { Text } from '/@/shared/components/text/text';\nimport { Folder, LibraryItem, QueueSong } from '/@/shared/types/domain-types';\n\nexport const DefaultTitleArtistColumn = (props: ItemTableListInnerColumn) => {\n    const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];\n    const row: object | undefined = (rowItem as any)?.id;\n    const item = rowItem as any;\n    const align = props.columns[props.columnIndex]?.align || 'start';\n\n    if (item && 'name' in item && 'artists' in item) {\n        const rowHeight = props.getRowHeight(props.rowIndex, props);\n        const path = getTitlePath(props.itemType, (rowItem as any).id as string);\n\n        const item = rowItem as any;\n        const titleLinkProps = path\n            ? {\n                  component: Link,\n                  isLink: true,\n                  state: { item },\n                  to: path,\n              }\n            : {};\n\n        return (\n            <TableColumnContainer\n                className={clsx(styles.titleArtist)}\n                containerStyle={{ '--row-height': `${rowHeight}px` } as CSSProperties}\n                {...props}\n            >\n                <div\n                    className={clsx(styles.textContainer, {\n                        [styles.alignCenter]: align === 'center',\n                        [styles.alignLeft]: align === 'start',\n                        [styles.alignRight]: align === 'end',\n                        [styles.compact]: props.size === 'compact',\n                    })}\n                >\n                    <Text className={styles.title} isNoSelect size=\"md\" {...titleLinkProps}>\n                        <ExplicitIndicator explicitStatus={item?.explicitStatus} />\n                        {item.name as string}\n                    </Text>\n                    <div className={styles.artists}>\n                        <JoinedArtists\n                            artistName={item.albumArtist}\n                            artists={item.albumArtists}\n                            linkProps={{ fw: 400, isMuted: true }}\n                            rootTextProps={{ fw: 400, isMuted: true, size: 'sm' }}\n                        />\n                    </div>\n                </div>\n            </TableColumnContainer>\n        );\n    }\n\n    if (row === null) {\n        return <ColumnNullFallback {...props} />;\n    }\n\n    return <ColumnSkeletonVariable {...props} />;\n};\n\nexport const QueueSongTitleArtistColumn = (props: ItemTableListInnerColumn) => {\n    const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];\n    const row: object | undefined = rowItem as any;\n\n    const song = rowItem as QueueSong;\n    const isActive = useIsActiveRow(song?.id, song?._uniqueId);\n    const align = props.columns[props.columnIndex]?.align || 'start';\n    const alignClass =\n        align === 'center' ? 'align-center' : align === 'end' ? 'align-right' : 'align-left';\n\n    if (row && 'name' in row && 'artists' in row) {\n        const rowHeight = props.getRowHeight(props.rowIndex, props);\n        const path = getTitlePath(props.itemType, (rowItem as any).id as string);\n\n        const item = rowItem as any;\n\n        const titleLinkProps = path\n            ? {\n                  component: Link,\n                  isLink: true,\n                  state: { item },\n                  to: path,\n              }\n            : {};\n\n        return (\n            <TableColumnContainer\n                className={clsx(styles.titleArtist, styles[alignClass])}\n                containerStyle={{ '--row-height': `${rowHeight}px` } as CSSProperties}\n                {...props}\n            >\n                <div\n                    className={clsx(styles.textContainer, styles[alignClass], {\n                        [styles.active]: isActive,\n                        [styles.compact]: props.size === 'compact',\n                    })}\n                >\n                    <Text\n                        className={clsx({\n                            [styles.active]: isActive,\n                            [styles.title]: true,\n                        })}\n                        isNoSelect\n                        size=\"md\"\n                        {...titleLinkProps}\n                    >\n                        <ExplicitIndicator explicitStatus={song?.explicitStatus} />\n                        {row.name as string}\n                        {song?.trackSubtitle && props.itemType !== LibraryItem.QUEUE_SONG && (\n                            <Text\n                                className={clsx({\n                                    [styles.active]: isActive,\n                                })}\n                                component=\"span\"\n                                isMuted\n                                size=\"sm\"\n                            >\n                                {' ('}\n                                {song.trackSubtitle}\n                                {')'}\n                            </Text>\n                        )}\n                    </Text>\n                    <div className={styles.artists}>\n                        <JoinedArtists\n                            artistName={item.artistName}\n                            artists={item.artists}\n                            linkProps={{ fw: 400, isMuted: true }}\n                            rootTextProps={{ fw: 400, isMuted: true, size: 'sm' }}\n                        />\n                    </div>\n                </div>\n            </TableColumnContainer>\n        );\n    }\n\n    if ((rowItem as unknown as Folder)?._itemType === LibraryItem.FOLDER) {\n        const rowHeight = props.getRowHeight(props.rowIndex, props);\n        const path = getTitlePath(props.itemType, (rowItem as any).id as string);\n\n        const item = rowItem as any;\n        const textStyles = isActive ? { color: 'var(--theme-colors-primary)' } : {};\n\n        const titleLinkProps = path\n            ? {\n                  component: Link,\n                  isLink: true,\n                  state: { item },\n                  to: path,\n              }\n            : {};\n\n        const title = (rowItem as unknown as Folder)?.name;\n\n        return (\n            <TableColumnContainer\n                className={clsx(styles.titleArtist, styles[alignClass])}\n                containerStyle={{ '--row-height': `${rowHeight}px` } as CSSProperties}\n                {...props}\n            >\n                <Icon className={styles.folderIcon} icon=\"folder\" size=\"2xl\" />\n                <Text\n                    className={styles.title}\n                    isNoSelect\n                    size=\"md\"\n                    {...titleLinkProps}\n                    style={textStyles}\n                >\n                    {title}\n                </Text>\n            </TableColumnContainer>\n        );\n    }\n\n    if (row === null) {\n        return <ColumnNullFallback {...props} />;\n    }\n\n    return <ColumnSkeletonVariable {...props} />;\n};\n\nconst TitleArtistColumnBase = (props: ItemTableListInnerColumn) => {\n    const { itemType } = props;\n\n    switch (itemType) {\n        case LibraryItem.FOLDER:\n        case LibraryItem.PLAYLIST_SONG:\n        case LibraryItem.QUEUE_SONG:\n        case LibraryItem.SONG:\n            return <QueueSongTitleArtistColumn {...props} />;\n        default:\n            return <DefaultTitleArtistColumn {...props} />;\n    }\n};\n\nexport const TitleArtistColumn = TitleArtistColumnBase;\n"
  },
  {
    "path": "src/renderer/components/item-list/item-table-list/columns/title-column.module.css",
    "content": ".name-container {\n    display: -webkit-inline-box;\n    align-self: flex-start;\n    max-width: 100%;\n    overflow: hidden;\n    text-overflow: ellipsis;\n    -webkit-line-clamp: 2;\n    -webkit-box-orient: vertical;\n}\n\na.name-container {\n    width: auto;\n}\n\n.name-container.compact {\n    -webkit-line-clamp: 1;\n}\n\n.name-container.large {\n    -webkit-line-clamp: 3;\n}\n\n.active {\n    color: var(--theme-colors-primary);\n}\n"
  },
  {
    "path": "src/renderer/components/item-list/item-table-list/columns/title-column.tsx",
    "content": "import clsx from 'clsx';\nimport { Link } from 'react-router';\n\nimport styles from './title-column.module.css';\n\nimport { getTitlePath } from '/@/renderer/components/item-list/helpers/get-title-path';\nimport {\n    ColumnNullFallback,\n    ColumnSkeletonVariable,\n    ItemTableListInnerColumn,\n    TableColumnContainer,\n} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';\nimport { useIsActiveRow } from '/@/renderer/components/item-list/item-table-list/item-table-list-context';\nimport { ExplicitIndicator } from '/@/shared/components/explicit-indicator/explicit-indicator';\nimport { Text } from '/@/shared/components/text/text';\nimport { LibraryItem, QueueSong } from '/@/shared/types/domain-types';\n\nconst TitleColumnBase = (props: ItemTableListInnerColumn) => {\n    const { itemType } = props;\n\n    switch (itemType) {\n        case LibraryItem.FOLDER:\n        case LibraryItem.PLAYLIST_SONG:\n        case LibraryItem.QUEUE_SONG:\n        case LibraryItem.SONG:\n            return <QueueSongTitleColumn {...props} />;\n        default:\n            return <DefaultTitleColumn {...props} />;\n    }\n};\n\nexport const TitleColumn = TitleColumnBase;\n\nfunction DefaultTitleColumn(props: ItemTableListInnerColumn) {\n    const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];\n    const row: string | undefined = rowItem?.[props.columns[props.columnIndex].id];\n\n    if (typeof row === 'string') {\n        const path = getTitlePath(props.itemType, (rowItem as any).id as string);\n        const item = rowItem as any;\n\n        const titleLinkProps = path\n            ? {\n                  component: Link,\n                  isLink: true,\n                  state: { item },\n                  to: path,\n              }\n            : {};\n\n        return (\n            <TableColumnContainer {...props}>\n                <Text\n                    className={clsx({\n                        [styles.compact]: props.size === 'compact',\n                        [styles.large]: props.size === 'large',\n                        [styles.nameContainer]: true,\n                    })}\n                    isNoSelect\n                    {...titleLinkProps}\n                >\n                    <ExplicitIndicator explicitStatus={item?.explicitStatus} />\n                    {row}\n                </Text>\n            </TableColumnContainer>\n        );\n    }\n\n    if (row === null) {\n        return <ColumnNullFallback {...props} />;\n    }\n\n    return <ColumnSkeletonVariable {...props} />;\n}\n\nfunction QueueSongTitleColumn(props: ItemTableListInnerColumn) {\n    const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];\n    const row: string | undefined = rowItem?.[props.columns[props.columnIndex].id];\n\n    const song = rowItem as QueueSong;\n    const isActive = useIsActiveRow(song?.id, song?._uniqueId);\n\n    if (typeof row === 'string') {\n        const path = getTitlePath(props.itemType, (rowItem as any).id as string);\n        const item = rowItem as any;\n\n        const titleLinkProps = path\n            ? {\n                  component: Link,\n                  isLink: true,\n                  state: { item },\n                  to: path,\n              }\n            : {};\n\n        return (\n            <TableColumnContainer {...props}>\n                <Text\n                    className={clsx({\n                        [styles.active]: isActive,\n                        [styles.compact]: props.size === 'compact',\n                        [styles.large]: props.size === 'large',\n                        [styles.nameContainer]: true,\n                    })}\n                    isNoSelect\n                    {...titleLinkProps}\n                >\n                    <ExplicitIndicator explicitStatus={song?.explicitStatus} />\n                    {row}\n                    {song?.trackSubtitle && props.itemType !== LibraryItem.QUEUE_SONG && (\n                        <Text\n                            className={clsx({\n                                [styles.active]: isActive,\n                            })}\n                            component=\"span\"\n                            isMuted\n                            size=\"sm\"\n                        >\n                            {' ('}\n                            {song.trackSubtitle}\n                            {')'}\n                        </Text>\n                    )}\n                </Text>\n            </TableColumnContainer>\n        );\n    }\n\n    if (row === null) {\n        return <ColumnNullFallback {...props} />;\n    }\n\n    return <ColumnSkeletonVariable {...props} />;\n}\n"
  },
  {
    "path": "src/renderer/components/item-list/item-table-list/columns/title-combined-column.module.css",
    "content": ".title-combined {\n    display: grid;\n    grid-template-columns: calc(var(--row-height) - var(--theme-spacing-sm)) 1fr;\n    gap: var(--theme-spacing-sm);\n    align-items: center;\n    height: 100%;\n}\n\n.title-combined.no-image {\n    grid-template-columns: 1fr;\n}\n\n.text-container {\n    display: grid;\n    grid-template-rows: 1fr 1fr;\n    gap: var(--theme-spacing-xs);\n    min-width: 0;\n}\n\n.text-container.align-left {\n    justify-items: start;\n}\n\n.text-container.align-center {\n    justify-items: center;\n}\n\n.text-container.align-right {\n    justify-items: end;\n}\n\n.text-container.compact {\n    gap: 0;\n}\n\n.title {\n    display: inline-block;\n    width: 100%;\n    max-width: 100%;\n    overflow: hidden;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n}\n\na.title {\n    width: auto;\n}\n\n\n.artists {\n    display: block;\n    width: 100%;\n    min-width: 0;\n    max-width: 100%;\n    overflow: hidden;\n    text-overflow: ellipsis;\n    font-size: var(--theme-font-size-xs) !important;\n    color: var(--theme-colors-foreground-muted);\n    white-space: nowrap;\n    user-select: none;\n}\n\n.folder-icon {\n    color: black;\n    fill: rgb(255 215 100);\n}\n\n.image-container {\n    position: relative;\n    width: 100%;\n    height: 100%;\n}\n\n.play-button-overlay {\n    position: absolute;\n    top: 50%;\n    left: 50%;\n    z-index: 10;\n    opacity: 0.6;\n    transform: translate(-50%, -50%);\n    transition: opacity 0.2s ease-in-out;\n\n    &:hover {\n        opacity: 1;\n    }\n}\n\n.play-button-overlay button {\n    width: 32px;\n    height: 32px;\n}\n\n.compact-play-button-overlay button {\n    width: 24px;\n    height: 24px;\n}\n\n.active {\n    color: var(--theme-colors-primary);\n}\n"
  },
  {
    "path": "src/renderer/components/item-list/item-table-list/columns/title-combined-column.tsx",
    "content": "import clsx from 'clsx';\nimport { CSSProperties, useState } from 'react';\nimport { Link } from 'react-router';\n\nimport styles from './title-combined-column.module.css';\n\nimport { ItemImage } from '/@/renderer/components/item-image/item-image';\nimport { getTitlePath } from '/@/renderer/components/item-list/helpers/get-title-path';\nimport {\n    ColumnNullFallback,\n    ColumnSkeletonVariable,\n    ItemTableListInnerColumn,\n    TableColumnContainer,\n} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';\nimport { useIsActiveRow } from '/@/renderer/components/item-list/item-table-list/item-table-list-context';\nimport { JoinedArtists } from '/@/renderer/features/albums/components/joined-artists';\nimport { PlayButton } from '/@/renderer/features/shared/components/play-button';\nimport {\n    LONG_PRESS_PLAY_BEHAVIOR,\n    PlayTooltip,\n} from '/@/renderer/features/shared/components/play-button-group';\nimport { usePlayButtonBehavior } from '/@/renderer/store';\nimport { ExplicitIndicator } from '/@/shared/components/explicit-indicator/explicit-indicator';\nimport { Icon } from '/@/shared/components/icon/icon';\nimport { Text } from '/@/shared/components/text/text';\nimport { Folder, LibraryItem, QueueSong } from '/@/shared/types/domain-types';\nimport { Play } from '/@/shared/types/types';\n\nexport const DefaultTitleCombinedColumn = (props: ItemTableListInnerColumn) => {\n    const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];\n    const row: object | undefined = (rowItem as any)?.id;\n    const item = rowItem as any;\n    const internalState = (props as any).internalState;\n    const playButtonBehavior = usePlayButtonBehavior();\n    const [isHovered, setIsHovered] = useState(false);\n\n    const handlePlay = (playType: Play, event: React.MouseEvent<HTMLButtonElement>) => {\n        if (!item) {\n            return;\n        }\n\n        // For SONG items, use double click behavior\n        if (\n            (props.itemType === LibraryItem.SONG ||\n                props.itemType === LibraryItem.PLAYLIST_SONG ||\n                item._itemType === LibraryItem.SONG) &&\n            props.controls?.onDoubleClick\n        ) {\n            // Calculate the index based on rowIndex, accounting for header if enabled\n            const isHeaderEnabled = !!props.enableHeader;\n            const index = isHeaderEnabled ? props.rowIndex - 1 : props.rowIndex;\n\n            props.controls.onDoubleClick({\n                event: null,\n                index,\n                internalState,\n                item,\n                itemType: props.itemType,\n                meta: {\n                    playType,\n                    singleSongOnly: true,\n                },\n            });\n            return;\n        }\n\n        // For other item types, use regular onPlay\n        if (!props.controls?.onPlay) {\n            return;\n        }\n\n        props.controls.onPlay({\n            event,\n            item,\n            itemType: props.itemType,\n            playType,\n        });\n    };\n\n    if (item && 'name' in item && 'imageUrl' in item && 'artists' in item) {\n        const rowHeight = props.getRowHeight(props.rowIndex, props);\n        const path = getTitlePath(props.itemType, (rowItem as any).id as string);\n        const align = props.columns[props.columnIndex]?.align || 'start';\n        const hasAlbumGroupColumn = props.hasAlbumGroupColumn ?? false;\n\n        const item = rowItem as any;\n        const titleLinkProps = path\n            ? {\n                  component: Link,\n                  isLink: true,\n                  state: { item },\n                  to: path,\n              }\n            : {};\n\n        return (\n            <TableColumnContainer\n                className={clsx(styles.titleCombined, {\n                    [styles.noImage]: hasAlbumGroupColumn,\n                })}\n                containerStyle={{ '--row-height': `${rowHeight}px` } as CSSProperties}\n                {...props}\n            >\n                {!hasAlbumGroupColumn && (\n                    <div\n                        className={styles.imageContainer}\n                        onMouseEnter={() => setIsHovered(true)}\n                        onMouseLeave={() => setIsHovered(false)}\n                    >\n                        <ItemImage\n                            containerClassName={styles.image}\n                            enableDebounce={true}\n                            enableViewport={false}\n                            explicitStatus={item?.explicitStatus}\n                            id={item?.imageId}\n                            itemType={item?._itemType}\n                            src={item?.imageUrl}\n                            type=\"table\"\n                        />\n                        {isHovered && (\n                            <div\n                                className={clsx(styles.playButtonOverlay, {\n                                    [styles.compactPlayButtonOverlay]: props.size === 'compact',\n                                })}\n                            >\n                                <PlayTooltip\n                                    disabled={props.itemType === LibraryItem.QUEUE_SONG}\n                                    type={playButtonBehavior}\n                                >\n                                    <PlayButton\n                                        fill\n                                        onClick={(e) => handlePlay(playButtonBehavior, e)}\n                                        onLongPress={(e) =>\n                                            handlePlay(\n                                                LONG_PRESS_PLAY_BEHAVIOR[playButtonBehavior],\n                                                e,\n                                            )\n                                        }\n                                    />\n                                </PlayTooltip>\n                            </div>\n                        )}\n                    </div>\n                )}\n                <div\n                    className={clsx(styles.textContainer, {\n                        [styles.alignCenter]: align === 'center',\n                        [styles.alignLeft]: align === 'start',\n                        [styles.alignRight]: align === 'end',\n                        [styles.compact]: props.size === 'compact',\n                    })}\n                >\n                    <Text className={styles.title} isNoSelect size=\"md\" {...titleLinkProps}>\n                        <ExplicitIndicator explicitStatus={item?.explicitStatus} />\n                        {item.name as string}\n                    </Text>\n                    <div className={styles.artists}>\n                        <JoinedArtists\n                            artistName={item.albumArtist}\n                            artists={item.albumArtists}\n                            linkProps={{ fw: 400, isMuted: true }}\n                            rootTextProps={{ fw: 400, isMuted: true, size: 'sm' }}\n                        />\n                    </div>\n                </div>\n            </TableColumnContainer>\n        );\n    }\n\n    if (row === null) {\n        return <ColumnNullFallback {...props} />;\n    }\n\n    return <ColumnSkeletonVariable {...props} />;\n};\n\nexport const QueueSongTitleCombinedColumn = (props: ItemTableListInnerColumn) => {\n    const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];\n    const row: object | undefined = rowItem as any;\n\n    const song = rowItem as QueueSong;\n    const item = rowItem as any;\n    const internalState = (props as any).internalState;\n    const playButtonBehavior = usePlayButtonBehavior();\n    const [isHovered, setIsHovered] = useState(false);\n    const isActive = useIsActiveRow(song?.id, song?._uniqueId);\n\n    const handlePlay = (playType: Play, event: React.MouseEvent<HTMLButtonElement>) => {\n        if (!item) {\n            return;\n        }\n\n        // For SONG items, use double click behavior\n        if (\n            (props.itemType === LibraryItem.SONG ||\n                props.itemType === LibraryItem.PLAYLIST_SONG ||\n                item._itemType === LibraryItem.SONG) &&\n            props.controls?.onDoubleClick\n        ) {\n            // Calculate the index based on rowIndex, accounting for header if enabled\n            const isHeaderEnabled = !!props.enableHeader;\n            const index = isHeaderEnabled ? props.rowIndex - 1 : props.rowIndex;\n\n            props.controls.onDoubleClick({\n                event: null,\n                index,\n                internalState,\n                item,\n                itemType: props.itemType,\n                meta: {\n                    playType,\n                    singleSongOnly: true,\n                },\n            });\n            return;\n        }\n\n        // For other item types, use regular onPlay\n        if (!props.controls?.onPlay) {\n            return;\n        }\n\n        props.controls.onPlay({\n            event,\n            item,\n            itemType: props.itemType,\n            playType,\n        });\n    };\n\n    if (row && 'name' in row && 'imageUrl' in row && 'artists' in row) {\n        const rowHeight = props.getRowHeight(props.rowIndex, props);\n        const path = getTitlePath(props.itemType, (rowItem as any).id as string);\n        const align = props.columns[props.columnIndex]?.align || 'start';\n        const hasAlbumGroupColumn = props.hasAlbumGroupColumn ?? false;\n\n        const item = rowItem as any;\n\n        const titleLinkProps = path\n            ? {\n                  component: Link,\n                  isLink: true,\n                  state: { item },\n                  to: path,\n              }\n            : {};\n\n        return (\n            <TableColumnContainer\n                className={clsx(styles.titleCombined, {\n                    [styles.noImage]: hasAlbumGroupColumn,\n                })}\n                containerStyle={{ '--row-height': `${rowHeight}px` } as CSSProperties}\n                {...props}\n            >\n                {!hasAlbumGroupColumn && (\n                    <div\n                        className={styles.imageContainer}\n                        onMouseEnter={() => setIsHovered(true)}\n                        onMouseLeave={() => setIsHovered(false)}\n                    >\n                        <ItemImage\n                            containerClassName={styles.image}\n                            explicitStatus={item?.explicitStatus}\n                            id={item?.imageId}\n                            itemType={item?._itemType}\n                            serverId={item?._serverId}\n                            src={item?.imageUrl}\n                            type=\"table\"\n                        />\n                        {isHovered && (\n                            <div\n                                className={clsx(styles.playButtonOverlay, {\n                                    [styles.compactPlayButtonOverlay]: props.size === 'compact',\n                                })}\n                            >\n                                <PlayTooltip\n                                    disabled={props.itemType === LibraryItem.QUEUE_SONG}\n                                    type={playButtonBehavior}\n                                >\n                                    <PlayButton\n                                        fill\n                                        onClick={(e) => handlePlay(playButtonBehavior, e)}\n                                        onLongPress={(e) =>\n                                            handlePlay(\n                                                LONG_PRESS_PLAY_BEHAVIOR[playButtonBehavior],\n                                                e,\n                                            )\n                                        }\n                                    />\n                                </PlayTooltip>\n                            </div>\n                        )}\n                    </div>\n                )}\n                <div\n                    className={clsx(styles.textContainer, {\n                        [styles.active]: isActive,\n                        [styles.alignCenter]: align === 'center',\n                        [styles.alignLeft]: align === 'start',\n                        [styles.alignRight]: align === 'end',\n                        [styles.compact]: props.size === 'compact',\n                    })}\n                >\n                    <Text\n                        className={clsx({\n                            [styles.active]: isActive,\n                            [styles.title]: true,\n                        })}\n                        isNoSelect\n                        size=\"md\"\n                        {...titleLinkProps}\n                    >\n                        <ExplicitIndicator explicitStatus={song?.explicitStatus} />\n                        {row.name as string}\n                        {song?.trackSubtitle && props.itemType !== LibraryItem.QUEUE_SONG && (\n                            <Text\n                                className={clsx({\n                                    [styles.active]: isActive,\n                                })}\n                                component=\"span\"\n                                isMuted\n                                size=\"sm\"\n                            >\n                                {' ('}\n                                {song.trackSubtitle}\n                                {')'}\n                            </Text>\n                        )}\n                    </Text>\n                    <div className={styles.artists}>\n                        <JoinedArtists\n                            artistName={item.artistName}\n                            artists={item.artists}\n                            linkProps={{ fw: 400, isMuted: true }}\n                            rootTextProps={{ fw: 400, isMuted: true, size: 'sm' }}\n                        />\n                    </div>\n                </div>\n            </TableColumnContainer>\n        );\n    }\n\n    if ((rowItem as unknown as Folder)?._itemType === LibraryItem.FOLDER) {\n        const rowHeight = props.getRowHeight(props.rowIndex, props);\n        const path = getTitlePath(props.itemType, (rowItem as any).id as string);\n\n        const item = rowItem as any;\n        const textStyles = isActive ? { color: 'var(--theme-colors-primary)' } : {};\n\n        const titleLinkProps = path\n            ? {\n                  component: Link,\n                  isLink: true,\n                  state: { item },\n                  to: path,\n              }\n            : {};\n\n        const title = (rowItem as unknown as Folder)?.name;\n\n        return (\n            <TableColumnContainer\n                className={styles.titleCombined}\n                containerStyle={{ '--row-height': `${rowHeight}px` } as CSSProperties}\n                {...props}\n            >\n                <Icon className={styles.folderIcon} icon=\"folder\" size=\"2xl\" />\n                <Text\n                    className={styles.title}\n                    isNoSelect\n                    size=\"md\"\n                    {...titleLinkProps}\n                    style={textStyles}\n                >\n                    {title}\n                </Text>\n            </TableColumnContainer>\n        );\n    }\n\n    if (row === null) {\n        return <ColumnNullFallback {...props} />;\n    }\n\n    return <ColumnSkeletonVariable {...props} />;\n};\n\nconst TitleCombinedColumnBase = (props: ItemTableListInnerColumn) => {\n    const { itemType } = props;\n\n    switch (itemType) {\n        case LibraryItem.FOLDER:\n        case LibraryItem.PLAYLIST_SONG:\n        case LibraryItem.QUEUE_SONG:\n        case LibraryItem.SONG:\n            return <QueueSongTitleCombinedColumn {...props} />;\n        default:\n            return <DefaultTitleCombinedColumn {...props} />;\n    }\n};\n\nexport const TitleCombinedColumn = TitleCombinedColumnBase;\n"
  },
  {
    "path": "src/renderer/components/item-list/item-table-list/columns/year-column.tsx",
    "content": "import { useMemo } from 'react';\n\nimport {\n    ColumnNullFallback,\n    ColumnSkeletonFixed,\n    ItemTableListInnerColumn,\n    TableColumnTextContainer,\n} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';\nimport { SEPARATOR_STRING } from '/@/shared/api/utils';\n\nconst YearColumnBase = (props: ItemTableListInnerColumn) => {\n    const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];\n    const item = rowItem as any;\n\n    const yearDisplay = useMemo(() => {\n        if (item && 'releaseYear' in item && item.releaseYear !== null) {\n            const releaseYear = item.releaseYear;\n            const originalYear =\n                'originalYear' in item && item.originalYear !== null ? item.originalYear : null;\n\n            if (originalYear !== null && originalYear !== releaseYear) {\n                return `${originalYear}${SEPARATOR_STRING}${releaseYear}`;\n            }\n\n            if (typeof releaseYear === 'number') {\n                return releaseYear;\n            }\n        }\n        return null;\n    }, [item]);\n\n    if (yearDisplay !== null) {\n        return <TableColumnTextContainer {...props}>{yearDisplay}</TableColumnTextContainer>;\n    }\n\n    const row: number | undefined = (rowItem as any)?.[props.columns[props.columnIndex].id];\n\n    if (row === null) {\n        return <ColumnNullFallback {...props} />;\n    }\n\n    return <ColumnSkeletonFixed {...props} />;\n};\n\nexport const YearColumn = YearColumnBase;\n"
  },
  {
    "path": "src/renderer/components/item-list/item-table-list/default-columns.ts",
    "content": "import i18n from '/@/i18n/i18n';\nimport { ItemGridListRowConfig, ItemTableListColumnConfig } from '/@/renderer/store';\nimport { TableColumn } from '/@/shared/types/types';\n\nexport type DefaultTableColumn = {\n    align: 'center' | 'end' | 'start';\n    autoSize: boolean;\n    isEnabled: boolean;\n    label: string;\n    pinned: 'left' | 'right' | null;\n    value: TableColumn;\n    width: number;\n};\n\nexport const SONG_TABLE_COLUMNS: DefaultTableColumn[] = [\n    {\n        align: 'start',\n        autoSize: false,\n        isEnabled: false,\n        label: i18n.t('table.config.label.albumGroup', { postProcess: 'titleCase' }),\n        pinned: 'left',\n        value: TableColumn.ALBUM_GROUP,\n        width: 200,\n    },\n    {\n        align: 'center',\n        autoSize: false,\n        isEnabled: true,\n        label: i18n.t('table.config.label.rowIndex', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.ROW_INDEX,\n        width: 60,\n    },\n    {\n        align: 'center',\n        autoSize: false,\n        isEnabled: false,\n        label: i18n.t('table.config.label.image', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.IMAGE,\n        width: 70,\n    },\n    {\n        align: 'start',\n        autoSize: false,\n        isEnabled: false,\n        label: i18n.t('table.config.label.title', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.TITLE,\n        width: 300,\n    },\n    {\n        align: 'start',\n        autoSize: false,\n        isEnabled: true,\n        label: i18n.t('table.config.label.titleCombined', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.TITLE_COMBINED,\n        width: 300,\n    },\n    {\n        align: 'start',\n        autoSize: false,\n        isEnabled: false,\n        label: i18n.t('table.config.label.titleArtist', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.TITLE_ARTIST,\n        width: 300,\n    },\n    {\n        align: 'center',\n        autoSize: false,\n        isEnabled: true,\n        label: i18n.t('table.config.label.duration', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.DURATION,\n        width: 100,\n    },\n    {\n        align: 'start',\n        autoSize: false,\n        isEnabled: true,\n        label: i18n.t('table.config.label.album', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.ALBUM,\n        width: 300,\n    },\n    {\n        align: 'start',\n        autoSize: true,\n        isEnabled: false,\n        label: i18n.t('table.config.label.albumArtist', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.ALBUM_ARTIST,\n        width: 300,\n    },\n    {\n        align: 'start',\n        autoSize: false,\n        isEnabled: false,\n        label: i18n.t('table.config.label.artist', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.ARTIST,\n        width: 300,\n    },\n    {\n        align: 'start',\n        autoSize: false,\n        isEnabled: false,\n        label: i18n.t('table.config.label.composer', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.COMPOSER,\n        width: 300,\n    },\n    {\n        align: 'start',\n        autoSize: false,\n        isEnabled: true,\n        label: i18n.t('table.config.label.genre', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.GENRE,\n        width: 300,\n    },\n    {\n        align: 'start',\n        autoSize: false,\n        isEnabled: false,\n        label: i18n.t('table.config.label.genreBadge', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.GENRE_BADGE,\n        width: 300,\n    },\n    {\n        align: 'center',\n        autoSize: false,\n        isEnabled: true,\n        label: i18n.t('table.config.label.year', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.YEAR,\n        width: 200,\n    },\n    {\n        align: 'center',\n        autoSize: false,\n        isEnabled: false,\n        label: i18n.t('table.config.label.releaseDate', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.RELEASE_DATE,\n        width: 240,\n    },\n    {\n        align: 'center',\n        autoSize: false,\n        isEnabled: false,\n        label: i18n.t('table.config.label.discNumber', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.DISC_NUMBER,\n        width: 100,\n    },\n    {\n        align: 'center',\n        autoSize: false,\n        isEnabled: false,\n        label: i18n.t('table.config.label.trackNumber', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.TRACK_NUMBER,\n        width: 100,\n    },\n    {\n        align: 'center',\n        autoSize: false,\n        isEnabled: false,\n        label: i18n.t('table.config.label.bitDepth', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.BIT_DEPTH,\n        width: 100,\n    },\n    {\n        align: 'center',\n        autoSize: false,\n        isEnabled: false,\n        label: i18n.t('table.config.label.bitrate', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.BIT_RATE,\n        width: 100,\n    },\n    {\n        align: 'center',\n        autoSize: false,\n        isEnabled: false,\n        label: i18n.t('table.config.label.codec', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.CODEC,\n        width: 100,\n    },\n    {\n        align: 'center',\n        autoSize: false,\n        isEnabled: false,\n        label: i18n.t('table.config.label.sampleRate', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.SAMPLE_RATE,\n        width: 100,\n    },\n    {\n        align: 'center',\n        autoSize: false,\n        isEnabled: false,\n        label: i18n.t('table.config.label.lastPlayed', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.LAST_PLAYED,\n        width: 150,\n    },\n    {\n        align: 'start',\n        autoSize: false,\n        isEnabled: false,\n        label: i18n.t('table.config.label.note', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.COMMENT,\n        width: 300,\n    },\n    {\n        align: 'center',\n        autoSize: false,\n        isEnabled: false,\n        label: i18n.t('table.config.label.channels', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.CHANNELS,\n        width: 100,\n    },\n    {\n        align: 'center',\n        autoSize: false,\n        isEnabled: false,\n        label: i18n.t('table.config.label.bpm', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.BPM,\n        width: 100,\n    },\n    {\n        align: 'center',\n        autoSize: false,\n        isEnabled: false,\n        label: i18n.t('table.config.label.dateAdded', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.DATE_ADDED,\n        width: 120,\n    },\n    {\n        align: 'start',\n        autoSize: false,\n        isEnabled: false,\n        label: i18n.t('table.config.label.path', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.PATH,\n        width: 300,\n    },\n    {\n        align: 'center',\n        autoSize: false,\n        isEnabled: false,\n        label: i18n.t('table.config.label.playCount', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.PLAY_COUNT,\n        width: 100,\n    },\n    {\n        align: 'center',\n        autoSize: false,\n        isEnabled: false,\n        label: i18n.t('table.config.label.size', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.SIZE,\n        width: 100,\n    },\n    {\n        align: 'center',\n        autoSize: false,\n        isEnabled: true,\n        label: i18n.t('table.config.label.favorite', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.USER_FAVORITE,\n        width: 60,\n    },\n    {\n        align: 'center',\n        autoSize: false,\n        isEnabled: false,\n        label: i18n.t('table.config.label.rating', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.USER_RATING,\n        width: 100,\n    },\n    {\n        align: 'center',\n        autoSize: false,\n        isEnabled: false,\n        label: i18n.t('table.config.label.actions', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.ACTIONS,\n        width: 60,\n    },\n];\n\nexport const PLAYLIST_SONG_TABLE_COLUMNS: DefaultTableColumn[] = SONG_TABLE_COLUMNS;\n\nexport const ALBUM_TABLE_COLUMNS: DefaultTableColumn[] = [\n    {\n        align: 'center',\n        autoSize: false,\n        isEnabled: true,\n        label: i18n.t('table.config.label.rowIndex', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.ROW_INDEX,\n        width: 60,\n    },\n    {\n        align: 'center',\n        autoSize: false,\n        isEnabled: false,\n        label: i18n.t('table.config.label.image', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.IMAGE,\n        width: 70,\n    },\n    {\n        align: 'start',\n        autoSize: false,\n        isEnabled: false,\n        label: i18n.t('table.config.label.title', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.TITLE,\n        width: 300,\n    },\n    {\n        align: 'start',\n        autoSize: false,\n        isEnabled: true,\n        label: i18n.t('table.config.label.titleCombined', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.TITLE_COMBINED,\n        width: 300,\n    },\n    {\n        align: 'start',\n        autoSize: false,\n        isEnabled: false,\n        label: i18n.t('table.config.label.titleArtist', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.TITLE_ARTIST,\n        width: 300,\n    },\n    {\n        align: 'center',\n        autoSize: false,\n        isEnabled: true,\n        label: i18n.t('table.config.label.duration', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.DURATION,\n        width: 100,\n    },\n    {\n        align: 'start',\n        autoSize: true,\n        isEnabled: false,\n        label: i18n.t('table.config.label.albumArtist', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.ALBUM_ARTIST,\n        width: 300,\n    },\n    {\n        align: 'start',\n        autoSize: false,\n        isEnabled: false,\n        label: i18n.t('table.config.label.artist', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.ARTIST,\n        width: 300,\n    },\n    {\n        align: 'start',\n        autoSize: false,\n        isEnabled: false,\n        label: i18n.t('table.config.label.composer', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.COMPOSER,\n        width: 300,\n    },\n    {\n        align: 'center',\n        autoSize: false,\n        isEnabled: false,\n        label: i18n.t('table.config.label.songCount', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.SONG_COUNT,\n        width: 100,\n    },\n    {\n        align: 'start',\n        autoSize: false,\n        isEnabled: true,\n        label: i18n.t('table.config.label.genre', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.GENRE,\n        width: 300,\n    },\n    {\n        align: 'start',\n        autoSize: false,\n        isEnabled: false,\n        label: i18n.t('table.config.label.genreBadge', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.GENRE_BADGE,\n        width: 300,\n    },\n    {\n        align: 'center',\n        autoSize: false,\n        isEnabled: true,\n        label: i18n.t('table.config.label.year', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.YEAR,\n        width: 200,\n    },\n    {\n        align: 'center',\n        autoSize: false,\n        isEnabled: false,\n        label: i18n.t('table.config.label.releaseDate', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.RELEASE_DATE,\n        width: 240,\n    },\n    {\n        align: 'center',\n        autoSize: false,\n        isEnabled: false,\n        label: i18n.t('table.config.label.lastPlayed', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.LAST_PLAYED,\n        width: 150,\n    },\n    {\n        align: 'center',\n        autoSize: false,\n        isEnabled: false,\n        label: i18n.t('table.config.label.dateAdded', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.DATE_ADDED,\n        width: 120,\n    },\n    {\n        align: 'center',\n        autoSize: false,\n        isEnabled: false,\n        label: i18n.t('table.config.label.playCount', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.PLAY_COUNT,\n        width: 100,\n    },\n    {\n        align: 'center',\n        autoSize: false,\n        isEnabled: true,\n        label: i18n.t('table.config.label.favorite', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.USER_FAVORITE,\n        width: 60,\n    },\n    {\n        align: 'center',\n        autoSize: false,\n        isEnabled: false,\n        label: i18n.t('table.config.label.rating', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.USER_RATING,\n        width: 100,\n    },\n    {\n        align: 'center',\n        autoSize: false,\n        isEnabled: false,\n        label: i18n.t('table.config.label.actions', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.ACTIONS,\n        width: 60,\n    },\n];\n\nexport const ALBUM_ARTIST_TABLE_COLUMNS: DefaultTableColumn[] = [\n    {\n        align: 'center',\n        autoSize: false,\n        isEnabled: true,\n        label: i18n.t('table.config.label.rowIndex', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.ROW_INDEX,\n        width: 60,\n    },\n    {\n        align: 'center',\n        autoSize: false,\n        isEnabled: true,\n        label: i18n.t('table.config.label.image', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.IMAGE,\n        width: 70,\n    },\n    {\n        align: 'start',\n        autoSize: false,\n        isEnabled: true,\n        label: i18n.t('table.config.label.title', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.TITLE,\n        width: 300,\n    },\n    {\n        align: 'center',\n        autoSize: false,\n        isEnabled: false,\n        label: i18n.t('table.config.label.duration', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.DURATION,\n        width: 100,\n    },\n    {\n        align: 'start',\n        autoSize: false,\n        isEnabled: false,\n        label: i18n.t('table.config.label.biography', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.BIOGRAPHY,\n        width: 300,\n    },\n    {\n        align: 'start',\n        autoSize: false,\n        isEnabled: false,\n        label: i18n.t('table.config.label.genre', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.GENRE,\n        width: 300,\n    },\n    {\n        align: 'center',\n        autoSize: false,\n        isEnabled: true,\n        label: i18n.t('table.config.label.lastPlayed', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.LAST_PLAYED,\n        width: 150,\n    },\n    {\n        align: 'center',\n        autoSize: false,\n        isEnabled: true,\n        label: i18n.t('table.config.label.playCount', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.PLAY_COUNT,\n        width: 100,\n    },\n    {\n        align: 'center',\n        autoSize: false,\n        isEnabled: true,\n        label: i18n.t('filter.albumCount', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.ALBUM_COUNT,\n        width: 100,\n    },\n    {\n        align: 'center',\n        autoSize: false,\n        isEnabled: true,\n        label: i18n.t('table.config.label.songCount', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.SONG_COUNT,\n        width: 100,\n    },\n    {\n        align: 'center',\n        autoSize: false,\n        isEnabled: true,\n        label: i18n.t('table.config.label.favorite', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.USER_FAVORITE,\n        width: 60,\n    },\n    {\n        align: 'center',\n        autoSize: false,\n        isEnabled: false,\n        label: i18n.t('table.config.label.rating', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.USER_RATING,\n        width: 100,\n    },\n    {\n        align: 'center',\n        autoSize: false,\n        isEnabled: false,\n        label: i18n.t('table.config.label.actions', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.ACTIONS,\n        width: 60,\n    },\n];\n\nexport const PLAYLIST_TABLE_COLUMNS: DefaultTableColumn[] = [\n    {\n        align: 'center',\n        autoSize: false,\n        isEnabled: true,\n        label: i18n.t('table.config.label.rowIndex', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.ROW_INDEX,\n        width: 60,\n    },\n    {\n        align: 'center',\n        autoSize: false,\n        isEnabled: true,\n        label: i18n.t('table.config.label.image', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.IMAGE,\n        width: 70,\n    },\n    {\n        align: 'start',\n        autoSize: false,\n        isEnabled: true,\n        label: i18n.t('table.config.label.title', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.TITLE,\n        width: 300,\n    },\n    {\n        align: 'start',\n        autoSize: false,\n        isEnabled: false,\n        label: i18n.t('table.config.label.titleCombined', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.TITLE_COMBINED,\n        width: 300,\n    },\n    {\n        align: 'center',\n        autoSize: false,\n        isEnabled: true,\n        label: i18n.t('table.config.label.duration', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.DURATION,\n        width: 100,\n    },\n    {\n        align: 'center',\n        autoSize: false,\n        isEnabled: true,\n        label: i18n.t('table.config.label.owner', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.OWNER,\n        width: 150,\n    },\n    {\n        align: 'center',\n        autoSize: false,\n        isEnabled: true,\n        label: i18n.t('table.config.label.songCount', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.SONG_COUNT,\n        width: 100,\n    },\n    {\n        align: 'center',\n        autoSize: false,\n        isEnabled: false,\n        label: i18n.t('table.config.label.actions', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.ACTIONS,\n        width: 60,\n    },\n];\n\nexport const GENRE_TABLE_COLUMNS: DefaultTableColumn[] = [\n    {\n        align: 'center',\n        autoSize: false,\n        isEnabled: true,\n        label: i18n.t('table.config.label.rowIndex', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.ROW_INDEX,\n        width: 60,\n    },\n    {\n        align: 'start',\n        autoSize: true,\n        isEnabled: true,\n        label: i18n.t('table.config.label.title', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.TITLE,\n        width: 300,\n    },\n    {\n        align: 'center',\n        autoSize: false,\n        isEnabled: true,\n        label: i18n.t('table.config.label.songCount', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.SONG_COUNT,\n        width: 100,\n    },\n    {\n        align: 'center',\n        autoSize: false,\n        isEnabled: true,\n        label: i18n.t('table.config.label.albumCount', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.ALBUM_COUNT,\n        width: 100,\n    },\n    {\n        align: 'center',\n        autoSize: false,\n        isEnabled: false,\n        label: i18n.t('table.config.label.actions', { postProcess: 'titleCase' }),\n        pinned: null,\n        value: TableColumn.ACTIONS,\n        width: 60,\n    },\n];\n\nexport const pickTableColumns = (options: {\n    alignCenterColumns?: TableColumn[];\n    alignLeftColumns?: TableColumn[];\n    alignRightColumns?: TableColumn[];\n    autoSizeColumns?: TableColumn[];\n    columns: DefaultTableColumn[];\n    columnWidths?: Partial<Record<TableColumn, number>>;\n    enabledColumns: TableColumn[];\n    pickColumns?: TableColumn[];\n    pinnedLeftColumns?: TableColumn[];\n    pinnedRightColumns?: TableColumn[];\n}): ItemTableListColumnConfig[] => {\n    const {\n        alignCenterColumns = [],\n        alignLeftColumns = [],\n        alignRightColumns = [],\n        autoSizeColumns = [],\n        columns,\n        columnWidths = {},\n        enabledColumns,\n        pickColumns = [],\n        pinnedLeftColumns = [],\n        pinnedRightColumns = [],\n    } = options;\n\n    const columnsToPick: ItemTableListColumnConfig[] = [];\n\n    const columnMap = new Map<TableColumn, DefaultTableColumn>();\n    columns.forEach((column) => {\n        columnMap.set(column.value, column);\n    });\n\n    let columnsToProcess: DefaultTableColumn[];\n    if (enabledColumns.length > 0) {\n        columnsToProcess = enabledColumns\n            .map((col) => columnMap.get(col))\n            .filter((col): col is DefaultTableColumn => col !== undefined);\n\n        if (pickColumns.length === 0) {\n            const enabledSet = new Set(enabledColumns);\n            const remaining = columns.filter((col) => !enabledSet.has(col.value));\n            columnsToProcess = [...columnsToProcess, ...remaining];\n        } else {\n            // When pickColumns is provided, include pickColumns that aren't in enabledColumns\n            // so they can be added as disabled entries\n            const enabledSet = new Set(enabledColumns);\n            const pickColumnsNotEnabled = pickColumns\n                .filter((col) => !enabledSet.has(col))\n                .map((col) => columnMap.get(col))\n                .filter((col): col is DefaultTableColumn => col !== undefined);\n            columnsToProcess = [...columnsToProcess, ...pickColumnsNotEnabled];\n        }\n    } else {\n        columnsToProcess = columns;\n    }\n\n    columnsToProcess.forEach((column) => {\n        if (pickColumns.length > 0 && !pickColumns?.includes(column.value)) {\n            return;\n        }\n\n        let pinned: 'left' | 'right' | null = null;\n\n        if (pinnedLeftColumns.includes(column.value)) {\n            pinned = 'left';\n        } else if (pinnedRightColumns.includes(column.value)) {\n            pinned = 'right';\n        }\n\n        let align: 'center' | 'end' | 'start' = column.align;\n\n        if (alignCenterColumns.includes(column.value)) {\n            align = 'center';\n        } else if (alignLeftColumns.includes(column.value)) {\n            align = 'start';\n        } else if (alignRightColumns.includes(column.value)) {\n            align = 'end';\n        }\n\n        const isEnabled = enabledColumns.includes(column.value);\n\n        const autoSize = autoSizeColumns.includes(column.value);\n\n        // Use custom width if provided, otherwise use default\n        const width = columnWidths[column.value] ?? column.width;\n\n        columnsToPick.push({\n            align,\n            autoSize,\n            id: column.value,\n            isEnabled,\n            pinned,\n            width,\n        });\n    });\n\n    return columnsToPick;\n};\n\nexport const pickGridRows = (\n    options: Parameters<typeof pickTableColumns>[0],\n): ItemGridListRowConfig[] => {\n    const columns = pickTableColumns(options);\n    return columns.map((column) => ({\n        align: 'start',\n        id: column.id as TableColumn,\n        isEnabled: column.isEnabled,\n    }));\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/item-table-list/hooks/use-container-width-tracking.ts",
    "content": "import { useLayoutEffect } from 'react';\n\ninterface UseContainerWidthTrackingProps {\n    autoFitColumns: boolean;\n    containerRef: React.RefObject<HTMLDivElement | null>;\n    rowRef: React.RefObject<HTMLDivElement | null>;\n    setCenterContainerWidth: (width: number) => void;\n    setTotalContainerWidth: (width: number) => void;\n}\n\n/**\n * Hook to track container widths using ResizeObserver for column width calculations.\n */\nexport const useContainerWidthTracking = ({\n    autoFitColumns,\n    containerRef,\n    rowRef,\n    setCenterContainerWidth,\n    setTotalContainerWidth,\n}: UseContainerWidthTrackingProps) => {\n    const createWidthUpdater = (\n        el: HTMLDivElement,\n        setWidth: (width: number) => void,\n        opts?: { maxRafRetries?: number },\n    ) => {\n        const maxRafRetries = opts?.maxRafRetries ?? 10;\n        let rafId: null | number = null;\n\n        const cancel = () => {\n            if (rafId !== null) cancelAnimationFrame(rafId);\n            rafId = null;\n        };\n\n        const updateWidth = () => {\n            const measured = el.clientWidth || 0;\n            if (measured > 0) {\n                cancel();\n                setWidth(measured);\n                return;\n            }\n\n            // Some layouts can report 0 on first paint\n            // Retry a few frames to catch the first non-zero measurement\n            cancel();\n            let attempts = 0;\n            const retry = () => {\n                const next = el.clientWidth || 0;\n                if (next > 0) {\n                    rafId = null;\n                    setWidth(next);\n                    return;\n                }\n                attempts++;\n                if (attempts < maxRafRetries) {\n                    rafId = requestAnimationFrame(retry);\n                } else {\n                    rafId = null;\n                    setWidth(0);\n                }\n            };\n            rafId = requestAnimationFrame(retry);\n        };\n\n        return { cancel, updateWidth };\n    };\n\n    // Track center container width (for column distribution)\n    useLayoutEffect(() => {\n        const el = rowRef.current;\n        if (!el) return;\n\n        const { cancel, updateWidth } = createWidthUpdater(el, setCenterContainerWidth);\n\n        updateWidth();\n\n        let debounceTimeout: NodeJS.Timeout | null = null;\n        const resizeObserver = new ResizeObserver(() => {\n            if (debounceTimeout) {\n                clearTimeout(debounceTimeout);\n            }\n            debounceTimeout = setTimeout(() => {\n                updateWidth();\n            }, 100);\n        });\n\n        resizeObserver.observe(el);\n\n        return () => {\n            if (debounceTimeout) {\n                clearTimeout(debounceTimeout);\n            }\n            cancel();\n            resizeObserver.disconnect();\n        };\n    }, [rowRef, setCenterContainerWidth]);\n\n    // Track total container width for autoFitColumns\n    useLayoutEffect(() => {\n        const el = containerRef.current;\n        if (!el || !autoFitColumns) return;\n\n        const { cancel, updateWidth } = createWidthUpdater(el, setTotalContainerWidth);\n\n        updateWidth();\n\n        let debounceTimeout: NodeJS.Timeout | null = null;\n        const resizeObserver = new ResizeObserver(() => {\n            if (debounceTimeout) {\n                clearTimeout(debounceTimeout);\n            }\n            debounceTimeout = setTimeout(() => {\n                updateWidth();\n            }, 100);\n        });\n\n        resizeObserver.observe(el);\n\n        return () => {\n            if (debounceTimeout) {\n                clearTimeout(debounceTimeout);\n            }\n            cancel();\n            resizeObserver.disconnect();\n        };\n    }, [autoFitColumns, containerRef, setTotalContainerWidth]);\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/item-table-list/hooks/use-item-drag-drop-state.tsx",
    "content": "import { getDraggedItems } from '/@/renderer/components/item-list/helpers/get-dragged-items';\nimport { useItemDraggingState } from '/@/renderer/components/item-list/helpers/item-list-state';\nimport { ItemListStateActions } from '/@/renderer/components/item-list/helpers/item-list-state';\nimport { eventEmitter } from '/@/renderer/events/event-emitter';\nimport { PlayerContext } from '/@/renderer/features/player/context/player-context';\nimport { useDragDrop } from '/@/renderer/hooks/use-drag-drop';\nimport { Folder, LibraryItem, QueueSong, Song } from '/@/shared/types/domain-types';\nimport { DragOperation, DragTarget, DragTargetMap } from '/@/shared/types/drag-and-drop';\n\ninterface DragDropState<TElement extends HTMLElement = HTMLDivElement> {\n    dragRef: null | React.Ref<TElement>;\n    isDraggedOver: 'bottom' | 'top' | null;\n    isDragging: boolean;\n}\n\ninterface UseItemDragDropStateProps {\n    enableDrag: boolean;\n    internalState: ItemListStateActions;\n    isDataRow: boolean;\n    item: unknown;\n    itemType: LibraryItem;\n    playerContext: PlayerContext;\n    playlistId?: string;\n}\n\nexport const useItemDragDropState = <TElement extends HTMLElement = HTMLDivElement>({\n    enableDrag,\n    internalState,\n    isDataRow,\n    item,\n    itemType,\n    playerContext,\n    playlistId,\n}: UseItemDragDropStateProps): DragDropState<TElement> => {\n    const shouldEnableDrag = enableDrag && isDataRow && !!item;\n\n    const {\n        isDraggedOver,\n        isDragging: isDraggingLocal,\n        ref: dragRef,\n    } = useDragDrop<TElement>({\n        drag: {\n            getId: () => {\n                if (!item || !isDataRow) {\n                    return [];\n                }\n\n                const draggedItems = getDraggedItems(item as any, internalState);\n\n                return draggedItems.map((draggedItem) => draggedItem.id);\n            },\n            getItem: () => {\n                if (!item || !isDataRow) {\n                    return [];\n                }\n\n                const draggedItems = getDraggedItems(item as any, internalState);\n\n                return draggedItems;\n            },\n            itemType,\n            onDragStart: () => {\n                if (!item || !isDataRow) {\n                    return;\n                }\n\n                const draggedItems = getDraggedItems(item as any, internalState);\n                if (internalState) {\n                    internalState.setDragging(draggedItems);\n                }\n            },\n            onDrop: () => {\n                if (internalState) {\n                    internalState.setDragging([]);\n                }\n            },\n            operation:\n                itemType === LibraryItem.QUEUE_SONG\n                    ? [DragOperation.REORDER, DragOperation.ADD]\n                    : itemType === LibraryItem.PLAYLIST_SONG\n                      ? [DragOperation.REORDER, DragOperation.ADD]\n                      : [DragOperation.ADD],\n            target: DragTargetMap[itemType] || DragTarget.GENERIC,\n        },\n        drop: {\n            canDrop: (args) => {\n                if (args.source.type === DragTarget.TABLE_COLUMN) {\n                    return false;\n                }\n\n                // Allow drops for QUEUE_SONG (queue reordering)\n                if (itemType === LibraryItem.QUEUE_SONG) {\n                    return true;\n                }\n\n                // Allow drops for PLAYLIST_SONG (playlist reordering)\n                // Only allow drops when drag is started from the reorder handle\n                if (\n                    itemType === LibraryItem.PLAYLIST_SONG &&\n                    args.source.itemType === LibraryItem.PLAYLIST_SONG &&\n                    args.source.metadata?.fromReorderHandle === true\n                ) {\n                    return true;\n                }\n\n                return false;\n            },\n            getData: () => {\n                return {\n                    id: [(item as unknown as { id: string }).id],\n                    item: [item as unknown as unknown[]],\n                    itemType,\n                    type: DragTargetMap[itemType] || DragTarget.GENERIC,\n                };\n            },\n            onDrag: () => {\n                return;\n            },\n            onDragLeave: () => {\n                return;\n            },\n            onDrop: (args) => {\n                if (args.self.type === DragTarget.QUEUE_SONG) {\n                    const sourceServerId = (\n                        args.source.item?.[0] as unknown as { _serverId: string }\n                    )._serverId;\n\n                    const sourceItemType = args.source.itemType as LibraryItem;\n\n                    const droppedOnUniqueId = (\n                        args.self.item?.[0] as unknown as { _uniqueId: string }\n                    )._uniqueId;\n\n                    switch (args.source.type) {\n                        case DragTarget.ALBUM: {\n                            playerContext.addToQueueByFetch(\n                                sourceServerId,\n                                args.source.id,\n                                sourceItemType,\n                                { edge: args.edge, uniqueId: droppedOnUniqueId },\n                            );\n                            break;\n                        }\n                        case DragTarget.ALBUM_ARTIST: {\n                            playerContext.addToQueueByFetch(\n                                sourceServerId,\n                                args.source.id,\n                                sourceItemType,\n                                { edge: args.edge, uniqueId: droppedOnUniqueId },\n                            );\n                            break;\n                        }\n                        case DragTarget.ARTIST: {\n                            playerContext.addToQueueByFetch(\n                                sourceServerId,\n                                args.source.id,\n                                sourceItemType,\n                                { edge: args.edge, uniqueId: droppedOnUniqueId },\n                            );\n                            break;\n                        }\n                        case DragTarget.FOLDER: {\n                            const items = args.source.item;\n\n                            const { folders, songs } = (items || []).reduce<{\n                                folders: Folder[];\n                                songs: Song[];\n                            }>(\n                                (acc, item) => {\n                                    if ((item as unknown as Song)._itemType === LibraryItem.SONG) {\n                                        acc.songs.push(item as unknown as Song);\n                                    } else if (\n                                        (item as unknown as Folder)._itemType === LibraryItem.FOLDER\n                                    ) {\n                                        acc.folders.push(item as unknown as Folder);\n                                    }\n                                    return acc;\n                                },\n                                { folders: [], songs: [] },\n                            );\n\n                            const folderIds = folders.map((folder) => folder.id);\n\n                            // Handle folders: fetch and add to queue\n                            if (folderIds.length > 0) {\n                                playerContext.addToQueueByFetch(\n                                    sourceServerId,\n                                    folderIds,\n                                    LibraryItem.FOLDER,\n                                    { edge: args.edge, uniqueId: droppedOnUniqueId },\n                                );\n                            }\n\n                            // Handle songs: add directly to queue\n                            if (songs.length > 0) {\n                                playerContext.addToQueueByData(songs, {\n                                    edge: args.edge,\n                                    uniqueId: droppedOnUniqueId,\n                                });\n                            }\n\n                            break;\n                        }\n                        case DragTarget.GENRE: {\n                            playerContext.addToQueueByFetch(\n                                sourceServerId,\n                                args.source.id,\n                                sourceItemType,\n                                { edge: args.edge, uniqueId: droppedOnUniqueId },\n                            );\n                            break;\n                        }\n                        case DragTarget.PLAYLIST: {\n                            playerContext.addToQueueByFetch(\n                                sourceServerId,\n                                args.source.id,\n                                sourceItemType,\n                                { edge: args.edge, uniqueId: droppedOnUniqueId },\n                            );\n                            break;\n                        }\n                        case DragTarget.QUEUE_SONG: {\n                            const sourceItems = (args.source.item || []) as QueueSong[];\n                            if (\n                                sourceItems.length > 0 &&\n                                args.edge &&\n                                (args.edge === 'top' || args.edge === 'bottom')\n                            ) {\n                                playerContext.moveSelectedTo(\n                                    sourceItems,\n                                    args.edge,\n                                    droppedOnUniqueId,\n                                );\n                            }\n                            break;\n                        }\n                        case DragTarget.SONG: {\n                            const sourceItems = (args.source.item || []) as Song[];\n                            if (sourceItems.length > 0) {\n                                playerContext.addToQueueByData(sourceItems, {\n                                    edge: args.edge,\n                                    uniqueId: droppedOnUniqueId,\n                                });\n                            }\n                            break;\n                        }\n                        default: {\n                            break;\n                        }\n                    }\n                }\n\n                // Handle PLAYLIST_SONG reordering\n                // Only allow drops when drag is started from the reorder handle\n                if (\n                    args.self.itemType === LibraryItem.PLAYLIST_SONG &&\n                    args.source.itemType === LibraryItem.PLAYLIST_SONG &&\n                    args.source.metadata?.fromReorderHandle === true &&\n                    playlistId\n                ) {\n                    const sourceItems = (args.source.item || []) as any[];\n                    const targetItem = item as any;\n\n                    if (\n                        sourceItems.length > 0 &&\n                        args.edge &&\n                        (args.edge === 'top' || args.edge === 'bottom') &&\n                        targetItem\n                    ) {\n                        // Emit event to reorder playlist songs\n                        eventEmitter.emit('PLAYLIST_REORDER', {\n                            edge: args.edge,\n                            playlistId,\n                            sourceIds: args.source.id,\n                            targetId: targetItem.id,\n                        });\n                    }\n                }\n\n                if (internalState) {\n                    internalState.setDragging([]);\n                }\n\n                return;\n            },\n        },\n        isEnabled: shouldEnableDrag,\n    });\n\n    const itemRowId =\n        item && typeof item === 'object' && 'id' in item && internalState\n            ? internalState.extractRowId(item)\n            : undefined;\n    const isDraggingState = useItemDraggingState(\n        internalState,\n        itemRowId ||\n            (item && typeof item === 'object' && 'id' in item ? (item as any).id : undefined),\n    );\n    const isDragging = internalState ? isDraggingState : isDraggingLocal;\n\n    return {\n        dragRef: shouldEnableDrag ? dragRef : null,\n        isDraggedOver: isDraggedOver === 'top' || isDraggedOver === 'bottom' ? isDraggedOver : null,\n        isDragging,\n    };\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/item-table-list/hooks/use-row-interaction-delegate.ts",
    "content": "import { useEffect } from 'react';\n\ninterface UseRowInteractionDelegateProps {\n    containerRef: React.RefObject<HTMLDivElement | null>;\n    enableRowHoverHighlight: boolean;\n}\n\n/**\n * Hook to handle row hover and drag-over styling via delegated event listeners.\n * This is intentionally imperative to avoid React re-rendering the entire visible grid on hover.\n */\nexport const useRowInteractionDelegate = ({\n    containerRef,\n    enableRowHoverHighlight,\n}: UseRowInteractionDelegateProps) => {\n    // Row hover highlight: do one delegated listener per table rather than per cell\n    useEffect(() => {\n        if (!enableRowHoverHighlight) return;\n        const root = containerRef.current;\n        if (!root) return;\n\n        let hoveredKey: null | string = null;\n        let rafId: null | number = null;\n\n        const getRowKey = (target: EventTarget | null): null | string => {\n            const el = target instanceof Element ? target : null;\n            const rowEl = el?.closest?.('[data-row-index]') as HTMLElement | null;\n            return rowEl?.getAttribute('data-row-index') ?? null;\n        };\n\n        const apply = (prev: null | string, next: null | string) => {\n            if (rafId !== null) cancelAnimationFrame(rafId);\n            rafId = requestAnimationFrame(() => {\n                if (prev) {\n                    root.querySelectorAll(`[data-row-index=\"${prev}\"]`).forEach((node) => {\n                        (node as HTMLElement).removeAttribute('data-row-hovered');\n                    });\n                }\n                if (next) {\n                    root.querySelectorAll(`[data-row-index=\"${next}\"]`).forEach((node) => {\n                        (node as HTMLElement).setAttribute('data-row-hovered', 'true');\n                    });\n                }\n            });\n        };\n\n        const setHovered = (next: null | string) => {\n            if (next === hoveredKey) return;\n            const prev = hoveredKey;\n            hoveredKey = next;\n            apply(prev, next);\n        };\n\n        const onPointerOver = (e: PointerEvent) => {\n            setHovered(getRowKey(e.target));\n        };\n\n        const onPointerOut = (e: PointerEvent) => {\n            // If moving within the same row, keep it hovered\n            const relatedKey = getRowKey((e as any).relatedTarget);\n            if (relatedKey === hoveredKey) return;\n            setHovered(relatedKey);\n        };\n\n        root.addEventListener('pointerover', onPointerOver);\n        root.addEventListener('pointerout', onPointerOut);\n\n        return () => {\n            root.removeEventListener('pointerover', onPointerOver);\n            root.removeEventListener('pointerout', onPointerOut);\n            if (rafId !== null) cancelAnimationFrame(rafId);\n            // Ensure we don't leave stale attributes behind\n            if (hoveredKey) apply(hoveredKey, null);\n        };\n    }, [containerRef, enableRowHoverHighlight]);\n\n    // Dragged-over row border styling delegation\n    useEffect(() => {\n        const root = containerRef.current;\n        if (!root) return;\n\n        let current: null | { edge: 'bottom' | 'top'; rowKey: string } = null;\n        let pending: null | { edge: 'bottom' | 'top' | null; rowKey: string } = null;\n        let rafId: null | number = null;\n\n        const clearRow = (rowKey: string) => {\n            root.querySelectorAll(`[data-row-index=\"${rowKey}\"]`).forEach((node) => {\n                const el = node as HTMLElement;\n                el.removeAttribute('data-row-dragged-over');\n                el.removeAttribute('data-row-dragged-over-first');\n            });\n        };\n\n        const applyRow = (rowKey: string, edge: 'bottom' | 'top') => {\n            const nodes = root.querySelectorAll(`[data-row-index=\"${rowKey}\"]`);\n            nodes.forEach((node, idx) => {\n                const el = node as HTMLElement;\n                el.setAttribute('data-row-dragged-over', edge);\n                if (idx === 0) {\n                    el.setAttribute('data-row-dragged-over-first', 'true');\n                } else {\n                    el.removeAttribute('data-row-dragged-over-first');\n                }\n            });\n        };\n\n        const flush = () => {\n            rafId = null;\n            const next = pending;\n            pending = null;\n            if (!next) return;\n\n            // Clear previous row if we're moving rows or clearing.\n            if (current && current.rowKey !== next.rowKey) {\n                clearRow(current.rowKey);\n                current = null;\n            }\n\n            if (!next.edge) {\n                if (current) {\n                    clearRow(current.rowKey);\n                    current = null;\n                }\n                return;\n            }\n\n            // If same row + edge, no-op.\n            if (current && current.rowKey === next.rowKey && current.edge === next.edge) return;\n\n            if (current) clearRow(current.rowKey);\n            applyRow(next.rowKey, next.edge);\n            current = { edge: next.edge, rowKey: next.rowKey };\n        };\n\n        const scheduleFlush = () => {\n            if (rafId !== null) return;\n            rafId = requestAnimationFrame(flush);\n        };\n\n        const onRowDragOver = (e: Event) => {\n            const ev = e as CustomEvent<{ edge?: 'bottom' | 'top' | null; rowKey?: string }>;\n            const rowKey = ev.detail?.rowKey;\n            const edge = ev.detail?.edge ?? null;\n            if (!rowKey) return;\n\n            pending = { edge, rowKey };\n            scheduleFlush();\n        };\n\n        root.addEventListener('itl:row-drag-over', onRowDragOver as any);\n\n        return () => {\n            root.removeEventListener('itl:row-drag-over', onRowDragOver as any);\n            if (rafId !== null) cancelAnimationFrame(rafId);\n            if (current) clearRow(current.rowKey);\n        };\n    }, [containerRef]);\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/item-table-list/hooks/use-sticky-group-row-positioning.ts",
    "content": "import { useEffect } from 'react';\n\ninterface UseStickyGroupRowPositioningProps {\n    containerRef: React.RefObject<HTMLDivElement | null>;\n    shouldRenderStickyGroupRow: boolean;\n    stickyGroupRowRef: React.RefObject<HTMLDivElement | null>;\n}\n\n/**\n * Hook to update the position and width of the sticky group row based on container position.\n */\nexport const useStickyGroupRowPositioning = ({\n    containerRef,\n    shouldRenderStickyGroupRow,\n    stickyGroupRowRef,\n}: UseStickyGroupRowPositioningProps) => {\n    useEffect(() => {\n        if (!shouldRenderStickyGroupRow || !stickyGroupRowRef.current || !containerRef.current) {\n            return;\n        }\n\n        const stickyGroupRow = stickyGroupRowRef.current;\n        const container = containerRef.current;\n        let isMounted = true;\n\n        const updatePosition = () => {\n            // Guard against updates after unmount\n            if (!isMounted || !stickyGroupRow || !container) {\n                return;\n            }\n            try {\n                const containerRect = container.getBoundingClientRect();\n                stickyGroupRow.style.left = `${containerRect.left}px`;\n                stickyGroupRow.style.width = `${containerRect.width}px`;\n            } catch {\n                // Silently handle errors if elements are no longer in DOM\n            }\n        };\n\n        updatePosition();\n\n        window.addEventListener('resize', updatePosition);\n        window.addEventListener('scroll', updatePosition, true);\n\n        return () => {\n            isMounted = false;\n            window.removeEventListener('resize', updatePosition);\n            window.removeEventListener('scroll', updatePosition, true);\n        };\n    }, [containerRef, shouldRenderStickyGroupRow, stickyGroupRowRef]);\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/item-table-list/hooks/use-sticky-header-positioning.ts",
    "content": "import { useEffect } from 'react';\n\ninterface UseStickyHeaderPositioningProps {\n    containerRef: React.RefObject<HTMLDivElement | null>;\n    shouldShowStickyHeader: boolean;\n    stickyHeaderRef: React.RefObject<HTMLDivElement | null>;\n}\n\n/**\n * Hook to update the position and width of the sticky header based on container position.\n * Scroll synchronization is handled separately in useStickyTableHeader.\n */\nexport const useStickyHeaderPositioning = ({\n    containerRef,\n    shouldShowStickyHeader,\n    stickyHeaderRef,\n}: UseStickyHeaderPositioningProps) => {\n    useEffect(() => {\n        if (!shouldShowStickyHeader || !stickyHeaderRef.current || !containerRef.current) {\n            return;\n        }\n\n        const stickyHeader = stickyHeaderRef.current;\n        const container = containerRef.current;\n        let isMounted = true;\n\n        const updatePosition = () => {\n            // Guard against updates after unmount\n            if (!isMounted || !stickyHeader || !container) {\n                return;\n            }\n            try {\n                const containerRect = container.getBoundingClientRect();\n                stickyHeader.style.left = `${containerRect.left}px`;\n                stickyHeader.style.width = `${containerRect.width}px`;\n            } catch {\n                // Silently handle errors if elements are no longer in DOM\n            }\n        };\n\n        updatePosition();\n\n        window.addEventListener('resize', updatePosition);\n        window.addEventListener('scroll', updatePosition, true);\n\n        return () => {\n            isMounted = false;\n            window.removeEventListener('resize', updatePosition);\n            window.removeEventListener('scroll', updatePosition, true);\n        };\n    }, [containerRef, shouldShowStickyHeader, stickyHeaderRef]);\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/item-table-list/hooks/use-sticky-table-group-rows.tsx",
    "content": "import { useInView } from 'motion/react';\nimport { useEffect, useMemo, useState } from 'react';\n\nimport { useWindowSettings } from '/@/renderer/store/settings.store';\nimport { Platform } from '/@/shared/types/types';\n\nexport interface GroupRowInfo {\n    groupIndex: number;\n    rowIndex: number;\n}\n\nexport const useStickyTableGroupRows = ({\n    containerRef,\n    enabled,\n    getRowHeight,\n    groups,\n    headerHeight,\n    mainGridRef,\n    shouldShowStickyHeader,\n    stickyHeaderTop,\n}: {\n    containerRef: React.RefObject<HTMLDivElement | null>;\n    enabled: boolean;\n    getRowHeight: (index: number) => number;\n    groups?: Array<{ itemCount: number }>;\n    headerHeight: number;\n    mainGridRef: React.RefObject<HTMLDivElement | null>;\n    shouldShowStickyHeader?: boolean;\n    stickyHeaderTop?: number;\n}) => {\n    const { windowBarStyle } = useWindowSettings();\n    const [stickyGroupIndex, setStickyGroupIndex] = useState<null | number>(null);\n\n    const topMargin =\n        windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS\n            ? '-130px'\n            : '-100px';\n\n    const isTableInView = useInView(containerRef, {\n        margin: `${topMargin} 0px 0px 0px`,\n    });\n\n    const stickyTop = useMemo(() => {\n        // If sticky header is showing, position group row below it with 1px offset to avoid conflict\n        // Otherwise, use the base sticky position\n        if (shouldShowStickyHeader && stickyHeaderTop !== undefined) {\n            return stickyHeaderTop + headerHeight + 1;\n        }\n        return windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS ? 95 : 65;\n    }, [windowBarStyle, shouldShowStickyHeader, stickyHeaderTop, headerHeight]);\n\n    // Calculate group row indexes\n    const groupRowIndexes = useMemo(() => {\n        if (!groups || groups.length === 0) {\n            return [];\n        }\n\n        const indexes: GroupRowInfo[] = [];\n        let cumulativeDataIndex = 0;\n        const headerOffset = 1; // Assuming header is enabled\n\n        groups.forEach((group, groupIndex) => {\n            const groupHeaderIndex = headerOffset + cumulativeDataIndex + groupIndex;\n            indexes.push({\n                groupIndex,\n                rowIndex: groupHeaderIndex,\n            });\n            cumulativeDataIndex += group.itemCount;\n        });\n\n        return indexes;\n    }, [groups]);\n\n    useEffect(() => {\n        if (\n            !enabled ||\n            !groups ||\n            groups.length === 0 ||\n            !mainGridRef.current ||\n            !containerRef.current\n        ) {\n            return;\n        }\n\n        // Get the actual scrollable grid element (first child of the container)\n        const mainGridContainer = mainGridRef.current;\n        const mainGrid = mainGridContainer.childNodes[0] as HTMLDivElement | null;\n\n        if (!mainGrid) {\n            return;\n        }\n\n        const updateStickyGroup = () => {\n            const scrollTop = mainGrid.scrollTop || 0;\n            const containerRect = containerRef.current?.getBoundingClientRect();\n\n            if (!containerRect) {\n                return;\n            }\n\n            // Calculate the sticky threshold position\n            // The sticky group row should appear when a group row scrolls past this position\n            // stickyTop already accounts for window bar style and sticky header offset\n            const containerTop = containerRect.top;\n            const baseStickyPosition = stickyTop; // Base position (window bar + sticky header if showing)\n\n            // Find which group row should be sticky\n            // We want to show the current group as soon as its row reaches the sticky position\n            // This way it updates \"on scroll\" when scrolling into a new group section\n            let targetGroupIndex: null | number = null;\n\n            // Iterate forward through groups to find which one is at or about to reach the sticky position\n            for (let i = 0; i < groupRowIndexes.length; i++) {\n                const { groupIndex, rowIndex } = groupRowIndexes[i];\n\n                // Calculate the top position of this group row relative to the grid scroll\n                let rowTop = headerHeight;\n                for (let r = 0; r < rowIndex; r++) {\n                    rowTop += getRowHeight(r);\n                }\n\n                // Calculate where this row would be in the viewport (absolute position from top of viewport)\n                const rowViewportTop = containerTop + rowTop - scrollTop;\n\n                // Get the height of this group row to account for its own offset\n                // Use getRowHeight to get the actual row height for the group header row\n                const groupRowHeight = getRowHeight(rowIndex);\n\n                // Calculate the sticky position accounting for the sticky group row's own height\n                // Similar to how stickyTop accounts for sticky header height, we add the group row height\n                const stickyPosition = baseStickyPosition + groupRowHeight;\n\n                // Check if this group row has reached or is about to reach the sticky position\n                // The sticky group row appears at baseStickyPosition, but we check when the actual group row\n                // reaches baseStickyPosition + groupRowHeight to account for the sticky group row's own height\n                if (rowViewportTop <= stickyPosition) {\n                    // This group has reached the sticky position, so show this group\n                    targetGroupIndex = groupIndex;\n                    // Don't break here - continue checking to see if a later group should replace it\n                } else {\n                    // This group hasn't reached the sticky position yet\n                    // If we already found a target group, keep it and stop\n                    // Otherwise, no group should be sticky yet\n                    if (targetGroupIndex !== null) {\n                        break;\n                    }\n                }\n            }\n\n            setStickyGroupIndex((prev) => {\n                if (prev !== targetGroupIndex) {\n                    return targetGroupIndex;\n                }\n                return prev;\n            });\n        };\n\n        updateStickyGroup();\n\n        mainGrid.addEventListener('scroll', updateStickyGroup, { passive: true });\n        window.addEventListener('scroll', updateStickyGroup, true);\n        window.addEventListener('resize', updateStickyGroup);\n\n        return () => {\n            mainGrid.removeEventListener('scroll', updateStickyGroup);\n            window.removeEventListener('scroll', updateStickyGroup, true);\n            window.removeEventListener('resize', updateStickyGroup);\n        };\n    }, [\n        enabled,\n        groups,\n        groupRowIndexes,\n        mainGridRef,\n        containerRef,\n        getRowHeight,\n        headerHeight,\n        stickyTop,\n    ]);\n\n    const shouldShowStickyGroupRow = useMemo(() => {\n        return enabled && stickyGroupIndex !== null && isTableInView;\n    }, [enabled, stickyGroupIndex, isTableInView]);\n\n    return {\n        shouldShowStickyGroupRow,\n        stickyGroupIndex,\n        stickyTop,\n    };\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/item-table-list/hooks/use-sticky-table-header.tsx",
    "content": "import { useInView } from 'motion/react';\nimport { RefObject, useEffect, useMemo, useRef } from 'react';\n\nimport { useWindowSettings } from '/@/renderer/store/settings.store';\nimport { Platform } from '/@/shared/types/types';\n\nexport const useStickyTableHeader = ({\n    containerRef,\n    enabled,\n    headerRef,\n    mainGridRef,\n    pinnedLeftColumnRef,\n    pinnedRightColumnRef,\n    stickyHeaderMainRef,\n}: {\n    containerRef: RefObject<HTMLDivElement | null>;\n    enabled: boolean;\n    headerRef: RefObject<HTMLDivElement | null>;\n    mainGridRef?: RefObject<HTMLDivElement | null>;\n    pinnedLeftColumnRef?: RefObject<HTMLDivElement | null>;\n    pinnedRightColumnRef?: RefObject<HTMLDivElement | null>;\n    stickyHeaderMainRef?: RefObject<HTMLDivElement | null>;\n}) => {\n    const { windowBarStyle } = useWindowSettings();\n    const isScrollingRef = useRef({\n        main: false,\n        pinnedLeft: false,\n        pinnedRight: false,\n        stickyHeader: false,\n    });\n\n    const topMargin =\n        windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS\n            ? '-130px'\n            : '-100px';\n\n    const isTableHeaderInView = useInView(headerRef, {\n        margin: `${topMargin} 0px 0px 0px`,\n    });\n\n    const isTableInView = useInView(containerRef, {\n        margin: `${topMargin} 0px 0px 0px`,\n    });\n\n    const shouldShowStickyHeader = useMemo(() => {\n        return enabled && !isTableHeaderInView && isTableInView;\n    }, [enabled, isTableHeaderInView, isTableInView]);\n\n    const stickyTop = useMemo(() => {\n        return windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS ? 95 : 65;\n    }, [windowBarStyle]);\n\n    // Sync scroll between sticky header and main grid/pinned columns\n    useEffect(() => {\n        if (!shouldShowStickyHeader || !stickyHeaderMainRef?.current || !mainGridRef?.current) {\n            return;\n        }\n\n        const stickyMainSection = stickyHeaderMainRef.current;\n        const mainGrid = mainGridRef.current.childNodes[0] as HTMLDivElement;\n        const pinnedLeft = pinnedLeftColumnRef?.current?.childNodes[0] as HTMLDivElement | null;\n        const pinnedRight = pinnedRightColumnRef?.current?.childNodes[0] as HTMLDivElement | null;\n\n        if (!mainGrid) {\n            return;\n        }\n\n        // Sync initial scroll position when sticky header becomes visible\n        const syncInitialScroll = () => {\n            const scrollLeft = mainGrid.scrollLeft;\n            const scrollTop = mainGrid.scrollTop;\n\n            // Sync horizontal scroll position\n            stickyMainSection.scrollTo({\n                behavior: 'instant',\n                left: scrollLeft,\n            });\n\n            // Sync vertical scroll position with pinned columns\n            if (pinnedLeft) {\n                pinnedLeft.scrollTo({\n                    behavior: 'instant',\n                    top: scrollTop,\n                });\n            }\n            if (pinnedRight) {\n                pinnedRight.scrollTo({\n                    behavior: 'instant',\n                    top: scrollTop,\n                });\n            }\n        };\n\n        // Sync initial position after a frame to ensure elements are ready\n        requestAnimationFrame(() => {\n            requestAnimationFrame(syncInitialScroll);\n        });\n\n        const syncScroll = (e: Event) => {\n            const target = e.currentTarget as HTMLDivElement;\n            const scrollLeft = target.scrollLeft;\n            const scrollTop = target.scrollTop;\n\n            // Sync horizontal scroll from main grid to sticky header main section\n            if (target === mainGrid && !isScrollingRef.current.stickyHeader) {\n                isScrollingRef.current.stickyHeader = true;\n                stickyMainSection.scrollTo({\n                    behavior: 'instant',\n                    left: scrollLeft,\n                });\n                isScrollingRef.current.stickyHeader = false;\n            }\n\n            // Sync horizontal scroll from sticky header to main grid\n            if (target === stickyMainSection && !isScrollingRef.current.main) {\n                isScrollingRef.current.main = true;\n                mainGrid.scrollTo({\n                    behavior: 'instant',\n                    left: scrollLeft,\n                });\n                isScrollingRef.current.main = false;\n            }\n\n            // Sync vertical scroll from main grid to pinned columns\n            if (target === mainGrid) {\n                if (pinnedLeft && !isScrollingRef.current.pinnedLeft) {\n                    isScrollingRef.current.pinnedLeft = true;\n                    pinnedLeft.scrollTo({\n                        behavior: 'instant',\n                        top: scrollTop,\n                    });\n                    isScrollingRef.current.pinnedLeft = false;\n                }\n                if (pinnedRight && !isScrollingRef.current.pinnedRight) {\n                    isScrollingRef.current.pinnedRight = true;\n                    pinnedRight.scrollTo({\n                        behavior: 'instant',\n                        top: scrollTop,\n                    });\n                    isScrollingRef.current.pinnedRight = false;\n                }\n            }\n\n            // Sync vertical scroll from pinned columns to main grid\n            if (pinnedLeft && target === pinnedLeft && !isScrollingRef.current.main) {\n                isScrollingRef.current.main = true;\n                mainGrid.scrollTo({\n                    behavior: 'instant',\n                    top: scrollTop,\n                });\n                isScrollingRef.current.main = false;\n            }\n\n            if (pinnedRight && target === pinnedRight && !isScrollingRef.current.main) {\n                isScrollingRef.current.main = true;\n                mainGrid.scrollTo({\n                    behavior: 'instant',\n                    top: scrollTop,\n                });\n                isScrollingRef.current.main = false;\n            }\n        };\n\n        mainGrid.addEventListener('scroll', syncScroll);\n        stickyMainSection.addEventListener('scroll', syncScroll);\n        if (pinnedLeft) {\n            pinnedLeft.addEventListener('scroll', syncScroll);\n        }\n        if (pinnedRight) {\n            pinnedRight.addEventListener('scroll', syncScroll);\n        }\n\n        return () => {\n            mainGrid.removeEventListener('scroll', syncScroll);\n            stickyMainSection.removeEventListener('scroll', syncScroll);\n            if (pinnedLeft) {\n                pinnedLeft.removeEventListener('scroll', syncScroll);\n            }\n            if (pinnedRight) {\n                pinnedRight.removeEventListener('scroll', syncScroll);\n            }\n        };\n    }, [\n        shouldShowStickyHeader,\n        mainGridRef,\n        pinnedLeftColumnRef,\n        pinnedRightColumnRef,\n        stickyHeaderMainRef,\n    ]);\n\n    return {\n        shouldShowStickyHeader,\n        stickyTop,\n    };\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/item-table-list/hooks/use-table-column-model.ts",
    "content": "import { useMemo } from 'react';\n\nimport { parseTableColumns } from '/@/renderer/components/item-list/helpers/parse-table-columns';\nimport { ItemTableListColumnConfig } from '/@/renderer/components/item-list/types';\n\nexport const useTableColumnModel = ({\n    autoFitColumns,\n    centerContainerWidth,\n    columns,\n    totalContainerWidth,\n}: {\n    autoFitColumns: boolean;\n    centerContainerWidth: number;\n    columns: ItemTableListColumnConfig[];\n    totalContainerWidth: number;\n}) => {\n    const parsedColumns = useMemo(() => parseTableColumns(columns), [columns]);\n\n    const calculatedColumnWidths = useMemo(() => {\n        const baseWidths = parsedColumns.map((c) => c.width);\n\n        // When autoSizeColumns is enabled, treat unpinned widths as proportions and scale to fit container.\n        // Pinned columns keep their base width so they don't get squeezed.\n        if (autoFitColumns) {\n            const pinnedWidth = parsedColumns.reduce(\n                (sum, col, idx) => (col.pinned !== null ? sum + baseWidths[idx] : sum),\n                0,\n            );\n            const unpinnedIndices: number[] = [];\n            parsedColumns.forEach((col, idx) => {\n                if (col.pinned === null) {\n                    unpinnedIndices.push(idx);\n                }\n            });\n\n            const unpinnedReferenceWidth = unpinnedIndices.reduce(\n                (sum, idx) => sum + baseWidths[idx],\n                0,\n            );\n            const availableForUnpinned = totalContainerWidth - pinnedWidth;\n\n            if (unpinnedReferenceWidth === 0 || availableForUnpinned <= 0) {\n                return baseWidths.map((width) => Math.round(width));\n            }\n\n            const scaleFactor = availableForUnpinned / unpinnedReferenceWidth;\n            const scaledWidths = baseWidths.map((width, idx) => {\n                if (parsedColumns[idx].pinned !== null) {\n                    return Math.round(width);\n                }\n                return Math.round(width * scaleFactor);\n            });\n\n            // Adjust for rounding errors on unpinned columns only\n            const totalScaled = scaledWidths.reduce((sum, width) => sum + width, 0);\n            const difference = totalContainerWidth - totalScaled;\n\n            if (difference !== 0 && unpinnedIndices.length > 0) {\n                const sortedIndices = unpinnedIndices\n                    .map((idx) => ({ idx, width: scaledWidths[idx] }))\n                    .sort((a, b) => b.width - a.width);\n\n                const adjustmentPerColumn = Math.sign(difference);\n                const adjustmentCount = Math.abs(difference);\n\n                for (let i = 0; i < adjustmentCount && i < sortedIndices.length; i++) {\n                    scaledWidths[sortedIndices[i].idx] += adjustmentPerColumn;\n                }\n            }\n\n            return scaledWidths;\n        }\n\n        // Original behavior: distribute extra space to auto-size columns\n        const distributed = baseWidths.slice();\n        const unpinnedIndices: number[] = [];\n        const autoUnpinnedIndices: number[] = [];\n\n        parsedColumns.forEach((col, idx) => {\n            if (col.pinned === null) {\n                unpinnedIndices.push(idx);\n                if (col.autoSize) {\n                    autoUnpinnedIndices.push(idx);\n                }\n            }\n        });\n\n        if (unpinnedIndices.length === 0 || autoUnpinnedIndices.length === 0) {\n            return distributed.map((width) => Math.round(width));\n        }\n\n        const unpinnedBaseTotal = unpinnedIndices.reduce((sum, idx) => sum + baseWidths[idx], 0);\n        const extra = Math.max(0, centerContainerWidth - unpinnedBaseTotal);\n        if (extra <= 0) {\n            return distributed.map((width) => Math.round(width));\n        }\n\n        const extraPer = extra / autoUnpinnedIndices.length;\n        autoUnpinnedIndices.forEach((idx) => {\n            distributed[idx] = Math.round(baseWidths[idx] + extraPer);\n        });\n\n        return distributed.map((width) => Math.round(width));\n    }, [autoFitColumns, centerContainerWidth, parsedColumns, totalContainerWidth]);\n\n    const pinnedLeftColumnCount = useMemo(\n        () => parsedColumns.filter((col) => col.pinned === 'left').length,\n        [parsedColumns],\n    );\n    const pinnedRightColumnCount = useMemo(\n        () => parsedColumns.filter((col) => col.pinned === 'right').length,\n        [parsedColumns],\n    );\n\n    const columnCount = parsedColumns.length;\n    const totalColumnCount = columnCount - pinnedLeftColumnCount - pinnedRightColumnCount;\n\n    return useMemo(\n        () => ({\n            calculatedColumnWidths,\n            columnCount,\n            parsedColumns,\n            pinnedLeftColumnCount,\n            pinnedRightColumnCount,\n            totalColumnCount,\n        }),\n        [\n            calculatedColumnWidths,\n            columnCount,\n            parsedColumns,\n            pinnedLeftColumnCount,\n            pinnedRightColumnCount,\n            totalColumnCount,\n        ],\n    );\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/item-table-list/hooks/use-table-imperative-handle.ts",
    "content": "import { useEffect, useImperativeHandle, useMemo } from 'react';\n\nimport { ItemListStateActions } from '/@/renderer/components/item-list/helpers/item-list-state';\nimport { ItemListHandle } from '/@/renderer/components/item-list/types';\n\ninterface UseTableImperativeHandleProps {\n    enableHeader: boolean;\n    handleRef: React.RefObject<ItemListHandle | null>;\n    internalState: ItemListStateActions;\n    ref?: React.Ref<ItemListHandle>;\n    scrollToTableIndex: (index: number, options?: { align?: 'bottom' | 'center' | 'top' }) => void;\n    scrollToTableOffset: (offset: number) => void;\n}\n\n/**\n * Hook to set up the imperative handle for ItemTableList, providing scroll methods and internal state.\n */\nexport const useTableImperativeHandle = ({\n    enableHeader,\n    handleRef,\n    internalState,\n    ref,\n    scrollToTableIndex,\n    scrollToTableOffset,\n}: UseTableImperativeHandleProps) => {\n    const imperativeHandle: ItemListHandle = useMemo(\n        () => ({\n            internalState,\n            scrollToIndex: (index: number, options?: { align?: 'bottom' | 'center' | 'top' }) => {\n                scrollToTableIndex(enableHeader ? index + 1 : index, options);\n            },\n            scrollToOffset: (offset: number) => {\n                scrollToTableOffset(offset);\n            },\n        }),\n        [enableHeader, internalState, scrollToTableIndex, scrollToTableOffset],\n    );\n\n    useImperativeHandle(ref, () => imperativeHandle);\n\n    useEffect(() => {\n        handleRef.current = imperativeHandle;\n    }, [handleRef, imperativeHandle]);\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/item-table-list/hooks/use-table-initial-scroll.ts",
    "content": "import { useEffect, useRef } from 'react';\n\ninterface UseTableInitialScrollProps {\n    initialTop?: {\n        behavior?: 'auto' | 'smooth';\n        to: number;\n        type: 'index' | 'offset';\n    };\n    scrollToTableIndex: (index: number, options?: { align?: 'bottom' | 'center' | 'top' }) => void;\n    scrollToTableOffset: (offset: number) => void;\n    startRowIndex?: number;\n}\n\n/**\n * Hook to handle initial scroll position and scrolling to top when startRowIndex changes.\n */\nexport const useTableInitialScroll = ({\n    initialTop,\n    scrollToTableIndex,\n    scrollToTableOffset,\n    startRowIndex,\n}: UseTableInitialScrollProps) => {\n    const isInitialScrollPositionSet = useRef<boolean>(false);\n\n    useEffect(() => {\n        if (!initialTop || isInitialScrollPositionSet.current) return;\n        isInitialScrollPositionSet.current = true;\n\n        if (initialTop.type === 'offset') {\n            scrollToTableOffset(initialTop.to);\n        } else {\n            scrollToTableIndex(initialTop.to);\n        }\n    }, [initialTop, scrollToTableIndex, scrollToTableOffset]);\n\n    // Scroll to top when startRowIndex changes\n    useEffect(() => {\n        if (startRowIndex !== undefined) {\n            scrollToTableOffset(0);\n        }\n    }, [startRowIndex, scrollToTableOffset]);\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/item-table-list/hooks/use-table-keyboard-navigation.ts",
    "content": "import { useCallback } from 'react';\n\nimport {\n    ItemListStateActions,\n    ItemListStateItemWithRequiredProperties,\n} from '/@/renderer/components/item-list/helpers/item-list-state';\nimport { TableItemProps } from '/@/renderer/components/item-list/item-table-list/item-table-list';\nimport { ItemControls } from '/@/renderer/components/item-list/types';\nimport { PlayerContext } from '/@/renderer/features/player/context/player-context';\nimport { LibraryItem } from '/@/shared/types/domain-types';\n\ninterface UseTableKeyboardNavigationProps {\n    calculateScrollTopForIndex: (index: number) => number;\n    cellPadding: TableItemProps['cellPadding'];\n    data: unknown[];\n    DEFAULT_ROW_HEIGHT: number;\n    enableHeader: boolean;\n    enableSelection: boolean;\n    extractRowId: (item: unknown) => string | undefined;\n    getItem?: (index: number) => undefined | unknown;\n    getItemIndex?: (rowId: string) => number | undefined;\n    getStateItem: (item: any) => ItemListStateItemWithRequiredProperties | null;\n    hasRequiredStateItemProperties: (\n        item: unknown,\n    ) => item is ItemListStateItemWithRequiredProperties;\n    internalState: ItemListStateActions;\n    itemCount?: number;\n    itemType: LibraryItem;\n    parsedColumns: TableItemProps['columns'];\n    pinnedRightColumnCount: number;\n    pinnedRightColumnRef: React.RefObject<HTMLDivElement | null>;\n    playerContext: PlayerContext;\n    rowHeight: ((index: number, cellProps: TableItemProps) => number) | number | undefined;\n    rowRef: React.RefObject<HTMLDivElement | null>;\n    scrollToTableIndex: (index: number, options?: { align?: 'bottom' | 'center' | 'top' }) => void;\n    size: TableItemProps['size'];\n    tableId: string;\n}\n\n/**\n * Hook to handle keyboard navigation (ArrowUp/ArrowDown) for table row selection and scrolling.\n */\nexport const useTableKeyboardNavigation = ({\n    calculateScrollTopForIndex,\n    cellPadding,\n    data,\n    DEFAULT_ROW_HEIGHT,\n    enableHeader,\n    enableSelection,\n    extractRowId,\n    getItem,\n    getItemIndex,\n    getStateItem,\n    hasRequiredStateItemProperties,\n    internalState,\n    itemCount,\n    itemType,\n    parsedColumns,\n    pinnedRightColumnCount,\n    pinnedRightColumnRef,\n    playerContext,\n    rowHeight,\n    rowRef,\n    scrollToTableIndex,\n    size,\n    tableId,\n}: UseTableKeyboardNavigationProps) => {\n    const handleKeyDown = useCallback(\n        (e: React.KeyboardEvent<HTMLDivElement>) => {\n            if (!enableSelection) return;\n            if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;\n            e.preventDefault();\n            e.stopPropagation();\n\n            const selected = internalState.getSelected();\n            const validSelected = selected.filter(hasRequiredStateItemProperties);\n            let currentIndex = -1;\n            const totalCount = itemCount ?? data.length;\n\n            if (validSelected.length > 0) {\n                const lastSelected = validSelected[validSelected.length - 1];\n                const rowId = extractRowId(lastSelected);\n                if (rowId) {\n                    currentIndex =\n                        getItemIndex?.(rowId) ?? data.findIndex((d) => extractRowId(d) === rowId);\n                }\n            }\n\n            let newIndex = 0;\n            if (currentIndex !== -1) {\n                newIndex =\n                    e.key === 'ArrowDown'\n                        ? Math.min(currentIndex + 1, totalCount - 1)\n                        : Math.max(currentIndex - 1, 0);\n            }\n\n            const newItem: any = getItem ? getItem(newIndex) : data[newIndex];\n            if (!newItem) return;\n\n            const newItemListItem = getStateItem(newItem);\n            if (newItemListItem && extractRowId(newItemListItem)) {\n                internalState.setSelected([newItemListItem]);\n            }\n\n            // Check if we need to scroll by determining if the item is at the edge of the viewport\n            const gridIndex = enableHeader ? newIndex + 1 : newIndex;\n\n            const mainContainer = rowRef.current?.childNodes[0] as HTMLDivElement | undefined;\n            const pinnedRightContainer = pinnedRightColumnRef.current?.childNodes[0] as\n                | HTMLDivElement\n                | undefined;\n\n            // Use right pinned column scroll position if right-pinned columns exist\n            const scrollContainer =\n                pinnedRightColumnCount > 0 && pinnedRightContainer\n                    ? pinnedRightContainer\n                    : mainContainer;\n\n            if (scrollContainer) {\n                const viewportTop = scrollContainer.scrollTop;\n                const viewportHeight = scrollContainer.clientHeight;\n                const viewportBottom = viewportTop + viewportHeight;\n\n                const rowTop = calculateScrollTopForIndex(gridIndex);\n                const adjustedIndex = enableHeader ? Math.max(0, newIndex - 1) : newIndex;\n                const mockCellProps: TableItemProps = {\n                    cellPadding,\n                    columns: parsedColumns,\n                    controls: {} as ItemControls,\n                    data: enableHeader ? [null] : [],\n                    enableAlternateRowColors: false,\n                    enableExpansion: false,\n                    enableHeader,\n                    enableHorizontalBorders: false,\n                    enableRowHoverHighlight: false,\n                    enableSelection,\n                    enableVerticalBorders: false,\n                    getRowHeight: () => DEFAULT_ROW_HEIGHT,\n                    getRowItem: (rowIndex: number) => {\n                        if (!getItem) return undefined;\n                        if (enableHeader && rowIndex === 0) return null;\n                        const dataIndex = enableHeader ? rowIndex - 1 : rowIndex;\n                        return getItem(dataIndex);\n                    },\n                    internalState: {} as ItemListStateActions,\n                    itemType,\n                    playerContext,\n                    size,\n                    tableId,\n                };\n\n                let calculatedRowHeight: number;\n                if (typeof rowHeight === 'number') {\n                    calculatedRowHeight = rowHeight;\n                } else if (typeof rowHeight === 'function') {\n                    calculatedRowHeight = rowHeight(adjustedIndex, mockCellProps);\n                } else {\n                    calculatedRowHeight = DEFAULT_ROW_HEIGHT;\n                }\n\n                const rowBottom = rowTop + calculatedRowHeight;\n\n                // Check if row is fully visible within viewport\n                const isFullyVisible = rowTop >= viewportTop && rowBottom <= viewportBottom;\n\n                // Check if row is at the edge (top or bottom of viewport)\n                const isAtTopEdge = rowTop < viewportTop;\n                const isAtBottomEdge = rowBottom >= viewportBottom;\n\n                // Only scroll if the item is not fully visible or at the edge\n                if (!isFullyVisible || isAtTopEdge || isAtBottomEdge) {\n                    // Determine alignment based on direction\n                    const align: 'bottom' | 'top' =\n                        e.key === 'ArrowDown' && isAtBottomEdge\n                            ? 'bottom'\n                            : e.key === 'ArrowUp' && isAtTopEdge\n                              ? 'top'\n                              : isAtBottomEdge\n                                ? 'bottom'\n                                : isAtTopEdge\n                                  ? 'top'\n                                  : 'top';\n\n                    scrollToTableIndex(gridIndex, { align });\n                }\n            }\n        },\n        [\n            calculateScrollTopForIndex,\n            cellPadding,\n            data,\n            getItem,\n            getItemIndex,\n            DEFAULT_ROW_HEIGHT,\n            enableHeader,\n            enableSelection,\n            extractRowId,\n            getStateItem,\n            hasRequiredStateItemProperties,\n            internalState,\n            itemCount,\n            itemType,\n            parsedColumns,\n            pinnedRightColumnCount,\n            pinnedRightColumnRef,\n            playerContext,\n            rowHeight,\n            rowRef,\n            scrollToTableIndex,\n            size,\n            tableId,\n        ],\n    );\n\n    return { handleKeyDown };\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/item-table-list/hooks/use-table-pane-sync.ts",
    "content": "import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element';\nimport throttle from 'lodash/throttle';\nimport { useOverlayScrollbars } from 'overlayscrollbars-react';\nimport { useEffect } from 'react';\n\nimport { ItemListStateActions } from '/@/renderer/components/item-list/helpers/item-list-state';\n\nexport const useTablePaneSync = ({\n    enableDrag,\n    enableDragScroll,\n    enableHeader,\n    handleRef,\n    onScrollEndRef,\n    pinnedLeftColumnCount,\n    pinnedLeftColumnRef,\n    pinnedRightColumnCount,\n    pinnedRightColumnRef,\n    pinnedRowRef,\n    rowRef,\n    scrollContainerRef,\n    setShowLeftShadow,\n    setShowRightShadow,\n    setShowTopShadow,\n}: {\n    enableDrag: boolean | undefined;\n    enableDragScroll: boolean | undefined;\n    enableHeader: boolean;\n    handleRef: React.RefObject<null | { internalState: ItemListStateActions }>;\n    onScrollEndRef: React.RefObject<\n        ((offset: number, internalState: ItemListStateActions) => void) | undefined\n    >;\n    pinnedLeftColumnCount: number;\n    pinnedLeftColumnRef: React.RefObject<HTMLDivElement | null>;\n    pinnedRightColumnCount: number;\n    pinnedRightColumnRef: React.RefObject<HTMLDivElement | null>;\n    pinnedRowRef: React.RefObject<HTMLDivElement | null>;\n    rowRef: React.RefObject<HTMLDivElement | null>;\n    scrollContainerRef: React.RefObject<HTMLDivElement | null>;\n    setShowLeftShadow: (v: boolean) => void;\n    setShowRightShadow: (v: boolean) => void;\n    setShowTopShadow: (v: boolean) => void;\n}) => {\n    // Main grid overlayscrollbars - only handle X-axis if right-pinned columns exist\n    const [initialize, osInstance] = useOverlayScrollbars({\n        defer: false,\n        events: {\n            initialized(osInstance) {\n                const { viewport } = osInstance.elements();\n                viewport.style.overflowX = `var(--os-viewport-overflow-x)`;\n\n                if (pinnedRightColumnCount > 0) {\n                    viewport.style.overflowY = 'auto';\n                } else {\n                    viewport.style.overflowY = `var(--os-viewport-overflow-y)`;\n                }\n            },\n        },\n        options: {\n            overflow: {\n                x: 'scroll',\n                y: pinnedRightColumnCount > 0 ? 'hidden' : 'scroll',\n            },\n            paddingAbsolute: true,\n            scrollbars: {\n                autoHide: 'leave',\n                autoHideDelay: 500,\n                pointers: ['mouse', 'pen', 'touch'],\n                theme: 'feishin-os-scrollbar',\n            },\n        },\n    });\n\n    // Right pinned columns overlayscrollbars - enable Y-axis scroll when right-pinned columns exist\n    const [initializeRightPinned, osInstanceRightPinned] = useOverlayScrollbars({\n        defer: false,\n        events: {\n            initialized(osInstance) {\n                const { viewport } = osInstance.elements();\n                viewport.style.overflowX = `var(--os-viewport-overflow-x)`;\n                viewport.style.overflowY = `var(--os-viewport-overflow-y)`;\n            },\n        },\n        options: {\n            overflow: { x: 'hidden', y: 'scroll' },\n            paddingAbsolute: true,\n            scrollbars: {\n                autoHide: 'leave',\n                autoHideDelay: 500,\n                pointers: ['mouse', 'pen', 'touch'],\n                theme: 'feishin-os-scrollbar',\n            },\n        },\n    });\n\n    useEffect(() => {\n        const { current: root } = scrollContainerRef;\n\n        if (!root || !root.firstElementChild) {\n            return;\n        }\n\n        const viewport = root.firstElementChild as HTMLElement;\n\n        initialize({\n            elements: { viewport },\n            target: root,\n        });\n\n        let autoScrollCleanup: (() => void) | null = null;\n        if (enableDrag && enableDragScroll) {\n            autoScrollCleanup = autoScrollForElements({\n                canScroll: () => true,\n                element: viewport,\n                getAllowedAxis: () => 'vertical',\n                getConfiguration: () => ({ maxScrollSpeed: 'fast' }),\n            });\n        }\n\n        return () => {\n            if (autoScrollCleanup) {\n                autoScrollCleanup();\n            }\n\n            try {\n                const instance = osInstance();\n                const { current: root } = scrollContainerRef;\n\n                if (instance && root) {\n                    const viewport = root.firstElementChild as HTMLElement;\n\n                    const rootInDocument = document.contains(root);\n                    const viewportInDocument = viewport && document.contains(viewport);\n\n                    if (rootInDocument && viewportInDocument) {\n                        instance.destroy();\n                    }\n                }\n            } catch {\n                // Ignore error\n            }\n        };\n    }, [\n        enableDrag,\n        enableDragScroll,\n        initialize,\n        osInstance,\n        pinnedRightColumnCount,\n        scrollContainerRef,\n    ]);\n\n    useEffect(() => {\n        if (pinnedLeftColumnCount === 0) {\n            return;\n        }\n\n        const { current: root } = pinnedLeftColumnRef;\n\n        if (!root || !root.firstElementChild) {\n            return;\n        }\n\n        const viewport = root.firstElementChild as HTMLElement;\n\n        let autoScrollCleanup: (() => void) | null = null;\n        if (enableDrag && enableDragScroll) {\n            autoScrollCleanup = autoScrollForElements({\n                canScroll: () => true,\n                element: viewport,\n                getAllowedAxis: () => 'vertical',\n                getConfiguration: () => ({ maxScrollSpeed: 'fast' }),\n            });\n        }\n\n        return () => {\n            if (autoScrollCleanup) {\n                autoScrollCleanup();\n            }\n        };\n    }, [enableDrag, enableDragScroll, pinnedLeftColumnCount, pinnedLeftColumnRef]);\n\n    // Initialize overlayscrollbars for right pinned columns\n    useEffect(() => {\n        if (pinnedRightColumnCount === 0) {\n            return;\n        }\n\n        const { current: root } = pinnedRightColumnRef;\n\n        if (!root || !root.firstElementChild) {\n            return;\n        }\n\n        const viewport = root.firstElementChild as HTMLElement;\n\n        initializeRightPinned({\n            elements: { viewport },\n            target: root,\n        });\n\n        let autoScrollCleanup: (() => void) | null = null;\n        if (enableDrag && enableDragScroll) {\n            autoScrollCleanup = autoScrollForElements({\n                canScroll: () => true,\n                element: viewport,\n                getAllowedAxis: () => 'vertical',\n                getConfiguration: () => ({ maxScrollSpeed: 'fast' }),\n            });\n        }\n\n        return () => {\n            if (autoScrollCleanup) {\n                autoScrollCleanup();\n            }\n\n            try {\n                const instance = osInstanceRightPinned();\n                const { current: root } = pinnedRightColumnRef;\n\n                if (instance && root) {\n                    const viewport = root.firstElementChild as HTMLElement;\n\n                    const rootInDocument = document.contains(root);\n                    const viewportInDocument = viewport && document.contains(viewport);\n\n                    if (rootInDocument && viewportInDocument) {\n                        instance.destroy();\n                    }\n                }\n            } catch {\n                // Ignore error\n            }\n        };\n    }, [\n        enableDrag,\n        enableDragScroll,\n        initializeRightPinned,\n        osInstanceRightPinned,\n        pinnedRightColumnCount,\n        pinnedRightColumnRef,\n    ]);\n\n    useEffect(() => {\n        const header = pinnedRowRef.current?.childNodes[0] as HTMLDivElement;\n        const row = rowRef.current?.childNodes[0] as HTMLDivElement;\n        const pinnedLeft = pinnedLeftColumnRef.current?.childNodes[0] as HTMLDivElement;\n        const pinnedRight = pinnedRightColumnRef.current?.childNodes[0] as HTMLDivElement;\n\n        if (!row) return;\n\n        // Ensure all containers have the same height\n        const syncHeights = () => {\n            const rowHeight = row.scrollHeight;\n            let targetHeight = rowHeight;\n\n            if (pinnedLeft) {\n                const pinnedLeftHeight = pinnedLeft.scrollHeight;\n                targetHeight = Math.max(targetHeight, pinnedLeftHeight);\n            }\n\n            if (pinnedRight) {\n                const pinnedRightHeight = pinnedRight.scrollHeight;\n                targetHeight = Math.max(targetHeight, pinnedRightHeight);\n            }\n\n            if (pinnedLeft && pinnedLeft.style.height !== `${targetHeight}px`) {\n                pinnedLeft.style.height = `${targetHeight}px`;\n            }\n            if (pinnedRight && pinnedRight.style.height !== `${targetHeight}px`) {\n                pinnedRight.style.height = `${targetHeight}px`;\n            }\n            if (row.style.height !== `${targetHeight}px`) {\n                row.style.height = `${targetHeight}px`;\n            }\n        };\n\n        const timeoutId = setTimeout(syncHeights, 0);\n\n        const activeElement = { element: null } as { element: HTMLDivElement | null };\n        const scrollingElements = new Set<HTMLDivElement>();\n        const scrollTimeouts = new Map<HTMLDivElement, NodeJS.Timeout>();\n\n        const setActiveElement = (e: HTMLElementEventMap['pointermove']) => {\n            activeElement.element = e.currentTarget as HTMLDivElement;\n        };\n        const setActiveElementFromWheel = (e: HTMLElementEventMap['wheel']) => {\n            activeElement.element = e.currentTarget as HTMLDivElement;\n        };\n\n        const markElementAsScrolling = (element: HTMLDivElement) => {\n            scrollingElements.add(element);\n\n            const existingTimeout = scrollTimeouts.get(element);\n            if (existingTimeout) {\n                clearTimeout(existingTimeout);\n            }\n\n            const timeout = setTimeout(() => {\n                scrollingElements.delete(element);\n\n                const hasRightPinnedColumns = pinnedRightColumnCount > 0;\n                const scrollElement = hasRightPinnedColumns && pinnedRight ? pinnedRight : row;\n\n                if (scrollElement && onScrollEndRef.current) {\n                    onScrollEndRef.current(\n                        scrollElement.scrollTop,\n                        (handleRef.current?.internalState ??\n                            (undefined as any)) as ItemListStateActions,\n                    );\n                }\n\n                scrollTimeouts.delete(element);\n            }, 150);\n\n            scrollTimeouts.set(element, timeout);\n        };\n\n        const syncScroll = (e: HTMLElementEventMap['scroll']) => {\n            const currentElement = e.currentTarget as HTMLDivElement;\n            markElementAsScrolling(currentElement);\n\n            const shouldSync =\n                currentElement === activeElement.element || scrollingElements.has(currentElement);\n            if (!shouldSync) return;\n\n            const scrollTop = (e.currentTarget as HTMLDivElement).scrollTop;\n            const scrollLeft = (e.currentTarget as HTMLDivElement).scrollLeft;\n\n            const isScrolling = {\n                header: false,\n                pinnedLeft: false,\n                pinnedRight: false,\n                row: false,\n            };\n\n            const hasRightPinnedColumns = pinnedRightColumnCount > 0;\n\n            if (header && e.currentTarget === header && !isScrolling.row) {\n                isScrolling.row = true;\n                row.scrollTo({ behavior: 'instant', left: scrollLeft });\n                isScrolling.row = false;\n            }\n\n            if (\n                e.currentTarget === row &&\n                !isScrolling.header &&\n                !isScrolling.pinnedLeft &&\n                !isScrolling.pinnedRight\n            ) {\n                if (header) {\n                    isScrolling.header = true;\n                    header.scrollTo({ behavior: 'instant', left: scrollLeft });\n                }\n                if (hasRightPinnedColumns && pinnedRight) {\n                    isScrolling.pinnedRight = true;\n                    pinnedRight.scrollTo({ behavior: 'instant', top: scrollTop });\n                    isScrolling.pinnedRight = false;\n                } else {\n                    if (pinnedLeft) {\n                        isScrolling.pinnedLeft = true;\n                        pinnedLeft.scrollTo({ behavior: 'instant', top: scrollTop });\n                    }\n                    if (pinnedRight) {\n                        isScrolling.pinnedRight = true;\n                        pinnedRight.scrollTo({ behavior: 'instant', top: scrollTop });\n                    }\n                }\n                isScrolling.header = false;\n                isScrolling.pinnedLeft = false;\n            }\n\n            if (pinnedLeft && e.currentTarget === pinnedLeft && !isScrolling.row) {\n                if (hasRightPinnedColumns && pinnedRight) {\n                    isScrolling.pinnedRight = true;\n                    pinnedRight.scrollTo({ behavior: 'instant', top: scrollTop });\n                    isScrolling.pinnedRight = false;\n                } else {\n                    isScrolling.row = true;\n                    row.scrollTo({ behavior: 'instant', top: scrollTop });\n                    isScrolling.row = false;\n                }\n            }\n\n            if (pinnedRight && e.currentTarget === pinnedRight && !isScrolling.row) {\n                isScrolling.row = true;\n                row.scrollTo({ behavior: 'instant', top: scrollTop });\n                isScrolling.row = false;\n                if (pinnedLeft) {\n                    isScrolling.pinnedLeft = true;\n                    pinnedLeft.scrollTo({ behavior: 'instant', top: scrollTop });\n                    isScrolling.pinnedLeft = false;\n                }\n            }\n        };\n\n        if (header) {\n            header.addEventListener('pointermove', setActiveElement);\n            header.addEventListener('wheel', setActiveElementFromWheel);\n            header.addEventListener('scroll', syncScroll);\n        }\n        row.addEventListener('pointermove', setActiveElement);\n        row.addEventListener('wheel', setActiveElementFromWheel);\n        row.addEventListener('scroll', syncScroll);\n        if (pinnedLeft) {\n            pinnedLeft.addEventListener('pointermove', setActiveElement);\n            pinnedLeft.addEventListener('wheel', setActiveElementFromWheel);\n            pinnedLeft.addEventListener('scroll', syncScroll);\n        }\n        if (pinnedRight) {\n            pinnedRight.addEventListener('pointermove', setActiveElement);\n            pinnedRight.addEventListener('wheel', setActiveElementFromWheel);\n            pinnedRight.addEventListener('scroll', syncScroll);\n        }\n\n        let heightSyncDebounceTimeout: NodeJS.Timeout | null = null;\n        const resizeObserver = new ResizeObserver(() => {\n            if (heightSyncDebounceTimeout) {\n                clearTimeout(heightSyncDebounceTimeout);\n            }\n            heightSyncDebounceTimeout = setTimeout(() => {\n                syncHeights();\n            }, 100);\n        });\n\n        resizeObserver.observe(row);\n        if (pinnedLeft) resizeObserver.observe(pinnedLeft);\n        if (pinnedRight) resizeObserver.observe(pinnedRight);\n\n        return () => {\n            clearTimeout(timeoutId);\n            scrollTimeouts.forEach((timeout) => clearTimeout(timeout));\n            scrollTimeouts.clear();\n            scrollingElements.clear();\n\n            if (header) {\n                header.removeEventListener('pointermove', setActiveElement);\n                header.removeEventListener('wheel', setActiveElementFromWheel);\n                header.removeEventListener('scroll', syncScroll);\n            }\n            row.removeEventListener('pointermove', setActiveElement);\n            row.removeEventListener('wheel', setActiveElementFromWheel);\n            row.removeEventListener('scroll', syncScroll);\n            if (pinnedLeft) {\n                pinnedLeft.removeEventListener('pointermove', setActiveElement);\n                pinnedLeft.removeEventListener('wheel', setActiveElementFromWheel);\n                pinnedLeft.removeEventListener('scroll', syncScroll);\n            }\n            if (pinnedRight) {\n                pinnedRight.removeEventListener('pointermove', setActiveElement);\n                pinnedRight.removeEventListener('wheel', setActiveElementFromWheel);\n                pinnedRight.removeEventListener('scroll', syncScroll);\n            }\n            if (heightSyncDebounceTimeout) {\n                clearTimeout(heightSyncDebounceTimeout);\n            }\n            resizeObserver.disconnect();\n        };\n    }, [\n        handleRef,\n        onScrollEndRef,\n        pinnedLeftColumnCount,\n        pinnedLeftColumnRef,\n        pinnedRightColumnCount,\n        pinnedRightColumnRef,\n        pinnedRowRef,\n        rowRef,\n    ]);\n\n    // Handle left and right shadow visibility based on horizontal scroll\n    useEffect(() => {\n        const row = rowRef.current?.childNodes[0] as HTMLDivElement;\n\n        if (!row) {\n            const timeout = setTimeout(() => {\n                setShowLeftShadow(false);\n                setShowRightShadow(false);\n            }, 0);\n\n            return () => clearTimeout(timeout);\n        }\n\n        const checkScrollPosition = throttle(() => {\n            const scrollLeft = row.scrollLeft;\n            const maxScrollLeft = row.scrollWidth - row.clientWidth;\n\n            setShowLeftShadow(pinnedLeftColumnCount > 0 && scrollLeft > 0);\n            setShowRightShadow(pinnedRightColumnCount > 0 && scrollLeft < maxScrollLeft);\n        }, 50);\n\n        checkScrollPosition();\n\n        row.addEventListener('scroll', checkScrollPosition, { passive: true });\n\n        return () => {\n            checkScrollPosition.cancel();\n            row.removeEventListener('scroll', checkScrollPosition);\n        };\n    }, [\n        pinnedLeftColumnCount,\n        pinnedRightColumnCount,\n        rowRef,\n        setShowLeftShadow,\n        setShowRightShadow,\n    ]);\n\n    // Handle top shadow visibility based on vertical scroll\n    useEffect(() => {\n        const row = rowRef.current?.childNodes[0] as HTMLDivElement;\n        const pinnedRight = pinnedRightColumnRef.current?.childNodes[0] as HTMLDivElement;\n\n        if (!row || !enableHeader) {\n            const timeout = setTimeout(() => {\n                setShowTopShadow(false);\n            }, 0);\n\n            return () => clearTimeout(timeout);\n        }\n\n        const scrollElement = pinnedRightColumnCount > 0 && pinnedRight ? pinnedRight : row;\n\n        const checkScrollPosition = throttle(() => {\n            const currentScrollTop = scrollElement.scrollTop;\n            setShowTopShadow(currentScrollTop > 0);\n        }, 50);\n\n        checkScrollPosition();\n\n        scrollElement.addEventListener('scroll', checkScrollPosition, { passive: true });\n\n        return () => {\n            checkScrollPosition.cancel();\n            scrollElement.removeEventListener('scroll', checkScrollPosition);\n        };\n    }, [enableHeader, pinnedRightColumnCount, pinnedRightColumnRef, rowRef, setShowTopShadow]);\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/item-table-list/hooks/use-table-row-model.ts",
    "content": "import { useMemo } from 'react';\n\nimport { TableGroupHeader } from '/@/renderer/components/item-list/item-table-list/item-table-list';\n\nexport const useTableRowModel = ({\n    data,\n    enableHeader,\n    groups,\n}: {\n    data: unknown[];\n    enableHeader: boolean;\n    groups?: TableGroupHeader[];\n}) => {\n    const dataWithGroups = useMemo(() => {\n        const result: (null | unknown)[] = enableHeader ? [null] : [];\n\n        if (!groups || groups.length === 0) {\n            result.push(...data);\n            return result;\n        }\n\n        // Build the expanded row model: [header?] + (groupHeader + groupItems)* + any remaining items.\n        let dataIndex = 0;\n        for (const group of groups) {\n            // Group header row\n            result.push(null);\n\n            // Group items\n            const end = Math.min(data.length, dataIndex + group.itemCount);\n            for (; dataIndex < end; dataIndex++) {\n                result.push(data[dataIndex]);\n            }\n        }\n\n        // If groups don't account for all items, append the remainder.\n        for (; dataIndex < data.length; dataIndex++) {\n            result.push(data[dataIndex]);\n        }\n\n        return result;\n    }, [data, enableHeader, groups]);\n\n    const groupHeaderRowCount = useMemo(() => {\n        if (!groups || groups.length === 0) return 0;\n        return groups.length;\n    }, [groups]);\n\n    return useMemo(\n        () => ({\n            dataWithGroups,\n            groupHeaderRowCount,\n        }),\n        [dataWithGroups, groupHeaderRowCount],\n    );\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/item-table-list/hooks/use-table-scroll-to-index.ts",
    "content": "import { useCallback, useMemo } from 'react';\n\nimport { TableItemProps } from '../item-table-list';\n\nimport { ItemListStateActions } from '/@/renderer/components/item-list/helpers/item-list-state';\nimport { ItemControls } from '/@/renderer/components/item-list/types';\nimport { PlayerContext } from '/@/renderer/features/player/context/player-context';\nimport { LibraryItem } from '/@/shared/types/domain-types';\n\nexport const useTableScrollToIndex = ({\n    cellPadding,\n    columns,\n    data,\n    enableAlternateRowColors,\n    enableExpansion,\n    enableHeader,\n    enableHorizontalBorders,\n    enableRowHoverHighlight,\n    enableSelection,\n    enableVerticalBorders,\n    itemType,\n    pinnedLeftColumnRef,\n    pinnedRightColumnRef,\n    playerContext,\n    rowHeight,\n    rowRef,\n    size,\n    tableId,\n}: {\n    cellPadding: 'lg' | 'md' | 'sm' | 'xl' | 'xs';\n    columns: TableItemProps['columns'];\n    data: unknown[];\n    enableAlternateRowColors: boolean;\n    enableExpansion: boolean;\n    enableHeader: boolean;\n    enableHorizontalBorders: boolean;\n    enableRowHoverHighlight: boolean;\n    enableSelection: boolean;\n    enableVerticalBorders: boolean;\n    itemType: LibraryItem;\n    pinnedLeftColumnRef: React.RefObject<HTMLDivElement | null>;\n    pinnedRightColumnRef: React.RefObject<HTMLDivElement | null>;\n    playerContext: PlayerContext;\n    rowHeight: ((index: number, cellProps: TableItemProps) => number) | number | undefined;\n    rowRef: React.RefObject<HTMLDivElement | null>;\n    size: 'compact' | 'default' | 'large';\n    tableId: string;\n}) => {\n    const DEFAULT_ROW_HEIGHT = useMemo(() => {\n        return size === 'compact' ? 40 : size === 'large' ? 88 : 64;\n    }, [size]);\n\n    const mockCellPropsBase = useMemo<TableItemProps>(\n        () => ({\n            cellPadding,\n            columns,\n            controls: {} as ItemControls,\n            data: enableHeader ? [null, ...data] : data,\n            enableAlternateRowColors,\n            enableExpansion,\n            enableHeader,\n            enableHorizontalBorders,\n            enableRowHoverHighlight,\n            enableSelection,\n            enableVerticalBorders,\n            getRowHeight: () => DEFAULT_ROW_HEIGHT,\n            internalState: {} as ItemListStateActions,\n            itemType,\n            playerContext,\n            size,\n            tableId,\n        }),\n        [\n            DEFAULT_ROW_HEIGHT,\n            cellPadding,\n            columns,\n            data,\n            enableAlternateRowColors,\n            enableExpansion,\n            enableHeader,\n            enableHorizontalBorders,\n            enableRowHoverHighlight,\n            enableSelection,\n            enableVerticalBorders,\n            itemType,\n            playerContext,\n            size,\n            tableId,\n        ],\n    );\n\n    const getRowHeightAtIndex = useCallback(\n        (index: number) => {\n            if (typeof rowHeight === 'number') return rowHeight;\n            if (typeof rowHeight === 'function') return rowHeight(index, mockCellPropsBase);\n            return DEFAULT_ROW_HEIGHT;\n        },\n        [DEFAULT_ROW_HEIGHT, mockCellPropsBase, rowHeight],\n    );\n\n    const scrollToTableOffset = useCallback(\n        (offset: number) => {\n            const mainContainer = rowRef.current?.childNodes[0] as HTMLDivElement | undefined;\n            const pinnedLeftContainer = pinnedLeftColumnRef.current?.childNodes[0] as\n                | HTMLDivElement\n                | undefined;\n            const pinnedRightContainer = pinnedRightColumnRef.current?.childNodes[0] as\n                | HTMLDivElement\n                | undefined;\n\n            const behavior = 'instant';\n\n            if (mainContainer) {\n                mainContainer.scrollTo({ behavior, top: offset });\n            }\n            if (pinnedLeftContainer) {\n                pinnedLeftContainer.scrollTo({ behavior, top: offset });\n            }\n            if (pinnedRightContainer) {\n                pinnedRightContainer.scrollTo({ behavior, top: offset });\n            }\n        },\n        [pinnedLeftColumnRef, pinnedRightColumnRef, rowRef],\n    );\n\n    const calculateScrollTopForIndex = useCallback(\n        (index: number) => {\n            const adjustedIndex = enableHeader ? Math.max(0, index - 1) : index;\n            let scrollTop = 0;\n\n            for (let i = 0; i < adjustedIndex; i++) {\n                scrollTop += getRowHeightAtIndex(i);\n            }\n            return scrollTop;\n        },\n        [enableHeader, getRowHeightAtIndex],\n    );\n\n    const scrollToTableIndex = useCallback(\n        (index: number, options?: { align?: 'bottom' | 'center' | 'top' }) => {\n            const mainContainer = rowRef.current?.childNodes[0] as HTMLDivElement | undefined;\n            if (!mainContainer) return;\n\n            const viewportHeight = mainContainer.clientHeight;\n            const align = options?.align || 'top';\n\n            // Calculate the base scroll offset (top of the row)\n            let offset = calculateScrollTopForIndex(index);\n\n            // Calculate row height for the target index\n            const adjustedIndex = enableHeader ? Math.max(0, index - 1) : index;\n            const targetRowHeight = getRowHeightAtIndex(adjustedIndex);\n\n            if (align === 'center') {\n                offset = offset - viewportHeight / 2 + targetRowHeight / 2;\n            } else if (align === 'bottom') {\n                offset = offset - viewportHeight + targetRowHeight;\n            }\n\n            offset = Math.max(0, offset);\n            scrollToTableOffset(offset);\n        },\n        [\n            calculateScrollTopForIndex,\n            enableHeader,\n            getRowHeightAtIndex,\n            rowRef,\n            scrollToTableOffset,\n        ],\n    );\n\n    return useMemo(\n        () => ({\n            calculateScrollTopForIndex,\n            DEFAULT_ROW_HEIGHT,\n            scrollToTableIndex,\n            scrollToTableOffset,\n        }),\n        [calculateScrollTopForIndex, DEFAULT_ROW_HEIGHT, scrollToTableIndex, scrollToTableOffset],\n    );\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/item-table-list/item-table-list-column.module.css",
    "content": ".container {\n    position: relative;\n    display: flex;\n    flex-direction: column;\n    justify-content: center;\n    height: 100%;\n}\n\n.container.padding-xs {\n    padding: var(--theme-spacing-xs);\n}\n\n.container.padding-sm {\n    padding: var(--theme-spacing-xs) var(--theme-spacing-sm);\n}\n\n.container.padding-md {\n    padding: var(--theme-spacing-xs) var(--theme-spacing-md);\n}\n\n.container.padding-lg {\n    padding: var(--theme-spacing-xs) var(--theme-spacing-lg);\n}\n\n.container.padding-xl {\n    padding: var(--theme-spacing-xs) var(--theme-spacing-xl);\n}\n\n.container.no-horizontal-padding {\n    padding-right: 0;\n    padding-left: 0;\n}\n\n.container.center {\n    align-items: center;\n    text-align: center;\n}\n\n.container.center > * {\n    align-self: center;\n}\n\n.container.start {\n    align-items: flex-start;\n    text-align: left;\n}\n\n.container.start > * {\n    align-self: flex-start;\n}\n\n.container.right {\n    align-items: flex-end;\n    text-align: right;\n}\n\n.container.right > * {\n    align-self: flex-end;\n}\n\n.content {\n    position: relative;\n    z-index: 2;\n    display: -webkit-box;\n    overflow: hidden;\n    text-overflow: ellipsis;\n    -webkit-line-clamp: 2;\n    line-clamp: 2;\n    -webkit-box-orient: vertical;\n}\n\n.content.compact {\n    -webkit-line-clamp: 1;\n    line-clamp: 1;\n}\n\n.content.large {\n    -webkit-line-clamp: 3;\n    line-clamp: 3;\n}\n\n.container.data-row {\n    cursor: default;\n}\n\n.container.with-horizontal-border {\n    border-bottom: 1px solid var(--theme-colors-border);\n}\n\n.container.with-vertical-border {\n    border-right: 1px solid var(--theme-colors-border);\n}\n\n.container.alternate-row-even {\n    background-color: var(--theme-colors-background);\n}\n\n.container.alternate-row-odd {\n    @mixin dark {\n        background-color: darken(var(--theme-colors-background), 30%);\n    }\n\n    @mixin light {\n        background-color: darken(var(--theme-colors-background), 2%);\n    }\n}\n\n.container.data-row.row-hover-highlight-enabled[data-row-hovered='true']::before {\n    position: absolute;\n    top: 0;\n    right: 0;\n    bottom: 0;\n    left: 0;\n    z-index: 1;\n    pointer-events: none;\n    content: '';\n    background-color: var(--theme-colors-surface);\n    opacity: 0.7;\n}\n\n.container.data-row.row-selected::before {\n    position: absolute;\n    top: 0;\n    right: 0;\n    bottom: 0;\n    left: 0;\n    z-index: 1;\n    pointer-events: none;\n    content: '';\n    opacity: 0.7;\n\n    @mixin dark {\n        background-color: lighten(var(--theme-colors-surface), 5%);\n    }\n\n    @mixin light {\n        background-color: darken(var(--theme-colors-surface), 5%);\n    }\n}\n\n.container.data-row.dragging {\n    opacity: 0.5;\n}\n\n.container.data-row[data-row-dragged-over='top']::after {\n    position: absolute;\n    top: -1px;\n    right: 0;\n    left: 0;\n    z-index: 3;\n    height: 2px;\n    pointer-events: none;\n    content: '';\n    background-color: var(--theme-colors-primary);\n}\n\n.container.data-row[data-row-dragged-over='top'][data-row-dragged-over-first='true']::after {\n    right: -9999px;\n    left: -9999px;\n    margin-right: 9999px;\n    margin-left: 9999px;\n}\n\n.container.data-row[data-row-dragged-over='bottom']::after {\n    position: absolute;\n    right: 0;\n    bottom: -1px;\n    left: 0;\n    z-index: 3;\n    height: 2px;\n    pointer-events: none;\n    content: '';\n    background-color: var(--theme-colors-primary);\n}\n\n.container.data-row[data-row-dragged-over='bottom'][data-row-dragged-over-first='true']::after {\n    right: -9999px;\n    left: -9999px;\n    margin-right: 9999px;\n    margin-left: 9999px;\n}\n\n.container.data-row > * {\n    position: relative;\n    z-index: 2;\n}\n\n.header-container {\n    position: relative;\n    background: none;\n}\n\n.header-container.padding-xs {\n    padding: 0 var(--theme-spacing-xs);\n}\n\n.header-container.padding-sm {\n    padding: 0 var(--theme-spacing-sm);\n}\n\n.header-container.padding-md {\n    padding: 0 var(--theme-spacing-md);\n}\n\n.header-container.padding-lg {\n    padding: 0 var(--theme-spacing-lg);\n}\n\n.header-container.padding-xl {\n    padding: 0 var(--theme-spacing-xl);\n}\n\n.header-container.no-horizontal-padding {\n    padding-right: 0;\n    padding-left: 0;\n}\n\n.header-dragging {\n    cursor: grabbing;\n    opacity: 0.5;\n}\n\n.header-dragged-over-left::before {\n    position: absolute;\n    top: 0;\n    bottom: 0;\n    left: 0;\n    z-index: 10;\n    width: 3px;\n    content: '';\n    background-color: var(--theme-colors-primary);\n}\n\n.header-dragged-over-right::after {\n    position: absolute;\n    top: 0;\n    right: 0;\n    bottom: 0;\n    z-index: 10;\n    width: 3px;\n    content: '';\n    background-color: var(--theme-colors-primary);\n}\n\n.header-content {\n    display: flex;\n    align-items: center;\n    overflow: hidden;\n    text-overflow: ellipsis;\n    font-size: var(--theme-font-size-sm);\n    white-space: nowrap;\n}\n\n.header-content.center {\n    justify-content: center;\n    text-align: center;\n}\n\n.header-content.left {\n    justify-content: flex-start;\n    text-align: left;\n}\n\n.header-content.right {\n    justify-content: flex-end;\n    text-align: right;\n}\n\n.header-icon-wrapper {\n    display: inline-flex;\n    flex-shrink: 0;\n    align-items: center;\n    font-size: var(--theme-font-size-sm);\n    line-height: 1;\n}\n\n.header-icon-wrapper :global(svg) {\n    width: var(--theme-font-size-sm);\n    height: var(--theme-font-size-sm);\n}\n\n.header-content.center .header-icon-wrapper {\n    justify-content: center;\n}\n\n.header-content.left .header-icon-wrapper {\n    justify-content: flex-start;\n}\n\n.header-content.right .header-icon-wrapper {\n    justify-content: flex-end;\n}\n\n.container :global(.hover-only),\n.container :global(.hover-only-flex) {\n    display: none;\n}\n\n.container.data-row:hover :global(.hover-only),\n.container.data-row[data-row-hovered='true'] :global(.hover-only) {\n    display: block;\n}\n\n.container.data-row:hover :global(.hover-only-flex),\n.container.data-row[data-row-hovered='true'] :global(.hover-only-flex) {\n    display: flex;\n}\n\n.container :global(.hide-on-hover) {\n    display: block;\n}\n\n.container.data-row:hover :global(.hide-on-hover),\n.container.data-row[data-row-hovered='true'] :global(.hide-on-hover) {\n    display: none;\n}\n\n.resize-handle {\n    position: absolute;\n    top: 8px;\n    bottom: 8px;\n    z-index: 10;\n    width: 8px;\n    margin-right: -4px;\n    cursor: col-resize;\n    opacity: 0;\n    transition: opacity 0.3s ease;\n}\n\n.resize-handle::before {\n    position: absolute;\n    top: 0;\n    right: 0;\n    bottom: 0;\n    width: 2px;\n    content: '';\n    background-color: transparent;\n    transition: background-color 0.15s ease;\n}\n\n.header-container:hover .resize-handle {\n    opacity: 1;\n}\n\n.header-container:hover .resize-handle::before {\n    background-color: var(--theme-colors-border);\n}\n\n.resize-handle-left {\n    left: 0;\n    margin-right: 0;\n    margin-left: -4px;\n}\n\n.resize-handle-left::before {\n    right: auto;\n    left: 0;\n}\n\n.resize-handle-right {\n    right: 0;\n    margin-right: 0;\n}\n\n.resize-handle-dragging {\n    opacity: 1;\n}\n\n.resize-handle:hover {\n    opacity: 1;\n}\n"
  },
  {
    "path": "src/renderer/components/item-list/item-table-list/item-table-list-column.tsx",
    "content": "import {\n    attachClosestEdge,\n    type Edge,\n    extractClosestEdge,\n} from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge';\nimport { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';\nimport {\n    draggable,\n    dropTargetForElements,\n} from '@atlaskit/pragmatic-drag-and-drop/element/adapter';\nimport { disableNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/disable-native-drag-preview';\nimport clsx from 'clsx';\nimport React, {\n    CSSProperties,\n    memo,\n    ReactElement,\n    ReactNode,\n    useEffect,\n    useRef,\n    useState,\n} from 'react';\nimport { useParams } from 'react-router';\nimport { CellComponentProps } from 'react-window-v2';\n\nimport styles from './item-table-list-column.module.css';\n\nimport i18n from '/@/i18n/i18n';\nimport { useItemSelectionState } from '/@/renderer/components/item-list/helpers/item-list-state';\nimport { isNoHorizontalPaddingColumn } from '/@/renderer/components/item-list/item-detail-list/utils';\nimport { ActionsColumn } from '/@/renderer/components/item-list/item-table-list/columns/actions-column';\nimport { AlbumArtistsColumn } from '/@/renderer/components/item-list/item-table-list/columns/album-artists-column';\nimport { AlbumColumn } from '/@/renderer/components/item-list/item-table-list/columns/album-column';\nimport { AlbumGroupColumn } from '/@/renderer/components/item-list/item-table-list/columns/album-group-column';\nimport { ArtistsColumn } from '/@/renderer/components/item-list/item-table-list/columns/artists-column';\nimport { ComposerColumn } from '/@/renderer/components/item-list/item-table-list/columns/composer-column';\nimport { CountColumn } from '/@/renderer/components/item-list/item-table-list/columns/count-column';\nimport {\n    AbsoluteDateColumn,\n    DateColumn,\n    RelativeDateColumn,\n} from '/@/renderer/components/item-list/item-table-list/columns/date-column';\nimport { DefaultColumn } from '/@/renderer/components/item-list/item-table-list/columns/default-column';\nimport { DurationColumn } from '/@/renderer/components/item-list/item-table-list/columns/duration-column';\nimport { FavoriteColumn } from '/@/renderer/components/item-list/item-table-list/columns/favorite-column';\nimport { GenreBadgeColumn } from '/@/renderer/components/item-list/item-table-list/columns/genre-badge-column';\nimport { GenreColumn } from '/@/renderer/components/item-list/item-table-list/columns/genre-column';\nimport { ImageColumn } from '/@/renderer/components/item-list/item-table-list/columns/image-column';\nimport { NumericColumn } from '/@/renderer/components/item-list/item-table-list/columns/numeric-column';\nimport { PathColumn } from '/@/renderer/components/item-list/item-table-list/columns/path-column';\nimport { PlaylistReorderColumn } from '/@/renderer/components/item-list/item-table-list/columns/playlist-reorder-column';\nimport { RatingColumn } from '/@/renderer/components/item-list/item-table-list/columns/rating-column';\nimport { RowIndexColumn } from '/@/renderer/components/item-list/item-table-list/columns/row-index-column';\nimport { SizeColumn } from '/@/renderer/components/item-list/item-table-list/columns/size-column';\nimport { TextColumn } from '/@/renderer/components/item-list/item-table-list/columns/text-column';\nimport { TitleArtistColumn } from '/@/renderer/components/item-list/item-table-list/columns/title-artist-column';\nimport { TitleColumn } from '/@/renderer/components/item-list/item-table-list/columns/title-column';\nimport { TitleCombinedColumn } from '/@/renderer/components/item-list/item-table-list/columns/title-combined-column';\nimport { YearColumn } from '/@/renderer/components/item-list/item-table-list/columns/year-column';\nimport { useItemDragDropState } from '/@/renderer/components/item-list/item-table-list/hooks/use-item-drag-drop-state';\nimport { TableItemProps } from '/@/renderer/components/item-list/item-table-list/item-table-list';\nimport { ItemControls, ItemListItem } from '/@/renderer/components/item-list/types';\nimport { Flex } from '/@/shared/components/flex/flex';\nimport { Icon } from '/@/shared/components/icon/icon';\nimport { Skeleton } from '/@/shared/components/skeleton/skeleton';\nimport { Text } from '/@/shared/components/text/text';\nimport { useDoubleClick } from '/@/shared/hooks/use-double-click';\nimport { useMergedRef } from '/@/shared/hooks/use-merged-ref';\nimport { LibraryItem } from '/@/shared/types/domain-types';\nimport { dndUtils, DragData, DragOperation, DragTarget } from '/@/shared/types/drag-and-drop';\nimport { TableColumn } from '/@/shared/types/types';\n\nexport interface ItemTableListColumn extends CellComponentProps<TableItemProps> {\n    columnType?: TableColumn;\n}\n\nexport interface ItemTableListInnerColumn extends ItemTableListColumn {\n    controls: ItemControls;\n    dragRef?: null | React.Ref<HTMLDivElement>;\n    isDraggedOver?: 'bottom' | 'top' | null;\n    isDragging?: boolean;\n    type: TableColumn;\n}\n\nconst ItemTableListColumnBase = (props: ItemTableListColumn) => {\n    const { playlistId } = useParams() as { playlistId?: string };\n    const type = props.columnType ?? (props.columns[props.columnIndex].id as TableColumn);\n\n    const isHeaderEnabled = !!props.enableHeader;\n    const isDataRow = isHeaderEnabled ? props.rowIndex > 0 : true;\n    const item = isDataRow\n        ? (props.getRowItem?.(props.rowIndex) ?? props.data[props.rowIndex])\n        : null;\n    const shouldEnableDrag = !!props.enableDrag && isDataRow && !!item;\n    const itemType = (item as unknown as { _itemType?: LibraryItem })?._itemType || props.itemType;\n\n    // Check if this row should render a group header (must be before conditional returns)\n    // Group headers need to be rendered consistently across all grids (pinned left, main, pinned right)\n    // to maintain proper styling and row heights\n    let groupHeader: 'GROUP_HEADER' | null | ReactElement = null;\n    if (props.groups && isDataRow && props.groups.length > 0) {\n        const groupInfo = props.groupHeaderInfoByRowIndex?.get(props.rowIndex);\n        const group = groupInfo ? props.groups[groupInfo.groupIndex] : undefined;\n\n        if (groupInfo && group) {\n            // Determine where to render the group header content:\n            // - If pinned left columns exist, render in the first pinned left column\n            // - Otherwise, render in the first column of the main grid\n            const hasPinnedLeftColumns = (props.pinnedLeftColumnCount || 0) > 0;\n            const isFirstPinnedLeftColumn = props.columnIndex === 0 && hasPinnedLeftColumns;\n            const isMainGridFirstColumn =\n                !hasPinnedLeftColumns &&\n                (props.columnIndex === (props.pinnedLeftColumnCount || 0) ||\n                    (props.columnIndex === 0 && (props.pinnedLeftColumnCount || 0) === 0));\n\n            // Render group header content in the first pinned left column (if exists) or first main grid column\n            if (isFirstPinnedLeftColumn || isMainGridFirstColumn) {\n                groupHeader = group.render({\n                    data: props.getGroupRenderData?.() ?? [],\n                    groupIndex: groupInfo.groupIndex,\n                    index: props.rowIndex,\n                    internalState: props.internalState,\n                    startDataIndex: groupInfo.startDataIndex,\n                });\n            } else {\n                // For other columns, mark as group header row for styled rendering\n                groupHeader = 'GROUP_HEADER';\n            }\n        }\n    }\n\n    const { dragRef, isDraggedOver, isDragging } = useItemDragDropState({\n        enableDrag: !!props.enableDrag,\n        internalState: props.internalState,\n        isDataRow,\n        item,\n        itemType: props.itemType,\n        playerContext: props.playerContext,\n        playlistId,\n    });\n\n    const controls = props.controls;\n\n    const dragProps = {\n        dragRef: shouldEnableDrag ? dragRef : null,\n        isDraggedOver: isDraggedOver === 'top' || isDraggedOver === 'bottom' ? isDraggedOver : null,\n        isDragging,\n    };\n\n    if (isHeaderEnabled && props.rowIndex === 0) {\n        return <TableColumnHeaderContainer {...props} controls={controls} type={type} />;\n    }\n\n    // Render group header if this row should have one\n    if (groupHeader) {\n        if (groupHeader === 'GROUP_HEADER') {\n            // For non-first columns (pinned left, other main columns, pinned right),\n            // render a styled cell that matches the group header styling\n            // This ensures consistent row heights and styling across all grids\n            return <div style={{ ...props.style }} />;\n        }\n        // Render the group header spanning full table width\n        // If rendering in pinned left column, extend right to cover all columns\n        // If rendering in main grid, extend left to cover pinned columns\n        const pinnedLeftWidth =\n            props.pinnedLeftColumnWidths?.reduce((sum, width) => sum + width, 0) || 0;\n\n        // Determine if we're rendering in the first pinned left column\n        const isFirstPinnedLeftColumn =\n            props.columnIndex === 0 && (props.pinnedLeftColumnCount || 0) > 0;\n\n        if (isFirstPinnedLeftColumn) {\n            return (\n                <div\n                    style={{\n                        ...props.style,\n                        marginLeft: 0,\n                        marginRight: 0,\n                    }}\n                >\n                    {groupHeader}\n                </div>\n            );\n        }\n\n        // For main grid, use negative margin to extend left\n        return (\n            <div\n                style={{\n                    ...props.style,\n                    marginLeft: pinnedLeftWidth > 0 ? `-${pinnedLeftWidth}px` : 0,\n                }}\n            >\n                {groupHeader}\n            </div>\n        );\n    }\n\n    if (itemType !== LibraryItem.FOLDER) {\n        switch (type) {\n            case TableColumn.ACTIONS:\n            case TableColumn.SKIP:\n                return <ActionsColumn {...props} {...dragProps} controls={controls} type={type} />;\n\n            case TableColumn.ALBUM:\n                return <AlbumColumn {...props} {...dragProps} controls={controls} type={type} />;\n\n            case TableColumn.ALBUM_ARTIST:\n                return (\n                    <AlbumArtistsColumn {...props} {...dragProps} controls={controls} type={type} />\n                );\n\n            case TableColumn.ALBUM_COUNT:\n            case TableColumn.PLAY_COUNT:\n            case TableColumn.SONG_COUNT:\n                return <CountColumn {...props} {...dragProps} controls={controls} type={type} />;\n\n            case TableColumn.ALBUM_GROUP:\n                return (\n                    <AlbumGroupColumn {...props} {...dragProps} controls={controls} type={type} />\n                );\n\n            case TableColumn.ARTIST:\n                return <ArtistsColumn {...props} {...dragProps} controls={controls} type={type} />;\n\n            case TableColumn.BIOGRAPHY:\n            case TableColumn.COMMENT:\n                return <TextColumn {...props} {...dragProps} controls={controls} type={type} />;\n\n            case TableColumn.BIT_DEPTH:\n            case TableColumn.BIT_RATE:\n            case TableColumn.BPM:\n            case TableColumn.CHANNELS:\n            case TableColumn.DISC_NUMBER:\n            case TableColumn.SAMPLE_RATE:\n            case TableColumn.TRACK_NUMBER:\n                return <NumericColumn {...props} {...dragProps} controls={controls} type={type} />;\n\n            case TableColumn.COMPOSER:\n                return <ComposerColumn {...props} {...dragProps} controls={controls} type={type} />;\n\n            case TableColumn.DATE_ADDED:\n                return <DateColumn {...props} {...dragProps} controls={controls} type={type} />;\n\n            case TableColumn.DURATION:\n                return <DurationColumn {...props} {...dragProps} controls={controls} type={type} />;\n\n            case TableColumn.GENRE:\n                return <GenreColumn {...props} {...dragProps} controls={controls} type={type} />;\n\n            case TableColumn.GENRE_BADGE:\n                return (\n                    <GenreBadgeColumn {...props} {...dragProps} controls={controls} type={type} />\n                );\n\n            case TableColumn.IMAGE:\n                return <ImageColumn {...props} {...dragProps} controls={controls} type={type} />;\n\n            case TableColumn.LAST_PLAYED:\n                return (\n                    <RelativeDateColumn {...props} {...dragProps} controls={controls} type={type} />\n                );\n\n            case TableColumn.PATH:\n                return <PathColumn {...props} {...dragProps} controls={controls} type={type} />;\n\n            case TableColumn.PLAYLIST_REORDER:\n                return <PlaylistReorderColumn {...props} controls={controls} type={type} />;\n\n            case TableColumn.RELEASE_DATE:\n                return (\n                    <AbsoluteDateColumn {...props} {...dragProps} controls={controls} type={type} />\n                );\n\n            case TableColumn.ROW_INDEX:\n                return <RowIndexColumn {...props} {...dragProps} controls={controls} type={type} />;\n\n            case TableColumn.SIZE:\n                return <SizeColumn {...props} {...dragProps} controls={controls} type={type} />;\n\n            case TableColumn.TITLE:\n                return <TitleColumn {...props} {...dragProps} controls={controls} type={type} />;\n\n            case TableColumn.TITLE_ARTIST:\n                return (\n                    <TitleArtistColumn {...props} {...dragProps} controls={controls} type={type} />\n                );\n\n            case TableColumn.TITLE_COMBINED:\n                return (\n                    <TitleCombinedColumn\n                        {...props}\n                        {...dragProps}\n                        controls={controls}\n                        type={type}\n                    />\n                );\n\n            case TableColumn.USER_FAVORITE:\n                return <FavoriteColumn {...props} {...dragProps} controls={controls} type={type} />;\n\n            case TableColumn.USER_RATING:\n                return <RatingColumn {...props} {...dragProps} controls={controls} type={type} />;\n\n            case TableColumn.YEAR:\n                return <YearColumn {...props} {...dragProps} controls={controls} type={type} />;\n\n            default:\n                return <DefaultColumn {...props} {...dragProps} controls={controls} type={type} />;\n        }\n    }\n\n    switch (type) {\n        case TableColumn.ACTIONS:\n            return <ActionsColumn {...props} {...dragProps} controls={controls} type={type} />;\n\n        case TableColumn.IMAGE:\n            return <ImageColumn {...props} {...dragProps} controls={controls} type={type} />;\n\n        case TableColumn.ROW_INDEX:\n            return <RowIndexColumn {...props} {...dragProps} controls={controls} type={type} />;\n\n        case TableColumn.TITLE:\n            return <TitleColumn {...props} {...dragProps} controls={controls} type={type} />;\n\n        case TableColumn.TITLE_ARTIST:\n            return <TitleArtistColumn {...props} {...dragProps} controls={controls} type={type} />;\n\n        case TableColumn.TITLE_COMBINED:\n            return (\n                <TitleCombinedColumn {...props} {...dragProps} controls={controls} type={type} />\n            );\n\n        default:\n            return <ColumnNullFallback {...props} {...dragProps} controls={controls} type={type} />;\n    }\n};\n\nexport const ItemTableListColumn = memo(ItemTableListColumnBase, (prevProps, nextProps) => {\n    const prevItem = prevProps.getRowItem?.(prevProps.rowIndex);\n    const nextItem = nextProps.getRowItem?.(nextProps.rowIndex);\n\n    return (\n        prevProps.rowIndex === nextProps.rowIndex &&\n        prevProps.columnIndex === nextProps.columnIndex &&\n        prevProps.data === nextProps.data &&\n        prevProps.columns === nextProps.columns &&\n        prevProps.style === nextProps.style &&\n        prevProps.columnType === nextProps.columnType &&\n        prevProps.itemType === nextProps.itemType &&\n        prevProps.enableHeader === nextProps.enableHeader &&\n        prevProps.enableDrag === nextProps.enableDrag &&\n        prevProps.groups === nextProps.groups &&\n        prevProps.groupHeaderInfoByRowIndex === nextProps.groupHeaderInfoByRowIndex &&\n        prevProps.pinnedLeftColumnCount === nextProps.pinnedLeftColumnCount &&\n        prevProps.pinnedLeftColumnWidths === nextProps.pinnedLeftColumnWidths &&\n        prevProps.size === nextProps.size &&\n        prevProps.enableAlternateRowColors === nextProps.enableAlternateRowColors &&\n        prevProps.enableHorizontalBorders === nextProps.enableHorizontalBorders &&\n        prevProps.enableVerticalBorders === nextProps.enableVerticalBorders &&\n        prevProps.enableRowHoverHighlight === nextProps.enableRowHoverHighlight &&\n        prevProps.enableSelection === nextProps.enableSelection &&\n        prevProps.enableColumnResize === nextProps.enableColumnResize &&\n        prevProps.enableColumnReorder === nextProps.enableColumnReorder &&\n        prevProps.cellPadding === nextProps.cellPadding &&\n        prevItem === nextItem\n    );\n});\n\nconst NonMutedColumns = [TableColumn.TITLE, TableColumn.TITLE_ARTIST, TableColumn.TITLE_COMBINED];\n\nexport function isAlbumGroupingActive(columns: { id: string; isEnabled?: boolean }[]): boolean {\n    return columns.some((col) => col.id === TableColumn.ALBUM_GROUP && col.isEnabled);\n}\n\nexport function isLastInAlbumGroup(\n    rowIndex: number,\n    getRowItem: ((index: number) => unknown) | undefined,\n    enableHeader: boolean | undefined,\n    dataLength: number,\n): boolean {\n    const item = getRowItem?.(rowIndex) as null | undefined | { album?: string };\n    if (!item?.album) return true;\n\n    const nextRowIndex = rowIndex + 1;\n    const maxRow = enableHeader ? dataLength + 1 : dataLength;\n    if (nextRowIndex >= maxRow) return true;\n\n    const nextItem = getRowItem?.(nextRowIndex) as null | undefined | { album?: string };\n    return !nextItem || nextItem.album !== item.album;\n}\n\nexport const TableColumnTextContainer = (\n    props: ItemTableListColumn & {\n        children: React.ReactNode;\n        className?: string;\n        containerClassName?: string;\n        controls: ItemControls;\n        dragRef?: null | React.Ref<HTMLDivElement>;\n        isDraggedOver?: 'bottom' | 'top' | null;\n        isDragging?: boolean;\n        type: TableColumn;\n    },\n) => {\n    const containerRef = useRef<HTMLDivElement>(null);\n    const isDataRow = props.enableHeader ? props.rowIndex > 0 : true;\n    const dataIndex = props.enableHeader ? props.rowIndex - 1 : props.rowIndex;\n    const item = isDataRow\n        ? (props.getRowItem?.(props.rowIndex) ?? props.data[props.rowIndex])\n        : null;\n    const itemRowId =\n        item && typeof item === 'object' && 'id' in item\n            ? props.internalState.extractRowId(item)\n            : undefined;\n    const isSelected = useItemSelectionState(props.internalState, itemRowId || undefined);\n\n    const isDragging = props.isDragging ?? false;\n    const mergedRef = useMergedRef(containerRef, props.dragRef ?? null);\n\n    const isLastColumn = props.columnIndex === props.columns.length - 1;\n    const isLastRow =\n        isDataRow &&\n        (props.enableHeader\n            ? props.rowIndex === props.data.length\n            : props.rowIndex === props.data.length - 1);\n\n    // Apply dragged over state to all cells in the row so border can span entire row\n    useEffect(() => {\n        if (!isDataRow || !containerRef.current) return;\n        const rowKey = `${props.tableId}-${props.rowIndex}`;\n        const edge =\n            props.isDraggedOver === 'top' || props.isDraggedOver === 'bottom'\n                ? props.isDraggedOver\n                : null;\n\n        containerRef.current.dispatchEvent(\n            new CustomEvent('itl:row-drag-over', {\n                bubbles: true,\n                detail: { edge, rowKey },\n            }),\n        );\n    }, [isDataRow, props.isDraggedOver, props.rowIndex, props.tableId]);\n\n    const handleClick = useDoubleClick({\n        onDoubleClick: (event: React.MouseEvent<HTMLDivElement>) => {\n            if (isDataRow && item) {\n                const rowId = props.internalState.extractRowId(item);\n                const index = rowId ? props.internalState.findItemIndex(rowId) : -1;\n                props.controls.onDoubleClick?.({\n                    event,\n                    index,\n                    internalState: props.internalState,\n                    item: item as ItemListItem,\n                    itemType: props.itemType,\n                });\n            }\n        },\n        onSingleClick: (event: React.MouseEvent<HTMLDivElement>) => {\n            // Don't trigger row selection if clicking on interactive elements\n            const target = event.target as HTMLElement;\n            const isInteractiveElement = target.closest(\n                'button, a, input, select, textarea, [role=\"button\"]',\n            );\n\n            if (isInteractiveElement) {\n                return;\n            }\n\n            if (isDataRow && item && props.enableSelection) {\n                const rowId = props.internalState.extractRowId(item);\n                const index = rowId ? props.internalState.findItemIndex(rowId) : -1;\n                props.controls.onClick?.({\n                    event,\n                    index,\n                    internalState: props.internalState,\n                    item: item as ItemListItem,\n                    itemType: props.itemType,\n                });\n            }\n        },\n    });\n\n    const handleContextMenu = (event: React.MouseEvent<HTMLDivElement>) => {\n        if (isDataRow && item) {\n            event.preventDefault();\n            const rowId = props.internalState.extractRowId(item);\n            const index = rowId ? props.internalState.findItemIndex(rowId) : -1;\n            props.controls.onMore?.({\n                event,\n                index,\n                internalState: props.internalState,\n                item: item as ItemListItem,\n                itemType: props.itemType,\n            });\n        }\n    };\n\n    return (\n        <div\n            className={clsx(styles.container, props.containerClassName, {\n                [styles.alternateRowEven]:\n                    props.enableAlternateRowColors && isDataRow && dataIndex % 2 === 0,\n                [styles.alternateRowOdd]:\n                    props.enableAlternateRowColors && isDataRow && dataIndex % 2 === 1,\n                [styles.center]: props.columns[props.columnIndex].align === 'center',\n                [styles.compact]: props.size === 'compact',\n                [styles.dataRow]: isDataRow,\n                [styles.dragging]: isDataRow && isDragging,\n                [styles.large]: props.size === 'large',\n                [styles.left]: props.columns[props.columnIndex].align === 'start',\n                [styles.noHorizontalPadding]: isNoHorizontalPaddingColumn(props.type),\n                [styles.paddingLg]: props.cellPadding === 'lg',\n                [styles.paddingMd]: props.cellPadding === 'md',\n                [styles.paddingSm]: props.cellPadding === 'sm',\n                [styles.paddingXl]: props.cellPadding === 'xl',\n                [styles.paddingXs]: props.cellPadding === 'xs',\n                [styles.right]: props.columns[props.columnIndex].align === 'end',\n                [styles.rowHoverHighlightEnabled]: isDataRow && props.enableRowHoverHighlight,\n                [styles.rowSelected]: isDataRow && isSelected,\n                [styles.withHorizontalBorder]:\n                    props.enableHorizontalBorders &&\n                    props.enableHeader &&\n                    props.rowIndex > 0 &&\n                    (isAlbumGroupingActive(props.columns)\n                        ? isLastInAlbumGroup(\n                              props.rowIndex,\n                              props.getRowItem,\n                              !!props.enableHeader,\n                              props.data.length,\n                          )\n                        : props.rowIndex === 1 || !isLastRow),\n                [styles.withVerticalBorder]: props.enableVerticalBorders && !isLastColumn,\n            })}\n            data-row-index={isDataRow ? `${props.tableId}-${props.rowIndex}` : undefined}\n            onClick={handleClick}\n            onContextMenu={handleContextMenu}\n            ref={mergedRef}\n            style={props.style}\n        >\n            <Text\n                className={clsx(styles.content, props.className, {\n                    [styles.compact]: props.size === 'compact',\n                    [styles.large]: props.size === 'large',\n                })}\n                isMuted={!NonMutedColumns.includes(props.type)}\n                isNoSelect\n            >\n                {props.children}\n            </Text>\n        </div>\n    );\n};\n\nexport const TableColumnContainer = (\n    props: ItemTableListColumn & {\n        children: React.ReactNode;\n        className?: string;\n        containerStyle?: CSSProperties;\n        controls: ItemControls;\n        dragRef?: null | React.Ref<HTMLDivElement>;\n        isDraggedOver?: 'bottom' | 'top' | null;\n        isDragging?: boolean;\n        type: TableColumn;\n    },\n) => {\n    const containerRef = useRef<HTMLDivElement>(null);\n    const isDataRow = props.enableHeader ? props.rowIndex > 0 : true;\n    const dataIndex = props.enableHeader ? props.rowIndex - 1 : props.rowIndex;\n    const item = isDataRow\n        ? (props.getRowItem?.(props.rowIndex) ?? props.data[props.rowIndex])\n        : null;\n    const itemRowId =\n        item && typeof item === 'object' && 'id' in item\n            ? props.internalState.extractRowId(item)\n            : undefined;\n    const isSelected = useItemSelectionState(props.internalState, itemRowId || undefined);\n\n    const isDragging = props.isDragging ?? false;\n    const mergedRef = useMergedRef(containerRef, props.dragRef ?? null);\n\n    const isLastColumn = props.columnIndex === props.columns.length - 1;\n    const isLastRow =\n        isDataRow &&\n        (props.enableHeader\n            ? props.rowIndex === props.data.length\n            : props.rowIndex === props.data.length - 1);\n\n    // Apply dragged over state to all cells in the row so border can span entire row\n    useEffect(() => {\n        if (!isDataRow || !containerRef.current) return;\n        const rowKey = `${props.tableId}-${props.rowIndex}`;\n        const edge =\n            props.isDraggedOver === 'top' || props.isDraggedOver === 'bottom'\n                ? props.isDraggedOver\n                : null;\n\n        containerRef.current.dispatchEvent(\n            new CustomEvent('itl:row-drag-over', {\n                bubbles: true,\n                detail: { edge, rowKey },\n            }),\n        );\n    }, [isDataRow, props.isDraggedOver, props.rowIndex, props.tableId]);\n\n    const handleClick = useDoubleClick({\n        onDoubleClick: (event: React.MouseEvent<HTMLDivElement>) => {\n            if (isDataRow && item) {\n                const rowId = props.internalState.extractRowId(item);\n                const index = rowId ? props.internalState.findItemIndex(rowId) : -1;\n                props.controls.onDoubleClick?.({\n                    event,\n                    index,\n                    internalState: props.internalState,\n                    item: item as ItemListItem,\n                    itemType: props.itemType,\n                });\n            }\n        },\n        onSingleClick: (event: React.MouseEvent<HTMLDivElement>) => {\n            // Don't trigger row selection if clicking on interactive elements\n            const target = event.target as HTMLElement;\n            const isInteractiveElement = target.closest(\n                'button, a, input, select, textarea, [role=\"button\"]',\n            );\n\n            if (isInteractiveElement) {\n                return;\n            }\n\n            if (isDataRow && item && props.enableSelection) {\n                const rowId = props.internalState.extractRowId(item);\n                const index = rowId ? props.internalState.findItemIndex(rowId) : -1;\n                props.controls.onClick?.({\n                    event,\n                    index,\n                    internalState: props.internalState,\n                    item: item as ItemListItem,\n                    itemType: props.itemType,\n                });\n            }\n        },\n    });\n\n    const handleContextMenu = (event: React.MouseEvent<HTMLDivElement>) => {\n        if (isDataRow && item) {\n            event.preventDefault();\n            const rowId = props.internalState.extractRowId(item);\n            const index = rowId ? props.internalState.findItemIndex(rowId) : -1;\n            props.controls.onMore?.({\n                event,\n                index,\n                internalState: props.internalState,\n                item: item as ItemListItem,\n                itemType: props.itemType,\n            });\n        }\n    };\n\n    return (\n        <div\n            className={clsx(styles.container, props.className, {\n                [styles.alternateRowEven]:\n                    props.enableAlternateRowColors && isDataRow && dataIndex % 2 === 0,\n                [styles.alternateRowOdd]:\n                    props.enableAlternateRowColors && isDataRow && dataIndex % 2 === 1,\n                [styles.center]: props.columns[props.columnIndex].align === 'center',\n                [styles.compact]: props.size === 'compact',\n                [styles.dataRow]: isDataRow,\n                [styles.dragging]: isDataRow && isDragging,\n                [styles.large]: props.size === 'large',\n                [styles.left]: props.columns[props.columnIndex].align === 'start',\n                [styles.noHorizontalPadding]: isNoHorizontalPaddingColumn(props.type),\n                [styles.paddingLg]: props.cellPadding === 'lg',\n                [styles.paddingMd]: props.cellPadding === 'md',\n                [styles.paddingSm]: props.cellPadding === 'sm',\n                [styles.paddingXl]: props.cellPadding === 'xl',\n                [styles.paddingXs]: props.cellPadding === 'xs',\n                [styles.right]: props.columns[props.columnIndex].align === 'end',\n                [styles.rowHoverHighlightEnabled]:\n                    isDataRow &&\n                    props.enableRowHoverHighlight &&\n                    props.type !== TableColumn.ALBUM_GROUP,\n                [styles.rowSelected]:\n                    isDataRow && isSelected && props.type !== TableColumn.ALBUM_GROUP,\n                [styles.withHorizontalBorder]:\n                    props.enableHorizontalBorders &&\n                    props.enableHeader &&\n                    props.rowIndex > 0 &&\n                    (isAlbumGroupingActive(props.columns)\n                        ? isLastInAlbumGroup(\n                              props.rowIndex,\n                              props.getRowItem,\n                              !!props.enableHeader,\n                              props.data.length,\n                          )\n                        : props.rowIndex === 1 || !isLastRow),\n                [styles.withVerticalBorder]: props.enableVerticalBorders && !isLastColumn,\n            })}\n            data-row-index={isDataRow ? `${props.tableId}-${props.rowIndex}` : undefined}\n            onClick={handleClick}\n            onContextMenu={handleContextMenu}\n            ref={mergedRef}\n            style={{ ...props.containerStyle, ...props.style }}\n        >\n            {props.children}\n        </div>\n    );\n};\n\ninterface ColumnResizeHandleProps {\n    columnId: TableColumn;\n    initialWidth: number;\n    onResize: (columnId: TableColumn, width: number) => void;\n    side: 'left' | 'right';\n}\n\nconst ColumnResizeHandle = ({\n    columnId,\n    initialWidth,\n    onResize,\n    side,\n}: ColumnResizeHandleProps) => {\n    const [isDragging, setIsDragging] = useState(false);\n    const handleRef = useRef<HTMLDivElement>(null);\n    const startWidthRef = useRef<number>(initialWidth);\n    const startXRef = useRef<number>(0);\n    const finalWidthRef = useRef<number>(initialWidth);\n\n    // Update the ref when initialWidth changes (but not during drag)\n    useEffect(() => {\n        if (!isDragging) {\n            startWidthRef.current = initialWidth;\n        }\n    }, [initialWidth, isDragging]);\n\n    useEffect(() => {\n        if (!isDragging) return;\n\n        const handleMouseMove = (event: MouseEvent) => {\n            const deltaX = event.clientX - startXRef.current;\n            const newWidth = Math.min(Math.max(10, startWidthRef.current + deltaX), 1000);\n            finalWidthRef.current = newWidth;\n        };\n\n        const handleMouseUp = () => {\n            setIsDragging(false);\n            document.body.style.cursor = '';\n            document.body.style.userSelect = '';\n            document.removeEventListener('mousemove', handleMouseMove);\n            document.removeEventListener('mouseup', handleMouseUp);\n            onResize(columnId, finalWidthRef.current);\n        };\n\n        document.addEventListener('mousemove', handleMouseMove);\n        document.addEventListener('mouseup', handleMouseUp);\n\n        return () => {\n            document.removeEventListener('mousemove', handleMouseMove);\n            document.removeEventListener('mouseup', handleMouseUp);\n        };\n    }, [isDragging, columnId, onResize]);\n\n    const handleMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {\n        event.preventDefault();\n        event.stopPropagation();\n        setIsDragging(true);\n        startWidthRef.current = initialWidth;\n        startXRef.current = event.clientX;\n        document.body.style.cursor = 'col-resize';\n        document.body.style.userSelect = 'none';\n    };\n\n    return (\n        <div\n            className={clsx(styles.resizeHandle, {\n                [styles.resizeHandleDragging]: isDragging,\n                [styles.resizeHandleLeft]: side === 'left',\n                [styles.resizeHandleRight]: side === 'right',\n            })}\n            onMouseDown={handleMouseDown}\n            ref={handleRef}\n        />\n    );\n};\n\nexport const TableColumnHeaderContainer = (\n    props: ItemTableListColumn & {\n        className?: string;\n        containerClassName?: string;\n        controls: ItemControls;\n        type: TableColumn;\n    },\n) => {\n    const columnConfig = props.columns[props.columnIndex];\n    // Use the actual rendered width from style if available, otherwise fall back to config width\n    const currentWidth = (props.style?.width as number | undefined) || columnConfig.width;\n\n    const handleResize = (columnId: TableColumn, width: number) => {\n        props.controls.onColumnResized?.({ columnId, width });\n    };\n\n    const containerRef = useRef<HTMLDivElement>(null);\n    const [isDragging, setIsDragging] = useState(false);\n    const [isDraggedOver, setIsDraggedOver] = useState<Edge | null>(null);\n\n    useEffect(() => {\n        if (!containerRef.current || !props.enableColumnReorder) {\n            return;\n        }\n\n        const handleReorder = (\n            columnIdFrom: TableColumn,\n            columnIdTo: TableColumn,\n            edge: Edge | null,\n        ) => {\n            props.controls.onColumnReordered?.({ columnIdFrom, columnIdTo, edge });\n        };\n\n        return combine(\n            draggable({\n                element: containerRef.current,\n                getInitialData: () => {\n                    const data = dndUtils.generateDragData(\n                        {\n                            id: [props.type],\n                            operation: [DragOperation.REORDER],\n                            type: DragTarget.TABLE_COLUMN,\n                        },\n                        { tableId: props.tableId },\n                    );\n                    return data;\n                },\n                onDragStart: () => {\n                    setIsDragging(true);\n                },\n                onDrop: () => {\n                    setIsDragging(false);\n                },\n                onGenerateDragPreview: (data) => {\n                    disableNativeDragPreview({ nativeSetDragImage: data.nativeSetDragImage });\n                },\n            }),\n            dropTargetForElements({\n                canDrop: (args) => {\n                    const data = args.source.data as unknown as DragData;\n                    const sourceTableId = (data.metadata as { tableId?: string })?.tableId;\n                    const isSelf = (args.source.data.id as string[])[0] === props.type;\n                    const isSameTable = sourceTableId === props.tableId;\n                    return (\n                        dndUtils.isDropTarget(data.type, [DragTarget.TABLE_COLUMN]) &&\n                        !isSelf &&\n                        isSameTable\n                    );\n                },\n                element: containerRef.current,\n                getData: ({ element, input }) => {\n                    const data = dndUtils.generateDragData(\n                        {\n                            id: [props.type],\n                            operation: [DragOperation.REORDER],\n                            type: DragTarget.TABLE_COLUMN,\n                        },\n                        { tableId: props.tableId },\n                    );\n\n                    return attachClosestEdge(data, {\n                        allowedEdges: ['left', 'right'],\n                        element,\n                        input,\n                    });\n                },\n                onDrag: (args) => {\n                    const closestEdgeOfTarget: Edge | null = extractClosestEdge(args.self.data);\n                    setIsDraggedOver(closestEdgeOfTarget);\n                },\n                onDragLeave: () => {\n                    setIsDraggedOver(null);\n                },\n                onDrop: (args) => {\n                    const closestEdgeOfTarget: Edge | null = extractClosestEdge(args.self.data);\n\n                    const from = args.source.data.id as string[];\n                    const to = args.self.data.id as string[];\n\n                    handleReorder(\n                        from[0] as TableColumn,\n                        to[0] as TableColumn,\n                        closestEdgeOfTarget,\n                    );\n                    setIsDraggedOver(null);\n                },\n            }),\n        );\n    }, [props.type, props.enableColumnReorder, props.controls, props.tableId]);\n\n    return (\n        <Flex\n            className={clsx(styles.container, styles.headerContainer, props.containerClassName, {\n                [styles.headerDraggedOverLeft]: isDraggedOver === 'left',\n                [styles.headerDraggedOverRight]: isDraggedOver === 'right',\n                [styles.headerDragging]: isDragging,\n                [styles.noHorizontalPadding]: isNoHorizontalPaddingColumn(props.type),\n                [styles.paddingLg]: props.cellPadding === 'lg',\n                [styles.paddingMd]: props.cellPadding === 'md',\n                [styles.paddingSm]: props.cellPadding === 'sm',\n                [styles.paddingXl]: props.cellPadding === 'xl',\n                [styles.paddingXs]: props.cellPadding === 'xs',\n            })}\n            ref={containerRef}\n            style={props.style}\n        >\n            <Text\n                className={clsx(styles.headerContent, props.className, {\n                    [styles.center]: props.columns[props.columnIndex].align === 'center',\n                    [styles.left]: props.columns[props.columnIndex].align === 'start',\n                    [styles.right]: props.columns[props.columnIndex].align === 'end',\n                })}\n                isNoSelect\n            >\n                {columnLabelMap[props.type]}\n            </Text>\n            {!columnConfig.autoSize && props.enableColumnResize && (\n                <ColumnResizeHandle\n                    columnId={props.type}\n                    initialWidth={currentWidth}\n                    onResize={handleResize}\n                    side=\"right\"\n                />\n            )}\n        </Flex>\n    );\n};\n\nexport const columnLabelMap: Record<TableColumn, ReactNode | string> = {\n    [TableColumn.ACTIONS]: (\n        <Flex className={styles.headerIconWrapper}>\n            <Icon fill=\"default\" icon=\"ellipsisHorizontal\" />\n        </Flex>\n    ),\n    [TableColumn.ALBUM]: i18n.t('table.column.album', { postProcess: 'upperCase' }) as string,\n    [TableColumn.ALBUM_ARTIST]: i18n.t('table.column.albumArtist', {\n        postProcess: 'upperCase',\n    }) as string,\n    [TableColumn.ALBUM_COUNT]: i18n.t('table.column.albumCount', {\n        postProcess: 'upperCase',\n    }) as string,\n    [TableColumn.ALBUM_GROUP]: i18n.t('table.config.label.albumGroup', {\n        postProcess: 'upperCase',\n    }) as string,\n    [TableColumn.ARTIST]: i18n.t('table.column.artist', { postProcess: 'upperCase' }) as string,\n    [TableColumn.BIOGRAPHY]: i18n.t('table.column.biography', {\n        postProcess: 'upperCase',\n    }) as string,\n    [TableColumn.BIT_DEPTH]: i18n.t('table.column.bitDepth', {\n        postProcess: 'upperCase',\n    }) as string,\n    [TableColumn.BIT_RATE]: i18n.t('table.column.bitrate', { postProcess: 'upperCase' }) as string,\n    [TableColumn.BPM]: i18n.t('table.column.bpm', { postProcess: 'upperCase' }) as string,\n    [TableColumn.CHANNELS]: i18n.t('table.column.channels', { postProcess: 'upperCase' }) as string,\n    [TableColumn.CODEC]: i18n.t('table.column.codec', { postProcess: 'upperCase' }) as string,\n    [TableColumn.COMMENT]: i18n.t('table.column.comment', { postProcess: 'upperCase' }) as string,\n    [TableColumn.COMPOSER]: i18n.t('table.config.label.composer', {\n        postProcess: 'upperCase',\n    }) as string,\n    [TableColumn.DATE_ADDED]: i18n.t('table.column.dateAdded', {\n        postProcess: 'upperCase',\n    }) as string,\n    [TableColumn.DISC_NUMBER]: (\n        <Flex className={styles.headerIconWrapper}>\n            <Icon icon=\"disc\" />\n        </Flex>\n    ),\n    [TableColumn.DURATION]: (\n        <Flex className={styles.headerIconWrapper}>\n            <Icon icon=\"duration\" />\n        </Flex>\n    ),\n    [TableColumn.GENRE]: i18n.t('table.column.genre', { postProcess: 'upperCase' }) as string,\n    [TableColumn.GENRE_BADGE]: i18n.t('table.column.genre', {\n        postProcess: 'upperCase',\n    }) as string,\n    [TableColumn.ID]: 'ID',\n    [TableColumn.IMAGE]: '',\n    [TableColumn.LAST_PLAYED]: i18n.t('table.column.lastPlayed', {\n        postProcess: 'upperCase',\n    }) as string,\n    [TableColumn.OWNER]: i18n.t('table.column.owner', { postProcess: 'upperCase' }) as string,\n    [TableColumn.PATH]: i18n.t('table.column.path', { postProcess: 'upperCase' }) as string,\n    [TableColumn.PLAY_COUNT]: i18n.t('table.column.playCount', {\n        postProcess: 'upperCase',\n    }) as string,\n    [TableColumn.PLAYLIST_REORDER]: (\n        <Flex className={styles.headerIconWrapper}>\n            <Icon icon=\"dragVertical\" />\n        </Flex>\n    ),\n    [TableColumn.RELEASE_DATE]: i18n.t('table.column.releaseDate', {\n        postProcess: 'upperCase',\n    }) as string,\n    [TableColumn.ROW_INDEX]: (\n        <Flex className={styles.headerIconWrapper}>\n            <Icon icon=\"hash\" />\n        </Flex>\n    ),\n    [TableColumn.SAMPLE_RATE]: i18n.t('table.column.sampleRate', {\n        postProcess: 'upperCase',\n    }) as string,\n    [TableColumn.SIZE]: i18n.t('table.column.size', { postProcess: 'upperCase' }) as string,\n    [TableColumn.SKIP]: '',\n    [TableColumn.SONG_COUNT]: i18n.t('table.column.songCount', {\n        postProcess: 'upperCase',\n    }) as string,\n    [TableColumn.TITLE]: i18n.t('table.column.title', { postProcess: 'upperCase' }) as string,\n    [TableColumn.TITLE_ARTIST]: i18n.t('table.column.title', {\n        postProcess: 'upperCase',\n    }) as string,\n    [TableColumn.TITLE_COMBINED]: i18n.t('table.column.title', {\n        postProcess: 'upperCase',\n    }) as string,\n    [TableColumn.TRACK_NUMBER]: (\n        <Flex className={styles.headerIconWrapper}>\n            <Icon icon=\"itemSong\" />\n        </Flex>\n    ),\n    [TableColumn.USER_FAVORITE]: (\n        <Flex className={styles.headerIconWrapper}>\n            <Icon icon=\"favorite\" />\n        </Flex>\n    ),\n    [TableColumn.USER_RATING]: (\n        <Flex className={styles.headerIconWrapper}>\n            <Icon icon=\"star\" />\n        </Flex>\n    ),\n    [TableColumn.YEAR]: i18n.t('table.column.releaseYear', { postProcess: 'upperCase' }) as string,\n};\n\nexport const ColumnNullFallback = (props: ItemTableListInnerColumn) => {\n    return <TableColumnTextContainer {...props}>&nbsp;</TableColumnTextContainer>;\n};\n\nexport const ColumnSkeletonVariable = (props: ItemTableListInnerColumn) => {\n    return (\n        <TableColumnContainer {...props}>\n            <Skeleton height=\"1rem\" width={`${props.rowIndex % 2 === 0 ? '80%' : '60%'}`} />\n        </TableColumnContainer>\n    );\n};\n\nexport const ColumnSkeletonFixed = (props: ItemTableListInnerColumn) => {\n    return (\n        <TableColumnContainer {...props}>\n            <Skeleton height=\"1rem\" width=\"80%\" />\n        </TableColumnContainer>\n    );\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/item-table-list/item-table-list-context.tsx",
    "content": "import React, { createContext, useContext, useEffect, useMemo, useRef } from 'react';\nimport { useSyncExternalStore } from 'react';\n\nimport { ItemListStateActions } from '/@/renderer/components/item-list/helpers/item-list-state';\nimport { ItemControls, ItemTableListColumnConfig } from '/@/renderer/components/item-list/types';\nimport { PlayerContext } from '/@/renderer/features/player/context/player-context';\nimport { LibraryItem } from '/@/shared/types/domain-types';\n\n/**\n * Stage A/B: Provide table-scoped config + external stores so churny values can update\n * without forcing `cellProps` identity changes (and therefore without rerendering every visible cell).\n */\n\nexport type ItemTableListConfig = {\n    cellPadding: 'lg' | 'md' | 'sm' | 'xl' | 'xs';\n    columns: ItemTableListColumnConfig[];\n    controls: ItemControls;\n    enableHeader: boolean;\n    enableRowHoverHighlight: boolean;\n    enableSelection: boolean;\n    internalState: ItemListStateActions;\n    itemType: LibraryItem;\n    playerContext: PlayerContext;\n    size: 'compact' | 'default' | 'large';\n    startRowIndex?: number;\n    tableId: string;\n};\n\nconst ItemTableListConfigContext = createContext<ItemTableListConfig | null>(null);\n\nexport const ItemTableListConfigProvider = ({\n    children,\n    value,\n}: {\n    children: React.ReactNode;\n    value: ItemTableListConfig;\n}) => {\n    // Keep reference stable when the input reference is stable.\n    const memoValue = useMemo(() => value, [value]);\n    return (\n        <ItemTableListConfigContext.Provider value={memoValue}>\n            {children}\n        </ItemTableListConfigContext.Provider>\n    );\n};\n\nexport const useItemTableListConfig = (): ItemTableListConfig | null => {\n    return useContext(ItemTableListConfigContext);\n};\n\ntype ItemTableListStoreContextValue = {\n    activeRowStore: ActiveRowStore;\n};\n\nclass ActiveRowStore {\n    private activeRowId: null | string = null;\n    private listeners = new Set<() => void>();\n\n    getActiveRowId(): null | string {\n        return this.activeRowId;\n    }\n\n    setActiveRowId(next: null | string | undefined): void {\n        const normalized = next ?? null;\n        if (this.activeRowId === normalized) return;\n        this.activeRowId = normalized;\n        this.listeners.forEach((l) => l());\n    }\n\n    subscribe(listener: () => void): () => void {\n        this.listeners.add(listener);\n        return () => {\n            this.listeners.delete(listener);\n        };\n    }\n}\n\nconst ItemTableListStoreContext = createContext<ItemTableListStoreContextValue | null>(null);\n\nexport const ItemTableListStoreProvider = ({\n    activeRowId,\n    children,\n}: {\n    activeRowId?: string;\n    children: React.ReactNode;\n}) => {\n    const storeRef = useRef<ActiveRowStore | null>(null);\n    if (!storeRef.current) {\n        storeRef.current = new ActiveRowStore();\n    }\n    const store = storeRef.current;\n\n    useEffect(() => {\n        store.setActiveRowId(activeRowId);\n    }, [activeRowId, store]);\n\n    const value = useMemo<ItemTableListStoreContextValue>(\n        () => ({ activeRowStore: store }),\n        [store],\n    );\n\n    return (\n        <ItemTableListStoreContext.Provider value={value}>\n            {children}\n        </ItemTableListStoreContext.Provider>\n    );\n};\n\nexport const useItemTableListStore = (): ItemTableListStoreContextValue | null => {\n    return useContext(ItemTableListStoreContext);\n};\n\nexport const useActiveRowSubscription = <T,>(selector: (activeRowId: null | string) => T): T => {\n    const store = useItemTableListStore()?.activeRowStore ?? null;\n\n    return useSyncExternalStore(store?.subscribe.bind(store) || (() => () => {}), () =>\n        selector(store?.getActiveRowId() ?? null),\n    );\n};\n\nexport const useIsActiveRow = (...rowIds: Array<string | undefined>): boolean => {\n    return useActiveRowSubscription((activeRowId) =>\n        rowIds.some((id) => !!id && id === activeRowId),\n    );\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/item-table-list/item-table-list.module.css",
    "content": ".item-table-list-container {\n    position: relative;\n    display: flex;\n    flex-direction: column;\n    height: 100%;\n    min-height: 0;\n    overflow: hidden;\n}\n\n.item-table-container {\n    display: flex;\n    flex-direction: row;\n    width: 100%;\n    min-width: 0;\n    height: 100%;\n    min-height: 0;\n    overflow: hidden;\n}\n\n.item-table-grid-container {\n    position: relative;\n    flex: 1 1 auto;\n    width: 100%;\n    min-width: 0;\n    height: 100%;\n    min-height: 0;\n    overflow: hidden;\n}\n\n.item-table-grid-container > div[role='grid'] {\n    height: unset !important;\n}\n\n.item-table-pinned-rows-container {\n    position: relative;\n    display: flex;\n    flex: 1 1 auto;\n    flex-direction: column;\n    min-width: 0;\n    min-height: 0;\n}\n\n.item-table-pinned-rows-grid-container {\n    position: relative;\n    flex: 0 1 auto;\n    min-width: 0;\n}\n\n.item-table-pinned-rows-grid-container.with-header {\n    position: relative;\n}\n\n.item-table-pinned-rows-grid-container.header-fixed {\n    position: fixed !important;\n    top: 65px;\n    z-index: 15;\n    background-color: var(--theme-bg-primary);\n    box-shadow: 0 -1px 0 0 var(--theme-colors-border);\n    transition: position 0.2s ease-in-out;\n}\n\n.item-table-pinned-rows-grid-container.header-window-bar {\n    top: 95px;\n}\n\n.item-table-list-container.header-fixed-margin {\n    margin-top: 36px !important;\n}\n\n.sticky-header {\n    position: fixed;\n    z-index: 15;\n    display: flex;\n    flex-direction: row;\n    overflow: hidden;\n    pointer-events: none;\n    background-color: var(--theme-colors-background);\n    border-bottom: 1px solid var(--theme-colors-border);\n}\n\n.sticky-header-row {\n    display: flex;\n    flex-direction: row;\n    width: 100%;\n}\n\n.sticky-header-section {\n    display: flex;\n    flex-direction: row;\n    overflow: hidden;\n    pointer-events: auto;\n}\n\n.sticky-group-row {\n    position: fixed;\n    z-index: 15;\n    display: flex;\n    flex-direction: row;\n    padding: 0;\n    margin: 0;\n    pointer-events: none;\n    background-color: var(--theme-colors-background);\n    border-bottom: 1px solid var(--theme-colors-border);\n}\n\n.sticky-group-row-content {\n    display: flex;\n    flex-direction: row;\n    width: 100%;\n}\n\n.sticky-group-row-section {\n    display: flex;\n    flex-direction: row;\n    overflow: hidden;\n    pointer-events: auto;\n}\n\n.item-table-pinned-rows-grid-container.with-header::after {\n    position: absolute;\n    right: 0;\n    bottom: 0;\n    left: 0;\n    height: 1px;\n    content: '';\n    background-color: var(--theme-colors-border);\n}\n\n.item-table-pinned-columns-grid-container {\n    position: relative;\n    display: flex;\n    flex: 0 1 auto;\n    flex-direction: column;\n    min-height: 0;\n}\n\n.item-table-pinned-intersection-grid-container {\n    position: relative;\n    flex: 0 1 auto;\n    min-width: 0;\n}\n\n.item-table-pinned-intersection-grid-container.with-header {\n    position: relative;\n}\n\n.item-table-pinned-intersection-grid-container.with-header::after {\n    position: absolute;\n    right: 0;\n    bottom: 0;\n    left: 0;\n    height: 1px;\n    content: '';\n    background-color: var(--theme-colors-border);\n}\n\n.item-table-pinned-columns-container {\n    flex: 1 1 auto;\n    min-width: 0;\n    height: 100%;\n    min-height: 0;\n    overflow-x: visible; /* Allow group headers to extend beyond container */\n    overflow-y: auto; /* Maintain vertical scrolling */\n}\n\n.item-table-pinned-right-columns-container {\n    flex: 1 1 auto;\n    min-width: 0;\n    height: 100%;\n    min-height: 0;\n}\n\n.no-scrollbar {\n    scrollbar-width: none;\n}\n\n.height-100 {\n    height: 100%;\n}\n\n.item-table-pinned-header-shadow {\n    position: absolute;\n    top: 100%;\n    right: 0;\n    left: 0;\n    z-index: 1;\n    height: 8px;\n    pointer-events: none;\n\n    @mixin dark {\n        background: linear-gradient(\n            to bottom,\n            color-mix(in srgb, var(--theme-colors-background) 85%, black) 0%,\n            color-mix(in srgb, var(--theme-colors-background) 92%, black) 50%,\n            transparent 100%\n        );\n    }\n\n    @mixin light {\n        background: linear-gradient(\n            to bottom,\n            color-mix(in srgb, var(--theme-colors-background) 92%, black) 0%,\n            color-mix(in srgb, var(--theme-colors-background) 96%, black) 50%,\n            transparent 100%\n        );\n    }\n}\n\n.item-table-left-scroll-shadow {\n    position: absolute;\n    top: 0;\n    bottom: 0;\n    left: 0;\n    z-index: 1;\n    width: 8px;\n    pointer-events: none;\n\n    @mixin dark {\n        background: linear-gradient(\n            to right,\n            color-mix(in srgb, var(--theme-colors-background) 85%, black) 0%,\n            color-mix(in srgb, var(--theme-colors-background) 92%, black) 50%,\n            transparent 100%\n        );\n    }\n\n    @mixin light {\n        background: linear-gradient(\n            to right,\n            color-mix(in srgb, var(--theme-colors-background) 92%, black) 0%,\n            color-mix(in srgb, var(--theme-colors-background) 96%, black) 50%,\n            transparent 100%\n        );\n    }\n}\n\n.item-table-right-scroll-shadow {\n    position: absolute;\n    top: 0;\n    right: 0;\n    bottom: 0;\n    z-index: 1;\n    width: 8px;\n    pointer-events: none;\n\n    @mixin dark {\n        background: linear-gradient(\n            to left,\n            color-mix(in srgb, var(--theme-colors-background) 85%, black) 0%,\n            color-mix(in srgb, var(--theme-colors-background) 92%, black) 50%,\n            transparent 100%\n        );\n    }\n\n    @mixin light {\n        background: linear-gradient(\n            to left,\n            color-mix(in srgb, var(--theme-colors-background) 92%, black) 0%,\n            color-mix(in srgb, var(--theme-colors-background) 96%, black) 50%,\n            transparent 100%\n        );\n    }\n}\n\n.item-table-top-scroll-shadow {\n    position: absolute;\n    top: var(--header-height, 40px);\n    right: 0;\n    left: 0;\n    z-index: 1;\n    height: 8px;\n    pointer-events: none;\n\n    @mixin dark {\n        background: linear-gradient(\n            to bottom,\n            color-mix(in srgb, var(--theme-colors-background) 85%, black) 0%,\n            color-mix(in srgb, var(--theme-colors-background) 92%, black) 50%,\n            transparent 100%\n        );\n    }\n\n    @mixin light {\n        background: linear-gradient(\n            to bottom,\n            color-mix(in srgb, var(--theme-colors-background) 92%, black) 0%,\n            color-mix(in srgb, var(--theme-colors-background) 96%, black) 50%,\n            transparent 100%\n        );\n    }\n}\n"
  },
  {
    "path": "src/renderer/components/item-list/item-table-list/item-table-list.tsx",
    "content": "// Component adapted from https://github.com/bvaughn/react-window/issues/826\n\nimport clsx from 'clsx';\nimport { motion } from 'motion/react';\nimport React, {\n    type JSXElementConstructor,\n    memo,\n    ReactElement,\n    Ref,\n    RefObject,\n    useCallback,\n    useEffect,\n    useId,\n    useMemo,\n    useRef,\n    useState,\n} from 'react';\nimport { type CellComponentProps, Grid } from 'react-window-v2';\n\nimport styles from './item-table-list.module.css';\n\nimport { createExtractRowId } from '/@/renderer/components/item-list/helpers/extract-row-id';\nimport { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls';\nimport {\n    ItemListStateActions,\n    ItemListStateItemWithRequiredProperties,\n    useItemListState,\n} from '/@/renderer/components/item-list/helpers/item-list-state';\nimport { parseTableColumns } from '/@/renderer/components/item-list/helpers/parse-table-columns';\nimport { useListHotkeys } from '/@/renderer/components/item-list/helpers/use-list-hotkeys';\nimport { useContainerWidthTracking } from '/@/renderer/components/item-list/item-table-list/hooks/use-container-width-tracking';\nimport { useRowInteractionDelegate } from '/@/renderer/components/item-list/item-table-list/hooks/use-row-interaction-delegate';\nimport { useStickyGroupRowPositioning } from '/@/renderer/components/item-list/item-table-list/hooks/use-sticky-group-row-positioning';\nimport { useStickyHeaderPositioning } from '/@/renderer/components/item-list/item-table-list/hooks/use-sticky-header-positioning';\nimport { useStickyTableGroupRows } from '/@/renderer/components/item-list/item-table-list/hooks/use-sticky-table-group-rows';\nimport { useStickyTableHeader } from '/@/renderer/components/item-list/item-table-list/hooks/use-sticky-table-header';\nimport { useTableColumnModel } from '/@/renderer/components/item-list/item-table-list/hooks/use-table-column-model';\nimport { useTableImperativeHandle } from '/@/renderer/components/item-list/item-table-list/hooks/use-table-imperative-handle';\nimport { useTableInitialScroll } from '/@/renderer/components/item-list/item-table-list/hooks/use-table-initial-scroll';\nimport { useTableKeyboardNavigation } from '/@/renderer/components/item-list/item-table-list/hooks/use-table-keyboard-navigation';\nimport { useTablePaneSync } from '/@/renderer/components/item-list/item-table-list/hooks/use-table-pane-sync';\nimport { useTableRowModel } from '/@/renderer/components/item-list/item-table-list/hooks/use-table-row-model';\nimport { useTableScrollToIndex } from '/@/renderer/components/item-list/item-table-list/hooks/use-table-scroll-to-index';\nimport { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';\nimport {\n    ItemTableListConfigProvider,\n    ItemTableListStoreProvider,\n} from '/@/renderer/components/item-list/item-table-list/item-table-list-context';\nimport {\n    MemoizedCellRouter,\n    useColumnCellComponents,\n} from '/@/renderer/components/item-list/item-table-list/memoized-cell-router';\nimport {\n    ItemControls,\n    ItemListHandle,\n    ItemTableListColumnConfig,\n} from '/@/renderer/components/item-list/types';\nimport { PlayerContext, usePlayer } from '/@/renderer/features/player/context/player-context';\nimport { animationProps } from '/@/shared/components/animations/animation-props';\nimport { useFocusWithin } from '/@/shared/hooks/use-focus-within';\nimport { useMergedRef } from '/@/shared/hooks/use-merged-ref';\nimport { LibraryItem } from '/@/shared/types/domain-types';\nimport { TableColumn } from '/@/shared/types/types';\n\n/**\n * Type guard to check if an item has the required properties (id and serverId)\n * Similar to the type guard used in ItemCard\n */\nconst hasRequiredItemProperties = (item: unknown): item is { id: string; serverId: string } => {\n    return (\n        typeof item === 'object' &&\n        item !== null &&\n        'id' in item &&\n        typeof (item as any).id === 'string' &&\n        '_serverId' in item &&\n        typeof (item as any)._serverId === 'string'\n    );\n};\n\n/**\n * Type guard to check if an item has the required properties for ItemListStateItemWithRequiredProperties\n */\nconst hasRequiredStateItemProperties = (\n    item: unknown,\n): item is ItemListStateItemWithRequiredProperties => {\n    return (\n        typeof item === 'object' &&\n        item !== null &&\n        'id' in item &&\n        typeof (item as any).id === 'string' &&\n        '_serverId' in item &&\n        typeof (item as any)._serverId === 'string' &&\n        '_itemType' in item &&\n        typeof (item as any)._itemType === 'string'\n    );\n};\n\nexport enum TableItemSize {\n    COMPACT = 40,\n    DEFAULT = 64,\n    LARGE = 88,\n}\n\ninterface VirtualizedTableGridProps {\n    calculatedColumnWidths: number[];\n    CellComponent: JSXElementConstructor<CellComponentProps<TableItemProps>>;\n    cellPadding: 'lg' | 'md' | 'sm' | 'xl' | 'xs';\n    controls: ItemControls;\n    data: unknown[];\n    dataWithGroups: (null | unknown)[];\n    enableAlternateRowColors: boolean;\n    enableColumnReorder: boolean;\n    enableColumnResize: boolean;\n    enableDrag?: boolean;\n    enableExpansion: boolean;\n    enableHeader: boolean;\n    enableHorizontalBorders: boolean;\n    enableRowHoverHighlight: boolean;\n    enableScrollShadow: boolean;\n    enableSelection: boolean;\n    enableVerticalBorders: boolean;\n    getItem?: (index: number) => undefined | unknown;\n    getRowHeight: (index: number, cellProps: TableItemProps) => number;\n    groups?: TableGroupHeader[];\n    headerHeight: number;\n    internalState: ItemListStateActions;\n    itemType: LibraryItem;\n    mergedRowRef: React.Ref<HTMLDivElement>;\n    onRangeChanged?: ItemTableListProps['onRangeChanged'];\n    parsedColumns: ReturnType<typeof parseTableColumns>;\n    pinnedLeftColumnCount: number;\n    pinnedLeftColumnRef: React.RefObject<HTMLDivElement | null>;\n    pinnedRightColumnCount: number;\n    pinnedRightColumnRef: React.RefObject<HTMLDivElement | null>;\n    pinnedRowCount: number;\n    pinnedRowRef: React.RefObject<HTMLDivElement | null>;\n    playerContext: PlayerContext;\n    showLeftShadow: boolean;\n    showRightShadow: boolean;\n    showTopShadow: boolean;\n    size: 'compact' | 'default' | 'large';\n    startRowIndex?: number;\n    tableId: string;\n    totalColumnCount: number;\n    totalRowCount: number;\n}\n\nconst VirtualizedTableGrid = ({\n    calculatedColumnWidths,\n    CellComponent,\n    cellPadding,\n    controls,\n    data,\n    dataWithGroups,\n    enableAlternateRowColors,\n    enableColumnReorder,\n    enableColumnResize,\n    enableDrag,\n    enableExpansion,\n    enableHeader,\n    enableHorizontalBorders,\n    enableRowHoverHighlight,\n    enableScrollShadow,\n    enableSelection,\n    enableVerticalBorders,\n    getItem,\n    getRowHeight,\n    groups,\n    headerHeight,\n    internalState,\n    itemType,\n    mergedRowRef,\n    onRangeChanged,\n    parsedColumns,\n    pinnedLeftColumnCount,\n    pinnedLeftColumnRef,\n    pinnedRightColumnCount,\n    pinnedRightColumnRef,\n    pinnedRowCount,\n    pinnedRowRef,\n    playerContext,\n    showLeftShadow,\n    showRightShadow,\n    showTopShadow,\n    size,\n    startRowIndex,\n    tableId,\n    totalColumnCount,\n    totalRowCount,\n}: VirtualizedTableGridProps) => {\n    const hoverDelegateRef = useRef<HTMLDivElement | null>(null);\n\n    useRowInteractionDelegate({\n        containerRef: hoverDelegateRef,\n        enableRowHoverHighlight,\n    });\n\n    const columnWidth = useCallback(\n        (index: number) => calculatedColumnWidths[index],\n        [calculatedColumnWidths],\n    );\n\n    const columnWidthMemoized = useCallback(\n        (index: number) => columnWidth(index + pinnedLeftColumnCount),\n        [columnWidth, pinnedLeftColumnCount],\n    );\n\n    const groupHeaderInfoByRowIndex = useMemo(() => {\n        if (!groups || groups.length === 0) return undefined;\n\n        const map = new Map<number, { groupIndex: number; startDataIndex: number }>();\n        const headerOffset = enableHeader ? 1 : 0;\n        let cumulativeDataIndex = 0;\n\n        for (let groupIndex = 0; groupIndex < groups.length; groupIndex++) {\n            const groupHeaderIndex = headerOffset + cumulativeDataIndex + groupIndex;\n            map.set(groupHeaderIndex, { groupIndex, startDataIndex: cumulativeDataIndex });\n            cumulativeDataIndex += groups[groupIndex].itemCount;\n        }\n\n        return map;\n    }, [groups, enableHeader]);\n\n    const rowHeightMemoized = useCallback(\n        (index: number, cellProps: TableItemProps) => {\n            const adjustedIndex = index + pinnedRowCount;\n            return getRowHeight(adjustedIndex, cellProps);\n        },\n        [getRowHeight, pinnedRowCount],\n    );\n\n    const pinnedRightColumnWidthMemoized = useCallback(\n        (index: number) => columnWidth(index + pinnedLeftColumnCount + totalColumnCount),\n        [columnWidth, pinnedLeftColumnCount, totalColumnCount],\n    );\n\n    const getGroupRenderData = useCallback(() => data, [data]);\n\n    // Calculate pinned column widths for group header positioning\n    const pinnedLeftColumnWidths = useMemo(() => {\n        return Array.from({ length: pinnedLeftColumnCount }, (_, i) => columnWidth(i));\n    }, [pinnedLeftColumnCount, columnWidth]);\n\n    const pinnedRightColumnWidths = useMemo(() => {\n        return Array.from({ length: pinnedRightColumnCount }, (_, i) =>\n            columnWidth(i + pinnedLeftColumnCount + totalColumnCount),\n        );\n    }, [pinnedRightColumnCount, pinnedLeftColumnCount, totalColumnCount, columnWidth]);\n\n    const groupHeaderRowIndexes = useMemo(() => {\n        if (!groupHeaderInfoByRowIndex || groupHeaderInfoByRowIndex.size === 0) return [];\n        return Array.from(groupHeaderInfoByRowIndex.keys()).sort((a, b) => a - b);\n    }, [groupHeaderInfoByRowIndex]);\n\n    const adjustedRowIndexCacheRef = useRef<{ lastRowIndex: number; pos: number }>({\n        lastRowIndex: -1,\n        pos: 0,\n    });\n\n    useEffect(() => {\n        adjustedRowIndexCacheRef.current = { lastRowIndex: -1, pos: 0 };\n    }, [enableHeader, groupHeaderRowIndexes, groups]);\n\n    const getAdjustedRowIndex = useCallback(\n        (rowIndex: number) => {\n            if (!groups || groups.length === 0) {\n                if (enableHeader && rowIndex === 0) return 0;\n                return enableHeader ? rowIndex : rowIndex + 1;\n            }\n\n            if (enableHeader && rowIndex === 0) return 0;\n            if (groupHeaderInfoByRowIndex?.has(rowIndex)) return 0;\n\n            const headerOffset = enableHeader ? 1 : 0;\n            const cache = adjustedRowIndexCacheRef.current;\n\n            // Count group header rows strictly before this rowIndex.\n            let pos: number;\n            if (cache.lastRowIndex !== -1 && rowIndex >= cache.lastRowIndex) {\n                pos = cache.pos;\n                while (\n                    pos < groupHeaderRowIndexes.length &&\n                    groupHeaderRowIndexes[pos] < rowIndex\n                ) {\n                    pos++;\n                }\n            } else {\n                // upperBound(groupHeaderRowIndexes, rowIndex - 1)\n                let lo = 0;\n                let hi = groupHeaderRowIndexes.length;\n                const target = rowIndex - 1;\n                while (lo < hi) {\n                    const mid = (lo + hi) >>> 1;\n                    if (groupHeaderRowIndexes[mid] <= target) lo = mid + 1;\n                    else hi = mid;\n                }\n                pos = lo;\n            }\n\n            cache.lastRowIndex = rowIndex;\n            cache.pos = pos;\n\n            const groupHeadersBefore = pos;\n            const dataIndexZeroBased = rowIndex - headerOffset - groupHeadersBefore;\n            return dataIndexZeroBased + 1;\n        },\n        [enableHeader, groupHeaderInfoByRowIndex, groupHeaderRowIndexes, groups],\n    );\n\n    const getRowItem = useCallback(\n        (rowIndex: number): null | undefined | unknown => {\n            // Header row\n            if (enableHeader && rowIndex === 0) return null;\n            // Group header rows are represented as null in the row model\n            if (groupHeaderInfoByRowIndex?.has(rowIndex)) return null;\n\n            if (!groups || groups.length === 0) {\n                const dataIndex = enableHeader ? rowIndex - 1 : rowIndex;\n                return getItem ? getItem(dataIndex) : dataWithGroups[rowIndex];\n            }\n\n            const headerOffset = enableHeader ? 1 : 0;\n\n            // Count group header rows strictly before this rowIndex (upperBound on groupHeaderRowIndexes)\n            let lo = 0;\n            let hi = groupHeaderRowIndexes.length;\n            const target = rowIndex - 1;\n            while (lo < hi) {\n                const mid = (lo + hi) >>> 1;\n                if (groupHeaderRowIndexes[mid] <= target) lo = mid + 1;\n                else hi = mid;\n            }\n            const groupHeadersBefore = lo;\n\n            const dataIndex = rowIndex - headerOffset - groupHeadersBefore;\n            return getItem ? getItem(dataIndex) : undefined;\n        },\n        [\n            dataWithGroups,\n            enableHeader,\n            getItem,\n            groupHeaderInfoByRowIndex,\n            groupHeaderRowIndexes,\n            groups,\n        ],\n    );\n\n    const stableConfigProps = useMemo(\n        () => ({\n            cellPadding,\n            columns: parsedColumns,\n            controls,\n            enableHeader,\n            getRowHeight,\n            hasAlbumGroupColumn: parsedColumns.some((col) => col.id === TableColumn.ALBUM_GROUP),\n            internalState,\n            itemType,\n            playerContext,\n            size,\n            tableId,\n        }),\n        [\n            cellPadding,\n            parsedColumns,\n            controls,\n            enableHeader,\n            getRowHeight,\n            internalState,\n            itemType,\n            playerContext,\n            size,\n            tableId,\n        ],\n    );\n\n    const dynamicDataProps = useMemo(\n        () => ({\n            calculatedColumnWidths,\n            data: dataWithGroups,\n            getAdjustedRowIndex,\n            getGroupRenderData,\n            getRowItem,\n            groupHeaderInfoByRowIndex,\n            pinnedLeftColumnCount,\n            pinnedLeftColumnWidths,\n            pinnedRightColumnCount,\n            pinnedRightColumnWidths,\n            startRowIndex,\n        }),\n        [\n            calculatedColumnWidths,\n            dataWithGroups,\n            getRowItem,\n            getAdjustedRowIndex,\n            getGroupRenderData,\n            groupHeaderInfoByRowIndex,\n            pinnedLeftColumnCount,\n            pinnedLeftColumnWidths,\n            pinnedRightColumnCount,\n            pinnedRightColumnWidths,\n            startRowIndex,\n        ],\n    );\n\n    const featureFlags = useMemo(\n        () => ({\n            enableAlternateRowColors,\n            enableColumnReorder,\n            enableColumnResize,\n            enableDrag,\n            enableExpansion,\n            enableHorizontalBorders,\n            enableRowHoverHighlight,\n            enableSelection,\n            enableVerticalBorders,\n            groups,\n        }),\n        [\n            enableAlternateRowColors,\n            enableColumnReorder,\n            enableColumnResize,\n            enableDrag,\n            enableExpansion,\n            enableHorizontalBorders,\n            enableRowHoverHighlight,\n            enableSelection,\n            enableVerticalBorders,\n            groups,\n        ],\n    );\n\n    const itemProps: TableItemProps = useMemo(\n        () => ({\n            ...stableConfigProps,\n            ...dynamicDataProps,\n            ...featureFlags,\n        }),\n        [stableConfigProps, dynamicDataProps, featureFlags],\n    );\n\n    const PinnedRowCell = useCallback(\n        (cellProps: CellComponentProps & TableItemProps) => {\n            return (\n                <CellComponent\n                    {...cellProps}\n                    columnIndex={cellProps.columnIndex + pinnedLeftColumnCount}\n                />\n            );\n        },\n        // eslint-disable-next-line react-hooks/exhaustive-deps\n        [pinnedLeftColumnCount, CellComponent, featureFlags, calculatedColumnWidths],\n    );\n\n    const PinnedColumnCell = useCallback(\n        (cellProps: CellComponentProps & TableItemProps) => {\n            return <CellComponent {...cellProps} rowIndex={cellProps.rowIndex + pinnedRowCount} />;\n        },\n        // eslint-disable-next-line react-hooks/exhaustive-deps\n        [pinnedRowCount, CellComponent, featureFlags, calculatedColumnWidths],\n    );\n\n    const PinnedRightColumnCell = useCallback(\n        (cellProps: CellComponentProps & TableItemProps) => {\n            return (\n                <CellComponent\n                    {...cellProps}\n                    columnIndex={cellProps.columnIndex + pinnedLeftColumnCount + totalColumnCount}\n                    rowIndex={cellProps.rowIndex + pinnedRowCount}\n                />\n            );\n        },\n        // eslint-disable-next-line react-hooks/exhaustive-deps\n        [\n            pinnedLeftColumnCount,\n            pinnedRowCount,\n            totalColumnCount,\n            CellComponent,\n            featureFlags,\n            calculatedColumnWidths,\n        ],\n    );\n\n    const PinnedRightIntersectionCell = useCallback(\n        (cellProps: CellComponentProps & TableItemProps) => {\n            return (\n                <CellComponent\n                    {...cellProps}\n                    columnIndex={cellProps.columnIndex + pinnedLeftColumnCount + totalColumnCount}\n                />\n            );\n        },\n        // eslint-disable-next-line react-hooks/exhaustive-deps\n        [\n            pinnedLeftColumnCount,\n            totalColumnCount,\n            CellComponent,\n            featureFlags,\n            calculatedColumnWidths,\n        ],\n    );\n\n    const RowCell = useCallback(\n        (cellProps: CellComponentProps<TableItemProps>) => {\n            return (\n                <CellComponent\n                    {...cellProps}\n                    columnIndex={cellProps.columnIndex + pinnedLeftColumnCount}\n                    rowIndex={cellProps.rowIndex + pinnedRowCount}\n                />\n            );\n        },\n        // eslint-disable-next-line react-hooks/exhaustive-deps\n        [\n            pinnedLeftColumnCount,\n            pinnedRowCount,\n            CellComponent,\n            featureFlags,\n            calculatedColumnWidths,\n        ],\n    );\n\n    const handleOnCellsRendered = useCallback(\n        (items: {\n            columnStartIndex: number;\n            columnStopIndex: number;\n            rowStartIndex: number;\n            rowStopIndex: number;\n        }) => {\n            onRangeChanged?.({\n                startIndex: items.rowStartIndex,\n                stopIndex: items.rowStopIndex,\n            });\n        },\n        [onRangeChanged],\n    );\n\n    return (\n        <div className={styles.itemTableContainer} ref={hoverDelegateRef}>\n            <div\n                className={styles.itemTablePinnedColumnsGridContainer}\n                style={\n                    {\n                        '--header-height': `${headerHeight}px`,\n                        minWidth: `${Array.from({ length: pinnedLeftColumnCount }, () => 0).reduce(\n                            (a, _, i) => a + columnWidth(i),\n                            0,\n                        )}px`,\n                    } as React.CSSProperties\n                }\n            >\n                {!!(pinnedLeftColumnCount || pinnedRowCount) && (\n                    <div\n                        className={clsx(styles.itemTablePinnedIntersectionGridContainer, {\n                            [styles.withHeader]: enableHeader,\n                        })}\n                        style={{\n                            minHeight: `${Array.from({ length: pinnedRowCount }, () => 0).reduce(\n                                (a, _, i) => a + getRowHeight(i, itemProps),\n                                0,\n                            )}px`,\n                            overflow: 'hidden',\n                        }}\n                    >\n                        <Grid\n                            cellComponent={CellComponent as any}\n                            cellProps={itemProps}\n                            className={styles.noScrollbar}\n                            columnCount={pinnedLeftColumnCount}\n                            columnWidth={columnWidth}\n                            rowCount={pinnedRowCount}\n                            rowHeight={getRowHeight}\n                        />\n                    </div>\n                )}\n                {enableHeader && enableScrollShadow && showTopShadow && (\n                    <div className={styles.itemTableTopScrollShadow} />\n                )}\n                {!!pinnedLeftColumnCount && (\n                    <div\n                        className={styles.itemTablePinnedColumnsContainer}\n                        ref={pinnedLeftColumnRef}\n                    >\n                        <Grid\n                            cellComponent={PinnedColumnCell}\n                            cellProps={itemProps}\n                            className={clsx(styles.noScrollbar, styles.height100)}\n                            columnCount={pinnedLeftColumnCount}\n                            columnWidth={columnWidth}\n                            rowCount={totalRowCount}\n                            rowHeight={(index, cellProps) => {\n                                return getRowHeight(index + pinnedRowCount, cellProps);\n                            }}\n                        />\n                    </div>\n                )}\n            </div>\n            <div\n                className={styles.itemTablePinnedRowsContainer}\n                style={\n                    {\n                        '--header-height': `${headerHeight}px`,\n                    } as React.CSSProperties\n                }\n            >\n                {!!pinnedRowCount && (\n                    <div\n                        className={clsx(styles.itemTablePinnedRowsGridContainer, {\n                            [styles.withHeader]: enableHeader,\n                        })}\n                        ref={pinnedRowRef}\n                        style={\n                            {\n                                '--header-height': `${headerHeight}px`,\n                                minHeight: `${Array.from(\n                                    { length: pinnedRowCount },\n                                    () => 0,\n                                ).reduce((a, _, i) => a + getRowHeight(i, itemProps), 0)}px`,\n                                overflow: 'hidden',\n                            } as React.CSSProperties\n                        }\n                    >\n                        <Grid\n                            cellComponent={PinnedRowCell}\n                            cellProps={itemProps}\n                            className={styles.noScrollbar}\n                            columnCount={totalColumnCount}\n                            columnWidth={(index) => {\n                                return columnWidth(index + pinnedLeftColumnCount);\n                            }}\n                            rowCount={Array.from({ length: pinnedRowCount }, () => 0).length}\n                            rowHeight={getRowHeight}\n                        />\n                    </div>\n                )}\n                {enableHeader && enableScrollShadow && showTopShadow && (\n                    <div className={styles.itemTableTopScrollShadow} />\n                )}\n                <div className={styles.itemTableGridContainer} ref={mergedRowRef}>\n                    <Grid\n                        cellComponent={RowCell}\n                        cellProps={itemProps}\n                        className={styles.height100}\n                        columnCount={totalColumnCount}\n                        columnWidth={columnWidthMemoized}\n                        onCellsRendered={handleOnCellsRendered}\n                        rowCount={totalRowCount}\n                        rowHeight={rowHeightMemoized}\n                    />\n                    {pinnedLeftColumnCount > 0 && enableScrollShadow && showLeftShadow && (\n                        <div className={styles.itemTableLeftScrollShadow} />\n                    )}\n                    {pinnedRightColumnCount > 0 && enableScrollShadow && showRightShadow && (\n                        <div className={styles.itemTableRightScrollShadow} />\n                    )}\n                </div>\n            </div>\n            {!!pinnedRightColumnCount && (\n                <div\n                    className={styles.itemTablePinnedColumnsGridContainer}\n                    style={\n                        {\n                            '--header-height': `${headerHeight}px`,\n                            minWidth: `${Array.from(\n                                { length: pinnedRightColumnCount },\n                                () => 0,\n                            ).reduce(\n                                (a, _, i) =>\n                                    a + columnWidth(i + pinnedLeftColumnCount + totalColumnCount),\n                                0,\n                            )}px`,\n                        } as React.CSSProperties\n                    }\n                >\n                    {!!(pinnedRightColumnCount || pinnedRowCount) && (\n                        <div\n                            className={clsx(styles.itemTablePinnedIntersectionGridContainer, {\n                                [styles.withHeader]: enableHeader,\n                            })}\n                            style={{\n                                minHeight: `${Array.from(\n                                    { length: pinnedRowCount },\n                                    () => 0,\n                                ).reduce((a, _, i) => a + getRowHeight(i, itemProps), 0)}px`,\n                                overflow: 'hidden',\n                            }}\n                        >\n                            <Grid\n                                cellComponent={PinnedRightIntersectionCell}\n                                cellProps={itemProps}\n                                className={styles.noScrollbar}\n                                columnCount={pinnedRightColumnCount}\n                                columnWidth={(index) => {\n                                    return columnWidth(\n                                        index + pinnedLeftColumnCount + totalColumnCount,\n                                    );\n                                }}\n                                rowCount={pinnedRowCount}\n                                rowHeight={getRowHeight}\n                            />\n                        </div>\n                    )}\n                    {enableHeader && enableScrollShadow && showTopShadow && (\n                        <div className={styles.itemTableTopScrollShadow} />\n                    )}\n                    <div\n                        className={styles.itemTablePinnedRightColumnsContainer}\n                        ref={pinnedRightColumnRef}\n                    >\n                        <Grid\n                            cellComponent={PinnedRightColumnCell}\n                            cellProps={itemProps}\n                            className={clsx(styles.noScrollbar, styles.height100)}\n                            columnCount={pinnedRightColumnCount}\n                            columnWidth={pinnedRightColumnWidthMemoized}\n                            rowCount={totalRowCount}\n                            rowHeight={rowHeightMemoized}\n                        />\n                    </div>\n                </div>\n            )}\n        </div>\n    );\n};\n\nVirtualizedTableGrid.displayName = 'VirtualizedTableGrid';\n\nfunction shallowEqualNumberArrays(a: number[], b: number[]): boolean {\n    if (a === b) return true;\n    if (a.length !== b.length) return false;\n    for (let i = 0; i < a.length; i++) {\n        if (a[i] !== b[i]) return false;\n    }\n    return true;\n}\n\nconst MemoizedVirtualizedTableGrid = memo(VirtualizedTableGrid, (prevProps, nextProps) => {\n    return (\n        shallowEqualNumberArrays(\n            prevProps.calculatedColumnWidths,\n            nextProps.calculatedColumnWidths,\n        ) &&\n        prevProps.cellPadding === nextProps.cellPadding &&\n        prevProps.controls === nextProps.controls &&\n        prevProps.data === nextProps.data &&\n        prevProps.dataWithGroups === nextProps.dataWithGroups &&\n        prevProps.enableAlternateRowColors === nextProps.enableAlternateRowColors &&\n        prevProps.enableColumnReorder === nextProps.enableColumnReorder &&\n        prevProps.enableColumnResize === nextProps.enableColumnResize &&\n        prevProps.enableDrag === nextProps.enableDrag &&\n        prevProps.enableExpansion === nextProps.enableExpansion &&\n        prevProps.enableHeader === nextProps.enableHeader &&\n        prevProps.enableHorizontalBorders === nextProps.enableHorizontalBorders &&\n        prevProps.enableRowHoverHighlight === nextProps.enableRowHoverHighlight &&\n        prevProps.enableScrollShadow === nextProps.enableScrollShadow &&\n        prevProps.enableSelection === nextProps.enableSelection &&\n        prevProps.enableVerticalBorders === nextProps.enableVerticalBorders &&\n        prevProps.getItem === nextProps.getItem &&\n        prevProps.getRowHeight === nextProps.getRowHeight &&\n        prevProps.groups === nextProps.groups &&\n        prevProps.headerHeight === nextProps.headerHeight &&\n        prevProps.internalState === nextProps.internalState &&\n        prevProps.itemType === nextProps.itemType &&\n        prevProps.mergedRowRef === nextProps.mergedRowRef &&\n        prevProps.onRangeChanged === nextProps.onRangeChanged &&\n        prevProps.parsedColumns === nextProps.parsedColumns &&\n        prevProps.pinnedLeftColumnCount === nextProps.pinnedLeftColumnCount &&\n        prevProps.pinnedLeftColumnRef === nextProps.pinnedLeftColumnRef &&\n        prevProps.pinnedRightColumnCount === nextProps.pinnedRightColumnCount &&\n        prevProps.pinnedRightColumnRef === nextProps.pinnedRightColumnRef &&\n        prevProps.pinnedRowCount === nextProps.pinnedRowCount &&\n        prevProps.pinnedRowRef === nextProps.pinnedRowRef &&\n        prevProps.playerContext === nextProps.playerContext &&\n        prevProps.showLeftShadow === nextProps.showLeftShadow &&\n        prevProps.showRightShadow === nextProps.showRightShadow &&\n        prevProps.showTopShadow === nextProps.showTopShadow &&\n        prevProps.size === nextProps.size &&\n        prevProps.startRowIndex === nextProps.startRowIndex &&\n        prevProps.tableId === nextProps.tableId &&\n        prevProps.totalColumnCount === nextProps.totalColumnCount &&\n        prevProps.totalRowCount === nextProps.totalRowCount &&\n        prevProps.CellComponent === nextProps.CellComponent\n    );\n});\n\nMemoizedVirtualizedTableGrid.displayName = 'MemoizedVirtualizedTableGrid';\n\nexport interface TableGroupHeader {\n    itemCount: number;\n    render: (props: {\n        data: unknown[];\n        groupIndex: number;\n        index: number;\n        internalState: ItemListStateActions;\n        startDataIndex: number;\n    }) => ReactElement;\n}\n\nexport interface TableItemProps {\n    adjustedRowIndexMap?: Map<number, number>;\n    calculatedColumnWidths?: number[];\n    cellPadding?: ItemTableListProps['cellPadding'];\n    columns: ItemTableListColumnConfig[];\n    controls: ItemControls;\n    data: ItemTableListProps['data'];\n    enableAlternateRowColors?: ItemTableListProps['enableAlternateRowColors'];\n    enableColumnReorder?: boolean;\n    enableColumnResize?: boolean;\n    enableDrag?: ItemTableListProps['enableDrag'];\n    enableDragScroll?: boolean;\n    enableExpansion?: ItemTableListProps['enableExpansion'];\n    enableHeader?: ItemTableListProps['enableHeader'];\n    enableHorizontalBorders?: ItemTableListProps['enableHorizontalBorders'];\n    enableRowHoverHighlight?: ItemTableListProps['enableRowHoverHighlight'];\n    enableSelection?: ItemTableListProps['enableSelection'];\n    enableVerticalBorders?: ItemTableListProps['enableVerticalBorders'];\n    getAdjustedRowIndex?: (rowIndex: number) => number;\n    getGroupRenderData?: () => unknown[];\n    getRowHeight: (index: number, cellProps: TableItemProps) => number;\n    getRowItem?: (rowIndex: number) => null | undefined | unknown;\n    groupHeaderInfoByRowIndex?: Map<number, { groupIndex: number; startDataIndex: number }>;\n    groups?: TableGroupHeader[];\n    hasAlbumGroupColumn?: boolean;\n    internalState: ItemListStateActions;\n    itemType: ItemTableListProps['itemType'];\n    onRowClick?: (item: any, event: React.MouseEvent<HTMLDivElement>) => void;\n    pinnedLeftColumnCount?: number;\n    pinnedLeftColumnWidths?: number[];\n    pinnedRightColumnCount?: number;\n    pinnedRightColumnWidths?: number[];\n    playerContext: PlayerContext;\n    size?: ItemTableListProps['size'];\n    startRowIndex?: number;\n    tableId: string;\n}\n\ninterface ItemTableListProps {\n    activeRowId?: string;\n    autoFitColumns?: boolean;\n    CellComponent?: JSXElementConstructor<CellComponentProps<TableItemProps>>;\n    cellPadding?: 'lg' | 'md' | 'sm' | 'xl' | 'xs';\n    columns: ItemTableListColumnConfig[];\n    data: unknown[];\n    enableAlternateRowColors?: boolean;\n    enableDrag?: boolean;\n    enableDragScroll?: boolean;\n    enableEntranceAnimation?: boolean;\n    enableExpansion?: boolean;\n    enableHeader?: boolean;\n    enableHorizontalBorders?: boolean;\n    enableRowHoverHighlight?: boolean;\n    enableScrollShadow?: boolean;\n    enableSelection?: boolean;\n    enableSelectionDialog?: boolean;\n    enableStickyGroupRows?: boolean;\n    enableStickyHeader?: boolean;\n    enableVerticalBorders?: boolean;\n    getItem?: (index: number) => undefined | unknown;\n    getItemIndex?: (rowId: string) => number | undefined;\n    getRowId?: ((item: unknown) => string) | string;\n    groups?: TableGroupHeader[];\n    headerHeight?: number;\n    initialTop?: {\n        behavior?: 'auto' | 'smooth';\n        to: number;\n        type: 'index' | 'offset';\n    };\n    itemCount?: number;\n    itemType: LibraryItem;\n    onColumnReordered?: (\n        columnIdFrom: TableColumn,\n        columnIdTo: TableColumn,\n        edge: 'bottom' | 'left' | 'right' | 'top' | null,\n    ) => void;\n    onColumnResized?: (columnId: TableColumn, width: number) => void;\n    onRangeChanged?: (range: { startIndex: number; stopIndex: number }) => void;\n    onScrollEnd?: (offset: number, internalState: ItemListStateActions) => void;\n    overrideControls?: Partial<ItemControls>;\n    ref?: Ref<ItemListHandle>;\n    rowHeight?: ((index: number, cellProps: TableItemProps) => number) | number;\n    size?: 'compact' | 'default' | 'large';\n    startRowIndex?: number;\n}\n\nconst ItemTableListStickyUI = memo(\n    ({\n        calculatedColumnWidths,\n        CellComponent,\n        containerRef,\n        data,\n        enableHeader,\n        enableStickyGroupRows,\n        enableStickyHeader,\n        getRowHeightWrapper,\n        groups,\n        headerHeight,\n        internalState,\n        parsedColumns,\n        pinnedLeftColumnCount,\n        pinnedLeftColumnRef,\n        pinnedRightColumnCount,\n        pinnedRightColumnRef,\n        pinnedRowRef,\n        rowHeight,\n        rowRef,\n        size,\n        stickyHeaderItemProps,\n        totalColumnCount,\n    }: {\n        calculatedColumnWidths: number[];\n        CellComponent: JSXElementConstructor<CellComponentProps<TableItemProps>>;\n        containerRef: RefObject<HTMLDivElement | null>;\n        data: unknown[];\n        enableHeader: boolean;\n        enableStickyGroupRows: boolean;\n        enableStickyHeader: boolean;\n        getRowHeightWrapper: (index: number) => number;\n        groups?: TableGroupHeader[];\n        headerHeight: number;\n        internalState: ItemListStateActions;\n        parsedColumns: ReturnType<typeof parseTableColumns>;\n        pinnedLeftColumnCount: number;\n        pinnedLeftColumnRef: RefObject<HTMLDivElement | null>;\n        pinnedRightColumnCount: number;\n        pinnedRightColumnRef: RefObject<HTMLDivElement | null>;\n        pinnedRowRef: RefObject<HTMLDivElement | null>;\n        rowHeight?: ((index: number, cellProps: TableItemProps) => number) | number;\n        rowRef: RefObject<HTMLDivElement | null>;\n        size: 'compact' | 'default' | 'large';\n        stickyHeaderItemProps: TableItemProps;\n        totalColumnCount: number;\n    }) => {\n        const stickyHeaderRef = useRef<HTMLDivElement | null>(null);\n        const stickyGroupRowRef = useRef<HTMLDivElement | null>(null);\n        const stickyHeaderLeftRef = useRef<HTMLDivElement | null>(null);\n        const stickyHeaderMainRef = useRef<HTMLDivElement | null>(null);\n        const stickyHeaderRightRef = useRef<HTMLDivElement | null>(null);\n\n        const { shouldShowStickyHeader, stickyTop } = useStickyTableHeader({\n            containerRef,\n            enabled: enableHeader && enableStickyHeader,\n            headerRef: pinnedRowRef,\n            mainGridRef: rowRef,\n            pinnedLeftColumnRef,\n            pinnedRightColumnRef,\n            stickyHeaderMainRef,\n        });\n\n        useStickyHeaderPositioning({\n            containerRef,\n            shouldShowStickyHeader,\n            stickyHeaderRef,\n        });\n\n        const {\n            shouldShowStickyGroupRow,\n            stickyGroupIndex,\n            stickyTop: stickyGroupTop,\n        } = useStickyTableGroupRows({\n            containerRef,\n            enabled: enableStickyGroupRows && !!groups && groups.length > 0,\n            getRowHeight: getRowHeightWrapper,\n            groups,\n            headerHeight,\n            mainGridRef: rowRef,\n            shouldShowStickyHeader,\n            stickyHeaderTop: stickyTop,\n        });\n\n        const shouldRenderStickyGroupRow = shouldShowStickyGroupRow;\n\n        useStickyGroupRowPositioning({\n            containerRef,\n            shouldRenderStickyGroupRow,\n            stickyGroupRowRef,\n        });\n\n        const StickyHeader = useMemo(() => {\n            if (!shouldShowStickyHeader || !enableHeader) {\n                return null;\n            }\n\n            const pinnedLeftWidth = calculatedColumnWidths\n                .slice(0, pinnedLeftColumnCount)\n                .reduce((sum, width) => sum + width, 0);\n            const mainWidth = calculatedColumnWidths\n                .slice(pinnedLeftColumnCount, pinnedLeftColumnCount + totalColumnCount)\n                .reduce((sum, width) => sum + width, 0);\n            const pinnedRightWidth = calculatedColumnWidths\n                .slice(pinnedLeftColumnCount + totalColumnCount)\n                .reduce((sum, width) => sum + width, 0);\n\n            return (\n                <div\n                    className={styles.stickyHeader}\n                    ref={stickyHeaderRef}\n                    style={{\n                        top: `${stickyTop}px`,\n                    }}\n                >\n                    <div className={styles.stickyHeaderRow}>\n                        {pinnedLeftColumnCount > 0 && (\n                            <div\n                                className={clsx(\n                                    styles.stickyHeaderSection,\n                                    styles.stickyHeaderPinnedLeft,\n                                )}\n                                ref={stickyHeaderLeftRef}\n                                style={{\n                                    flex: '0 1 auto',\n                                    minWidth: `${pinnedLeftWidth}px`,\n                                    overflow: 'hidden',\n                                }}\n                            >\n                                {parsedColumns\n                                    .filter((col) => col.pinned === 'left')\n                                    .map((col) => {\n                                        const columnIndex = parsedColumns.findIndex(\n                                            (c) => c === col,\n                                        );\n                                        return (\n                                            <CellComponent\n                                                ariaAttributes={{\n                                                    'aria-colindex': columnIndex + 1,\n                                                    role: 'gridcell',\n                                                }}\n                                                columnIndex={columnIndex}\n                                                key={col.id}\n                                                rowIndex={0}\n                                                style={{\n                                                    height: headerHeight,\n                                                    width: calculatedColumnWidths[columnIndex],\n                                                }}\n                                                {...stickyHeaderItemProps}\n                                            />\n                                        );\n                                    })}\n                            </div>\n                        )}\n                        <div\n                            className={clsx(\n                                styles.stickyHeaderSection,\n                                styles.stickyHeaderMain,\n                                styles.noScrollbar,\n                            )}\n                            ref={stickyHeaderMainRef}\n                            style={{\n                                flex: '1 1 auto',\n                                minWidth: 0,\n                                overflowX: 'auto',\n                                overflowY: 'hidden',\n                            }}\n                        >\n                            <div\n                                style={{\n                                    display: 'flex',\n                                    minWidth: `${mainWidth}px`,\n                                }}\n                            >\n                                {parsedColumns\n                                    .filter((col) => col.pinned === null)\n                                    .map((col) => {\n                                        const columnIndex = parsedColumns.findIndex(\n                                            (c) => c === col,\n                                        );\n                                        return (\n                                            <CellComponent\n                                                ariaAttributes={{\n                                                    'aria-colindex': columnIndex + 1,\n                                                    role: 'gridcell',\n                                                }}\n                                                columnIndex={columnIndex}\n                                                key={col.id}\n                                                rowIndex={0}\n                                                style={{\n                                                    flexShrink: 0,\n                                                    height: headerHeight,\n                                                    width: calculatedColumnWidths[columnIndex],\n                                                }}\n                                                {...stickyHeaderItemProps}\n                                            />\n                                        );\n                                    })}\n                            </div>\n                        </div>\n                        {pinnedRightColumnCount > 0 && (\n                            <div\n                                className={clsx(\n                                    styles.stickyHeaderSection,\n                                    styles.stickyHeaderPinnedRight,\n                                )}\n                                ref={stickyHeaderRightRef}\n                                style={{\n                                    flex: '0 1 auto',\n                                    minWidth: `${pinnedRightWidth}px`,\n                                    overflow: 'hidden',\n                                }}\n                            >\n                                {parsedColumns\n                                    .filter((col) => col.pinned === 'right')\n                                    .map((col) => {\n                                        const columnIndex = parsedColumns.findIndex(\n                                            (c) => c === col,\n                                        );\n                                        return (\n                                            <CellComponent\n                                                ariaAttributes={{\n                                                    'aria-colindex': columnIndex + 1,\n                                                    role: 'gridcell',\n                                                }}\n                                                columnIndex={columnIndex}\n                                                key={col.id}\n                                                rowIndex={0}\n                                                style={{\n                                                    height: headerHeight,\n                                                    width: calculatedColumnWidths[columnIndex],\n                                                }}\n                                                {...stickyHeaderItemProps}\n                                            />\n                                        );\n                                    })}\n                            </div>\n                        )}\n                    </div>\n                </div>\n            );\n        }, [\n            shouldShowStickyHeader,\n            enableHeader,\n            stickyTop,\n            calculatedColumnWidths,\n            pinnedLeftColumnCount,\n            pinnedRightColumnCount,\n            totalColumnCount,\n            parsedColumns,\n            headerHeight,\n            CellComponent,\n            stickyHeaderItemProps,\n        ]);\n\n        const groupRowHeight = useMemo(() => {\n            if (stickyGroupIndex === null || !groups) {\n                const height = size === 'compact' ? 40 : size === 'large' ? 88 : 64;\n                return typeof rowHeight === 'number' ? rowHeight : height;\n            }\n\n            let cumulativeDataIndex = 0;\n            const headerOffset = enableHeader ? 1 : 0;\n            for (let i = 0; i < stickyGroupIndex; i++) {\n                cumulativeDataIndex += groups[i].itemCount;\n            }\n            const groupHeaderIndex = headerOffset + cumulativeDataIndex + stickyGroupIndex;\n\n            return getRowHeightWrapper(groupHeaderIndex);\n        }, [stickyGroupIndex, groups, getRowHeightWrapper, enableHeader, rowHeight, size]);\n\n        const StickyGroupRow = useMemo(() => {\n            if (!shouldRenderStickyGroupRow || stickyGroupIndex === null || !groups) {\n                return null;\n            }\n\n            const group = groups[stickyGroupIndex];\n            const originalData = data.filter((item) => item !== null);\n            let cumulativeDataIndex = 0;\n            for (let i = 0; i < stickyGroupIndex; i++) {\n                cumulativeDataIndex += groups[i].itemCount;\n            }\n\n            const groupContent = group.render({\n                data: originalData,\n                groupIndex: stickyGroupIndex,\n                index: 0,\n                internalState,\n                startDataIndex: cumulativeDataIndex,\n            });\n\n            const pinnedLeftWidth = calculatedColumnWidths\n                .slice(0, pinnedLeftColumnCount)\n                .reduce((sum, width) => sum + width, 0);\n            const mainWidth = calculatedColumnWidths\n                .slice(pinnedLeftColumnCount, pinnedLeftColumnCount + totalColumnCount)\n                .reduce((sum, width) => sum + width, 0);\n            const pinnedRightWidth = calculatedColumnWidths\n                .slice(pinnedLeftColumnCount + totalColumnCount)\n                .reduce((sum, width) => sum + width, 0);\n\n            const totalTableWidth = calculatedColumnWidths.reduce((sum, width) => sum + width, 0);\n            const actualStickyTop = stickyGroupTop;\n\n            return (\n                <div\n                    className={styles.stickyGroupRow}\n                    ref={stickyGroupRowRef}\n                    style={{\n                        top: `${actualStickyTop}px`,\n                    }}\n                >\n                    <div className={styles.stickyGroupRowContent}>\n                        {pinnedLeftColumnCount > 0 && (\n                            <div\n                                className={styles.stickyGroupRowSection}\n                                style={{ width: `${pinnedLeftWidth}px` }}\n                            >\n                                <div\n                                    style={{\n                                        height: groupRowHeight,\n                                        width: `${pinnedLeftWidth}px`,\n                                    }}\n                                >\n                                    {groupContent}\n                                </div>\n                            </div>\n                        )}\n                        <div\n                            className={styles.stickyGroupRowSection}\n                            style={{\n                                marginLeft: pinnedLeftColumnCount > 0 ? 0 : '-2rem',\n                                marginRight: '-2rem',\n                                paddingLeft: pinnedLeftColumnCount > 0 ? 0 : '2rem',\n                                paddingRight: '2rem',\n                                width: `${mainWidth}px`,\n                            }}\n                        >\n                            <div\n                                style={{\n                                    height: groupRowHeight,\n                                    marginLeft: pinnedLeftWidth > 0 ? `-${pinnedLeftWidth}px` : 0,\n                                    width: `${totalTableWidth}px`,\n                                }}\n                            >\n                                {groupContent}\n                            </div>\n                        </div>\n                        {pinnedRightColumnCount > 0 && (\n                            <div\n                                className={styles.stickyGroupRowSection}\n                                style={{ width: `${pinnedRightWidth}px` }}\n                            >\n                                <div\n                                    style={{\n                                        height: groupRowHeight,\n                                        width: `${pinnedRightWidth}px`,\n                                    }}\n                                />\n                            </div>\n                        )}\n                    </div>\n                </div>\n            );\n        }, [\n            shouldRenderStickyGroupRow,\n            stickyGroupIndex,\n            groups,\n            data,\n            internalState,\n            calculatedColumnWidths,\n            pinnedLeftColumnCount,\n            pinnedRightColumnCount,\n            totalColumnCount,\n            groupRowHeight,\n            stickyGroupTop,\n        ]);\n\n        return (\n            <>\n                {StickyHeader}\n                {StickyGroupRow}\n            </>\n        );\n    },\n);\n\nItemTableListStickyUI.displayName = 'ItemTableListStickyUI';\n\nconst BaseItemTableList = ({\n    activeRowId,\n    autoFitColumns = false,\n    CellComponent = ItemTableListColumn,\n    cellPadding = 'sm',\n    columns,\n    data,\n    enableAlternateRowColors = false,\n    enableDrag = true,\n    enableDragScroll = true,\n    enableEntranceAnimation = true,\n    enableExpansion = true,\n    enableHeader = true,\n    enableHorizontalBorders = false,\n    enableRowHoverHighlight = true,\n    enableScrollShadow = true,\n    enableSelection = true,\n    enableStickyGroupRows = false,\n    enableStickyHeader = false,\n    enableVerticalBorders = false,\n    getItem,\n    getItemIndex,\n    getRowId,\n    groups,\n    headerHeight = 40,\n    initialTop,\n    itemCount,\n    itemType,\n    onColumnReordered,\n    onColumnResized,\n    onRangeChanged,\n    onScrollEnd,\n    overrideControls,\n    ref,\n    rowHeight,\n    size = 'default',\n    startRowIndex,\n}: ItemTableListProps) => {\n    const tableId = useId();\n    const baseItemCount = itemCount ?? data.length;\n    const totalItemCount = enableHeader ? baseItemCount + 1 : baseItemCount;\n    const [centerContainerWidth, setCenterContainerWidth] = useState(0);\n    const [totalContainerWidth, setTotalContainerWidth] = useState(0);\n\n    const {\n        calculatedColumnWidths,\n        parsedColumns,\n        pinnedLeftColumnCount,\n        pinnedRightColumnCount,\n        totalColumnCount,\n    } = useTableColumnModel({\n        autoFitColumns,\n        centerContainerWidth,\n        columns,\n        totalContainerWidth,\n    });\n    const playerContext = usePlayer();\n\n    const {\n        dataWithGroups: dataWithGroupsFromModel,\n        groupHeaderRowCount: groupHeaderRowCountFromModel,\n    } = useTableRowModel({\n        data,\n        enableHeader,\n        groups,\n    });\n\n    const shouldUseAccessor = typeof getItem === 'function' && typeof itemCount === 'number';\n\n    // Avoid constructing a massive row-model array for infinite lists.\n    // Cell renderers use `getRowItem` accessor when provided.\n    const dataWithGroups = useMemo<(null | unknown)[]>(() => {\n        if (!shouldUseAccessor) return dataWithGroupsFromModel;\n        return enableHeader ? [null] : [];\n    }, [dataWithGroupsFromModel, enableHeader, shouldUseAccessor]);\n\n    const groupHeaderRowCount = useMemo(() => {\n        if (!shouldUseAccessor) return groupHeaderRowCountFromModel;\n        return groups?.length ? groups.length : 0;\n    }, [groupHeaderRowCountFromModel, groups, shouldUseAccessor]);\n\n    const pinnedRowCount = enableHeader ? 1 : 0;\n\n    // Group headers are inserted at specific indexes, so they add to the total row count\n    const totalRowCount = totalItemCount - pinnedRowCount + groupHeaderRowCount;\n    const pinnedRowRef = useRef<HTMLDivElement>(null);\n    const rowRef = useRef<HTMLDivElement>(null);\n    const pinnedLeftColumnRef = useRef<HTMLDivElement>(null);\n    const pinnedRightColumnRef = useRef<HTMLDivElement>(null);\n    const scrollContainerRef = useRef<HTMLDivElement | null>(null);\n    const mergedRowRef = useMergedRef(rowRef, scrollContainerRef);\n    const [showLeftShadow, setShowLeftShadow] = useState(false);\n    const [showRightShadow, setShowRightShadow] = useState(false);\n    const [showTopShadow, setShowTopShadow] = useState(false);\n    const handleRef = useRef<ItemListHandle | null>(null);\n    const { focused, ref: focusRef } = useFocusWithin();\n    const containerRef = useRef<HTMLDivElement | null>(null);\n    const mergedContainerRef = useMergedRef(containerRef, focusRef);\n\n    useContainerWidthTracking({\n        autoFitColumns,\n        containerRef,\n        rowRef,\n        setCenterContainerWidth,\n        setTotalContainerWidth,\n    });\n\n    const onScrollEndRef = useRef<ItemTableListProps['onScrollEnd']>(onScrollEnd);\n    useEffect(() => {\n        onScrollEndRef.current = onScrollEnd;\n    }, [onScrollEnd]);\n\n    const {\n        calculateScrollTopForIndex,\n        DEFAULT_ROW_HEIGHT,\n        scrollToTableIndex,\n        scrollToTableOffset,\n    } = useTableScrollToIndex({\n        cellPadding,\n        columns: parsedColumns,\n        data,\n        enableAlternateRowColors,\n        enableExpansion,\n        enableHeader,\n        enableHorizontalBorders,\n        enableRowHoverHighlight,\n        enableSelection,\n        enableVerticalBorders,\n        itemType,\n        pinnedLeftColumnRef,\n        pinnedRightColumnRef,\n        playerContext,\n        rowHeight,\n        rowRef,\n        size,\n        tableId,\n    });\n\n    useTablePaneSync({\n        enableDrag,\n        enableDragScroll,\n        enableHeader,\n        handleRef,\n        onScrollEndRef,\n        pinnedLeftColumnCount,\n        pinnedLeftColumnRef,\n        pinnedRightColumnCount,\n        pinnedRightColumnRef,\n        pinnedRowRef,\n        rowRef,\n        scrollContainerRef,\n        setShowLeftShadow,\n        setShowRightShadow,\n        setShowTopShadow,\n    });\n\n    const getRowHeight = useCallback(\n        (index: number, cellProps: TableItemProps) => {\n            const height =\n                size === 'compact'\n                    ? TableItemSize.COMPACT\n                    : size === 'large'\n                      ? TableItemSize.LARGE\n                      : TableItemSize.DEFAULT;\n\n            const baseHeight =\n                typeof rowHeight === 'number' ? rowHeight : rowHeight?.(index, cellProps) || height;\n\n            // If enableHeader is true and this is the first sticky row, use fixed header height\n            if (enableHeader && index === 0 && pinnedRowCount > 0) {\n                return headerHeight;\n            }\n\n            return baseHeight;\n        },\n        [enableHeader, headerHeight, rowHeight, pinnedRowCount, size],\n    );\n\n    // Create a wrapper for getRowHeight that doesn't require cellProps (for sticky group rows hook)\n    const getRowHeightWrapper = useCallback(\n        (index: number) => {\n            const height =\n                size === 'compact'\n                    ? TableItemSize.COMPACT\n                    : size === 'large'\n                      ? TableItemSize.LARGE\n                      : TableItemSize.DEFAULT;\n\n            const baseHeight = typeof rowHeight === 'number' ? rowHeight : height;\n\n            // If enableHeader is true and this is the first sticky row, use fixed header height\n            if (enableHeader && index === 0 && pinnedRowCount > 0) {\n                return headerHeight;\n            }\n\n            return baseHeight;\n        },\n        [enableHeader, headerHeight, rowHeight, pinnedRowCount, size],\n    );\n\n    const getDataFn = useCallback(() => {\n        return data;\n    }, [data]);\n\n    const extractRowId = useMemo(() => createExtractRowId(getRowId), [getRowId]);\n\n    const internalState = useItemListState(getDataFn, extractRowId);\n\n    const getStateItem = useCallback(\n        (item: any): ItemListStateItemWithRequiredProperties | null => {\n            if (!hasRequiredItemProperties(item)) {\n                return null;\n            }\n            if (\n                typeof item === 'object' &&\n                item !== null &&\n                '_serverId' in item &&\n                '_itemType' in item\n            ) {\n                return item as ItemListStateItemWithRequiredProperties;\n            }\n            return null;\n        },\n        [],\n    );\n\n    const { handleKeyDown } = useTableKeyboardNavigation({\n        calculateScrollTopForIndex,\n        cellPadding,\n        data,\n        DEFAULT_ROW_HEIGHT,\n        enableHeader,\n        enableSelection,\n        extractRowId,\n        getItem,\n        getItemIndex,\n        getStateItem,\n        hasRequiredStateItemProperties,\n        internalState,\n        itemCount: baseItemCount,\n        itemType,\n        parsedColumns,\n        pinnedRightColumnCount,\n        pinnedRightColumnRef,\n        playerContext,\n        rowHeight,\n        rowRef,\n        scrollToTableIndex,\n        size,\n        tableId,\n    });\n\n    useTableInitialScroll({\n        initialTop,\n        scrollToTableIndex,\n        scrollToTableOffset,\n        startRowIndex,\n    });\n\n    useTableImperativeHandle({\n        enableHeader,\n        handleRef,\n        internalState,\n        ref,\n        scrollToTableIndex,\n        scrollToTableOffset,\n    });\n\n    const controls = useDefaultItemListControls({\n        onColumnReordered,\n        onColumnResized,\n        overrides: overrideControls,\n    });\n\n    // Create itemProps for sticky header\n    const stickyHeaderItemProps: TableItemProps = useMemo(\n        () => ({\n            calculatedColumnWidths,\n            cellPadding,\n            columns: parsedColumns,\n            controls,\n            data: [null], // Header row\n            enableAlternateRowColors,\n            enableColumnReorder: !!onColumnReordered,\n            enableColumnResize: !!onColumnResized,\n            enableDrag,\n            enableExpansion,\n            enableHeader,\n            enableHorizontalBorders,\n            enableRowHoverHighlight,\n            enableSelection,\n            enableVerticalBorders,\n            getRowHeight,\n            groups,\n            internalState,\n            itemType,\n            pinnedLeftColumnCount,\n            pinnedLeftColumnWidths: calculatedColumnWidths.slice(0, pinnedLeftColumnCount),\n            pinnedRightColumnCount,\n            pinnedRightColumnWidths: calculatedColumnWidths.slice(\n                pinnedLeftColumnCount + totalColumnCount,\n            ),\n            playerContext,\n            size,\n            tableId,\n        }),\n        [\n            calculatedColumnWidths,\n            cellPadding,\n            controls,\n            parsedColumns,\n            enableAlternateRowColors,\n            enableDrag,\n            enableExpansion,\n            enableHeader,\n            enableHorizontalBorders,\n            enableRowHoverHighlight,\n            enableSelection,\n            enableVerticalBorders,\n            getRowHeight,\n            groups,\n            internalState,\n            itemType,\n            onColumnReordered,\n            onColumnResized,\n            pinnedLeftColumnCount,\n            pinnedRightColumnCount,\n            playerContext,\n            size,\n            tableId,\n            totalColumnCount,\n        ],\n    );\n\n    useListHotkeys({\n        controls,\n        focused,\n        internalState,\n        itemType,\n    });\n\n    const tableConfigValue = useMemo(\n        () => ({\n            cellPadding,\n            columns: parsedColumns,\n            controls,\n            enableHeader,\n            enableRowHoverHighlight,\n            enableSelection,\n            internalState,\n            itemType,\n            playerContext,\n            size,\n            startRowIndex,\n            tableId,\n        }),\n        [\n            cellPadding,\n            parsedColumns,\n            controls,\n            enableHeader,\n            enableRowHoverHighlight,\n            enableSelection,\n            internalState,\n            itemType,\n            playerContext,\n            size,\n            startRowIndex,\n            tableId,\n        ],\n    );\n\n    const columnCellComponents = useColumnCellComponents(\n        parsedColumns.map((c) => c.id as TableColumn),\n        itemType,\n    );\n\n    const optimizedCellComponent = useMemo<\n        JSXElementConstructor<CellComponentProps<TableItemProps>>\n    >(() => {\n        if (CellComponent && CellComponent !== ItemTableListColumn) {\n            return CellComponent;\n        }\n\n        return (cellProps: CellComponentProps<TableItemProps>) => {\n            return (\n                <MemoizedCellRouter {...cellProps} columnCellComponents={columnCellComponents} />\n            );\n        };\n    }, [CellComponent, columnCellComponents]);\n\n    return (\n        <ItemTableListStoreProvider activeRowId={activeRowId}>\n            <ItemTableListConfigProvider value={tableConfigValue}>\n                <motion.div\n                    className={styles.itemTableListContainer}\n                    onKeyDown={handleKeyDown}\n                    onMouseDown={(e) => {\n                        const element = e.currentTarget as HTMLDivElement;\n                        // Focus without scrolling into view\n                        if (element.focus) {\n                            element.focus({ preventScroll: true });\n                        }\n                    }}\n                    ref={mergedContainerRef}\n                    tabIndex={0}\n                    {...animationProps.fadeIn}\n                    transition={{ duration: enableEntranceAnimation ? 0.3 : 0, ease: 'anticipate' }}\n                >\n                    <ItemTableListStickyUI\n                        calculatedColumnWidths={calculatedColumnWidths}\n                        CellComponent={optimizedCellComponent}\n                        containerRef={containerRef}\n                        data={data}\n                        enableHeader={!!enableHeader}\n                        enableStickyGroupRows={!!enableStickyGroupRows}\n                        enableStickyHeader={!!enableStickyHeader}\n                        getRowHeightWrapper={getRowHeightWrapper}\n                        groups={groups}\n                        headerHeight={headerHeight}\n                        internalState={internalState}\n                        parsedColumns={parsedColumns}\n                        pinnedLeftColumnCount={pinnedLeftColumnCount}\n                        pinnedLeftColumnRef={pinnedLeftColumnRef}\n                        pinnedRightColumnCount={pinnedRightColumnCount}\n                        pinnedRightColumnRef={pinnedRightColumnRef}\n                        pinnedRowRef={pinnedRowRef}\n                        rowHeight={rowHeight}\n                        rowRef={rowRef}\n                        size={size}\n                        stickyHeaderItemProps={stickyHeaderItemProps}\n                        totalColumnCount={totalColumnCount}\n                    />\n                    <MemoizedVirtualizedTableGrid\n                        calculatedColumnWidths={calculatedColumnWidths}\n                        CellComponent={optimizedCellComponent}\n                        cellPadding={cellPadding}\n                        controls={controls}\n                        data={data}\n                        dataWithGroups={dataWithGroups}\n                        enableAlternateRowColors={enableAlternateRowColors}\n                        enableColumnReorder={!!onColumnReordered}\n                        enableColumnResize={!!onColumnResized}\n                        enableDrag={enableDrag}\n                        enableExpansion={enableExpansion}\n                        enableHeader={enableHeader}\n                        enableHorizontalBorders={enableHorizontalBorders}\n                        enableRowHoverHighlight={enableRowHoverHighlight}\n                        enableScrollShadow={enableScrollShadow}\n                        enableSelection={enableSelection}\n                        enableVerticalBorders={enableVerticalBorders}\n                        getItem={getItem}\n                        getRowHeight={getRowHeight}\n                        groups={groups}\n                        headerHeight={headerHeight}\n                        internalState={internalState}\n                        itemType={itemType}\n                        mergedRowRef={mergedRowRef}\n                        onRangeChanged={onRangeChanged}\n                        parsedColumns={parsedColumns}\n                        pinnedLeftColumnCount={pinnedLeftColumnCount}\n                        pinnedLeftColumnRef={pinnedLeftColumnRef}\n                        pinnedRightColumnCount={pinnedRightColumnCount}\n                        pinnedRightColumnRef={pinnedRightColumnRef}\n                        pinnedRowCount={pinnedRowCount}\n                        pinnedRowRef={pinnedRowRef}\n                        playerContext={playerContext}\n                        showLeftShadow={showLeftShadow}\n                        showRightShadow={showRightShadow}\n                        showTopShadow={showTopShadow}\n                        size={size}\n                        startRowIndex={startRowIndex}\n                        tableId={tableId}\n                        totalColumnCount={totalColumnCount}\n                        totalRowCount={totalRowCount}\n                    />\n                </motion.div>\n            </ItemTableListConfigProvider>\n        </ItemTableListStoreProvider>\n    );\n};\n\nexport const ItemTableList = memo(BaseItemTableList);\n\nItemTableList.displayName = 'ItemTableList';\n"
  },
  {
    "path": "src/renderer/components/item-list/item-table-list/memoized-cell-router.tsx",
    "content": "import React, { memo, useMemo } from 'react';\nimport { CellComponentProps } from 'react-window-v2';\n\nimport { createColumnCellComponents } from './cell-component-factory';\nimport { TableItemProps } from './item-table-list';\nimport { ItemTableListColumn } from './item-table-list-column';\n\nimport { LibraryItem } from '/@/shared/types/domain-types';\nimport { TableColumn } from '/@/shared/types/types';\n\ninterface MemoizedCellRouterProps extends CellComponentProps<TableItemProps> {\n    columnCellComponents: Map<TableColumn, React.ComponentType<CellComponentProps<TableItemProps>>>;\n}\n\nconst MemoizedCellRouterBase = (props: MemoizedCellRouterProps) => {\n    const columnType = props.columns[props.columnIndex]?.id as TableColumn;\n    const ColumnComponent = props.columnCellComponents.get(columnType);\n\n    if (ColumnComponent) {\n        // eslint-disable-next-line react-hooks/static-components\n        return <ColumnComponent {...props} />;\n    }\n\n    return <ItemTableListColumn {...props} />;\n};\n\nexport const MemoizedCellRouter = memo(MemoizedCellRouterBase, (prevProps, nextProps) => {\n    return (\n        prevProps.rowIndex === nextProps.rowIndex &&\n        prevProps.columnIndex === nextProps.columnIndex &&\n        prevProps.data === nextProps.data &&\n        prevProps.columns === nextProps.columns &&\n        prevProps.columnCellComponents === nextProps.columnCellComponents &&\n        prevProps.size === nextProps.size &&\n        prevProps.enableAlternateRowColors === nextProps.enableAlternateRowColors &&\n        prevProps.enableHorizontalBorders === nextProps.enableHorizontalBorders &&\n        prevProps.enableVerticalBorders === nextProps.enableVerticalBorders &&\n        prevProps.enableRowHoverHighlight === nextProps.enableRowHoverHighlight &&\n        prevProps.enableSelection === nextProps.enableSelection &&\n        prevProps.enableColumnResize === nextProps.enableColumnResize &&\n        prevProps.enableColumnReorder === nextProps.enableColumnReorder &&\n        prevProps.cellPadding === nextProps.cellPadding\n    );\n});\n\nexport const useColumnCellComponents = (\n    columns: TableColumn[],\n    itemType: LibraryItem,\n): Map<TableColumn, React.ComponentType<CellComponentProps<TableItemProps>>> => {\n    const columnsKey = useMemo(() => columns.join(','), [columns]);\n\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n    return useMemo(() => createColumnCellComponents(columns, itemType), [columnsKey, itemType]);\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/selection-dialog.module.css",
    "content": ".selection-indicator {\n    position: absolute;\n    bottom: 0;\n    left: 50%;\n    z-index: 100;\n    min-width: 320px;\n    padding: var(--theme-spacing-sm) var(--theme-spacing-md);\n    color: var(--theme-colors-surface-foreground);\n    background: color-mix(in srgb, var(--theme-colors-surface) 85%, transparent);\n    border: 1px solid color-mix(in srgb, var(--theme-colors-border) 50%, transparent);\n    border-radius: var(--theme-radius-md);\n    box-shadow:\n        2px 2px 10px 2px rgb(0 0 0 / 40%),\n        0 0 0 1px rgb(255 255 255 / 5%);\n    backdrop-filter: blur(12px) saturate(180%);\n    transform: translateX(-50%);\n}\n\n.info-icon {\n    display: flex;\n    cursor: pointer;\n}\n"
  },
  {
    "path": "src/renderer/components/item-list/selection-dialog.tsx",
    "content": "import { AnimatePresence, motion } from 'motion/react';\nimport { useTranslation } from 'react-i18next';\n\nimport styles from './selection-dialog.module.css';\n\nimport i18n from '/@/i18n/i18n';\nimport {\n    ItemListStateActions,\n    useItemListStateSubscription,\n} from '/@/renderer/components/item-list/helpers/item-list-state';\nimport { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';\nimport { ActionIcon } from '/@/shared/components/action-icon/action-icon';\nimport { animationProps } from '/@/shared/components/animations/animation-props';\nimport { Group } from '/@/shared/components/group/group';\nimport { HoverCard } from '/@/shared/components/hover-card/hover-card';\nimport { Icon } from '/@/shared/components/icon/icon';\nimport { Kbd } from '/@/shared/components/kbd/kbd';\nimport { Table } from '/@/shared/components/table/table';\nimport { Text } from '/@/shared/components/text/text';\n\nconst controls = [\n    {\n        control1: <Kbd>CTRL</Kbd>,\n        control2: <Kbd>A</Kbd>,\n        label: i18n.t('action.selectAll', { postProcess: 'sentenceCase' }),\n    },\n    {\n        control1: <Kbd>CTRL</Kbd>,\n        control2: <Icon fill=\"default\" icon=\"mouseLeftClick\" />,\n        label: i18n.t('action.addOrRemoveFromSelection', { postProcess: 'sentenceCase' }),\n    },\n    {\n        control1: <Kbd>SHIFT</Kbd>,\n        control2: <Icon fill=\"default\" icon=\"mouseLeftClick\" />,\n        label: i18n.t('action.selectRangeOfItems', { postProcess: 'sentenceCase' }),\n    },\n];\n\nexport const SelectionDialog = ({ internalState }: { internalState: ItemListStateActions }) => {\n    const { t } = useTranslation();\n\n    const isListExpanded = useItemListStateSubscription(internalState, (state) =>\n        state ? state.expanded.size > 0 : false,\n    );\n\n    const selectedCount = useItemListStateSubscription(internalState, (state) =>\n        state ? state.selected.size : 0,\n    );\n\n    const handleClearSelection = () => {\n        internalState.clearSelected();\n    };\n\n    const handleOpenMoreActions = (event: React.MouseEvent<unknown>) => {\n        event.preventDefault();\n        event.stopPropagation();\n\n        const selectedItems = internalState.getSelected();\n\n        if (selectedItems.length === 0) {\n            return;\n        }\n\n        ContextMenuController.call({\n            cmd: { items: selectedItems as any[], type: (selectedItems[0] as any)._itemType },\n            event,\n        });\n    };\n\n    const isOpen = selectedCount > 0;\n\n    return (\n        <AnimatePresence initial={false} mode=\"sync\">\n            {isOpen && (\n                <motion.div\n                    {...animationProps.fadeIn}\n                    className={styles.selectionIndicator}\n                    style={{ bottom: isListExpanded ? '320px' : '1rem' }}\n                >\n                    <Group gap=\"xl\" justify=\"space-between\">\n                        <Group gap=\"sm\">\n                            <HoverCard offset={20} position=\"top\">\n                                <HoverCard.Target>\n                                    <span className={styles.infoIcon}>\n                                        <Icon icon=\"keyboard\" />\n                                    </span>\n                                </HoverCard.Target>\n                                <HoverCard.Dropdown>\n                                    <Table>\n                                        <Table.Tbody>\n                                            {controls.map((control) => (\n                                                <Table.Tr key={control.label}>\n                                                    <Table.Td ta=\"start\">\n                                                        {control.control1}\n                                                    </Table.Td>\n                                                    <Table.Td>+</Table.Td>\n                                                    <Table.Td ta=\"center\">\n                                                        {control.control2}\n                                                    </Table.Td>\n                                                    <Table.Td>\n                                                        <Text size=\"xs\">{control.label}</Text>\n                                                    </Table.Td>\n                                                </Table.Tr>\n                                            ))}\n                                        </Table.Tbody>\n                                    </Table>\n                                </HoverCard.Dropdown>\n                            </HoverCard>\n                            <Text fw={500} isNoSelect size=\"sm\">\n                                {t('common.countSelected', { count: selectedCount })}\n                            </Text>\n                        </Group>\n\n                        <Group gap=\"xs\">\n                            <ActionIcon\n                                icon=\"x\"\n                                iconProps={{ size: 'xl' }}\n                                onClick={handleClearSelection}\n                                size=\"xs\"\n                                variant=\"subtle\"\n                            />\n                            <ActionIcon\n                                icon=\"ellipsisHorizontal\"\n                                iconProps={{ size: 'xl' }}\n                                onClick={handleOpenMoreActions}\n                                size=\"xs\"\n                                variant=\"subtle\"\n                            />\n                        </Group>\n                    </Group>\n                </motion.div>\n            )}\n        </AnimatePresence>\n    );\n};\n"
  },
  {
    "path": "src/renderer/components/item-list/types.ts",
    "content": "import { ItemListStateActions } from '/@/renderer/components/item-list/helpers/item-list-state';\nimport {\n    Album,\n    AlbumArtist,\n    Artist,\n    Folder,\n    Genre,\n    LibraryItem,\n    Playlist,\n    Song,\n} from '/@/shared/types/domain-types';\nimport { Play, TableColumn } from '/@/shared/types/types';\n\nexport interface DefaultItemControlProps {\n    event: null | React.MouseEvent<unknown>;\n    index?: number;\n    internalState?: ItemListStateActions;\n    item: ItemListItem | undefined;\n    itemType: LibraryItem;\n    meta?: Record<string, any>;\n}\n\nexport interface ItemControls {\n    onClick?: ({ index, internalState, item, itemType }: DefaultItemControlProps) => void;\n    onColumnReordered?: ({\n        columnIdFrom,\n        columnIdTo,\n        edge,\n    }: {\n        columnIdFrom: TableColumn;\n        columnIdTo: TableColumn;\n        edge: 'bottom' | 'left' | 'right' | 'top' | null;\n    }) => void;\n    onColumnResized?: ({ columnId, width }: { columnId: TableColumn; width: number }) => void;\n    onDoubleClick?: ({ index, internalState, item, itemType }: DefaultItemControlProps) => void;\n    onExpand?: ({ index, internalState, item, itemType }: DefaultItemControlProps) => void;\n    onFavorite?: ({\n        index,\n        internalState,\n        item,\n        itemType,\n    }: DefaultItemControlProps & { favorite: boolean }) => void;\n    onMore?: ({ index, internalState, item, itemType }: DefaultItemControlProps) => void;\n    onPlay?: ({\n        index,\n        internalState,\n        item,\n        itemType,\n        playType,\n    }: DefaultItemControlProps & { playType: Play }) => void;\n    onRating?: ({\n        index,\n        internalState,\n        item,\n        itemType,\n        rating,\n    }: DefaultItemControlProps & { rating: number }) => void;\n}\n\nexport interface ItemListComponentProps<TQuery> {\n    itemsPerPage?: number;\n    query: Omit<TQuery, 'limit' | 'startIndex'>;\n    saveScrollOffset?: boolean;\n    serverId: string;\n}\n\nexport interface ItemListGridComponentProps<TQuery> extends ItemListComponentProps<TQuery> {\n    gap?: 'lg' | 'md' | 'sm' | 'xl' | 'xs';\n    itemsPerRow?: number;\n    size?: 'compact' | 'default' | 'large';\n}\n\nexport interface ItemListHandle {\n    internalState: ItemListStateActions;\n    scrollToIndex: (\n        index: number,\n        options?: { align?: 'bottom' | 'center' | 'top'; behavior?: 'auto' | 'smooth' },\n    ) => void;\n    scrollToOffset: (offset: number, options?: { behavior?: 'auto' | 'smooth' }) => void;\n}\n\nexport type ItemListItem =\n    | Album\n    | AlbumArtist\n    | Artist\n    | Folder\n    | Genre\n    | Playlist\n    | Song\n    | undefined;\n\nexport interface ItemListTableComponentProps<TQuery> extends ItemListComponentProps<TQuery> {\n    autoFitColumns?: boolean;\n    columns: ItemTableListColumnConfig[];\n    enableAlternateRowColors?: boolean;\n    enableHeader?: boolean;\n    enableHorizontalBorders?: boolean;\n    enableRowHoverHighlight?: boolean;\n    enableSelection?: boolean;\n    enableVerticalBorders?: boolean;\n    size?: 'compact' | 'default' | 'large';\n}\n\nexport interface ItemTableListColumnConfig {\n    align: 'center' | 'end' | 'start';\n    autoSize?: boolean;\n    id: TableColumn;\n    isEnabled: boolean;\n    pinned: 'left' | 'right' | null;\n    width: number;\n}\n"
  },
  {
    "path": "src/renderer/components/motion/index.tsx",
    "content": "import { motion } from 'motion/react';\n\nimport { Flex, FlexProps } from '/@/shared/components/flex/flex';\nimport { Group, GroupProps } from '/@/shared/components/group/group';\nimport { Stack, StackProps } from '/@/shared/components/stack/stack';\n\nexport const MotionFlex = motion.create<FlexProps>(Flex, { forwardMotionProps: true });\n\nexport const MotionGroup = motion.create<GroupProps>(Group, { forwardMotionProps: true });\n\nexport const MotionStack = motion.create<StackProps>(Stack, { forwardMotionProps: true });\n\nexport const MotionDiv = motion.div;\n"
  },
  {
    "path": "src/renderer/components/native-scroll-area/native-scroll-area.module.css",
    "content": ".scroll-area {\n    height: calc(100vh - 90px);\n}\n\n.drag-container {\n    position: absolute;\n    top: 0;\n    left: 0;\n    z-index: -1;\n    width: calc(100% - 130px);\n    height: 65px;\n    -webkit-app-region: drag;\n\n    button {\n        -webkit-app-region: no-drag;\n    }\n\n    input {\n        -webkit-app-region: no-drag;\n    }\n}\n\n.scroll-area :global(.os-scrollbar) {\n    z-index: 200;\n}\n\n.scroll-area :global(.os-scrollbar-track) {\n    z-index: 200;\n}\n\n.scroll-area :global(.os-scrollbar-handle) {\n    z-index: 200;\n}\n"
  },
  {
    "path": "src/renderer/components/native-scroll-area/native-scroll-area.tsx",
    "content": "import { useOverlayScrollbars } from 'overlayscrollbars-react';\nimport { CSSProperties, forwardRef, memo, ReactNode, Ref, useEffect, useRef } from 'react';\n\nimport styles from './native-scroll-area.module.css';\n\nimport { PageHeader, PageHeaderProps } from '/@/renderer/components/page-header/page-header';\nimport { useWindowSettings } from '/@/renderer/store/settings.store';\nimport { useMergedRef } from '/@/shared/hooks/use-merged-ref';\nimport { useThrottledCallback } from '/@/shared/hooks/use-throttled-callback';\nimport { Platform } from '/@/shared/types/types';\n\ninterface NativeScrollAreaProps {\n    children: ReactNode;\n    debugScrollPosition?: boolean;\n    noHeader?: boolean;\n    pageHeaderProps?: PageHeaderProps & { offset: number; target?: any };\n    scrollBarOffset?: string;\n    scrollHideDelay?: number;\n    style?: CSSProperties;\n}\n\nconst BaseNativeScrollArea = forwardRef(\n    (\n        { children, noHeader, pageHeaderProps, scrollHideDelay, ...props }: NativeScrollAreaProps,\n        ref: Ref<HTMLDivElement>,\n    ) => {\n        const { windowBarStyle } = useWindowSettings();\n        const containerRef = useRef<HTMLDivElement | null>(null);\n\n        const scrollHandler = useThrottledCallback((e: Event) => {\n            if (noHeader || !pageHeaderProps) {\n                return;\n            }\n\n            const scrollElement = e?.target as HTMLDivElement;\n            if (!scrollElement || !containerRef.current) {\n                return;\n            }\n\n            const offset = pageHeaderProps.offset || 0;\n            const scrollTop = scrollElement.scrollTop;\n\n            if (scrollTop > offset) {\n                containerRef.current.setAttribute('data-scrolled', 'true');\n            } else {\n                containerRef.current.setAttribute('data-scrolled', 'false');\n            }\n        }, 100);\n\n        const [initialize] = useOverlayScrollbars({\n            defer: false,\n            events: {\n                scroll: (_instance, e) => {\n                    scrollHandler(e);\n                },\n            },\n            options: {\n                overflow: { x: 'hidden', y: 'scroll' },\n                scrollbars: {\n                    autoHide: 'leave',\n                    autoHideDelay: scrollHideDelay || 500,\n                    pointers: ['mouse', 'pen', 'touch'],\n                    theme: 'feishin-os-scrollbar',\n                    visibility: 'visible',\n                },\n            },\n        });\n\n        useEffect(() => {\n            if (containerRef.current) {\n                initialize(containerRef.current as HTMLDivElement);\n                if (!noHeader && pageHeaderProps) {\n                    containerRef.current.setAttribute('data-scrolled', 'false');\n                }\n            }\n        }, [initialize, noHeader, pageHeaderProps]);\n\n        const mergedRef = useMergedRef(ref, containerRef);\n\n        return (\n            <>\n                {windowBarStyle === Platform.WEB && <div className={styles.dragContainer} />}\n                {!noHeader && pageHeaderProps && (\n                    <PageHeader\n                        animated\n                        position=\"absolute\"\n                        scrollContainerRef={containerRef}\n                        {...pageHeaderProps}\n                    />\n                )}\n                <div className={styles.scrollArea} ref={mergedRef} {...props}>\n                    {children}\n                </div>\n            </>\n        );\n    },\n);\n\nexport const NativeScrollArea = memo(BaseNativeScrollArea);\n\nNativeScrollArea.displayName = 'NativeScrollArea';\n"
  },
  {
    "path": "src/renderer/components/page-header/page-header.module.css",
    "content": ".container {\n    position: relative;\n    z-index: 190;\n    visibility: hidden;\n    width: 100%;\n    height: 65px;\n    overflow: hidden;\n    pointer-events: none;\n    background: var(--theme-colors-background);\n    opacity: 0;\n}\n\n.container[data-visible='true'] {\n    visibility: visible;\n    pointer-events: auto;\n    opacity: 1;\n}\n\n.container.visible {\n    visibility: visible;\n    pointer-events: auto;\n    opacity: 1;\n}\n\n.header {\n    position: relative;\n    z-index: 15;\n    width: 100%;\n    height: 100%;\n    pointer-events: auto;\n    user-select: auto;\n\n    button {\n        -webkit-app-region: no-drag;\n    }\n\n    input {\n        -webkit-app-region: no-drag;\n    }\n\n    [role='button'] {\n        -webkit-app-region: no-drag;\n    }\n\n    a {\n        -webkit-app-region: no-drag;\n    }\n\n    [style*='cursor: pointer'] {\n        -webkit-app-region: no-drag;\n    }\n}\n\n.header.pad-right {\n    margin-right: 140px;\n}\n\n.header.hidden {\n    pointer-events: none;\n    user-select: none;\n}\n\n.header.is-draggable {\n    -webkit-app-region: drag;\n}\n\n.background-image {\n    position: absolute;\n    top: 0;\n    z-index: 1;\n    width: 100%;\n    height: 100%;\n    background: var(--theme-colors-background);\n}\n\n.background-image-overlay {\n    position: absolute;\n    top: 0;\n    left: 0;\n    z-index: 10;\n    width: 100%;\n    height: 100%;\n}\n\n.background-image-overlay.light {\n    background: linear-gradient(rgb(255 255 255 / 25%), rgb(255 255 255 / 25%));\n}\n\n.background-image-overlay.dark {\n    background: linear-gradient(rgb(0 0 0 / 50%), rgb(0 0 0 / 50%));\n}\n\n.title-wrapper {\n    position: absolute;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    width: 100%;\n    height: 100%;\n    padding: var(--theme-spacing-sm);\n}\n\n.title-wrapper.hidden {\n    display: none;\n}\n"
  },
  {
    "path": "src/renderer/components/page-header/page-header.tsx",
    "content": "import clsx from 'clsx';\nimport { useInView } from 'motion/react';\nimport { AnimatePresence, motion, Variants } from 'motion/react';\nimport { CSSProperties, memo, ReactNode, RefObject, useEffect, useRef } from 'react';\n\nimport styles from './page-header.module.css';\n\nimport { LibraryBackgroundOverlay } from '/@/renderer/features/shared/components/library-background-overlay';\nimport { useShouldPadTitlebar } from '/@/renderer/hooks';\nimport { useWindowSettings } from '/@/renderer/store/settings.store';\nimport { Flex, FlexProps } from '/@/shared/components/flex/flex';\nimport { Platform } from '/@/shared/types/types';\n\nexport interface PageHeaderProps\n    extends Omit<FlexProps, 'onAnimationStart' | 'onDrag' | 'onDragEnd' | 'onDragStart'> {\n    animated?: boolean;\n    backgroundColor?: string;\n    children?: ReactNode;\n    height?: string;\n    isHidden?: boolean;\n    position?: string;\n    scrollContainerRef?: RefObject<HTMLDivElement | null>;\n    target?: RefObject<HTMLElement | null>;\n}\n\nconst variants: Variants = {\n    animate: {\n        opacity: 1,\n        transition: {\n            duration: 0.3,\n            ease: 'easeIn',\n        },\n    },\n    exit: { opacity: 0 },\n    initial: { opacity: 0 },\n};\n\nconst BasePageHeader = ({\n    animated,\n    backgroundColor,\n    children,\n    height,\n    isHidden,\n    position,\n    scrollContainerRef,\n    target,\n    ...props\n}: PageHeaderProps) => {\n    const ref = useRef(null);\n    const padRight = useShouldPadTitlebar();\n    const { windowBarStyle } = useWindowSettings();\n\n    const isInView = useInView({\n        current: target?.current || null,\n    });\n\n    useEffect(() => {\n        const headerElement = ref.current as HTMLElement | null;\n        const scrollContainer = scrollContainerRef?.current;\n\n        if (!scrollContainerRef) {\n            if (headerElement) {\n                headerElement.setAttribute('data-visible', isHidden ? 'false' : 'true');\n            }\n            return undefined;\n        }\n\n        if (!scrollContainer || !headerElement) {\n            if (headerElement) {\n                headerElement.setAttribute('data-visible', 'false');\n            }\n            return undefined;\n        }\n\n        const updateVisibility = () => {\n            const dataScrolled = scrollContainer.getAttribute('data-scrolled');\n            const isScrolled = dataScrolled === 'true';\n            const shouldShow = isScrolled && !isInView;\n\n            if (shouldShow) {\n                headerElement.setAttribute('data-visible', 'true');\n            } else {\n                headerElement.setAttribute('data-visible', 'false');\n            }\n        };\n\n        updateVisibility();\n\n        const observer = new MutationObserver(updateVisibility);\n        observer.observe(scrollContainer, {\n            attributeFilter: ['data-scrolled'],\n            attributes: true,\n        });\n\n        return () => observer.disconnect();\n    }, [isInView, scrollContainerRef, isHidden]);\n\n    return (\n        <>\n            <Flex\n                className={styles.container}\n                data-visible=\"false\"\n                ref={ref}\n                style={{ height, position: position as CSSProperties['position'] }}\n                {...props}\n            >\n                <div\n                    className={clsx(styles.header, {\n                        [styles.hidden]: isHidden,\n                        [styles.isDraggable]: windowBarStyle === Platform.WEB,\n                        [styles.padRight]: padRight,\n                    })}\n                >\n                    <AnimatePresence initial={animated ?? false}>\n                        <motion.div\n                            animate=\"animate\"\n                            className={styles.titleWrapper}\n                            exit=\"exit\"\n                            initial=\"initial\"\n                            variants={variants}\n                        >\n                            {children}\n                        </motion.div>\n                    </AnimatePresence>\n                </div>\n                {backgroundColor && (\n                    <LibraryBackgroundOverlay backgroundColor={backgroundColor} headerRef={ref} />\n                )}\n            </Flex>\n        </>\n    );\n};\n\nexport const PageHeader = memo(BasePageHeader);\n\nPageHeader.displayName = 'PageHeader';\n"
  },
  {
    "path": "src/renderer/components/query-builder/index.tsx",
    "content": "import { useMemo } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport { QueryBuilderOption } from '/@/renderer/components/query-builder/query-builder-option';\nimport { ActionIcon } from '/@/shared/components/action-icon/action-icon';\nimport { Box } from '/@/shared/components/box/box';\nimport { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu';\nimport { Group } from '/@/shared/components/group/group';\nimport { Icon } from '/@/shared/components/icon/icon';\nimport { Select } from '/@/shared/components/select/select';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { QueryBuilderGroup, QueryBuilderRule } from '/@/shared/types/types';\n\nexport type FilterGroup = { group: string; items: FilterItem[] };\n\nexport type FilterItem = { label: string; type: string; value: string };\n\nexport type Filters = FilterGroup[] | FilterItem[];\ntype AddArgs = {\n    groupIndex: number[];\n    level: number;\n};\ntype DeleteArgs = {\n    groupIndex: number[];\n    level: number;\n    uniqueId: string;\n};\n\ninterface QueryBuilderProps {\n    data: Record<string, any>;\n    filters: Filters;\n    groupIndex: number[];\n    level: number;\n    onAddRule: (args: AddArgs) => void;\n    onAddRuleGroup: (args: AddArgs) => void;\n    onChangeField: (args: any) => void;\n    onChangeOperator: (args: any) => void;\n    onChangeType: (args: any) => void;\n    onChangeValue: (args: any) => void;\n    onClearFilters: () => void;\n    onDeleteRule: (args: DeleteArgs) => void;\n    onDeleteRuleGroup: (args: DeleteArgs) => void;\n    onResetFilters: () => void;\n    operators: {\n        boolean: { label: string; value: string }[];\n        date: { label: string; value: string }[];\n        number: { label: string; value: string }[];\n        playlist: { label: string; value: string }[];\n        string: { label: string; value: string }[];\n    };\n    playlists?: { label: string; value: string }[];\n    saveActions?: React.ReactNode;\n    uniqueId: string;\n}\n\nexport const QueryBuilder = ({\n    data,\n    filters,\n    groupIndex,\n    level,\n    onAddRule,\n    onAddRuleGroup,\n    onChangeField,\n    onChangeOperator,\n    onChangeType,\n    onChangeValue,\n    onClearFilters,\n    onDeleteRule,\n    onDeleteRuleGroup,\n    onResetFilters,\n    operators,\n    playlists,\n    saveActions,\n    uniqueId,\n}: QueryBuilderProps) => {\n    const { t } = useTranslation();\n\n    const FILTER_GROUP_OPTIONS_DATA = [\n        {\n            label: t('form.queryEditor.input', {\n                context: 'optionMatchAll',\n                postProcess: 'sentenceCase',\n            }),\n            value: 'all',\n        },\n        {\n            label: t('form.queryEditor.input', {\n                context: 'optionMatchAny',\n                postProcess: 'sentenceCase',\n            }),\n            value: 'any',\n        },\n    ];\n\n    const handleAddRule = () => {\n        onAddRule({ groupIndex, level });\n    };\n\n    const handleAddRuleGroup = () => {\n        onAddRuleGroup({ groupIndex, level });\n    };\n\n    const handleDeleteRuleGroup = () => {\n        onDeleteRuleGroup({ groupIndex, level, uniqueId });\n    };\n\n    const handleChangeType = (value: null | string) => {\n        onChangeType({ groupIndex, level, value });\n    };\n\n    const boxStyle = useMemo(\n        () => ({\n            border: '1px solid var(--theme-colors-border)',\n            borderRadius: 'var(--theme-radius-md)',\n            marginLeft: level > 0 ? '20px' : '0px',\n        }),\n        [level],\n    );\n\n    return (\n        <Box p=\"md\" style={boxStyle}>\n            <Stack gap=\"sm\">\n                <Group gap=\"sm\" justify=\"space-between\" wrap=\"nowrap\">\n                    <Group gap=\"sm\" wrap=\"nowrap\">\n                        <Select\n                            data={FILTER_GROUP_OPTIONS_DATA}\n                            maxWidth={170}\n                            onChange={handleChangeType}\n                            size=\"sm\"\n                            value={data.type}\n                        />\n                        <ActionIcon icon=\"add\" onClick={handleAddRule} size=\"sm\" variant=\"subtle\" />\n                        <DropdownMenu position=\"bottom-start\">\n                            <DropdownMenu.Target>\n                                <ActionIcon\n                                    icon=\"ellipsisVertical\"\n                                    size=\"sm\"\n                                    style={{\n                                        padding: 0,\n                                    }}\n                                    variant=\"subtle\"\n                                />\n                            </DropdownMenu.Target>\n                            <DropdownMenu.Dropdown>\n                                <DropdownMenu.Item\n                                    leftSection={<Icon icon=\"add\" />}\n                                    onClick={handleAddRuleGroup}\n                                >\n                                    {t('form.queryEditor.addRuleGroup', {\n                                        postProcess: 'sentenceCase',\n                                    })}\n                                </DropdownMenu.Item>\n\n                                {level > 0 && (\n                                    <DropdownMenu.Item\n                                        leftSection={<Icon icon=\"delete\" />}\n                                        onClick={handleDeleteRuleGroup}\n                                    >\n                                        {t('form.queryEditor.removeRuleGroup', {\n                                            postProcess: 'sentenceCase',\n                                        })}\n                                    </DropdownMenu.Item>\n                                )}\n                                {level === 0 && (\n                                    <>\n                                        <DropdownMenu.Divider />\n                                        <DropdownMenu.Item\n                                            isDanger\n                                            leftSection={<Icon color=\"error\" icon=\"refresh\" />}\n                                            onClick={onResetFilters}\n                                        >\n                                            {t('form.queryEditor.resetToDefault', {\n                                                postProcess: 'sentenceCase',\n                                            })}\n                                        </DropdownMenu.Item>\n                                        <DropdownMenu.Item\n                                            isDanger\n                                            leftSection={<Icon color=\"error\" icon=\"delete\" />}\n                                            onClick={onClearFilters}\n                                        >\n                                            {t('form.queryEditor.clearFilters', {\n                                                postProcess: 'sentenceCase',\n                                            })}\n                                        </DropdownMenu.Item>\n                                    </>\n                                )}\n                            </DropdownMenu.Dropdown>\n                        </DropdownMenu>\n                    </Group>\n                    {level === 0 && saveActions}\n                </Group>\n                {data?.rules?.map((rule: QueryBuilderRule) => (\n                    <div key={rule.uniqueId}>\n                        <QueryBuilderOption\n                            data={rule}\n                            filters={filters}\n                            groupIndex={groupIndex || []}\n                            level={level}\n                            noRemove={false}\n                            onChangeField={onChangeField}\n                            onChangeOperator={onChangeOperator}\n                            onChangeValue={onChangeValue}\n                            onDeleteRule={onDeleteRule}\n                            operators={operators}\n                            selectData={playlists}\n                        />\n                    </div>\n                ))}\n                {data?.group && (\n                    <>\n                        {data.group?.map((group: QueryBuilderGroup, index: number) => (\n                            <div key={group.uniqueId}>\n                                <QueryBuilder\n                                    data={group}\n                                    filters={filters}\n                                    groupIndex={[...(groupIndex || []), index]}\n                                    level={level + 1}\n                                    onAddRule={onAddRule}\n                                    onAddRuleGroup={onAddRuleGroup}\n                                    onChangeField={onChangeField}\n                                    onChangeOperator={onChangeOperator}\n                                    onChangeType={onChangeType}\n                                    onChangeValue={onChangeValue}\n                                    onClearFilters={onClearFilters}\n                                    onDeleteRule={onDeleteRule}\n                                    onDeleteRuleGroup={onDeleteRuleGroup}\n                                    onResetFilters={onResetFilters}\n                                    operators={operators}\n                                    playlists={playlists}\n                                    uniqueId={group.uniqueId}\n                                />\n                            </div>\n                        ))}\n                    </>\n                )}\n            </Stack>\n        </Box>\n    );\n};\n"
  },
  {
    "path": "src/renderer/components/query-builder/query-builder-option.tsx",
    "content": "import { useEffect, useState } from 'react';\n\nimport { Filters } from '/@/renderer/components/query-builder';\nimport { ActionIcon } from '/@/shared/components/action-icon/action-icon';\nimport { DateInput } from '/@/shared/components/date-picker/date-picker';\nimport { Group } from '/@/shared/components/group/group';\nimport { NumberInput } from '/@/shared/components/number-input/number-input';\nimport { Select } from '/@/shared/components/select/select';\nimport { TextInput } from '/@/shared/components/text-input/text-input';\nimport { QueryBuilderRule } from '/@/shared/types/types';\n\ntype DeleteArgs = {\n    groupIndex: number[];\n    level: number;\n    uniqueId: string;\n};\n\ninterface QueryOptionProps {\n    data: QueryBuilderRule;\n    filters: Filters;\n    groupIndex: number[];\n    level: number;\n    noRemove: boolean;\n    onChangeField: (args: any) => void;\n    onChangeOperator: (args: any) => void;\n    onChangeValue: (args: any) => void;\n    onDeleteRule: (args: DeleteArgs) => void;\n    operators: {\n        boolean: { label: string; value: string }[];\n        date: { label: string; value: string }[];\n        number: { label: string; value: string }[];\n        string: { label: string; value: string }[];\n    };\n    selectData?: { label: string; value: string }[];\n}\n\nconst QueryValueInput = ({\n    data,\n    defaultValue,\n    onChange,\n    operator,\n    type,\n    value: valueProp,\n    ...props\n}: any) => {\n    const [numberRange, setNumberRange] = useState<number[]>([0, 0]);\n\n    // Parse date value helper - converts date string (YYYY-MM-DD) to Date for display\n    const parseDateValue = (val: any): Date | null => {\n        if (!val) return null;\n        if (val instanceof Date) return val;\n        if (typeof val === 'string') {\n            // Handle YYYY-MM-DD format strings\n            const parsed = new Date(val);\n            if (isNaN(parsed.getTime())) return null;\n            return parsed;\n        }\n        return null;\n    };\n\n    const value = valueProp !== undefined ? valueProp : defaultValue;\n\n    // Store date range as strings for state management\n    const [dateRange, setDateRange] = useState<[null | string, null | string]>(() => {\n        const currentValue = value !== undefined ? value : defaultValue;\n        if (currentValue && Array.isArray(currentValue)) {\n            return [\n                typeof currentValue[0] === 'string' ? currentValue[0] : null,\n                typeof currentValue[1] === 'string' ? currentValue[1] : null,\n            ];\n        }\n        return [null, null];\n    });\n\n    // Sync dateRange state when value changes\n    useEffect(() => {\n        const currentValue = value !== undefined ? value : defaultValue;\n        if (operator === 'inTheRangeDate' && currentValue && Array.isArray(currentValue)) {\n            setDateRange([\n                typeof currentValue[0] === 'string' ? currentValue[0] : null,\n                typeof currentValue[1] === 'string' ? currentValue[1] : null,\n            ]);\n        }\n    }, [value, defaultValue, operator]);\n\n    // Sync numberRange state when value changes\n    useEffect(() => {\n        const currentValue = value !== undefined ? value : defaultValue;\n        if (operator === 'inTheRange' && currentValue && Array.isArray(currentValue)) {\n            setNumberRange([\n                typeof currentValue[0] === 'number'\n                    ? currentValue[0]\n                    : Number(currentValue[0]) || 0,\n                typeof currentValue[1] === 'number'\n                    ? currentValue[1]\n                    : Number(currentValue[1]) || 0,\n            ]);\n        }\n    }, [value, defaultValue, operator]);\n\n    // Check if operator requires DatePicker\n    const isDatePickerOperator =\n        operator === 'beforeDate' || operator === 'afterDate' || operator === 'inTheRangeDate';\n\n    switch (type) {\n        case 'boolean':\n            return (\n                <Select\n                    data={[\n                        { label: 'true', value: 'true' },\n                        { label: 'false', value: 'false' },\n                    ]}\n                    onChange={onChange}\n                    value={value}\n                    {...props}\n                />\n            );\n        case 'date':\n            if (isDatePickerOperator && operator !== 'inTheRangeDate') {\n                const dateValue = value ? parseDateValue(value) : null;\n                return (\n                    <DateInput\n                        clearable\n                        defaultLevel=\"year\"\n                        maxWidth={170}\n                        onChange={(date) => {\n                            // DateInput returns string in 'YYYY-MM-DD' format (local timezone)\n                            // Return raw string value - no transformation needed\n                            onChange(date || '');\n                        }}\n                        size=\"sm\"\n                        value={dateValue}\n                        valueFormat=\"YYYY-MM-DD\"\n                        width=\"25%\"\n                    />\n                );\n            }\n            return <TextInput onChange={onChange} size=\"sm\" value={value} {...props} />;\n        case 'dateRange':\n            if (operator === 'inTheRangeDate') {\n                return (\n                    <Group gap=\"sm\" grow wrap=\"nowrap\">\n                        <DateInput\n                            clearable\n                            defaultLevel=\"year\"\n                            maxWidth={81}\n                            onChange={(date) => {\n                                // DateInput returns string in 'YYYY-MM-DD' format (local timezone)\n                                const newRange: [null | string, null | string] = [\n                                    date || null,\n                                    dateRange[1],\n                                ];\n                                setDateRange(newRange);\n                                // Return raw string values - no transformation needed\n                                onChange([date || null, dateRange[1] || null]);\n                            }}\n                            size=\"sm\"\n                            value={dateRange[0] ? parseDateValue(dateRange[0]) : null}\n                            valueFormat=\"YYYY-MM-DD\"\n                            width=\"10%\"\n                        />\n                        <DateInput\n                            clearable\n                            defaultLevel=\"year\"\n                            maxWidth={81}\n                            onChange={(date) => {\n                                // DateInput returns string in 'YYYY-MM-DD' format (local timezone)\n                                const newRange: [null | string, null | string] = [\n                                    dateRange[0],\n                                    date || null,\n                                ];\n                                setDateRange(newRange);\n                                // Return raw string values - no transformation needed\n                                onChange([dateRange[0] || null, date || null]);\n                            }}\n                            size=\"sm\"\n                            value={dateRange[1] ? parseDateValue(dateRange[1]) : null}\n                            valueFormat=\"YYYY-MM-DD\"\n                            width=\"10%\"\n                        />\n                    </Group>\n                );\n            }\n\n            return (\n                <>\n                    <NumberInput\n                        {...props}\n                        maxWidth={81}\n                        onChange={(e) => {\n                            const newRange = [Number(e) || 0, numberRange[1]];\n                            setNumberRange(newRange);\n                            onChange(newRange);\n                        }}\n                        value={numberRange[0] || undefined}\n                        width=\"10%\"\n                    />\n                    <NumberInput\n                        {...props}\n                        maxWidth={81}\n                        onChange={(e) => {\n                            const newRange = [numberRange[0], Number(e) || 0];\n                            setNumberRange(newRange);\n                            onChange(newRange);\n                        }}\n                        value={numberRange[1] || undefined}\n                        width=\"10%\"\n                    />\n                </>\n            );\n        case 'number':\n            return (\n                <NumberInput\n                    onChange={onChange}\n                    size=\"sm\"\n                    value={\n                        value !== undefined && value !== null && value !== ''\n                            ? Number(value)\n                            : undefined\n                    }\n                    {...props}\n                />\n            );\n        case 'playlist':\n            return <Select data={data} onChange={onChange} value={value} {...props} />;\n        case 'string':\n            return <TextInput onChange={onChange} size=\"sm\" value={value || ''} {...props} />;\n\n        default:\n            return <></>;\n    }\n};\n\nexport const QueryBuilderOption = ({\n    data,\n    filters,\n    groupIndex,\n    level,\n    noRemove,\n    onChangeField,\n    onChangeOperator,\n    onChangeValue,\n    onDeleteRule,\n    operators,\n    selectData,\n}: QueryOptionProps) => {\n    const { field, operator, uniqueId, value } = data;\n\n    const handleDeleteRule = () => {\n        onDeleteRule({ groupIndex, level, uniqueId });\n    };\n\n    const handleChangeField = (e: any) => {\n        onChangeField({ groupIndex, level, uniqueId, value: e });\n    };\n\n    const handleChangeOperator = (e: any) => {\n        onChangeOperator({ groupIndex, level, uniqueId, value: e });\n    };\n\n    const handleChangeValue = (e: any) => {\n        const isDirectValue =\n            typeof e === 'string' || typeof e === 'number' || typeof e === 'undefined';\n\n        if (isDirectValue) {\n            return onChangeValue({\n                groupIndex,\n                level,\n                uniqueId,\n                value: e,\n            });\n        }\n\n        // const isDate = e instanceof Date;\n\n        // if (isDate) {\n        //   return onChangeValue({\n        //     groupIndex,\n        //     level,\n        //     uniqueId,\n        //     value: dayjs(e).format('YYYY-MM-DD'),\n        //   });\n        // }\n\n        const isArray = Array.isArray(e);\n\n        if (isArray) {\n            return onChangeValue({\n                groupIndex,\n                level,\n                uniqueId,\n                value: e,\n            });\n        }\n\n        return onChangeValue({\n            groupIndex,\n            level,\n            uniqueId,\n            value: e.currentTarget.value,\n        });\n    };\n\n    // Handle both grouped and flat filter data\n    const flatFilters = filters.some((f: any) => f.group && f.items)\n        ? filters.flatMap((group: any) => group.items || [])\n        : filters;\n    const fieldType = flatFilters.find((f: any) => f.value === field)?.type;\n    const operatorsByFieldType = operators[fieldType as keyof typeof operators];\n    const ml = 20;\n\n    return (\n        <Group gap=\"sm\" ml={ml}>\n            <Select\n                data={filters}\n                maxWidth={170}\n                onChange={handleChangeField}\n                searchable\n                size=\"sm\"\n                value={field}\n                width=\"25%\"\n            />\n            <Select\n                data={operatorsByFieldType || []}\n                disabled={!field}\n                maxWidth={170}\n                onChange={handleChangeOperator}\n                searchable\n                size=\"sm\"\n                value={operator}\n                width=\"25%\"\n            />\n            {field ? (\n                <QueryValueInput\n                    data={selectData || []}\n                    maxWidth={170}\n                    onChange={handleChangeValue}\n                    operator={operator}\n                    size=\"sm\"\n                    type={\n                        operator === 'inTheRange' || operator === 'inTheRangeDate'\n                            ? 'dateRange'\n                            : fieldType\n                    }\n                    value={value}\n                    width=\"25%\"\n                />\n            ) : (\n                <TextInput\n                    disabled\n                    maxWidth={170}\n                    onChange={handleChangeValue}\n                    size=\"sm\"\n                    value={value || ''}\n                    width=\"25%\"\n                />\n            )}\n            <ActionIcon\n                disabled={noRemove}\n                icon=\"remove\"\n                onClick={handleDeleteRule}\n                px={5}\n                size=\"sm\"\n                variant=\"subtle\"\n            />\n        </Group>\n    );\n};\n"
  },
  {
    "path": "src/renderer/components/select-with-invalid-data/index.tsx",
    "content": "import { useMemo } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport { MultiSelect, MultiSelectProps } from '/@/shared/components/multi-select/multi-select';\nimport { Select, SelectProps } from '/@/shared/components/select/select';\n\nexport const SelectWithInvalidData = ({ data, defaultValue, ...props }: SelectProps) => {\n    const { t } = useTranslation();\n\n    const [fullData, hasError] = useMemo(() => {\n        if (typeof defaultValue === 'string') {\n            const missingField =\n                data?.find((item) =>\n                    typeof item === 'string'\n                        ? item === defaultValue\n                        : (item as any).value === defaultValue,\n                ) === undefined;\n\n            if (missingField) {\n                return [data?.concat(defaultValue), true];\n            }\n        }\n\n        return [data, false];\n    }, [data, defaultValue]);\n\n    return (\n        <Select\n            data={fullData}\n            defaultValue={defaultValue}\n            error={\n                hasError\n                    ? t('error.badValue', { postProcess: 'sentenceCase', value: defaultValue })\n                    : undefined\n            }\n            {...props}\n        />\n    );\n};\n\nexport const MultiSelectWithInvalidData = ({\n    data,\n    defaultValue,\n    value,\n    ...props\n}: MultiSelectProps) => {\n    const { t } = useTranslation();\n    const currentValue = value ?? defaultValue;\n    const [fullData, missing] = useMemo(() => {\n        if (currentValue?.length) {\n            const validValues = new Set<string>();\n            for (const item of data || []) {\n                if (typeof item === 'string') {\n                    validValues.add(item);\n                } else {\n                    validValues.add((item as any).value);\n                }\n            }\n\n            const missingFields: string[] = [];\n\n            for (const val of currentValue) {\n                if (!validValues.has(val)) {\n                    missingFields.push(val);\n                }\n            }\n\n            if (missingFields.length > 0) {\n                return [data?.concat(missingFields), missingFields];\n            }\n        }\n\n        return [data, []];\n    }, [data, currentValue]);\n\n    const error = useMemo(\n        () =>\n            missing.length\n                ? t('error.badValue', { postProcess: 'sentenceCase', value: missing })\n                : undefined,\n        [missing, t],\n    );\n\n    return (\n        <MultiSelect\n            data={fullData}\n            defaultValue={defaultValue}\n            error={error}\n            value={value}\n            {...props}\n        />\n    );\n};\n"
  },
  {
    "path": "src/renderer/components/settings-diff-visualiser/settings-diff-visualiser.tsx",
    "content": "import { SettingsState } from '/@/renderer/store';\nimport { Box } from '/@/shared/components/box/box';\nimport { Text } from '/@/shared/components/text/text';\n\ninterface DiffVisualiserProps {\n    newSettings: Omit<SettingsState, 'actions'>;\n    originalSettings: Omit<SettingsState, 'actions'>;\n}\n\nconst diff = (newSettings: SettingsState, originalSettings: SettingsState) => {\n    const diffs: string[] = [];\n\n    const newSettingsString = JSON.stringify(newSettings, null, 2);\n    const originalSettingsString = JSON.stringify(originalSettings, null, 2);\n\n    const newSettingsLines = newSettingsString.split('\\n');\n    const originalSettingsLines = originalSettingsString.split('\\n');\n\n    originalSettingsLines.forEach((line, index) => {\n        if (line !== newSettingsLines[index]) {\n            diffs.push(`- ${line}`);\n            if (newSettingsLines[index] !== undefined) {\n                diffs.push(`+ ${newSettingsLines[index]}`);\n            }\n        } else {\n            diffs.push(`  ${line}`);\n        }\n    });\n\n    return diffs;\n};\n\nexport const DiffVisualiser = ({ newSettings, originalSettings }: DiffVisualiserProps) => {\n    const differences = diff(newSettings, originalSettings);\n\n    return (\n        <Box\n            mah=\"400px\"\n            p=\"md\"\n            style={{ fontFamily: 'monospace', overflow: 'auto', whiteSpace: 'pre-wrap' }}\n        >\n            {differences.map((line, index) => (\n                <Text\n                    key={index}\n                    style={{\n                        color: line.startsWith('+')\n                            ? 'green'\n                            : line.startsWith('-')\n                              ? 'red'\n                              : 'white',\n                    }}\n                >\n                    {line}\n                </Text>\n            ))}\n        </Box>\n    );\n};\n"
  },
  {
    "path": "src/renderer/components/simple-item-table/simple-item-table.module.css",
    "content": ".simple-item-table-container {\n    width: 100%;\n}\n\n/* .alternate-row-even {\n    background-color: initial;\n}\n\n.alternate-row-odd {\n    @mixin dark {\n        background-color: darken(var(--theme-colors-background), 30%);\n    }\n\n    @mixin light {\n        background-color: darken(var(--theme-colors-background), 2%);\n    }\n} */\n\n/* .row-hover {\n    cursor: pointer;\n}\n\n.row-hover:hover {\n    background-color: var(--theme-colors-surface);\n    opacity: 0.7;\n} */\n\n.row-selected {\n}\n\n/* .with-horizontal-border {\n    border-bottom: 1px solid var(--theme-colors-border);\n}\n\n.with-vertical-border {\n    border-right: 1px solid var(--theme-colors-border);\n} */\n"
  },
  {
    "path": "src/renderer/components/simple-item-table/simple-item-table.tsx",
    "content": "import clsx from 'clsx';\nimport { memo, useId, useMemo } from 'react';\n\nimport styles from './simple-item-table.module.css';\n\nimport { createExtractRowId } from '/@/renderer/components/item-list/helpers/extract-row-id';\nimport { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls';\nimport {\n    ItemListStateActions,\n    useItemListState,\n    useItemSelectionState,\n} from '/@/renderer/components/item-list/helpers/item-list-state';\nimport { parseTableColumns } from '/@/renderer/components/item-list/helpers/parse-table-columns';\nimport { TableItemProps } from '/@/renderer/components/item-list/item-table-list/item-table-list';\nimport { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';\nimport { TableColumnHeaderContainer } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';\nimport { ItemTableListColumnConfig } from '/@/renderer/components/item-list/types';\nimport { usePlayer } from '/@/renderer/features/player/context/player-context';\nimport { Table } from '/@/shared/components/table/table';\nimport { LibraryItem } from '/@/shared/types/domain-types';\n\nenum TableItemSize {\n    COMPACT = 40,\n    DEFAULT = 64,\n    LARGE = 88,\n}\n\ninterface SimpleItemTableProps {\n    cellPadding?: 'lg' | 'md' | 'sm' | 'xl' | 'xs';\n    columns: ItemTableListColumnConfig[];\n    data: unknown[];\n    enableAlternateRowColors?: boolean;\n    enableHeader?: boolean;\n    enableHorizontalBorders?: boolean;\n    enableRowHoverHighlight?: boolean;\n    enableSelection?: boolean;\n    enableVerticalBorders?: boolean;\n    getRowId?: ((item: unknown) => string) | string;\n    itemType: LibraryItem;\n    size?: 'compact' | 'default' | 'large';\n}\n\nexport const SimpleItemTable = ({\n    cellPadding = 'sm',\n    columns,\n    data,\n    enableAlternateRowColors = false,\n    enableHeader = true,\n    enableHorizontalBorders = false,\n    enableRowHoverHighlight = true,\n    enableSelection = true,\n    enableVerticalBorders = false,\n    getRowId,\n    itemType,\n    size = 'default',\n}: SimpleItemTableProps) => {\n    const tableId = useId();\n    const playerContext = usePlayer();\n\n    // Filter out pinned columns by setting pinned to null\n    const columnsWithoutPinning = useMemo(\n        () =>\n            columns.map((col) => ({\n                ...col,\n                pinned: null,\n            })),\n        [columns],\n    );\n\n    // Parse columns (filters disabled and sorts by pinned position, but we've removed pinning)\n    const parsedColumns = useMemo(\n        () => parseTableColumns(columnsWithoutPinning),\n        [columnsWithoutPinning],\n    );\n\n    // Create extractRowId function\n    const extractRowId = useMemo(() => createExtractRowId(getRowId), [getRowId]);\n\n    // Use item list state for selection\n    const internalState = useItemListState(() => data, extractRowId);\n\n    // Get default item controls\n    const controls = useDefaultItemListControls();\n\n    // Calculate row height based on size\n    const DEFAULT_ROW_HEIGHT = useMemo(() => {\n        switch (size) {\n            case 'compact':\n                return TableItemSize.COMPACT;\n            case 'large':\n                return TableItemSize.LARGE;\n            case 'default':\n            default:\n                return TableItemSize.DEFAULT;\n        }\n    }, [size]);\n\n    const tableItemProps: TableItemProps = useMemo(\n        () => ({\n            cellPadding,\n            columns: parsedColumns,\n            controls,\n            data: enableHeader ? [null, ...data] : data,\n            enableAlternateRowColors,\n            enableColumnReorder: false,\n            enableColumnResize: false,\n            enableDrag: false,\n            enableExpansion: false,\n            enableHeader,\n            enableHorizontalBorders,\n            enableRowHoverHighlight,\n            enableSelection,\n            enableVerticalBorders,\n            getRowHeight: () => DEFAULT_ROW_HEIGHT,\n            internalState,\n            itemType,\n            playerContext,\n            size,\n            tableId,\n        }),\n        [\n            cellPadding,\n            parsedColumns,\n            controls,\n            enableHeader,\n            data,\n            enableAlternateRowColors,\n            enableHorizontalBorders,\n            enableRowHoverHighlight,\n            enableSelection,\n            enableVerticalBorders,\n            DEFAULT_ROW_HEIGHT,\n            internalState,\n            itemType,\n            playerContext,\n            size,\n            tableId,\n        ],\n    );\n\n    return (\n        <div className={styles.simpleItemTableContainer}>\n            <Table\n                highlightOnHover={enableRowHoverHighlight}\n                striped={enableAlternateRowColors}\n                withColumnBorders={enableVerticalBorders}\n                withRowBorders={enableHorizontalBorders}\n            >\n                {enableHeader && (\n                    <Table.Thead>\n                        <Table.Tr>\n                            {parsedColumns.map((column, columnIndex) => (\n                                <Table.Th\n                                    key={column.id}\n                                    style={{\n                                        textAlign:\n                                            column.align === 'start'\n                                                ? 'left'\n                                                : column.align === 'end'\n                                                  ? 'right'\n                                                  : 'center',\n                                        width: column.width,\n                                    }}\n                                >\n                                    <TableColumnHeaderContainer\n                                        {...tableItemProps}\n                                        ariaAttributes={{\n                                            'aria-colindex': columnIndex + 1,\n                                            role: 'gridcell',\n                                        }}\n                                        columnIndex={columnIndex}\n                                        controls={controls}\n                                        rowIndex={0}\n                                        style={{ width: column.width }}\n                                        type={column.id}\n                                    />\n                                </Table.Th>\n                            ))}\n                        </Table.Tr>\n                    </Table.Thead>\n                )}\n                <Table.Tbody>\n                    {data.map((item, rowIndex) => (\n                        <SimpleItemTableRow\n                            adjustedRowIndex={enableHeader ? rowIndex + 1 : rowIndex}\n                            enableAlternateRowColors={enableAlternateRowColors}\n                            enableHeader={enableHeader}\n                            enableHorizontalBorders={enableHorizontalBorders}\n                            enableRowHoverHighlight={enableRowHoverHighlight}\n                            enableVerticalBorders={enableVerticalBorders}\n                            internalState={internalState}\n                            isLastRow={rowIndex === data.length - 1}\n                            item={item}\n                            key={internalState.extractRowId(item) || rowIndex}\n                            parsedColumns={parsedColumns}\n                            rowIndex={rowIndex}\n                            tableId={tableId}\n                            tableItemProps={tableItemProps}\n                        />\n                    ))}\n                </Table.Tbody>\n            </Table>\n        </div>\n    );\n};\n\ninterface SimpleItemTableRowProps {\n    adjustedRowIndex: number;\n    enableAlternateRowColors: boolean;\n    enableHeader: boolean;\n    enableHorizontalBorders: boolean;\n    enableRowHoverHighlight: boolean;\n    enableVerticalBorders: boolean;\n    internalState: ItemListStateActions;\n    isLastRow: boolean;\n    item: unknown;\n    parsedColumns: ReturnType<typeof parseTableColumns>;\n    rowIndex: number;\n    tableId: string;\n    tableItemProps: TableItemProps;\n}\n\nconst SimpleItemTableRow = memo(\n    ({\n        adjustedRowIndex,\n        enableAlternateRowColors,\n        enableHeader,\n        enableHorizontalBorders,\n        enableRowHoverHighlight,\n        enableVerticalBorders,\n        internalState,\n        isLastRow,\n        item,\n        parsedColumns,\n        rowIndex,\n        tableId,\n        tableItemProps,\n    }: SimpleItemTableRowProps) => {\n        const itemRowId =\n            item && typeof item === 'object' && 'id' in item\n                ? internalState.extractRowId(item)\n                : undefined;\n        const isSelected = useItemSelectionState(internalState, itemRowId || undefined);\n\n        return (\n            <Table.Tr\n                className={clsx({\n                    [styles.alternateRowEven]: enableAlternateRowColors && rowIndex % 2 === 0,\n                    [styles.alternateRowOdd]: enableAlternateRowColors && rowIndex % 2 === 1,\n                    [styles.rowHover]: enableRowHoverHighlight,\n                    [styles.rowSelected]: isSelected,\n                    [styles.withHorizontalBorder]:\n                        enableHorizontalBorders && enableHeader && !isLastRow,\n                })}\n                data-row-index={`${tableId}-${adjustedRowIndex}`}\n            >\n                {parsedColumns.map((column, columnIndex) => {\n                    const isLastColumn = columnIndex === parsedColumns.length - 1;\n\n                    return (\n                        <Table.Td\n                            className={clsx({\n                                [styles.withVerticalBorder]: enableVerticalBorders && !isLastColumn,\n                            })}\n                            key={column.id}\n                            style={{\n                                textAlign:\n                                    column.align === 'start'\n                                        ? 'left'\n                                        : column.align === 'end'\n                                          ? 'right'\n                                          : 'center',\n                                width: column.width,\n                            }}\n                        >\n                            <ItemTableListColumn\n                                {...tableItemProps}\n                                ariaAttributes={{\n                                    'aria-colindex': columnIndex + 1,\n                                    role: 'gridcell',\n                                }}\n                                columnIndex={columnIndex}\n                                rowIndex={adjustedRowIndex}\n                                style={{ width: column.width }}\n                            />\n                        </Table.Td>\n                    );\n                })}\n            </Table.Tr>\n        );\n    },\n);\n\nSimpleItemTableRow.displayName = 'SimpleItemTableRow';\n"
  },
  {
    "path": "src/renderer/context/list-context.tsx",
    "content": "import { createContext, useContext } from 'react';\n\nimport { LibraryItem } from '/@/shared/types/domain-types';\nimport { ItemListKey } from '/@/shared/types/types';\n\nexport type ListDisplayMode = LibraryItem.ALBUM | LibraryItem.SONG;\n\ninterface ListContextProps {\n    customFilters?: Record<string, unknown>;\n    displayMode?: ListDisplayMode;\n    id?: string;\n    isSidebarOpen?: boolean;\n    isSmartPlaylist?: boolean;\n    itemCount?: number;\n    listData?: unknown[];\n    listKey?: ItemListKey;\n    mode?: 'edit' | 'view';\n    pageKey: ItemListKey | string;\n    setDisplayMode?: (displayMode: ListDisplayMode) => void;\n    setIsSidebarOpen?: (isSidebarOpen: boolean) => void;\n    setItemCount?: (itemCount: number) => void;\n    setListData?: (items: unknown[]) => void;\n    setMode?: (mode: 'edit' | 'view') => void;\n}\n\nexport const ListContext = createContext<ListContextProps>({\n    pageKey: '',\n});\n\nexport const useListContext = () => {\n    const ctxValue = useContext(ListContext);\n    return ctxValue;\n};\n"
  },
  {
    "path": "src/renderer/env.d.ts",
    "content": "/// <reference types=\"vite/client\" />\n"
  },
  {
    "path": "src/renderer/events/event-emitter.ts",
    "content": "import { ErrorHandler, EventCallback, TypedEventEmitter } from './types';\n\nimport { EventMap } from '/@/renderer/events/events';\n\nclass TypedEventEmitterImpl implements TypedEventEmitter<EventMap> {\n    private errorHandler: ErrorHandler | null = null;\n    private events: Map<string, EventCallback[]> = new Map();\n\n    emit<K extends keyof EventMap>(event: K, payload: EventMap[K]): void {\n        const callbacks = this.events.get(String(event));\n        if (callbacks) {\n            callbacks.forEach((callback) => {\n                try {\n                    callback(payload);\n                } catch (error) {\n                    this.handleError(error as Error, String(event), payload);\n                }\n            });\n        }\n    }\n\n    off<K extends keyof EventMap>(event: K, callback: EventCallback<EventMap[K]>): void {\n        const callbacks = this.events.get(String(event));\n        if (callbacks) {\n            const index = callbacks.indexOf(callback);\n            if (index > -1) {\n                callbacks.splice(index, 1);\n            }\n        }\n    }\n\n    on<K extends keyof EventMap>(event: K, callback: EventCallback<EventMap[K]>): void {\n        const eventKey = String(event);\n        if (!this.events.has(eventKey)) {\n            this.events.set(eventKey, []);\n        }\n        this.events.get(eventKey)!.push(callback);\n    }\n\n    removeAllListeners<K extends keyof EventMap>(event?: K): void {\n        if (event) {\n            // Remove specific event listeners\n            this.events.delete(String(event));\n        } else {\n            // Remove all listeners\n            this.events.clear();\n        }\n    }\n\n    setErrorHandler(handler: ErrorHandler): void {\n        this.errorHandler = handler;\n    }\n\n    private handleError(error: Error, event: string, payload: any): void {\n        if (this.errorHandler) {\n            this.errorHandler(error, event, payload);\n        } else {\n            console.error(`Event emitter error for event \"${event}\":`, error, payload);\n        }\n    }\n}\n\nexport const eventEmitter = new TypedEventEmitterImpl();\n"
  },
  {
    "path": "src/renderer/events/events.ts",
    "content": "import { LibraryItem, Song } from '/@/shared/types/domain-types';\n\nexport type AutoDJQueueAddedEventPayload = {\n    songCount: number;\n};\n\nexport type EventMap = {\n    AUTODJ_QUEUE_ADDED: AutoDJQueueAddedEventPayload;\n    ITEM_LIST_REFRESH: ItemListRefreshEventPayload;\n    ITEM_LIST_UPDATE_ITEM: ItemListUpdateItemEventPayload;\n    MEDIA_NEXT: MediaNextEventPayload;\n    MEDIA_PREV: MediaPrevEventPayload;\n    MPV_RELOAD: MpvReloadEventPayload;\n    PLAYER_PLAY: PlayerPlayEventPayload;\n    PLAYER_REPEATED: PlayerRepeatedEventPayload;\n    PLAYLIST_MOVE_DOWN: PlaylistMoveEventPayload;\n    PLAYLIST_MOVE_TO_BOTTOM: PlaylistMoveEventPayload;\n    PLAYLIST_MOVE_TO_TOP: PlaylistMoveEventPayload;\n    PLAYLIST_MOVE_UP: PlaylistMoveEventPayload;\n    PLAYLIST_REORDER: PlaylistReorderEventPayload;\n    QUEUE_RESTORED: QueueRestoredEventPayload;\n    USER_FAVORITE: UserFavoriteEventPayload;\n    USER_RATING: UserRatingEventPayload;\n};\n\nexport type ItemListRefreshEventPayload = {\n    key: string;\n};\n\nexport type ItemListUpdateItemEventPayload = {\n    index: number;\n    item: unknown;\n    key: string;\n};\n\nexport type MediaNextEventPayload = {\n    currentIndex: number;\n    nextIndex: number;\n};\n\nexport type MediaPrevEventPayload = {\n    currentIndex: number;\n    prevIndex: number;\n};\n\nexport type MpvReloadEventPayload = Record<string, never>;\n\nexport type PlayerPlayEventPayload = {\n    id: string;\n    index: number;\n};\n\nexport type PlayerRepeatedEventPayload = {\n    index: number;\n};\n\nexport type PlaylistMoveEventPayload = {\n    playlistId: string;\n    sourceIds: string[];\n};\n\nexport type PlaylistReorderEventPayload = {\n    edge: 'bottom' | 'top' | null;\n    playlistId: string;\n    sourceIds: string[];\n    targetId: string;\n};\n\nexport type QueueRestoredEventPayload = {\n    data: Song[];\n    index: number;\n    position: number;\n};\n\nexport type UserFavoriteEventPayload = {\n    favorite: boolean;\n    id: string[];\n    itemType: LibraryItem;\n    serverId: string;\n};\n\nexport type UserRatingEventPayload = {\n    id: string[];\n    itemType: LibraryItem;\n    rating: null | number;\n    serverId: string;\n};\n"
  },
  {
    "path": "src/renderer/events/types.ts",
    "content": "export type ErrorHandler = (error: Error, event: string, payload: any) => void;\n\nexport type EventCallback<T = any> = (payload: T) => void;\n\nexport interface TypedEventEmitter<T extends Record<string, any>> {\n    emit<K extends keyof T>(event: K, payload: T[K]): void;\n\n    off<K extends keyof T>(event: K, callback: EventCallback<T[K]>): void;\n\n    on<K extends keyof T>(event: K, callback: EventCallback<T[K]>): void;\n\n    removeAllListeners<K extends keyof T>(event?: K): void;\n\n    setErrorHandler(handler: ErrorHandler): void;\n}\n"
  },
  {
    "path": "src/renderer/features/action-required/components/action-required-container.tsx",
    "content": "import { ReactNode } from 'react';\n\nimport { Group } from '/@/shared/components/group/group';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { Text } from '/@/shared/components/text/text';\n\ninterface ActionRequiredContainerProps {\n    children: ReactNode;\n    title: string;\n}\n\nexport const ActionRequiredContainer = ({ children, title }: ActionRequiredContainerProps) => (\n    <Stack style={{ cursor: 'default', maxWidth: '700px' }}>\n        <Group>\n            <Text size=\"xl\" style={{ textTransform: 'uppercase' }}>\n                {title}\n            </Text>\n        </Group>\n        <Stack>{children}</Stack>\n    </Stack>\n);\n"
  },
  {
    "path": "src/renderer/features/action-required/components/error-fallback.module.css",
    "content": ".container {\n    background: var(--theme-colors-background);\n}\n"
  },
  {
    "path": "src/renderer/features/action-required/components/error-fallback.tsx",
    "content": "import type { FallbackProps } from 'react-error-boundary';\n\nimport { useTranslation } from 'react-i18next';\nimport { useRouteError } from 'react-router';\n\nimport styles from './error-fallback.module.css';\n\nimport { Button } from '/@/shared/components/button/button';\nimport { Center } from '/@/shared/components/center/center';\nimport { Group } from '/@/shared/components/group/group';\nimport { Icon } from '/@/shared/components/icon/icon';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { Text } from '/@/shared/components/text/text';\n\nexport const ErrorFallback = ({ resetErrorBoundary }: FallbackProps) => {\n    const error = useRouteError() as any;\n    const { t } = useTranslation();\n\n    return (\n        <div className={styles.container}>\n            <Center style={{ height: '100vh' }}>\n                <Stack style={{ maxWidth: '50%' }}>\n                    <Group gap=\"xs\">\n                        <Icon fill=\"error\" icon=\"error\" size=\"lg\" />\n                        <Text size=\"lg\">{t('error.genericError')}</Text>\n                    </Group>\n                    <Text>{error?.message}</Text>\n                    <Button onClick={resetErrorBoundary} variant=\"filled\">\n                        {t('common.reload')}\n                    </Button>\n                </Stack>\n            </Center>\n        </div>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/action-required/components/server-credential-required.tsx",
    "content": "import { useCurrentServer } from '/@/renderer/store';\nimport { Text } from '/@/shared/components/text/text';\n\nexport const ServerCredentialRequired = () => {\n    const currentServer = useCurrentServer();\n\n    return (\n        <>\n            <Text>\n                The selected server &apos;{currentServer?.name}&apos; requires an additional login\n                to access.\n            </Text>\n            <Text>\n                Add your credentials in the &apos;manage servers&apos; menu or switch to a different\n                server.\n            </Text>\n        </>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/action-required/components/server-required.tsx",
    "content": "import { closeAllModals, openModal } from '@mantine/modals';\nimport isElectron from 'is-electron';\nimport { useTranslation } from 'react-i18next';\nimport { useNavigate } from 'react-router';\n\nimport { isServerLock } from '/@/renderer/features/action-required/utils/window-properties';\nimport JellyfinLogo from '/@/renderer/features/servers/assets/jellyfin.png';\nimport NavidromeLogo from '/@/renderer/features/servers/assets/navidrome.png';\nimport OpenSubsonicLogo from '/@/renderer/features/servers/assets/opensubsonic.png';\nimport { AddServerForm } from '/@/renderer/features/servers/components/add-server-form';\nimport { EditServerForm } from '/@/renderer/features/servers/components/edit-server-form';\nimport { AppRoute } from '/@/renderer/router/routes';\nimport { useAuthStoreActions, useCurrentServer, useServerList } from '/@/renderer/store';\nimport { Button } from '/@/shared/components/button/button';\nimport { Divider } from '/@/shared/components/divider/divider';\nimport { Group } from '/@/shared/components/group/group';\nimport { Icon } from '/@/shared/components/icon/icon';\nimport { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { Text } from '/@/shared/components/text/text';\nimport {\n    ServerListItem,\n    ServerListItemWithCredential,\n    ServerType,\n} from '/@/shared/types/domain-types';\n\nconst localSettings = isElectron() ? window.api.localSettings : null;\n\nexport const ServerRequired = () => {\n    const serverList = useServerList();\n\n    if (Object.keys(serverList).length > 0) {\n        return (\n            <ScrollArea>\n                <Stack miw=\"300px\">\n                    <ServerSelector />\n                    {!isServerLock() && (\n                        <>\n                            <Divider my=\"lg\" />\n                            <AddServerForm onCancel={null} />\n                        </>\n                    )}\n                </Stack>\n            </ScrollArea>\n        );\n    }\n\n    return <AddServerForm onCancel={null} />;\n};\n\nfunction ServerSelector() {\n    const { t } = useTranslation();\n    const navigate = useNavigate();\n    const serverList = useServerList();\n    const currentServer = useCurrentServer();\n    const { setCurrentServer } = useAuthStoreActions();\n\n    const handleSetCurrentServer = (server: ServerListItemWithCredential) => {\n        navigate(AppRoute.HOME);\n        setCurrentServer(server);\n    };\n\n    const handleCredentialsModal = async (server: ServerListItem) => {\n        let password: null | string = null;\n\n        try {\n            if (localSettings && server.savePassword) {\n                password = await localSettings.passwordGet(server.id);\n            }\n        } catch (error) {\n            console.error(error);\n        }\n        openModal({\n            children: server && (\n                <EditServerForm\n                    isUpdate\n                    onCancel={closeAllModals}\n                    password={password}\n                    server={server}\n                />\n            ),\n            size: 'sm',\n            title: t('form.updateServer.title', { postProcess: 'titleCase' }),\n        });\n    };\n\n    return (\n        <>\n            {Object.keys(serverList).map((serverId) => {\n                const server = serverList[serverId];\n                const isNavidromeExpired =\n                    server.type === ServerType.NAVIDROME && !server.ndCredential;\n                const isJellyfinExpired = server.type === ServerType.JELLYFIN && !server.credential;\n                const isSessionExpired = isNavidromeExpired || isJellyfinExpired;\n\n                const logo =\n                    server.type === ServerType.NAVIDROME\n                        ? NavidromeLogo\n                        : server.type === ServerType.JELLYFIN\n                          ? JellyfinLogo\n                          : OpenSubsonicLogo;\n\n                return (\n                    <Button\n                        key={`server-${server.id}`}\n                        onClick={() => {\n                            if (!isSessionExpired) return handleSetCurrentServer(server);\n                            return handleCredentialsModal(server);\n                        }}\n                        size=\"lg\"\n                        styles={{\n                            label: {\n                                width: '100%',\n                            },\n                            root: {\n                                padding: 'var(--theme-spacing-sm)',\n                            },\n                        }}\n                        variant={server.id === currentServer?.id ? 'filled' : 'default'}\n                    >\n                        <Group justify=\"space-between\" w=\"100%\">\n                            <Group>\n                                <img\n                                    src={logo}\n                                    style={{\n                                        height: 'var(--theme-font-size-2xl)',\n                                        width: 'var(--theme-font-size-2xl)',\n                                    }}\n                                />\n                                <Text fw={600} size=\"lg\">\n                                    {server.name}\n                                </Text>\n                            </Group>\n                            {isSessionExpired ? <Icon icon=\"lock\" /> : <Icon icon=\"arrowRight\" />}\n                        </Group>\n                    </Button>\n                );\n            })}\n        </>\n    );\n}\n"
  },
  {
    "path": "src/renderer/features/action-required/routes/action-required-route.tsx",
    "content": "import { openModal } from '@mantine/modals';\nimport { useTranslation } from 'react-i18next';\nimport { Navigate } from 'react-router';\n\nimport { PageHeader } from '/@/renderer/components/page-header/page-header';\nimport { ActionRequiredContainer } from '/@/renderer/features/action-required/components/action-required-container';\nimport { ServerCredentialRequired } from '/@/renderer/features/action-required/components/server-credential-required';\nimport { ServerRequired } from '/@/renderer/features/action-required/components/server-required';\nimport { isServerLock } from '/@/renderer/features/action-required/utils/window-properties';\nimport LoginRoute from '/@/renderer/features/login/routes/login-route';\nimport { ServerList } from '/@/renderer/features/servers/components/server-list';\nimport { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';\nimport { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';\nimport { AppRoute } from '/@/renderer/router/routes';\nimport { useCurrentServerWithCredential } from '/@/renderer/store';\nimport { Button } from '/@/shared/components/button/button';\nimport { Center } from '/@/shared/components/center/center';\nimport { Group } from '/@/shared/components/group/group';\nimport { Icon } from '/@/shared/components/icon/icon';\nimport { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';\nimport { Stack } from '/@/shared/components/stack/stack';\n\nconst ActionRequiredRoute = () => {\n    const { t } = useTranslation();\n    const currentServer = useCurrentServerWithCredential();\n    const isServerRequired = !currentServer;\n    const isCredentialRequired = currentServer && !currentServer.credential;\n\n    const isLoginRequired = isServerLock() && !currentServer;\n\n    const checks = [\n        {\n            component: <ServerCredentialRequired />,\n            title: t('error.credentialsRequired', { postProcess: 'sentenceCase' }),\n            valid: !isCredentialRequired,\n        },\n        {\n            component: <ServerRequired />,\n            title: t('error.serverRequired', { postProcess: 'serverRequired' }),\n            valid: !isServerRequired,\n        },\n    ];\n\n    const canReturnHome = checks.every((c) => c.valid);\n    const displayedCheck = checks.find((c) => !c.valid);\n\n    const handleManageServersModal = () => {\n        openModal({\n            children: <ServerList />,\n            title: t('page.appMenu.manageServers', { postProcess: 'sentenceCase' }),\n        });\n    };\n\n    if (isLoginRequired) {\n        return <LoginRoute />;\n    }\n\n    return (\n        <AnimatedPage>\n            <PageHeader />\n            <Center style={{ height: '100%', width: '100vw' }}>\n                <Stack gap=\"xl\" style={{ maxWidth: '50%' }}>\n                    <ScrollArea style={{ maxHeight: 'calc(100vh - 50px)' }}>\n                        <Group wrap=\"nowrap\">\n                            {displayedCheck && (\n                                <ActionRequiredContainer title={displayedCheck.title}>\n                                    {displayedCheck?.component}\n                                </ActionRequiredContainer>\n                            )}\n                        </Group>\n                        <Stack mt=\"2rem\">\n                            {canReturnHome && <Navigate to={AppRoute.HOME} />}\n                            {/* This should be displayed if a credential is required */}\n                            {isCredentialRequired && !isServerLock && (\n                                <Group justify=\"center\" wrap=\"nowrap\">\n                                    <Button\n                                        fullWidth\n                                        leftSection={<Icon icon=\"edit\" />}\n                                        onClick={handleManageServersModal}\n                                        variant=\"filled\"\n                                    >\n                                        {t('page.appMenu.manageServers', {\n                                            postProcess: 'sentenceCase',\n                                        })}\n                                    </Button>\n                                </Group>\n                            )}\n                        </Stack>\n                    </ScrollArea>\n                </Stack>\n            </Center>\n        </AnimatedPage>\n    );\n};\n\nconst ActionRequiredRouteWithBoundary = () => {\n    return (\n        <PageErrorBoundary>\n            <ActionRequiredRoute />\n        </PageErrorBoundary>\n    );\n};\n\nexport default ActionRequiredRouteWithBoundary;\n"
  },
  {
    "path": "src/renderer/features/action-required/routes/invalid-route.tsx",
    "content": "import { useTranslation } from 'react-i18next';\nimport { useLocation, useNavigate } from 'react-router';\n\nimport { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';\nimport { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';\nimport { ActionIcon } from '/@/shared/components/action-icon/action-icon';\nimport { Center } from '/@/shared/components/center/center';\nimport { Group } from '/@/shared/components/group/group';\nimport { Icon } from '/@/shared/components/icon/icon';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { Text } from '/@/shared/components/text/text';\n\nconst InvalidRoute = () => {\n    const { t } = useTranslation();\n    const navigate = useNavigate();\n    const location = useLocation();\n\n    return (\n        <AnimatedPage>\n            <Center style={{ height: '100%', width: '100%' }}>\n                <Stack>\n                    <Group justify=\"center\" wrap=\"nowrap\">\n                        <Icon color=\"warn\" icon=\"error\" />\n                        <Text size=\"xl\">\n                            {t('error.apiRouteError', { postProcess: 'sentenceCase' })}\n                        </Text>\n                    </Group>\n                    <Text>{location.pathname}</Text>\n                    <ActionIcon icon=\"arrowLeftS\" onClick={() => navigate(-1)} variant=\"filled\" />\n                </Stack>\n            </Center>\n        </AnimatedPage>\n    );\n};\n\nconst InvalidRouteWithBoundary = () => {\n    return (\n        <PageErrorBoundary>\n            <InvalidRoute />\n        </PageErrorBoundary>\n    );\n};\n\nexport default InvalidRouteWithBoundary;\n"
  },
  {
    "path": "src/renderer/features/action-required/routes/no-network-route.tsx",
    "content": "import { useTranslation } from 'react-i18next';\nimport { useNavigate } from 'react-router';\n\nimport { PageHeader } from '/@/renderer/components/page-header/page-header';\nimport { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';\nimport { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';\nimport { AppRoute } from '/@/renderer/router/routes';\nimport { Button } from '/@/shared/components/button/button';\nimport { Center } from '/@/shared/components/center/center';\nimport { Icon } from '/@/shared/components/icon/icon';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { Text } from '/@/shared/components/text/text';\n\nconst NoNetworkRoute = () => {\n    const { t } = useTranslation();\n    const navigate = useNavigate();\n\n    const handleRetry = () => {\n        // Navigate to home which will trigger authentication again\n        navigate(AppRoute.HOME);\n    };\n\n    return (\n        <AnimatedPage>\n            <PageHeader />\n            <Center style={{ height: '100%' }}>\n                <Stack align=\"center\" gap=\"xl\" style={{ maxWidth: '50%', textAlign: 'center' }}>\n                    <Icon icon=\"wifiOff\" size=\"4rem\" />\n                    <Stack gap=\"md\">\n                        <Text size=\"xl\" weight={600}>\n                            {t('error.noNetwork', { postProcess: 'sentenceCase' })}\n                        </Text>\n                        <Text c=\"dimmed\" size=\"sm\">\n                            {t('error.noNetworkDescription', {\n                                postProcess: 'sentenceCase',\n                            })}\n                        </Text>\n                    </Stack>\n                    <Button\n                        leftSection={<Icon icon=\"refresh\" />}\n                        onClick={handleRetry}\n                        variant=\"filled\"\n                    >\n                        {t('common.retry', { postProcess: 'sentenceCase' })}\n                    </Button>\n                </Stack>\n            </Center>\n        </AnimatedPage>\n    );\n};\n\nconst NoNetworkRouteWithBoundary = () => {\n    return (\n        <PageErrorBoundary>\n            <NoNetworkRoute />\n        </PageErrorBoundary>\n    );\n};\n\nexport default NoNetworkRouteWithBoundary;\n"
  },
  {
    "path": "src/renderer/features/action-required/utils/window-properties.tsx",
    "content": "export const isLegacyAuth = () =>\n    window.LEGACY_AUTHENTICATION === true || window.LEGACY_AUTHENTICATION === 'true';\n\nexport const isServerLock = () => window.SERVER_LOCK === true || window.SERVER_LOCK === 'true';\n"
  },
  {
    "path": "src/renderer/features/albums/api/album-api.ts",
    "content": "import { queryOptions } from '@tanstack/react-query';\n\nimport { api } from '/@/renderer/api';\nimport { controller } from '/@/renderer/api/controller';\nimport { queryKeys } from '/@/renderer/api/query-keys';\nimport { getOptimizedListCount } from '/@/renderer/api/utils-list-count';\nimport { QueryHookArgs } from '/@/renderer/lib/react-query';\nimport { AlbumDetailQuery, AlbumListQuery, ListCountQuery } from '/@/shared/types/domain-types';\n\nexport const albumQueries = {\n    detail: (args: QueryHookArgs<AlbumDetailQuery>) => {\n        return queryOptions({\n            queryFn: ({ signal }) => {\n                return api.controller.getAlbumDetail({\n                    apiClientProps: { serverId: args.serverId, signal },\n                    query: args.query,\n                });\n            },\n            queryKey: queryKeys.albums.detail(args.serverId, args.query),\n            ...args.options,\n        });\n    },\n    list: (args: QueryHookArgs<AlbumListQuery>) => {\n        return queryOptions({\n            queryFn: ({ signal }) => {\n                return api.controller.getAlbumList({\n                    apiClientProps: { serverId: args.serverId, signal },\n                    query: args.query,\n                });\n            },\n            queryKey: queryKeys.albums.list(\n                args.serverId,\n                args.query,\n                args.query?.artistIds?.length === 1 ? args.query?.artistIds[0] : undefined,\n            ),\n            ...args.options,\n        });\n    },\n    listCount: (args: QueryHookArgs<ListCountQuery<AlbumListQuery>>) => {\n        return queryOptions({\n            gcTime: 1000 * 60 * 60,\n            queryFn: async ({ client, signal }) => {\n                const optimizedCount = await getOptimizedListCount<\n                    ListCountQuery<AlbumListQuery>,\n                    AlbumListQuery,\n                    { totalRecordCount: null | number }\n                >({\n                    client,\n                    listQueryFn: controller.getAlbumList,\n                    listQueryKeyFn: (serverId, query) => queryKeys.albums.list(serverId, query),\n                    query: args.query,\n                    serverId: args.serverId,\n                    signal,\n                });\n\n                if (optimizedCount !== null) {\n                    return optimizedCount;\n                }\n\n                return api.controller.getAlbumListCount({\n                    apiClientProps: { serverId: args.serverId, signal },\n                    query: args.query,\n                });\n            },\n            queryKey: queryKeys.albums.count(\n                args.serverId,\n                args.query,\n                args.query?.artistIds?.length === 1 ? args.query?.artistIds[0] : undefined,\n            ),\n            staleTime: 1000 * 60 * 60,\n            ...args.options,\n        });\n    },\n};\n"
  },
  {
    "path": "src/renderer/features/albums/components/album-detail-content.module.css",
    "content": ".content-container {\n    position: relative;\n    z-index: 0;\n    container-type: inline-size;\n}\n\n.detail-container {\n    display: flex;\n    flex-direction: column;\n    gap: var(--theme-spacing-lg);\n    padding: 1rem 2rem 5rem;\n}\n\n.content-layout {\n    display: grid;\n    grid-template-areas:\n        'metadata'\n        'songs';\n    grid-template-rows: auto auto;\n    grid-template-columns: 1fr;\n    gap: var(--theme-spacing-lg);\n    align-items: start;\n    width: 100%;\n    min-width: 0;\n}\n\n.metadata-column {\n    display: flex;\n    flex-direction: column;\n    flex-wrap: wrap;\n    grid-area: metadata;\n    gap: var(--theme-spacing-xl);\n    align-items: center;\n    text-align: center;\n}\n\n.songs-column {\n    display: flex;\n    flex-direction: column;\n    grid-area: songs;\n    min-width: 0;\n    overflow-x: hidden;\n}\n\n.search-text-input {\n    background: transparent;\n    border-color: rgb(255 255 255 / 5%);\n    border-style: solid;\n    border-width: 1px;\n\n    @mixin light {\n        border-color: rgb(0 0 0 / 5%);\n    }\n\n    @mixin dark {\n        border-color: rgb(255 255 255 / 5%);\n    }\n}\n\n.external-links-group {\n    justify-content: center;\n}\n\n.metadata-pill-group {\n    align-items: center;\n}\n\n.pill-group-wrapper {\n    display: flex;\n    width: 100%;\n\n    & > div {\n        justify-content: center;\n    }\n}\n\n@container (min-width: $mantine-breakpoint-sm) {\n    .metadata-column {\n        flex-direction: row;\n        justify-content: flex-start;\n        text-align: left;\n    }\n\n    .external-links-group {\n        justify-content: flex-start;\n    }\n\n    .metadata-pill-group {\n        align-items: flex-start;\n    }\n\n    .pill-group-wrapper {\n        justify-content: flex-start;\n\n        & > div {\n            justify-content: flex-start;\n        }\n    }\n}\n\n@container (min-width: $mantine-breakpoint-lg) {\n    .content-layout {\n        grid-template-areas: 'songs metadata';\n        grid-template-rows: 1fr;\n        grid-template-columns: minmax(0, 1fr) 300px;\n        gap: var(--theme-spacing-xl);\n        width: 100%;\n        max-width: 100%;\n    }\n\n    /* Prevent sticky headers from extending into the right margins */\n    .songs-column :global(.fs-item-table-list-module-sticky-header),\n    .songs-column :global(.fs-item-table-list-module-sticky-group-row) {\n        padding-right: 0;\n        margin-right: 0;\n    }\n\n    .metadata-column {\n        position: sticky;\n        top: calc(var(--theme-spacing-lg) + 4rem);\n        align-self: start;\n        width: 300px;\n        max-height: calc(100vh - 90px - var(--theme-spacing-lg) - 4rem);\n    }\n}\n"
  },
  {
    "path": "src/renderer/features/albums/components/album-detail-content.tsx",
    "content": "import type {\n    ItemListStateActions,\n    ItemListStateItemWithRequiredProperties,\n} from '/@/renderer/components/item-list/helpers/item-list-state';\n\nimport { useSuspenseQuery } from '@tanstack/react-query';\nimport { ReactNode, useMemo, useRef, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { generatePath, useParams } from 'react-router';\n\nimport styles from './album-detail-content.module.css';\n\nimport { useGridCarouselContainerQuery } from '/@/renderer/components/grid-carousel/grid-carousel-v2';\nimport { useItemListStateSubscription } from '/@/renderer/components/item-list/helpers/item-list-state';\nimport { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder';\nimport { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize';\nimport { SONG_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns';\nimport { ItemTableList } from '/@/renderer/components/item-list/item-table-list/item-table-list';\nimport { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';\nimport { ItemControls } from '/@/renderer/components/item-list/types';\nimport { albumQueries } from '/@/renderer/features/albums/api/album-api';\nimport { AlbumInfiniteCarousel } from '/@/renderer/features/albums/components/album-infinite-carousel';\nimport { usePlayer } from '/@/renderer/features/player/context/player-context';\nimport {\n    ListConfigMenu,\n    SONG_DISPLAY_TYPES,\n} from '/@/renderer/features/shared/components/list-config-menu';\nimport {\n    CLIENT_SIDE_SONG_FILTERS,\n    ListSortByDropdownControlled,\n} from '/@/renderer/features/shared/components/list-sort-by-dropdown';\nimport { ListSortOrderToggleButtonControlled } from '/@/renderer/features/shared/components/list-sort-order-toggle-button';\nimport { FILTER_KEYS, searchLibraryItems } from '/@/renderer/features/shared/utils';\nimport { AppRoute } from '/@/renderer/router/routes';\nimport { useCurrentServer, usePlayerSong } from '/@/renderer/store';\nimport { useExternalLinks, useSettingsStore } from '/@/renderer/store/settings.store';\nimport { sentenceCase, titleCase } from '/@/renderer/utils';\nimport { replaceURLWithHTMLLinks } from '/@/renderer/utils/linkify';\nimport { normalizeReleaseTypes } from '/@/renderer/utils/normalize-release-types';\nimport { setJsonSearchParam } from '/@/renderer/utils/query-params';\nimport { sortSongList } from '/@/shared/api/utils';\nimport { ActionIcon } from '/@/shared/components/action-icon/action-icon';\nimport { Checkbox } from '/@/shared/components/checkbox/checkbox';\nimport { Group } from '/@/shared/components/group/group';\nimport { Icon } from '/@/shared/components/icon/icon';\nimport { Pill, PillLink } from '/@/shared/components/pill/pill';\nimport { Spoiler } from '/@/shared/components/spoiler/spoiler';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { TextInput } from '/@/shared/components/text-input/text-input';\nimport { Text } from '/@/shared/components/text/text';\nimport { useDebouncedValue } from '/@/shared/hooks/use-debounced-value';\nimport { useHotkeys } from '/@/shared/hooks/use-hotkeys';\nimport {\n    Album,\n    AlbumListSort,\n    ExplicitStatus,\n    LibraryItem,\n    ServerType,\n    Song,\n    SongListSort,\n    SortOrder,\n} from '/@/shared/types/domain-types';\nimport { ItemListKey, ListDisplayType, Play } from '/@/shared/types/types';\n\nconst MetadataPillGroup = ({\n    items,\n    title,\n}: {\n    items: undefined | { id: string; value: ReactNode | string | undefined }[];\n    title: string;\n}) => {\n    if (!items || items.length === 0) return null;\n\n    return (\n        <Stack align=\"center\" className={styles.metadataPillGroup} gap=\"xs\">\n            <Text fw={600} isNoSelect size=\"sm\" tt=\"uppercase\">\n                {title}\n            </Text>\n            <div className={styles['pill-group-wrapper']}>\n                <Pill.Group>\n                    {items.map((tag, index) => (\n                        <Pill key={`item-${tag.id}-${index}`} size=\"md\">\n                            {tag.value}\n                        </Pill>\n                    ))}\n                </Pill.Group>\n            </div>\n        </Stack>\n    );\n};\n\ninterface AlbumMetadataTagsProps {\n    album: Album | undefined;\n}\n\nconst MOOD_TAG = 'mood';\nconst RELEASE_COUNTRY_TAG = 'releasecountry';\nconst RELEASE_STATUS_TAG = 'releasestatus';\n\nconst AlbumMetadataTags = ({ album }: AlbumMetadataTagsProps) => {\n    const { t } = useTranslation();\n\n    const defaultTagItems = useMemo(() => {\n        if (!album) return [];\n\n        const releaseTypes = normalizeReleaseTypes(album.releaseTypes ?? [], t).map((type) => ({\n            id: type,\n            value: titleCase(type),\n        }));\n\n        const releaseCountries =\n            album.tags?.[RELEASE_COUNTRY_TAG]?.map((country) => ({\n                id: country,\n                value: country,\n            })) || [];\n\n        const releaseStatuses =\n            album.tags?.[RELEASE_STATUS_TAG]?.map((status) => ({\n                id: status,\n                value: status,\n            })) || [];\n\n        const items: Array<{ id: string; value: ReactNode | string | undefined }> = [];\n\n        items.push(\n            ...releaseTypes,\n            {\n                id: 'isCompilation',\n                value: album?.isCompilation\n                    ? t('filter.isCompilation', { postProcess: 'sentenceCase' })\n                    : undefined,\n            },\n            ...releaseCountries,\n            ...releaseStatuses,\n            {\n                id: 'explicitStatus',\n                value:\n                    album.explicitStatus === ExplicitStatus.EXPLICIT\n                        ? t('common.explicit', { postProcess: 'sentenceCase' })\n                        : album.explicitStatus === ExplicitStatus.CLEAN\n                          ? t('common.clean', { postProcess: 'sentenceCase' })\n                          : undefined,\n            },\n        );\n\n        return items.filter((item) => item.value);\n    }, [album, t]);\n\n    const moodTagItems = useMemo(() => {\n        if (!album) return [];\n\n        return album.tags?.[MOOD_TAG]?.map((tag) => ({\n            id: tag,\n            value: tag,\n        }));\n    }, [album]);\n\n    const recordLabels = useMemo(() => {\n        if (!album?.recordLabels || album.recordLabels.length === 0) return [];\n\n        return album.recordLabels.map((label) => {\n            if (album._serverType === ServerType.SUBSONIC) {\n                return { id: label, label: label, url: null };\n            }\n\n            const searchParams = new URLSearchParams();\n            const customFilters =\n                album._serverType === ServerType.JELLYFIN\n                    ? { Studios: [label] }\n                    : { recordlabel: [label] };\n            const paramsWithCustom = setJsonSearchParam(\n                searchParams,\n                FILTER_KEYS.ALBUM._CUSTOM,\n                customFilters,\n            );\n            const url = `${AppRoute.LIBRARY_ALBUMS}?${paramsWithCustom.toString()}`;\n\n            return {\n                id: label,\n                label,\n                url,\n            };\n        });\n    }, [album]);\n\n    return (\n        <>\n            <MetadataPillGroup\n                items={defaultTagItems}\n                title={t('common.tags', { postProcess: 'sentenceCase' })}\n            />\n\n            {recordLabels.length > 0 && (\n                <Stack align=\"center\" className={styles.metadataPillGroup} gap=\"xs\">\n                    <Text fw={600} isNoSelect size=\"sm\" tt=\"uppercase\">\n                        {t('common.recordLabel', { postProcess: 'sentenceCase' })}\n                    </Text>\n                    <div className={styles['pill-group-wrapper']}>\n                        <Pill.Group>\n                            {recordLabels.map((recordLabel) =>\n                                recordLabel.url ? (\n                                    <PillLink\n                                        key={`recordlabel-${recordLabel.id}`}\n                                        size=\"md\"\n                                        to={recordLabel.url}\n                                    >\n                                        {recordLabel.label}\n                                    </PillLink>\n                                ) : (\n                                    <Pill key={`recordlabel-${recordLabel.id}`} size=\"md\">\n                                        {recordLabel.label}\n                                    </Pill>\n                                ),\n                            )}\n                        </Pill.Group>\n                    </div>\n                </Stack>\n            )}\n\n            <MetadataPillGroup\n                items={moodTagItems}\n                title={t('common.mood', { postProcess: 'sentenceCase' })}\n            />\n        </>\n    );\n};\n\ninterface AlbumMetadataGenresProps {\n    genres?: Array<{ id: string; name: string }>;\n}\n\nconst AlbumMetadataGenres = ({ genres }: AlbumMetadataGenresProps) => {\n    const { t } = useTranslation();\n\n    if (!genres || genres.length === 0) return null;\n\n    return (\n        <Stack gap=\"xs\">\n            <Text fw={600} isNoSelect size=\"sm\" tt=\"uppercase\">\n                {t('entity.genre', {\n                    count: genres.length,\n                })}\n            </Text>\n            <Pill.Group>\n                {genres.map((genre) => (\n                    <PillLink\n                        key={`genre-${genre.id}`}\n                        size=\"md\"\n                        to={generatePath(AppRoute.LIBRARY_GENRES_DETAIL, {\n                            genreId: genre.id,\n                        })}\n                    >\n                        {genre.name}\n                    </PillLink>\n                ))}\n            </Pill.Group>\n        </Stack>\n    );\n};\n\n// interface AlbumMetadataArtistsProps {\n//     artists?: Array<{ id: string; name: string }>;\n// }\n\n// const AlbumMetadataArtists = ({ artists }: AlbumMetadataArtistsProps) => {\n//     const { t } = useTranslation();\n\n//     if (!artists || artists.length === 0) return null;\n\n//     return (\n//         <Stack gap=\"xs\">\n//             <Text fw={600} isNoSelect size=\"sm\" tt=\"uppercase\">\n//                 {t('entity.albumArtist', {\n//                     count: artists.length,\n//                 })}\n//             </Text>\n//             <Pill.Group>\n//                 {artists.map((artist) => (\n//                     <PillLink\n//                         key={`artist-${artist.id}`}\n//                         size=\"md\"\n//                         to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {\n//                             albumArtistId: artist.id,\n//                         })}\n//                     >\n//                         {artist.name}\n//                     </PillLink>\n//                 ))}\n//             </Pill.Group>\n//         </Stack>\n//     );\n// };\n\ninterface AlbumMetadataExternalLinksProps {\n    albumArtist?: string;\n    albumName?: string;\n    externalLinks: boolean;\n    lastFM: boolean;\n    listenBrainz: boolean;\n    mbzId?: null | string;\n    mbzReleaseGroupId?: null | string;\n    musicBrainz: boolean;\n    nativeSpotify: boolean;\n    qobuz: boolean;\n    spotify: boolean;\n}\n\nconst getListenBrainzUrl = (\n    mbzReleaseGroupId: null | string,\n    albumArtist?: string,\n    albumName?: string,\n) => {\n    if (mbzReleaseGroupId) {\n        return `https://listenbrainz.org/album/${mbzReleaseGroupId}`;\n    }\n\n    if (albumArtist || albumName) {\n        return `https://listenbrainz.org/search/?search_term=${encodeURIComponent([albumArtist, albumName].filter(Boolean).join(' ').trim())}`;\n    }\n\n    return null;\n};\n\nconst getQobuzUrl = (albumArtist?: string, albumName?: string) => {\n    if (albumArtist || albumName) {\n        return `https://www.qobuz.com/us-en/search/albums/${encodeURIComponent([albumArtist, albumName].filter(Boolean).join(' ').trim())}`;\n    }\n\n    return null;\n};\n\nconst AlbumMetadataExternalLinks = ({\n    albumArtist,\n    albumName,\n    externalLinks,\n    lastFM,\n    listenBrainz,\n    mbzId,\n    mbzReleaseGroupId,\n    musicBrainz,\n    nativeSpotify,\n    qobuz,\n    spotify,\n}: AlbumMetadataExternalLinksProps) => {\n    const { t } = useTranslation();\n\n    const listenBrainzUrl = getListenBrainzUrl(mbzReleaseGroupId || null, albumArtist, albumName);\n    const qobuzUrl = getQobuzUrl(albumArtist, albumName);\n\n    if (!externalLinks || (!lastFM && !listenBrainz && !musicBrainz && !qobuz && !spotify)) {\n        return null;\n    }\n\n    return (\n        <Stack gap=\"xs\">\n            <Text fw={600} isNoSelect size=\"sm\" tt=\"uppercase\">\n                {t('common.externalLinks', {\n                    postProcess: 'sentenceCase',\n                })}\n            </Text>\n            <Group className={styles.externalLinksGroup} gap=\"xs\">\n                {lastFM && (\n                    <ActionIcon\n                        component=\"a\"\n                        href={`https://www.last.fm/music/${encodeURIComponent(\n                            albumArtist || '',\n                        )}/${encodeURIComponent(albumName || '')}`}\n                        icon=\"brandLastfm\"\n                        iconProps={{\n                            size: '2xl',\n                        }}\n                        radius=\"md\"\n                        rel=\"noopener noreferrer\"\n                        target=\"_blank\"\n                        tooltip={{\n                            label: t('action.openIn.lastfm'),\n                        }}\n                        variant=\"subtle\"\n                    />\n                )}\n                {mbzId && musicBrainz ? (\n                    <ActionIcon\n                        component=\"a\"\n                        href={`https://musicbrainz.org/release/${mbzId}`}\n                        icon=\"brandMusicBrainz\"\n                        iconProps={{\n                            size: '2xl',\n                        }}\n                        radius=\"md\"\n                        rel=\"noopener noreferrer\"\n                        target=\"_blank\"\n                        tooltip={{\n                            label: t('action.openIn.musicbrainz'),\n                        }}\n                        variant=\"subtle\"\n                    />\n                ) : null}\n                {listenBrainz && listenBrainzUrl && (\n                    <ActionIcon\n                        component=\"a\"\n                        href={listenBrainzUrl}\n                        icon=\"brandListenBrainz\"\n                        iconProps={{\n                            size: '2xl',\n                        }}\n                        radius=\"md\"\n                        rel=\"noopener noreferrer\"\n                        target=\"_blank\"\n                        tooltip={{\n                            label: t('action.openIn.listenbrainz'),\n                        }}\n                        variant=\"subtle\"\n                    />\n                )}\n                {qobuz && qobuzUrl && (\n                    <ActionIcon\n                        component=\"a\"\n                        href={qobuzUrl}\n                        icon=\"brandQobuz\"\n                        iconProps={{\n                            size: '2xl',\n                        }}\n                        radius=\"md\"\n                        rel=\"noopener noreferrer\"\n                        target=\"_blank\"\n                        tooltip={{\n                            label: t('action.openIn.qobuz'),\n                        }}\n                        variant=\"subtle\"\n                    />\n                )}\n                {spotify && (\n                    <ActionIcon\n                        component=\"a\"\n                        href={\n                            nativeSpotify\n                                ? `spotify:search:${encodeURIComponent(albumArtist || '')}%20${encodeURIComponent(albumName || '')}`\n                                : `https://open.spotify.com/search/${encodeURIComponent(albumArtist || '')}%20${encodeURIComponent(albumName || '')}`\n                        }\n                        icon=\"brandSpotify\"\n                        iconProps={{\n                            size: '2xl',\n                        }}\n                        radius=\"md\"\n                        rel=\"noopener noreferrer\"\n                        target={nativeSpotify ? undefined : '_blank'}\n                        tooltip={{\n                            label: t('action.openIn.spotify'),\n                        }}\n                        variant=\"subtle\"\n                    />\n                )}\n            </Group>\n        </Stack>\n    );\n};\n\nexport const AlbumDetailContent = () => {\n    const { albumId } = useParams() as { albumId: string };\n    const server = useCurrentServer();\n    const detailQuery = useSuspenseQuery(\n        albumQueries.detail({ query: { id: albumId }, serverId: server.id }),\n    );\n\n    const { externalLinks, lastFM, listenBrainz, musicBrainz, nativeSpotify, qobuz, spotify } =\n        useExternalLinks();\n\n    const comment = detailQuery?.data?.comment;\n\n    const releaseYear = detailQuery?.data?.releaseYear;\n    const labels = detailQuery?.data?.recordLabels;\n\n    const mbzId = detailQuery?.data?.mbzId;\n\n    return (\n        <div className={styles.contentContainer}>\n            <div className={styles.detailContainer}>\n                {comment && (\n                    <Spoiler maxHeight={75}>\n                        <Text pb=\"md\">{replaceURLWithHTMLLinks(comment)}</Text>\n                    </Spoiler>\n                )}\n                <div className={styles.contentLayout}>\n                    <div className={styles.songsColumn}>\n                        {detailQuery?.data?.songs && detailQuery.data.songs.length > 0 && (\n                            <AlbumDetailSongsTable songs={detailQuery.data.songs} />\n                        )}\n                    </div>\n                    <div className={styles.metadataColumn}>\n                        <AlbumMetadataGenres genres={detailQuery?.data?.genres} />\n                        <AlbumMetadataTags album={detailQuery?.data} />\n                        <AlbumMetadataExternalLinks\n                            albumArtist={detailQuery?.data?.albumArtistName}\n                            albumName={detailQuery?.data?.name}\n                            externalLinks={externalLinks}\n                            lastFM={lastFM}\n                            listenBrainz={listenBrainz}\n                            mbzId={mbzId || undefined}\n                            mbzReleaseGroupId={detailQuery?.data?.mbzReleaseGroupId}\n                            musicBrainz={musicBrainz}\n                            nativeSpotify={nativeSpotify}\n                            qobuz={qobuz}\n                            spotify={spotify}\n                        />\n                    </div>\n                </div>\n                {labels && (\n                    <Stack gap=\"xs\">\n                        {labels.map((label) => (\n                            <Text isMuted key={`label-${label}`} size=\"sm\">\n                                ℗{releaseYear ? ` ${releaseYear}` : ''} {label}\n                            </Text>\n                        ))}\n                    </Stack>\n                )}\n                <AlbumDetailCarousels data={detailQuery?.data} />\n            </div>\n        </div>\n    );\n};\n\ninterface AlbumDetailSongsTableProps {\n    songs: Song[];\n}\n\ninterface DiscGroupRowProps {\n    discGroup: {\n        discNumber: number;\n        discSubtitle: null | string;\n    };\n    groupItems: unknown[];\n    internalState: ItemListStateActions;\n    t: (key: string, options?: any) => string;\n}\n\nconst DiscGroupRow = ({ discGroup, groupItems, internalState, t }: DiscGroupRowProps) => {\n    const selectionVersion = useItemListStateSubscription(internalState, (state) =>\n        state ? state.version : 0,\n    );\n\n    const selectedCount = groupItems.filter((item) => {\n        if (!item || typeof item !== 'object' || !('id' in item)) return false;\n        const rowId = internalState.extractRowId(item);\n        return rowId ? internalState.isSelected(rowId) : false;\n    }).length;\n\n    const isAllSelected = selectedCount === groupItems.length;\n\n    void selectionVersion;\n\n    const handleCheckboxChange = () => {\n        const selectableItems = groupItems.filter(\n            (item): item is ItemListStateItemWithRequiredProperties =>\n                item !== null && typeof item === 'object',\n        );\n\n        if (isAllSelected) {\n            // Deselect all items in the group\n            const currentlySelected =\n                internalState.getSelected() as ItemListStateItemWithRequiredProperties[];\n            const groupItemIds = new Set(\n                selectableItems.map((item) => internalState.extractRowId(item)).filter(Boolean),\n            );\n            const itemsToKeep = currentlySelected.filter(\n                (item) => !groupItemIds.has(internalState.extractRowId(item) || ''),\n            );\n            internalState.setSelected(itemsToKeep);\n        } else {\n            // Select all items in the group (add to existing selection)\n            const currentlySelected =\n                internalState.getSelected() as ItemListStateItemWithRequiredProperties[];\n            const selectedIds = new Set(\n                currentlySelected.map((item) => internalState.extractRowId(item)).filter(Boolean),\n            );\n            const itemsToAdd = selectableItems.filter(\n                (item) => !selectedIds.has(internalState.extractRowId(item) || ''),\n            );\n            internalState.setSelected([...currentlySelected, ...itemsToAdd]);\n        }\n    };\n\n    return (\n        <Group align=\"center\" h=\"100%\" px=\"md\" w=\"100%\">\n            <Checkbox\n                checked={isAllSelected}\n                id={`disc-${discGroup.discNumber}`}\n                label={\n                    <Text component=\"label\" size=\"sm\" truncate>\n                        {t('common.disc', { postProcess: 'sentenceCase' })} {discGroup.discNumber}\n                        {discGroup.discSubtitle && ` - ${discGroup.discSubtitle}`}\n                    </Text>\n                }\n                onChange={handleCheckboxChange}\n                size=\"xs\"\n            />\n        </Group>\n    );\n};\n\nfunction AlbumDetailCarousels({ data }: { data: Album }) {\n    const { t } = useTranslation();\n\n    const genreCarousels = useMemo(() => {\n        const genreLimit = 2;\n        const selectedGenres = data?.genres?.slice(0, genreLimit);\n\n        if (!selectedGenres || selectedGenres.length === 0) return [];\n\n        return selectedGenres\n            .map((genre) => {\n                const uniqueId = `moreFromGenre-${genre.id}`;\n                return {\n                    enableRefresh: true,\n                    excludeIds: data?.id ? [data.id] : undefined,\n                    isHidden: !genre,\n                    query: {\n                        genreIds: [genre.id],\n                    },\n                    rowCount: 1,\n                    sortBy: AlbumListSort.RANDOM,\n                    sortOrder: SortOrder.ASC,\n                    title: sentenceCase(\n                        t('page.albumDetail.moreFromGeneric', {\n                            item: genre.name,\n                        }),\n                    ),\n                    uniqueId,\n                };\n            })\n            .filter((carousel) => !carousel.isHidden);\n    }, [data, t]);\n\n    const carousels = useMemo(() => {\n        const moreFromArtistUniqueId = 'moreFromArtist';\n        return [\n            {\n                enableRefresh: false,\n                excludeIds: data?.id ? [data.id] : undefined,\n                isHidden: !data?.albumArtists?.[0]?.id,\n                query: {\n                    artistIds: data?.albumArtists.length ? [data?.albumArtists[0].id] : undefined,\n                },\n                rowCount: 1,\n                sortBy: AlbumListSort.YEAR,\n                sortOrder: SortOrder.DESC,\n                title: t('page.albumDetail.moreFromArtist', { postProcess: 'sentenceCase' }),\n                uniqueId: moreFromArtistUniqueId,\n            },\n            ...genreCarousels,\n        ];\n    }, [data.albumArtists, data.id, genreCarousels, t]);\n\n    const cq = useGridCarouselContainerQuery();\n\n    return (\n        <Stack gap=\"lg\" mt=\"3rem\" ref={cq.ref}>\n            {carousels\n                .filter((c) => !c.isHidden)\n                .map((carousel) => (\n                    <AlbumInfiniteCarousel\n                        containerQuery={cq}\n                        enableRefresh={carousel.enableRefresh}\n                        excludeIds={carousel.excludeIds}\n                        key={`carousel-${carousel.uniqueId}`}\n                        query={carousel.query}\n                        rowCount={carousel.rowCount}\n                        sortBy={carousel.sortBy}\n                        sortOrder={carousel.sortOrder}\n                        title={carousel.title}\n                    />\n                ))}\n        </Stack>\n    );\n}\n\nconst AlbumDetailSongsTable = ({ songs }: AlbumDetailSongsTableProps) => {\n    const { t } = useTranslation();\n    const [searchTerm, setSearchTerm] = useState('');\n    const [debouncedSearchTerm] = useDebouncedValue(searchTerm, 300);\n    const tableConfig = useSettingsStore((state) => state.lists[ItemListKey.ALBUM_DETAIL]?.table);\n\n    const currentSong = usePlayerSong();\n\n    const [sortBy, setSortBy] = useState<SongListSort>(SongListSort.ID);\n    const [sortOrder, setSortOrder] = useState<SortOrder>(SortOrder.ASC);\n\n    const columns = useMemo(() => {\n        return tableConfig?.columns || [];\n    }, [tableConfig?.columns]);\n\n    const filteredSongs = useMemo(() => {\n        return sortSongList(\n            searchLibraryItems(songs, debouncedSearchTerm, LibraryItem.SONG),\n            sortBy,\n            sortOrder,\n        );\n    }, [songs, debouncedSearchTerm, sortBy, sortOrder]);\n\n    const { handleColumnReordered } = useItemListColumnReorder({\n        itemListKey: ItemListKey.ALBUM_DETAIL,\n    });\n\n    const { handleColumnResized } = useItemListColumnResize({\n        itemListKey: ItemListKey.ALBUM_DETAIL,\n    });\n\n    const discGroups = useMemo(() => {\n        if (filteredSongs.length === 0) return [];\n\n        const groups: Array<{\n            discNumber: number;\n            discSubtitle: null | string;\n            itemCount: number;\n        }> = [];\n        let lastDiscNumber = -1;\n        let currentGroupStartIndex = 0;\n\n        filteredSongs.forEach((song, index) => {\n            if (song.discNumber !== lastDiscNumber) {\n                // If we have a previous group, calculate its item count\n                if (groups.length > 0) {\n                    groups[groups.length - 1].itemCount = index - currentGroupStartIndex;\n                }\n                // Start a new group\n                groups.push({\n                    discNumber: song.discNumber,\n                    discSubtitle: song.discSubtitle,\n                    itemCount: 0, // Will be calculated when we encounter the next group or end\n                });\n                currentGroupStartIndex = index;\n                lastDiscNumber = song.discNumber;\n            }\n        });\n\n        // Set item count for the last group\n        if (groups.length > 0) {\n            groups[groups.length - 1].itemCount = filteredSongs.length - currentGroupStartIndex;\n        }\n\n        return groups;\n    }, [filteredSongs]);\n\n    const groups = useMemo(() => {\n        // Remove groups when filtering\n        if (debouncedSearchTerm?.trim()) {\n            return undefined;\n        }\n\n        // Remove groups when sorting\n        if (sortBy !== SongListSort.ID) {\n            return undefined;\n        }\n\n        if (discGroups.length <= 1) {\n            return undefined;\n        }\n\n        return discGroups.map((discGroup) => ({\n            itemCount: discGroup.itemCount,\n            render: ({\n                data,\n                internalState,\n                startDataIndex,\n            }: {\n                data: unknown[];\n                groupIndex: number;\n                index: number;\n                internalState: ItemListStateActions;\n                startDataIndex: number;\n            }) => {\n                const groupItems = data.slice(startDataIndex, startDataIndex + discGroup.itemCount);\n\n                return (\n                    <DiscGroupRow\n                        discGroup={discGroup}\n                        groupItems={groupItems}\n                        internalState={internalState}\n                        t={t}\n                    />\n                );\n            },\n            rowHeight: 40,\n        }));\n    }, [debouncedSearchTerm, sortBy, discGroups, t]);\n\n    const player = usePlayer();\n\n    const overrideControls: Partial<ItemControls> = useMemo(() => {\n        return {\n            onDoubleClick: ({ index, internalState, item, meta }) => {\n                if (!item) {\n                    return;\n                }\n\n                const playType = (meta?.playType as Play) || Play.NOW;\n\n                const items = internalState?.getData() as Song[];\n\n                if (index !== undefined) {\n                    player.addToQueueByData(items, playType, item.id);\n                }\n            },\n        };\n    }, [player]);\n\n    const binding = useSettingsStore((state) => state.hotkeys.bindings.localSearch);\n\n    const searchInputRef = useRef<HTMLInputElement>(null);\n\n    useHotkeys([\n        [\n            binding.hotkey,\n            () => {\n                searchInputRef.current?.focus();\n            },\n        ],\n    ]);\n\n    if (!tableConfig || columns.length === 0) {\n        return null;\n    }\n\n    const currentSongId = currentSong?.id;\n\n    return (\n        <Stack gap=\"md\">\n            <Group gap=\"sm\" w=\"100%\">\n                <TextInput\n                    classNames={{ input: styles.searchTextInput }}\n                    flex={1}\n                    leftSection={<Icon icon=\"search\" />}\n                    onChange={(e) => setSearchTerm(e.target.value)}\n                    placeholder={t('common.search', { postProcess: 'sentenceCase' })}\n                    radius=\"xl\"\n                    ref={searchInputRef}\n                    rightSection={\n                        searchTerm ? (\n                            <ActionIcon\n                                icon=\"x\"\n                                onClick={() => setSearchTerm('')}\n                                size=\"sm\"\n                                variant=\"transparent\"\n                            />\n                        ) : null\n                    }\n                    value={searchTerm}\n                />\n                <ListSortByDropdownControlled\n                    filters={CLIENT_SIDE_SONG_FILTERS}\n                    itemType={LibraryItem.SONG}\n                    setSortBy={(value) => setSortBy(value as SongListSort)}\n                    sortBy={sortBy}\n                />\n                <ListSortOrderToggleButtonControlled\n                    setSortOrder={(value) => setSortOrder(value as SortOrder)}\n                    sortOrder={sortOrder}\n                />\n                <ListConfigMenu\n                    displayTypes={[\n                        { hidden: true, value: ListDisplayType.GRID },\n                        ...SONG_DISPLAY_TYPES,\n                    ]}\n                    listKey={ItemListKey.ALBUM_DETAIL}\n                    optionsConfig={{\n                        table: {\n                            itemsPerPage: { hidden: true },\n                            pagination: { hidden: true },\n                        },\n                    }}\n                    tableColumnsData={SONG_TABLE_COLUMNS}\n                />\n            </Group>\n            <ItemTableList\n                activeRowId={currentSongId}\n                autoFitColumns={tableConfig.autoFitColumns}\n                CellComponent={ItemTableListColumn}\n                columns={columns}\n                data={filteredSongs}\n                enableAlternateRowColors={tableConfig.enableAlternateRowColors}\n                enableDrag\n                enableDragScroll={false}\n                enableEntranceAnimation={false}\n                enableExpansion={false}\n                enableHeader={tableConfig.enableHeader}\n                enableHorizontalBorders={tableConfig.enableHorizontalBorders}\n                enableRowHoverHighlight={tableConfig.enableRowHoverHighlight}\n                enableSelection\n                enableSelectionDialog={false}\n                enableStickyGroupRows\n                enableStickyHeader\n                enableVerticalBorders={tableConfig.enableVerticalBorders}\n                groups={groups}\n                itemType={LibraryItem.SONG}\n                onColumnReordered={handleColumnReordered}\n                onColumnResized={handleColumnResized}\n                overrideControls={overrideControls}\n                size={tableConfig.size}\n            />\n        </Stack>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/albums/components/album-detail-header.module.css",
    "content": ".metadata-group {\n    justify-content: center;\n    width: 100%;\n\n    @container (min-width: $mantine-breakpoint-sm) {\n        justify-content: flex-start;\n    }\n}\n"
  },
  {
    "path": "src/renderer/features/albums/components/album-detail-header.tsx",
    "content": "import { useQuery, useQueryClient } from '@tanstack/react-query';\nimport { forwardRef, Fragment, useMemo } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { Link, useParams } from 'react-router';\n\nimport styles from './album-detail-header.module.css';\n\nimport { queryKeys } from '/@/renderer/api/query-keys';\nimport { albumQueries } from '/@/renderer/features/albums/api/album-api';\nimport { JoinedArtists } from '/@/renderer/features/albums/components/joined-artists';\nimport { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';\nimport { usePlayer } from '/@/renderer/features/player/context/player-context';\nimport {\n    LibraryHeader,\n    LibraryHeaderMenu,\n} from '/@/renderer/features/shared/components/library-header';\nimport { useSetFavorite } from '/@/renderer/features/shared/hooks/use-set-favorite';\nimport { useSetRating } from '/@/renderer/features/shared/hooks/use-set-rating';\nimport { songsQueries } from '/@/renderer/features/songs/api/songs-api';\nimport { AppRoute } from '/@/renderer/router/routes';\nimport { useCurrentServer, useShowRatings } from '/@/renderer/store';\nimport { useArtistRadioCount, usePlayButtonBehavior } from '/@/renderer/store/settings.store';\nimport { formatDateAbsoluteUTC, formatDurationString, formatSizeString } from '/@/renderer/utils';\nimport { normalizeReleaseTypes } from '/@/renderer/utils/normalize-release-types';\nimport { Group } from '/@/shared/components/group/group';\nimport { Separator } from '/@/shared/components/separator/separator';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { Text } from '/@/shared/components/text/text';\nimport { LibraryItem, ServerType } from '/@/shared/types/domain-types';\nimport { Play } from '/@/shared/types/types';\n\nexport const AlbumDetailHeader = forwardRef<HTMLDivElement>((_props, ref) => {\n    const { albumId } = useParams() as { albumId: string };\n    const { t } = useTranslation();\n    const server = useCurrentServer();\n    const showRatings = useShowRatings();\n    const queryClient = useQueryClient();\n    const albumRadioCount = useArtistRadioCount();\n    const detailQuery = useQuery(\n        albumQueries.detail({ query: { id: albumId }, serverId: server?.id }),\n    );\n\n    const showRating =\n        showRatings &&\n        (detailQuery?.data?._serverType === ServerType.NAVIDROME ||\n            detailQuery?.data?._serverType === ServerType.SUBSONIC);\n\n    const { addToQueueByData, addToQueueByFetch } = usePlayer();\n    const playButtonBehavior = usePlayButtonBehavior();\n\n    const setRating = useSetRating();\n    const setFavorite = useSetFavorite();\n\n    const handleFavorite = () => {\n        if (!detailQuery?.data) return;\n        setFavorite(\n            detailQuery.data._serverId,\n            [detailQuery.data.id],\n            LibraryItem.ALBUM,\n            !detailQuery.data.userFavorite,\n        );\n    };\n\n    const handleUpdateRating = showRating\n        ? (rating: number) => {\n              if (!detailQuery?.data) return;\n\n              if (detailQuery.data.userRating === rating) {\n                  return setRating(\n                      detailQuery.data._serverId,\n                      [detailQuery.data.id],\n                      LibraryItem.ALBUM,\n                      0,\n                  );\n              }\n\n              return setRating(\n                  detailQuery.data._serverId,\n                  [detailQuery.data.id],\n                  LibraryItem.ALBUM,\n                  rating,\n              );\n          }\n        : undefined;\n\n    const handlePlay = (type?: Play) => {\n        if (!server?.id || !albumId) return;\n        addToQueueByFetch(server.id, [albumId], LibraryItem.ALBUM, type || playButtonBehavior);\n    };\n\n    const handleMoreOptions = (e: React.MouseEvent<HTMLButtonElement>) => {\n        if (!detailQuery?.data) return;\n        ContextMenuController.call({\n            cmd: { items: [detailQuery.data], type: LibraryItem.ALBUM },\n            event: e,\n        });\n    };\n\n    const handleAlbumRadio = async () => {\n        if (!server?.id || !albumId) return;\n\n        try {\n            const albumRadioSongs = await queryClient.fetchQuery({\n                ...songsQueries.albumRadio({\n                    query: {\n                        albumId: albumId,\n                        count: albumRadioCount,\n                    },\n                    serverId: server.id,\n                }),\n                queryKey: queryKeys.player.fetch({ albumId: albumId }),\n            });\n            if (albumRadioSongs && albumRadioSongs.length > 0) {\n                addToQueueByData(albumRadioSongs, Play.NOW);\n            }\n        } catch (error) {\n            console.error('Failed to load album radio:', error);\n        }\n    };\n\n    const releaseYear = detailQuery?.data?.releaseYear;\n    const releaseDate = detailQuery?.data?.releaseDate;\n\n    const metadataItems = useMemo(() => {\n        const items: Array<{ id: string; value: React.ReactNode | string | undefined }> = [];\n\n        const album = detailQuery?.data;\n\n        if (!album) return [];\n\n        const originalDifferentFromRelease =\n            album?.originalDate && album?.originalDate !== album?.releaseDate;\n\n        const originalYearDifferentFromRelease = album?.originalYear !== album?.releaseYear;\n\n        const playCount = album?.playCount;\n\n        const releasePrefix = originalDifferentFromRelease\n            ? t('page.albumDetail.released', { postProcess: 'sentenceCase' })\n            : '♫';\n\n        const releaseYearPrefix = originalYearDifferentFromRelease\n            ? t('page.albumDetail.released', { postProcess: 'sentenceCase' })\n            : '♫';\n\n        if (album.originalDate) {\n            if (originalDifferentFromRelease) {\n                items.push({\n                    id: 'originalDate',\n                    value: `♫ ${formatDateAbsoluteUTC(album.originalDate)}`,\n                });\n            }\n\n            if (releaseDate) {\n                items.push({\n                    id: 'releaseDate',\n                    value: `${releasePrefix} ${formatDateAbsoluteUTC(releaseDate)}`,\n                });\n            }\n        } else if (album.originalYear) {\n            if (originalYearDifferentFromRelease) {\n                items.push({\n                    id: 'originalYear',\n                    value: `♫ ${album.originalYear}`,\n                });\n            }\n\n            if (releaseDate) {\n                items.push({\n                    id: 'releaseDate',\n                    value: `${releaseYearPrefix} ${formatDateAbsoluteUTC(releaseDate)}`,\n                });\n            } else if (releaseYear) {\n                items.push({\n                    id: 'releaseYear',\n                    value: `${releaseYearPrefix} ${releaseYear}`,\n                });\n            }\n        }\n\n        items.push(\n            ...[\n                {\n                    id: 'songCount',\n                    value: t('entity.trackWithCount', { count: detailQuery?.data?.songCount || 0 }),\n                },\n                {\n                    id: 'duration',\n                    value: formatDurationString(detailQuery?.data?.duration || 0),\n                },\n                {\n                    id: 'explicitStatus',\n                    value: detailQuery?.data?.explicitStatus,\n                },\n                {\n                    id: 'size',\n                    value: detailQuery?.data?.size\n                        ? formatSizeString(detailQuery?.data?.size)\n                        : undefined,\n                },\n                {\n                    id: 'playCount',\n                    value: playCount ? t('entity.play', { count: playCount }) : undefined,\n                },\n            ],\n        );\n\n        return items.filter((item) => !!item.value);\n    }, [detailQuery?.data, releaseDate, releaseYear, t]);\n\n    const headerItem = useMemo(() => {\n        const album = detailQuery?.data;\n\n        if (!album) return null;\n\n        const releaseTypes = album.releaseType\n            ? normalizeReleaseTypes([album.releaseType], t)\n            : null;\n\n        const releaseTypeText = releaseTypes?.length ? releaseTypes[0] : null;\n\n        if (releaseTypeText) {\n            return (\n                <Group gap=\"sm\">\n                    <Text\n                        component={Link}\n                        fw={600}\n                        isLink\n                        size=\"md\"\n                        to={AppRoute.LIBRARY_ALBUMS}\n                        tt=\"uppercase\"\n                    >\n                        {releaseTypeText}\n                    </Text>\n                    {album.version && (\n                        <>\n                            <Text fw={600} isMuted>\n                                <Separator />\n                            </Text>\n                            <Text>{album.version}</Text>\n                        </>\n                    )}\n                </Group>\n            );\n        }\n\n        return null;\n    }, [detailQuery?.data, t]);\n\n    return (\n        <Stack ref={ref}>\n            <LibraryHeader\n                item={{\n                    children: headerItem,\n                    explicitStatus: detailQuery?.data?.explicitStatus ?? null,\n                    imageId: detailQuery?.data?.imageId,\n                    imageUrl: detailQuery?.data?.imageUrl,\n                    route: AppRoute.LIBRARY_ALBUMS,\n                    type: LibraryItem.ALBUM,\n                }}\n                title={detailQuery?.data?.name || ''}\n            >\n                <Stack gap=\"md\" w=\"100%\">\n                    <Group className={styles.metadataGroup} gap=\"xs\">\n                        {metadataItems.map((item, index) => (\n                            <Fragment key={item.id}>\n                                {index > 0 && (\n                                    <Text isMuted isNoSelect>\n                                        <Separator />\n                                    </Text>\n                                )}\n                                <Text fw={400}>{item.value}</Text>\n                            </Fragment>\n                        ))}\n                    </Group>\n                    <Group className={styles.metadataGroup}>\n                        <JoinedArtists\n                            artistName={detailQuery?.data?.albumArtistName || ''}\n                            artists={detailQuery?.data?.albumArtists || []}\n                        />\n                    </Group>\n                    <LibraryHeaderMenu\n                        favorite={detailQuery?.data?.userFavorite}\n                        onAlbumRadio={handleAlbumRadio}\n                        onFavorite={handleFavorite}\n                        onMore={handleMoreOptions}\n                        onPlay={(type) => handlePlay(type)}\n                        onRating={handleUpdateRating}\n                        onShuffle={() => handlePlay(Play.SHUFFLE)}\n                        rating={detailQuery?.data?.userRating || 0}\n                    />\n                </Stack>\n            </LibraryHeader>\n        </Stack>\n    );\n});\n"
  },
  {
    "path": "src/renderer/features/albums/components/album-grid-carousel.tsx",
    "content": "import { useMemo } from 'react';\n\nimport { GridCarousel } from '/@/renderer/components/grid-carousel/grid-carousel-v2';\nimport { MemoizedItemCard } from '/@/renderer/components/item-card/item-card';\nimport { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls';\nimport { useGridRows } from '/@/renderer/components/item-list/helpers/use-grid-rows';\nimport { Album, LibraryItem } from '/@/shared/types/domain-types';\nimport { ItemListKey } from '/@/shared/types/types';\n\ninterface AlbumGridCarouselProps {\n    data: Album[];\n    excludeIds?: string[];\n    rowCount?: number;\n    title: React.ReactNode | string;\n}\n\nexport function AlbumGridCarousel(props: AlbumGridCarouselProps) {\n    const { data, excludeIds, rowCount = 1, title } = props;\n    const rows = useGridRows(LibraryItem.ALBUM, ItemListKey.ALBUM);\n    const controls = useDefaultItemListControls();\n\n    const cards = useMemo(() => {\n        const filteredItems = excludeIds\n            ? data.filter((album) => !excludeIds.includes(album.id))\n            : data;\n\n        return filteredItems.map((album: Album) => ({\n            content: (\n                <MemoizedItemCard\n                    controls={controls}\n                    data={album}\n                    enableDrag\n                    enableExpansion\n                    imageFetchPriority=\"low\"\n                    itemType={LibraryItem.ALBUM}\n                    rows={rows}\n                    type=\"poster\"\n                    withControls\n                />\n            ),\n            id: album.id,\n        }));\n    }, [data, excludeIds, controls, rows]);\n\n    const handleNextPage = () => {};\n    const handlePrevPage = () => {};\n\n    if (cards.length === 0) {\n        return null;\n    }\n\n    return (\n        <GridCarousel\n            cards={cards}\n            onNextPage={handleNextPage}\n            onPrevPage={handlePrevPage}\n            rowCount={rowCount}\n            title={title}\n        />\n    );\n}\n"
  },
  {
    "path": "src/renderer/features/albums/components/album-infinite-carousel.tsx",
    "content": "import { QueryFunctionContext, useSuspenseInfiniteQuery } from '@tanstack/react-query';\nimport { Suspense, useCallback, useMemo } from 'react';\n\nimport { api } from '/@/renderer/api';\nimport { queryKeys } from '/@/renderer/api/query-keys';\nimport {\n    GridCarousel,\n    GridCarouselSkeletonFallback,\n    useGridCarouselContainerQuery,\n} from '/@/renderer/components/grid-carousel/grid-carousel-v2';\nimport { DataRow, MemoizedItemCard } from '/@/renderer/components/item-card/item-card';\nimport { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls';\nimport { useGridRows } from '/@/renderer/components/item-list/helpers/use-grid-rows';\nimport { useCurrentServerId } from '/@/renderer/store';\nimport {\n    Album,\n    AlbumListQuery,\n    AlbumListResponse,\n    AlbumListSort,\n    LibraryItem,\n    SortOrder,\n} from '/@/shared/types/domain-types';\nimport { ItemListKey } from '/@/shared/types/types';\n\ninterface AlbumCarouselProps {\n    containerQuery?: ReturnType<typeof useGridCarouselContainerQuery>;\n    enableRefresh?: boolean;\n    excludeIds?: string[];\n    query?: Partial<Omit<AlbumListQuery, 'startIndex'>>;\n    queryKey?: QueryFunctionContext['queryKey'];\n    rowCount?: number;\n    sortBy: AlbumListSort;\n    sortOrder: SortOrder;\n    title: React.ReactNode | string;\n}\n\nconst BaseAlbumInfiniteCarousel = (props: AlbumCarouselProps & { rows: DataRow[] }) => {\n    const {\n        containerQuery,\n        enableRefresh,\n        excludeIds,\n        query: additionalQuery,\n        queryKey,\n        rowCount = 1,\n        rows,\n        sortBy,\n        sortOrder,\n        title,\n    } = props;\n    const {\n        data: albums,\n        fetchNextPage,\n        hasNextPage,\n        isFetchingNextPage,\n        refetch,\n    } = useAlbumListInfinite(sortBy, sortOrder, 20, additionalQuery, queryKey);\n\n    const controls = useDefaultItemListControls();\n\n    const cards = useMemo(() => {\n        const allItems = albums?.pages.flatMap((page: AlbumListResponse) => page.items) || [];\n        const filteredItems = excludeIds\n            ? allItems.filter((album) => !excludeIds.includes(album.id))\n            : allItems;\n\n        return filteredItems.map((album: Album) => ({\n            content: (\n                <MemoizedItemCard\n                    controls={controls}\n                    data={album}\n                    enableDrag\n                    enableExpansion\n                    imageFetchPriority=\"low\"\n                    itemType={LibraryItem.ALBUM}\n                    rows={rows}\n                    type=\"poster\"\n                    withControls\n                />\n            ),\n            id: album.id,\n        }));\n    }, [albums, controls, excludeIds, rows]);\n\n    const handleNextPage = useCallback(() => {}, []);\n\n    const handlePrevPage = useCallback(() => {}, []);\n\n    const handleRefresh = useCallback(() => {\n        refetch();\n    }, [refetch]);\n\n    const firstPageItems = excludeIds\n        ? albums?.pages[0]?.items.filter((album) => !excludeIds.includes(album.id)) || []\n        : albums?.pages[0]?.items || [];\n\n    if (firstPageItems.length === 0) {\n        return null;\n    }\n\n    return (\n        <GridCarousel\n            cards={cards}\n            containerQuery={containerQuery}\n            enableRefresh={enableRefresh}\n            hasNextPage={hasNextPage}\n            isFetchingNextPage={isFetchingNextPage}\n            loadNextPage={fetchNextPage}\n            onNextPage={handleNextPage}\n            onPrevPage={handlePrevPage}\n            onRefresh={handleRefresh}\n            placeholderItemType={LibraryItem.ALBUM}\n            placeholderRows={rows}\n            rowCount={rowCount}\n            title={title}\n        />\n    );\n};\n\nexport const AlbumInfiniteCarousel = (props: AlbumCarouselProps) => {\n    const rows = useGridRows(LibraryItem.ALBUM, ItemListKey.ALBUM);\n\n    return (\n        <Suspense\n            fallback={\n                <GridCarouselSkeletonFallback\n                    containerQuery={props.containerQuery}\n                    placeholderItemType={LibraryItem.ALBUM}\n                    placeholderRows={rows}\n                    title={props.title}\n                />\n            }\n        >\n            <BaseAlbumInfiniteCarousel {...props} rows={rows} />\n        </Suspense>\n    );\n};\n\nfunction useAlbumListInfinite(\n    sortBy: AlbumListSort,\n    sortOrder: SortOrder,\n    itemLimit: number,\n    additionalQuery?: Partial<Omit<AlbumListQuery, 'startIndex'>>,\n    overrideQueryKey?: QueryFunctionContext['queryKey'],\n) {\n    const serverId = useCurrentServerId();\n\n    const defaultQueryKey = queryKeys.albums.infiniteList(serverId, {\n        sortBy,\n        sortOrder,\n        ...additionalQuery,\n    });\n\n    const query = useSuspenseInfiniteQuery<AlbumListResponse>({\n        getNextPageParam: (lastPage, _allPages, lastPageParam) => {\n            if (lastPage.items.length < itemLimit) {\n                return undefined;\n            }\n\n            const nextPageParam = Number(lastPageParam) + itemLimit;\n\n            return String(nextPageParam);\n        },\n        initialPageParam: '0',\n        queryFn: ({ pageParam, signal }) => {\n            return api.controller.getAlbumList({\n                apiClientProps: { serverId, signal },\n                query: {\n                    limit: itemLimit,\n                    sortBy,\n                    sortOrder,\n                    startIndex: Number(pageParam),\n                    ...additionalQuery,\n                },\n            });\n        },\n        queryKey: overrideQueryKey || defaultQueryKey,\n    });\n\n    return query;\n}\n"
  },
  {
    "path": "src/renderer/features/albums/components/album-list-content.tsx",
    "content": "import { lazy, Suspense, useMemo } from 'react';\n\nimport { useListContext } from '/@/renderer/context/list-context';\nimport { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album-list-filters';\nimport { ListFilters, ListFiltersTitle } from '/@/renderer/features/shared/components/list-filters';\nimport { ListWithSidebarContainer } from '/@/renderer/features/shared/components/list-with-sidebar-container';\nimport { SaveAsCollectionButton } from '/@/renderer/features/shared/components/save-as-collection-button';\nimport { ItemListSettings, useCurrentServer, useListSettings } from '/@/renderer/store';\nimport { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';\nimport { Spinner } from '/@/shared/components/spinner/spinner';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { AlbumListQuery, LibraryItem } from '/@/shared/types/domain-types';\nimport { ItemListKey, ListDisplayType, ListPaginationType } from '/@/shared/types/types';\n\nconst AlbumListInfiniteGrid = lazy(() =>\n    import('/@/renderer/features/albums/components/album-list-infinite-grid').then((module) => ({\n        default: module.AlbumListInfiniteGrid,\n    })),\n);\n\nconst AlbumListPaginatedGrid = lazy(() =>\n    import('/@/renderer/features/albums/components/album-list-paginated-grid').then((module) => ({\n        default: module.AlbumListPaginatedGrid,\n    })),\n);\n\nconst AlbumListInfiniteTable = lazy(() =>\n    import('/@/renderer/features/albums/components/album-list-infinite-table').then((module) => ({\n        default: module.AlbumListInfiniteTable,\n    })),\n);\n\nconst AlbumListPaginatedTable = lazy(() =>\n    import('/@/renderer/features/albums/components/album-list-paginated-table').then((module) => ({\n        default: module.AlbumListPaginatedTable,\n    })),\n);\n\nconst AlbumListInfiniteDetail = lazy(() =>\n    import('/@/renderer/features/albums/components/album-list-infinite-detail').then((module) => ({\n        default: module.AlbumListInfiniteDetail,\n    })),\n);\n\nconst AlbumListPaginatedDetail = lazy(() =>\n    import('/@/renderer/features/albums/components/album-list-paginated-detail').then((module) => ({\n        default: module.AlbumListPaginatedDetail,\n    })),\n);\n\nconst AlbumListFilters = () => {\n    return (\n        <ListWithSidebarContainer.SidebarPortal>\n            <Stack h=\"100%\" style={{ minHeight: 0 }}>\n                <ListFiltersTitle itemType={LibraryItem.ALBUM} />\n                <ScrollArea style={{ flex: 1, minHeight: 0 }}>\n                    <ListFilters itemType={LibraryItem.ALBUM} />\n                </ScrollArea>\n                <Stack p=\"sm\">\n                    <SaveAsCollectionButton fullWidth itemType={LibraryItem.ALBUM} />\n                </Stack>\n            </Stack>\n        </ListWithSidebarContainer.SidebarPortal>\n    );\n};\n\nexport const AlbumListContent = () => {\n    return (\n        <>\n            <AlbumListFilters />\n            <AlbumListSuspenseContainer />\n        </>\n    );\n};\n\nconst AlbumListSuspenseContainer = () => {\n    const { detail, display, grid, itemsPerPage, pagination, table } = useListSettings(\n        ItemListKey.ALBUM,\n    );\n\n    const { customFilters } = useListContext();\n\n    return (\n        <Suspense fallback={<Spinner container />}>\n            <AlbumListView\n                detail={detail}\n                display={display}\n                grid={grid}\n                itemsPerPage={itemsPerPage}\n                overrideQuery={customFilters}\n                pagination={pagination}\n                table={table}\n            />\n        </Suspense>\n    );\n};\n\nexport type OverrideAlbumListQuery = Omit<Partial<AlbumListQuery>, 'limit' | 'startIndex'>;\n\nexport const AlbumListView = ({\n    detail,\n    display,\n    grid,\n    itemsPerPage,\n    overrideQuery,\n    pagination,\n    table,\n}: ItemListSettings & {\n    detail?: ItemListSettings['detail'];\n    overrideQuery?: OverrideAlbumListQuery;\n}) => {\n    const server = useCurrentServer();\n    const { pageKey } = useListContext();\n\n    const { query } = useAlbumListFilters(pageKey as ItemListKey);\n\n    const mergedQuery = useMemo(() => {\n        if (!overrideQuery) {\n            return query;\n        }\n\n        return {\n            ...query,\n            ...overrideQuery,\n            sortBy: overrideQuery.sortBy || query.sortBy,\n            sortOrder: overrideQuery.sortOrder || query.sortOrder,\n        };\n    }, [query, overrideQuery]);\n\n    switch (display) {\n        case ListDisplayType.GRID: {\n            switch (pagination) {\n                case ListPaginationType.INFINITE: {\n                    return (\n                        <AlbumListInfiniteGrid\n                            gap={grid.itemGap}\n                            itemsPerPage={itemsPerPage}\n                            itemsPerRow={grid.itemsPerRowEnabled ? grid.itemsPerRow : undefined}\n                            query={mergedQuery}\n                            serverId={server.id}\n                            size={grid.size}\n                        />\n                    );\n                }\n                case ListPaginationType.PAGINATED: {\n                    return (\n                        <AlbumListPaginatedGrid\n                            gap={grid.itemGap}\n                            itemsPerPage={itemsPerPage}\n                            itemsPerRow={grid.itemsPerRowEnabled ? grid.itemsPerRow : undefined}\n                            query={mergedQuery}\n                            serverId={server.id}\n                            size={grid.size}\n                        />\n                    );\n                }\n                default:\n                    return null;\n            }\n        }\n        case ListDisplayType.TABLE: {\n            switch (pagination) {\n                case ListPaginationType.INFINITE: {\n                    return (\n                        <AlbumListInfiniteTable\n                            autoFitColumns={table.autoFitColumns}\n                            columns={table.columns}\n                            enableAlternateRowColors={table.enableAlternateRowColors}\n                            enableHeader={table.enableHeader}\n                            enableHorizontalBorders={table.enableHorizontalBorders}\n                            enableRowHoverHighlight={table.enableRowHoverHighlight}\n                            enableVerticalBorders={table.enableVerticalBorders}\n                            itemsPerPage={itemsPerPage}\n                            query={mergedQuery}\n                            serverId={server.id}\n                            size={table.size}\n                        />\n                    );\n                }\n                case ListPaginationType.PAGINATED: {\n                    return (\n                        <AlbumListPaginatedTable\n                            autoFitColumns={table.autoFitColumns}\n                            columns={table.columns}\n                            enableAlternateRowColors={table.enableAlternateRowColors}\n                            enableHeader={table.enableHeader}\n                            enableHorizontalBorders={table.enableHorizontalBorders}\n                            enableRowHoverHighlight={table.enableRowHoverHighlight}\n                            enableVerticalBorders={table.enableVerticalBorders}\n                            itemsPerPage={itemsPerPage}\n                            query={mergedQuery}\n                            serverId={server.id}\n                            size={table.size}\n                        />\n                    );\n                }\n                default:\n                    return null;\n            }\n        }\n        case ListDisplayType.DETAIL: {\n            switch (pagination) {\n                case ListPaginationType.INFINITE: {\n                    return (\n                        <AlbumListInfiniteDetail\n                            enableHeader={detail?.enableHeader}\n                            itemsPerPage={itemsPerPage}\n                            query={mergedQuery}\n                            serverId={server.id}\n                        />\n                    );\n                }\n                case ListPaginationType.PAGINATED: {\n                    return (\n                        <AlbumListPaginatedDetail\n                            enableHeader={detail?.enableHeader}\n                            itemsPerPage={itemsPerPage}\n                            query={mergedQuery}\n                            serverId={server.id}\n                        />\n                    );\n                }\n                default:\n                    return null;\n            }\n        }\n    }\n\n    return null;\n};\n"
  },
  {
    "path": "src/renderer/features/albums/components/album-list-header-filters.tsx",
    "content": "import { useCallback, useMemo } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport {\n    ALBUM_TABLE_COLUMNS,\n    SONG_TABLE_COLUMNS,\n} from '/@/renderer/components/item-list/item-table-list/default-columns';\nimport { useListContext } from '/@/renderer/context/list-context';\nimport { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album-list-filters';\nimport { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';\nimport { ListDisplayTypeToggleButton } from '/@/renderer/features/shared/components/list-display-type-toggle-button';\nimport {\n    isFilterValueSet,\n    ListFiltersModal,\n} from '/@/renderer/features/shared/components/list-filters';\nimport { ListRefreshButton } from '/@/renderer/features/shared/components/list-refresh-button';\nimport { ListSortByDropdown } from '/@/renderer/features/shared/components/list-sort-by-dropdown';\nimport { ListSortOrderToggleButton } from '/@/renderer/features/shared/components/list-sort-order-toggle-button';\nimport { FILTER_KEYS } from '/@/renderer/features/shared/utils';\nimport { useSongListFilters } from '/@/renderer/features/songs/hooks/use-song-list-filters';\nimport { GenreTarget, useGenreTarget, useSettingsStoreActions } from '/@/renderer/store';\nimport { Button } from '/@/shared/components/button/button';\nimport { Divider } from '/@/shared/components/divider/divider';\nimport { Flex } from '/@/shared/components/flex/flex';\nimport { Group } from '/@/shared/components/group/group';\nimport { Icon } from '/@/shared/components/icon/icon';\nimport { AlbumListSort, LibraryItem, SortOrder } from '/@/shared/types/domain-types';\nimport { ItemListKey } from '/@/shared/types/types';\n\nexport const AlbumListHeaderFilters = ({ toggleGenreTarget }: { toggleGenreTarget?: boolean }) => {\n    const { t } = useTranslation();\n    const target = useGenreTarget();\n    const { setGenreBehavior } = useSettingsStoreActions();\n    const albumFilters = useAlbumListFilters();\n    const songFilters = useSongListFilters();\n\n    const { pageKey } = useListContext();\n\n    const choice = useMemo(() => {\n        return target === GenreTarget.ALBUM\n            ? t('entity.album', { count: 2, postProcess: 'titleCase' })\n            : t('entity.track', { count: 2, postProcess: 'titleCase' });\n    }, [target, t]);\n\n    const handleToggleGenreTarget = useCallback(() => {\n        albumFilters.clear();\n        songFilters.clear();\n\n        setGenreBehavior(target === GenreTarget.ALBUM ? GenreTarget.TRACK : GenreTarget.ALBUM);\n    }, [target, setGenreBehavior, albumFilters, songFilters]);\n\n    const hasActiveFilters = useMemo(() => {\n        const query = albumFilters.query;\n\n        return Boolean(\n            isFilterValueSet(query[FILTER_KEYS.ALBUM._CUSTOM]) ||\n                isFilterValueSet(query[FILTER_KEYS.ALBUM.ARTIST_IDS]) ||\n                query[FILTER_KEYS.ALBUM.COMPILATION] !== undefined ||\n                query[FILTER_KEYS.ALBUM.FAVORITE] !== undefined ||\n                isFilterValueSet(query[FILTER_KEYS.ALBUM.GENRE_ID]) ||\n                query[FILTER_KEYS.ALBUM.HAS_RATING] !== undefined ||\n                isFilterValueSet(query[FILTER_KEYS.ALBUM.MAX_YEAR]) ||\n                isFilterValueSet(query[FILTER_KEYS.ALBUM.MIN_YEAR]) ||\n                query[FILTER_KEYS.ALBUM.RECENTLY_PLAYED] !== undefined ||\n                isFilterValueSet(query[FILTER_KEYS.SHARED.SEARCH_TERM]),\n        );\n    }, [albumFilters.query]);\n\n    return (\n        <Flex justify=\"space-between\">\n            <Group gap=\"sm\" w=\"100%\">\n                {toggleGenreTarget && (\n                    <>\n                        <Button\n                            leftSection={<Icon icon=\"arrowLeftRight\" />}\n                            onClick={handleToggleGenreTarget}\n                            variant=\"subtle\"\n                        >\n                            {choice}\n                        </Button>\n                        <Divider orientation=\"vertical\" />\n                    </>\n                )}\n                <ListSortByDropdown\n                    defaultSortByValue={AlbumListSort.NAME}\n                    itemType={LibraryItem.ALBUM}\n                    listKey={pageKey as ItemListKey}\n                />\n                <Divider orientation=\"vertical\" />\n                <ListSortOrderToggleButton\n                    defaultSortOrder={SortOrder.ASC}\n                    listKey={pageKey as ItemListKey}\n                />\n                <ListFiltersModal isActive={hasActiveFilters} itemType={LibraryItem.ALBUM} />\n                <ListRefreshButton listKey={pageKey as ItemListKey} />\n            </Group>\n            <Group gap=\"sm\" wrap=\"nowrap\">\n                <ListDisplayTypeToggleButton enableDetail listKey={ItemListKey.ALBUM} />\n                <ListConfigMenu\n                    detailConfig={{\n                        optionsConfig: {\n                            autoFitColumns: { hidden: true },\n                        },\n                        tableColumnsData: SONG_TABLE_COLUMNS,\n                        tableKey: 'detail',\n                    }}\n                    listKey={ItemListKey.ALBUM}\n                    tableColumnsData={ALBUM_TABLE_COLUMNS}\n                />\n            </Group>\n        </Flex>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/albums/components/album-list-header.tsx",
    "content": "import { useSuspenseQuery } from '@tanstack/react-query';\nimport { Suspense, useMemo } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport { useIsFetchingItemListCount } from '/@/renderer/components/item-list/helpers/use-is-fetching-item-list';\nimport { PageHeader } from '/@/renderer/components/page-header/page-header';\nimport { useListContext } from '/@/renderer/context/list-context';\nimport { AlbumListHeaderFilters } from '/@/renderer/features/albums/components/album-list-header-filters';\nimport { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album-list-filters';\nimport { artistsQueries } from '/@/renderer/features/artists/api/artists-api';\nimport { useGenreList } from '/@/renderer/features/genres/api/genres-api';\nimport { FilterBar } from '/@/renderer/features/shared/components/filter-bar';\nimport { LibraryHeaderBar } from '/@/renderer/features/shared/components/library-header-bar';\nimport { ListSearchInput } from '/@/renderer/features/shared/components/list-search-input';\nimport { useCurrentServerId } from '/@/renderer/store';\nimport { Group } from '/@/shared/components/group/group';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { LibraryItem } from '/@/shared/types/domain-types';\nimport { ItemListKey } from '/@/shared/types/types';\n\ninterface AlbumListHeaderProps {\n    title?: string;\n}\n\nexport const AlbumListHeader = ({ title }: AlbumListHeaderProps) => {\n    return (\n        <Stack gap={0}>\n            <PageHeader>\n                <LibraryHeaderBar ignoreMaxWidth>\n                    <PlayButton />\n                    <PageTitle title={title} />\n                    <AlbumListHeaderBadge />\n                </LibraryHeaderBar>\n                <Group>\n                    <ListSearchInput />\n                </Group>\n            </PageHeader>\n            <FilterBar>\n                <AlbumListHeaderFilters />\n            </FilterBar>\n        </Stack>\n    );\n};\n\nconst AlbumListHeaderBadge = () => {\n    const { itemCount } = useListContext();\n\n    const isFetching = useIsFetchingItemListCount({\n        itemType: LibraryItem.ALBUM,\n    });\n\n    return <LibraryHeaderBar.Badge isLoading={isFetching}>{itemCount}</LibraryHeaderBar.Badge>;\n};\n\nconst PageTitle = ({ title }: { title?: string }) => {\n    const { t } = useTranslation();\n    const { pageKey } = useListContext();\n    const pageTitle = title || t('page.albumList.title', { postProcess: 'titleCase' });\n\n    switch (pageKey) {\n        case ItemListKey.ALBUM_ARTIST_ALBUM:\n            return (\n                <Suspense fallback={<LibraryHeaderBar.Title>—</LibraryHeaderBar.Title>}>\n                    <AlbumArtistTitle />\n                </Suspense>\n            );\n        case ItemListKey.GENRE_ALBUM:\n            return (\n                <Suspense fallback={<LibraryHeaderBar.Title>—</LibraryHeaderBar.Title>}>\n                    <GenreTitle />\n                </Suspense>\n            );\n    }\n\n    return <LibraryHeaderBar.Title>{pageTitle}</LibraryHeaderBar.Title>;\n};\n\nconst GenreTitle = () => {\n    const { id } = useListContext();\n    const { data: genres } = useGenreList();\n\n    const name = useMemo(() => {\n        return genres?.items.find((g) => g.id === id)?.name || '—';\n    }, [id, genres]);\n\n    return <LibraryHeaderBar.Title>{name}</LibraryHeaderBar.Title>;\n};\n\nconst AlbumArtistTitle = () => {\n    const serverId = useCurrentServerId();\n    const { id } = useListContext();\n\n    const { data: albumArtist } = useSuspenseQuery(\n        artistsQueries.albumArtistDetail({\n            query: { id: id! },\n            serverId: serverId,\n        }),\n    );\n\n    return <LibraryHeaderBar.Title>{albumArtist?.name || '—'}</LibraryHeaderBar.Title>;\n};\n\nconst PlayButton = () => {\n    const { query } = useAlbumListFilters();\n    const { customFilters } = useListContext();\n\n    const mergedQuery = useMemo(() => {\n        return {\n            ...query,\n            ...(customFilters ?? {}),\n        };\n    }, [query, customFilters]);\n\n    return (\n        <LibraryHeaderBar.PlayButton\n            itemType={LibraryItem.ALBUM}\n            listQuery={mergedQuery}\n            variant=\"filled\"\n        />\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/albums/components/album-list-infinite-detail.tsx",
    "content": "import { UseSuspenseQueryOptions } from '@tanstack/react-query';\n\nimport { api } from '/@/renderer/api';\nimport { useItemListInfiniteLoader } from '/@/renderer/components/item-list/helpers/item-list-infinite-loader';\nimport { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder';\nimport { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize';\nimport { ItemDetailList } from '/@/renderer/components/item-list/item-detail-list/item-detail-list';\nimport { ItemListComponentProps } from '/@/renderer/components/item-list/types';\nimport { useListContext } from '/@/renderer/context/list-context';\nimport { albumQueries } from '/@/renderer/features/albums/api/album-api';\nimport {\n    AlbumListQuery,\n    AlbumListSort,\n    LibraryItem,\n    SortOrder,\n} from '/@/shared/types/domain-types';\nimport { ItemListKey } from '/@/shared/types/types';\n\ninterface AlbumListInfiniteDetailProps extends ItemListComponentProps<AlbumListQuery> {\n    enableHeader?: boolean;\n}\n\nexport const AlbumListInfiniteDetail = ({\n    enableHeader = true,\n    itemsPerPage = 100,\n    query = {\n        sortBy: AlbumListSort.NAME,\n        sortOrder: SortOrder.ASC,\n    },\n    serverId,\n}: AlbumListInfiniteDetailProps) => {\n    const listCountQuery = albumQueries.listCount({\n        query: { ...query },\n        serverId: serverId,\n    }) as UseSuspenseQueryOptions<number, Error, number, readonly unknown[]>;\n\n    const listQueryFn = api.controller.getAlbumList;\n    const { pageKey } = useListContext();\n\n    const { handleColumnReordered } = useItemListColumnReorder({\n        itemListKey: ItemListKey.ALBUM,\n        tableKey: 'detail',\n    });\n\n    const { handleColumnResized } = useItemListColumnResize({\n        itemListKey: ItemListKey.ALBUM,\n        tableKey: 'detail',\n    });\n\n    const { getItem, itemCount, loadedItems, onRangeChanged } = useItemListInfiniteLoader({\n        eventKey: pageKey || ItemListKey.ALBUM,\n        itemsPerPage,\n        itemType: LibraryItem.ALBUM,\n        listCountQuery,\n        listQueryFn,\n        query,\n        serverId,\n    });\n\n    return (\n        <ItemDetailList\n            data={loadedItems}\n            enableHeader={enableHeader}\n            getItem={getItem}\n            itemCount={itemCount}\n            onColumnReordered={handleColumnReordered}\n            onColumnResized={handleColumnResized}\n            onRangeChanged={onRangeChanged}\n        />\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/albums/components/album-list-infinite-grid.tsx",
    "content": "import { UseSuspenseQueryOptions } from '@tanstack/react-query';\n\nimport { api } from '/@/renderer/api';\nimport { useItemListInfiniteLoader } from '/@/renderer/components/item-list/helpers/item-list-infinite-loader';\nimport { useGridRows } from '/@/renderer/components/item-list/helpers/use-grid-rows';\nimport { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist';\nimport { ItemGridList } from '/@/renderer/components/item-list/item-grid-list/item-grid-list';\nimport { ItemListGridComponentProps } from '/@/renderer/components/item-list/types';\nimport { useListContext } from '/@/renderer/context/list-context';\nimport { albumQueries } from '/@/renderer/features/albums/api/album-api';\nimport { useGeneralSettings } from '/@/renderer/store';\nimport {\n    AlbumListQuery,\n    AlbumListSort,\n    LibraryItem,\n    SortOrder,\n} from '/@/shared/types/domain-types';\nimport { ItemListKey } from '/@/shared/types/types';\n\ninterface AlbumListInfiniteGridProps extends ItemListGridComponentProps<AlbumListQuery> {}\n\nexport const AlbumListInfiniteGrid = ({\n    gap = 'md',\n    itemsPerPage = 100,\n    itemsPerRow,\n    query = {\n        sortBy: AlbumListSort.NAME,\n        sortOrder: SortOrder.ASC,\n    },\n    saveScrollOffset = true,\n    serverId,\n    size,\n}: AlbumListInfiniteGridProps) => {\n    const listCountQuery = albumQueries.listCount({\n        query: { ...query, limit: itemsPerPage },\n        serverId: serverId,\n    }) as UseSuspenseQueryOptions<number, Error, number, readonly unknown[]>;\n\n    const listQueryFn = api.controller.getAlbumList;\n\n    const { pageKey } = useListContext();\n\n    const { dataVersion, getItem, getItemIndex, itemCount, loadedItems, onRangeChanged } =\n        useItemListInfiniteLoader({\n            eventKey: pageKey || ItemListKey.ALBUM,\n            itemsPerPage,\n            itemType: LibraryItem.ALBUM,\n            listCountQuery,\n            listQueryFn,\n            query,\n            serverId,\n        });\n\n    const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({\n        enabled: saveScrollOffset,\n    });\n\n    const rows = useGridRows(LibraryItem.ALBUM, ItemListKey.ALBUM, size);\n    const { enableGridMultiSelect } = useGeneralSettings();\n\n    return (\n        <ItemGridList\n            data={loadedItems}\n            dataVersion={dataVersion}\n            enableExpansion\n            enableMultiSelect={enableGridMultiSelect}\n            gap={gap}\n            getItem={getItem}\n            getItemIndex={getItemIndex}\n            initialTop={{\n                to: scrollOffset ?? 0,\n                type: 'offset',\n            }}\n            itemCount={itemCount}\n            itemsPerRow={itemsPerRow}\n            itemType={LibraryItem.ALBUM}\n            onRangeChanged={onRangeChanged}\n            onScrollEnd={handleOnScrollEnd}\n            rows={rows}\n            size={size}\n        />\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/albums/components/album-list-infinite-table.tsx",
    "content": "import { UseSuspenseQueryOptions } from '@tanstack/react-query';\n\nimport { api } from '/@/renderer/api';\nimport { useItemListInfiniteLoader } from '/@/renderer/components/item-list/helpers/item-list-infinite-loader';\nimport { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder';\nimport { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize';\nimport { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist';\nimport { ItemTableList } from '/@/renderer/components/item-list/item-table-list/item-table-list';\nimport { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';\nimport { ItemListTableComponentProps } from '/@/renderer/components/item-list/types';\nimport { useListContext } from '/@/renderer/context/list-context';\nimport { albumQueries } from '/@/renderer/features/albums/api/album-api';\nimport {\n    AlbumListQuery,\n    AlbumListSort,\n    LibraryItem,\n    SortOrder,\n} from '/@/shared/types/domain-types';\nimport { ItemListKey } from '/@/shared/types/types';\n\ninterface AlbumListInfiniteTableProps extends ItemListTableComponentProps<AlbumListQuery> {}\n\nexport const AlbumListInfiniteTable = ({\n    autoFitColumns = false,\n    columns,\n    enableAlternateRowColors = false,\n    enableHeader = true,\n    enableHorizontalBorders = false,\n    enableRowHoverHighlight = true,\n    enableSelection = true,\n    enableVerticalBorders = false,\n    itemsPerPage = 100,\n    query = {\n        sortBy: AlbumListSort.NAME,\n        sortOrder: SortOrder.ASC,\n    },\n    saveScrollOffset = true,\n    serverId,\n    size = 'default',\n}: AlbumListInfiniteTableProps) => {\n    const listCountQuery = albumQueries.listCount({\n        query: { ...query, limit: itemsPerPage },\n        serverId: serverId,\n    }) as UseSuspenseQueryOptions<number, Error, number, readonly unknown[]>;\n\n    const listQueryFn = api.controller.getAlbumList;\n    const { pageKey } = useListContext();\n\n    const { getItem, getItemIndex, itemCount, loadedItems, onRangeChanged } =\n        useItemListInfiniteLoader({\n            eventKey: pageKey || ItemListKey.ALBUM,\n            itemsPerPage,\n            itemType: LibraryItem.ALBUM,\n            listCountQuery,\n            listQueryFn,\n            query,\n            serverId,\n        });\n\n    const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({\n        enabled: saveScrollOffset,\n    });\n\n    const { handleColumnReordered } = useItemListColumnReorder({\n        itemListKey: ItemListKey.ALBUM,\n    });\n\n    const { handleColumnResized } = useItemListColumnResize({\n        itemListKey: ItemListKey.ALBUM,\n    });\n\n    return (\n        <ItemTableList\n            autoFitColumns={autoFitColumns}\n            CellComponent={ItemTableListColumn}\n            columns={columns}\n            data={loadedItems}\n            enableAlternateRowColors={enableAlternateRowColors}\n            enableHeader={enableHeader}\n            enableHorizontalBorders={enableHorizontalBorders}\n            enableRowHoverHighlight={enableRowHoverHighlight}\n            enableSelection={enableSelection}\n            enableVerticalBorders={enableVerticalBorders}\n            getItem={getItem}\n            getItemIndex={getItemIndex}\n            initialTop={{\n                to: scrollOffset ?? 0,\n                type: 'offset',\n            }}\n            itemCount={itemCount}\n            itemType={LibraryItem.ALBUM}\n            onColumnReordered={handleColumnReordered}\n            onColumnResized={handleColumnResized}\n            onRangeChanged={onRangeChanged}\n            onScrollEnd={handleOnScrollEnd}\n            size={size}\n        />\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/albums/components/album-list-paginated-detail.tsx",
    "content": "import { UseSuspenseQueryOptions } from '@tanstack/react-query';\n\nimport { api } from '/@/renderer/api';\nimport { useItemListPaginatedLoader } from '/@/renderer/components/item-list/helpers/item-list-paginated-loader';\nimport { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder';\nimport { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize';\nimport { ItemDetailList } from '/@/renderer/components/item-list/item-detail-list/item-detail-list';\nimport { ItemListWithPagination } from '/@/renderer/components/item-list/item-list-pagination/item-list-pagination';\nimport { useItemListPagination } from '/@/renderer/components/item-list/item-list-pagination/use-item-list-pagination';\nimport { ItemListComponentProps } from '/@/renderer/components/item-list/types';\nimport { useListContext } from '/@/renderer/context/list-context';\nimport { albumQueries } from '/@/renderer/features/albums/api/album-api';\nimport {\n    AlbumListQuery,\n    AlbumListSort,\n    LibraryItem,\n    SortOrder,\n} from '/@/shared/types/domain-types';\nimport { ItemListKey } from '/@/shared/types/types';\n\ninterface AlbumListPaginatedDetailProps extends ItemListComponentProps<AlbumListQuery> {\n    enableHeader?: boolean;\n}\n\nexport const AlbumListPaginatedDetail = ({\n    enableHeader = true,\n    itemsPerPage = 100,\n    query = {\n        sortBy: AlbumListSort.NAME,\n        sortOrder: SortOrder.ASC,\n    },\n    serverId,\n}: AlbumListPaginatedDetailProps) => {\n    const listCountQuery = albumQueries.listCount({\n        query: { ...query },\n        serverId: serverId,\n    }) as UseSuspenseQueryOptions<number, Error, number, readonly unknown[]>;\n\n    const listQueryFn = api.controller.getAlbumList;\n    const { pageKey } = useListContext();\n\n    const { handleColumnReordered } = useItemListColumnReorder({\n        itemListKey: ItemListKey.ALBUM,\n        tableKey: 'detail',\n    });\n\n    const { handleColumnResized } = useItemListColumnResize({\n        itemListKey: ItemListKey.ALBUM,\n        tableKey: 'detail',\n    });\n\n    const { currentPage, onChange } = useItemListPagination();\n\n    const { data, pageCount, totalItemCount } = useItemListPaginatedLoader({\n        currentPage,\n        eventKey: pageKey || ItemListKey.ALBUM,\n        itemsPerPage,\n        itemType: LibraryItem.ALBUM,\n        listCountQuery,\n        listQueryFn,\n        query,\n        serverId,\n    });\n\n    return (\n        <ItemListWithPagination\n            currentPage={currentPage}\n            itemsPerPage={itemsPerPage}\n            onChange={onChange}\n            pageCount={pageCount}\n            totalItemCount={totalItemCount}\n        >\n            <ItemDetailList\n                currentPage={currentPage}\n                enableHeader={enableHeader}\n                items={data || []}\n                onColumnReordered={handleColumnReordered}\n                onColumnResized={handleColumnResized}\n            />\n        </ItemListWithPagination>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/albums/components/album-list-paginated-grid.tsx",
    "content": "import { UseSuspenseQueryOptions } from '@tanstack/react-query';\n\nimport { api } from '/@/renderer/api';\nimport { useItemListPaginatedLoader } from '/@/renderer/components/item-list/helpers/item-list-paginated-loader';\nimport { useGridRows } from '/@/renderer/components/item-list/helpers/use-grid-rows';\nimport { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist';\nimport { ItemGridList } from '/@/renderer/components/item-list/item-grid-list/item-grid-list';\nimport { ItemListWithPagination } from '/@/renderer/components/item-list/item-list-pagination/item-list-pagination';\nimport { useItemListPagination } from '/@/renderer/components/item-list/item-list-pagination/use-item-list-pagination';\nimport { ItemListGridComponentProps } from '/@/renderer/components/item-list/types';\nimport { useListContext } from '/@/renderer/context/list-context';\nimport { albumQueries } from '/@/renderer/features/albums/api/album-api';\nimport { useGeneralSettings } from '/@/renderer/store';\nimport {\n    AlbumListQuery,\n    AlbumListSort,\n    LibraryItem,\n    SortOrder,\n} from '/@/shared/types/domain-types';\nimport { ItemListKey } from '/@/shared/types/types';\n\ninterface AlbumListPaginatedGridProps extends ItemListGridComponentProps<AlbumListQuery> {}\n\nexport const AlbumListPaginatedGrid = ({\n    gap = 'md',\n    itemsPerPage = 100,\n    itemsPerRow,\n    query = {\n        sortBy: AlbumListSort.NAME,\n        sortOrder: SortOrder.ASC,\n    },\n    saveScrollOffset = true,\n    serverId,\n    size,\n}: AlbumListPaginatedGridProps) => {\n    const { pageKey } = useListContext();\n    const { currentPage, onChange } = useItemListPagination();\n\n    const listCountQuery = albumQueries.listCount({\n        query: { ...query, limit: itemsPerPage },\n        serverId: serverId,\n    }) as UseSuspenseQueryOptions<number, Error, number, readonly unknown[]>;\n\n    const listQueryFn = api.controller.getAlbumList;\n\n    const { data, pageCount, totalItemCount } = useItemListPaginatedLoader({\n        currentPage,\n        eventKey: pageKey || ItemListKey.ALBUM,\n        itemsPerPage,\n        itemType: LibraryItem.ALBUM,\n        listCountQuery,\n        listQueryFn,\n        query,\n        serverId,\n    });\n\n    const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({\n        enabled: saveScrollOffset,\n    });\n\n    const rows = useGridRows(LibraryItem.ALBUM, ItemListKey.ALBUM, size);\n    const { enableGridMultiSelect } = useGeneralSettings();\n\n    return (\n        <ItemListWithPagination\n            currentPage={currentPage}\n            itemsPerPage={itemsPerPage}\n            onChange={onChange}\n            pageCount={pageCount}\n            totalItemCount={totalItemCount}\n        >\n            <ItemGridList\n                currentPage={currentPage}\n                data={data || []}\n                enableExpansion\n                enableMultiSelect={enableGridMultiSelect}\n                gap={gap}\n                initialTop={{\n                    to: scrollOffset ?? 0,\n                    type: 'offset',\n                }}\n                itemsPerRow={itemsPerRow}\n                itemType={LibraryItem.ALBUM}\n                onScrollEnd={handleOnScrollEnd}\n                rows={rows}\n                size={size}\n            />\n        </ItemListWithPagination>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/albums/components/album-list-paginated-table.tsx",
    "content": "import { UseSuspenseQueryOptions } from '@tanstack/react-query';\n\nimport { api } from '/@/renderer/api';\nimport { useItemListPaginatedLoader } from '/@/renderer/components/item-list/helpers/item-list-paginated-loader';\nimport { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder';\nimport { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize';\nimport { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist';\nimport { ItemListWithPagination } from '/@/renderer/components/item-list/item-list-pagination/item-list-pagination';\nimport { useItemListPagination } from '/@/renderer/components/item-list/item-list-pagination/use-item-list-pagination';\nimport { ItemTableList } from '/@/renderer/components/item-list/item-table-list/item-table-list';\nimport { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';\nimport { ItemListTableComponentProps } from '/@/renderer/components/item-list/types';\nimport { useListContext } from '/@/renderer/context/list-context';\nimport { albumQueries } from '/@/renderer/features/albums/api/album-api';\nimport {\n    AlbumListQuery,\n    AlbumListSort,\n    LibraryItem,\n    SortOrder,\n} from '/@/shared/types/domain-types';\nimport { ItemListKey } from '/@/shared/types/types';\n\ninterface AlbumListPaginatedTableProps extends ItemListTableComponentProps<AlbumListQuery> {}\n\nexport const AlbumListPaginatedTable = ({\n    autoFitColumns = false,\n    columns,\n    enableAlternateRowColors = false,\n    enableHeader = true,\n    enableHorizontalBorders = false,\n    enableRowHoverHighlight = true,\n    enableSelection = true,\n    enableVerticalBorders = false,\n    itemsPerPage = 100,\n    query = {\n        sortBy: AlbumListSort.NAME,\n        sortOrder: SortOrder.ASC,\n    },\n    saveScrollOffset = true,\n    serverId,\n    size = 'default',\n}: AlbumListPaginatedTableProps) => {\n    const { pageKey } = useListContext();\n    const { currentPage, onChange } = useItemListPagination();\n\n    const listCountQuery = albumQueries.listCount({\n        query: { ...query, limit: itemsPerPage },\n        serverId: serverId,\n    }) as UseSuspenseQueryOptions<number, Error, number, readonly unknown[]>;\n\n    const listQueryFn = api.controller.getAlbumList;\n\n    const { data, pageCount, totalItemCount } = useItemListPaginatedLoader({\n        currentPage,\n        eventKey: pageKey || ItemListKey.ALBUM,\n        itemsPerPage,\n        itemType: LibraryItem.ALBUM,\n        listCountQuery,\n        listQueryFn,\n        query,\n        serverId,\n    });\n\n    const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({\n        enabled: saveScrollOffset,\n    });\n\n    const { handleColumnReordered } = useItemListColumnReorder({\n        itemListKey: ItemListKey.ALBUM,\n    });\n\n    const { handleColumnResized } = useItemListColumnResize({\n        itemListKey: ItemListKey.ALBUM,\n    });\n\n    const startRowIndex = currentPage * itemsPerPage;\n\n    return (\n        <ItemListWithPagination\n            currentPage={currentPage}\n            itemsPerPage={itemsPerPage}\n            onChange={onChange}\n            pageCount={pageCount}\n            totalItemCount={totalItemCount}\n        >\n            <ItemTableList\n                autoFitColumns={autoFitColumns}\n                CellComponent={ItemTableListColumn}\n                columns={columns}\n                data={data || []}\n                enableAlternateRowColors={enableAlternateRowColors}\n                enableHeader={enableHeader}\n                enableHorizontalBorders={enableHorizontalBorders}\n                enableRowHoverHighlight={enableRowHoverHighlight}\n                enableSelection={enableSelection}\n                enableVerticalBorders={enableVerticalBorders}\n                initialTop={{\n                    to: scrollOffset ?? 0,\n                    type: 'offset',\n                }}\n                itemType={LibraryItem.ALBUM}\n                onColumnReordered={handleColumnReordered}\n                onColumnResized={handleColumnResized}\n                onScrollEnd={handleOnScrollEnd}\n                size={size}\n                startRowIndex={startRowIndex}\n            />\n        </ItemListWithPagination>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/albums/components/expanded-album-list-item.module.css",
    "content": ".container {\n    position: relative;\n    width: 100%;\n    height: 100%;\n    container-name: expanded-album-list-item;\n    container-type: inline-size;\n    background-color: var(--theme-colors-surface);\n    border-radius: var(--theme-radius-md);\n}\n\n.loading {\n    position: absolute;\n    right: 0;\n    bottom: 0;\n    padding: var(--theme-spacing-sm);\n}\n\n.expanded {\n    position: relative;\n    gap: var(--theme-spacing-md);\n    width: 100%;\n    height: 100%;\n    overflow: hidden;\n    user-select: none;\n    border-radius: var(--theme-radius-md);\n}\n\n.header {\n    display: flex;\n    flex-direction: column;\n    gap: var(--theme-spacing-xs);\n}\n\n.header-title {\n    position: relative;\n    display: flex;\n    align-items: center;\n    justify-content: space-between;\n}\n\n.close-button {\n    position: absolute;\n    top: 0;\n    right: 0;\n    z-index: 10;\n    opacity: 0.3;\n    transition: opacity 0.3s ease-in-out;\n\n    &:hover {\n        opacity: 0.6;\n    }\n}\n\n.content {\n    display: flex;\n    flex-direction: column;\n    gap: var(--theme-spacing-md);\n    width: 100%;\n    height: 100%;\n    min-height: 0;\n    padding: var(--theme-spacing-md);\n    overflow: hidden;\n}\n\n.item-title {\n    z-index: 10;\n    display: -webkit-box;\n    padding-right: var(--theme-spacing-xl);\n    overflow: hidden;\n    -webkit-line-clamp: 2;\n    line-clamp: 2;\n    line-height: 1.3;\n    color: black !important;\n    -webkit-box-orient: vertical;\n}\n\n.item-title.dark {\n    color: white !important;\n}\n\n.item-subtitle {\n    z-index: 10;\n    color: black;\n    white-space: nowrap;\n}\n\n.item-subtitle.dark {\n    color: white !important;\n}\n\n.dark {\n    color: white;\n}\n\n.image-container {\n    position: absolute;\n    top: 0;\n    right: 0;\n    z-index: 1;\n    display: flex;\n    grid-area: image;\n    align-items: center;\n    justify-content: center;\n    width: 50%;\n    height: 100%;\n}\n\n.play-button-group {\n    position: relative;\n    z-index: 10;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    opacity: 0;\n    transition: opacity 0.2s ease-in-out;\n}\n\n.container:hover .play-button-group {\n    opacity: 1;\n}\n\n.background-image {\n    position: absolute;\n    right: 0;\n    width: 60%;\n    height: 100%;\n    background-repeat: no-repeat;\n    background-position: center;\n    background-size: cover;\n    filter: blur(2px);\n\n    &::before {\n        position: absolute;\n        inset: 0;\n        content: '';\n        background: linear-gradient(to right, var(--bg-color) 0%, transparent 100%);\n    }\n\n    @container (min-width: 640px) {\n        width: 80%;\n    }\n\n    @container (min-width: 768px) {\n        width: 90%;\n    }\n\n    @container (min-width: 1200px) {\n        width: 100%;\n    }\n}\n\n.tracks {\n    display: flex;\n    flex: 1;\n    flex-direction: column;\n    gap: var(--theme-spacing-sm);\n    width: 60%;\n    max-width: 700px;\n    min-height: 0;\n\n    @container (min-width: 640px) {\n        width: 60%;\n    }\n\n    @container (min-width: 768px) {\n        width: 50%;\n    }\n\n    @container (min-width: 1200px) {\n        width: 40%;\n    }\n}\n\n.tracks-list {\n    display: flex;\n    flex-direction: column;\n}\n\n.track-row {\n    position: relative;\n    display: grid;\n    grid-template-columns: 55px 1fr 55px;\n    gap: var(--theme-spacing-sm);\n    align-items: center;\n    padding: var(--theme-spacing-xs) var(--theme-spacing-sm);\n    color: black;\n    cursor: pointer;\n}\n\n.track-row:hover {\n    background-color: rgb(0 0 0 / 20%);\n}\n\n.track-number {\n    text-align: left;\n}\n\n.track-name {\n    overflow: hidden;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n}\n\n.track-duration {\n    text-align: right;\n}\n\n.row-selected {\n    position: relative;\n}\n\n.row-selected::before {\n    position: absolute;\n    top: 0;\n    right: 0;\n    bottom: 0;\n    left: 0;\n    z-index: 0;\n    pointer-events: none;\n    content: '';\n    background-color: rgb(0 0 0 / 20%);\n    opacity: 0.7;\n}\n\n.row-selected > * {\n    position: relative;\n    z-index: 1;\n}\n\n.tracks.dark .track-row {\n    color: white;\n}\n\n.track-row.dragging {\n    opacity: 0.5;\n}\n\n.track-row.dragged-over-top::after {\n    position: absolute;\n    top: 0;\n    right: 0;\n    left: 0;\n    z-index: 3;\n    height: 2px;\n    pointer-events: none;\n    content: '';\n    background-color: var(--theme-colors-primary);\n}\n\n.track-row.dragged-over-bottom::after {\n    position: absolute;\n    right: 0;\n    bottom: 0;\n    left: 0;\n    z-index: 3;\n    height: 2px;\n    pointer-events: none;\n    content: '';\n    background-color: var(--theme-colors-primary);\n}\n"
  },
  {
    "path": "src/renderer/features/albums/components/expanded-album-list-item.tsx",
    "content": "import { useSuspenseQuery } from '@tanstack/react-query';\nimport clsx from 'clsx';\nimport formatDuration from 'format-duration';\nimport { motion } from 'motion/react';\nimport { Fragment, Suspense, useCallback, useRef } from 'react';\n\nimport styles from './expanded-album-list-item.module.css';\n\nimport { useItemImageUrl } from '/@/renderer/components/item-image/item-image';\nimport { getDraggedItems } from '/@/renderer/components/item-list/helpers/get-dragged-items';\nimport { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls';\nimport {\n    ItemListStateActions,\n    ItemListStateItem,\n    useItemDraggingState,\n    useItemListState,\n    useItemSelectionState,\n} from '/@/renderer/components/item-list/helpers/item-list-state';\nimport { ItemListItem } from '/@/renderer/components/item-list/types';\nimport { albumQueries } from '/@/renderer/features/albums/api/album-api';\nimport { usePlayer } from '/@/renderer/features/player/context/player-context';\nimport { PlayButtonGroup } from '/@/renderer/features/shared/components/play-button-group';\nimport { useFastAverageColor } from '/@/renderer/hooks';\nimport { useDragDrop } from '/@/renderer/hooks/use-drag-drop';\nimport { useSetGlobalExpanded } from '/@/renderer/store';\nimport { ActionIcon } from '/@/shared/components/action-icon/action-icon';\nimport { Group } from '/@/shared/components/group/group';\nimport { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';\nimport { Separator } from '/@/shared/components/separator/separator';\nimport { Spinner } from '/@/shared/components/spinner/spinner';\nimport { TextTitle } from '/@/shared/components/text-title/text-title';\nimport { Text } from '/@/shared/components/text/text';\nimport { useMergedRef } from '/@/shared/hooks/use-merged-ref';\nimport { LibraryItem, RelatedArtist, Song } from '/@/shared/types/domain-types';\nimport { DragOperation, DragTarget, DragTargetMap } from '/@/shared/types/drag-and-drop';\nimport { Play } from '/@/shared/types/types';\n\nexport interface ExpandedAlbumData {\n    _serverId: string;\n    albumArtists: RelatedArtist[];\n    id: string;\n    imageId: null | string;\n    name: string;\n    songs?: null | Song[];\n}\n\nexport interface ExpandedAlbumListItemProps {\n    album?: ExpandedAlbumData;\n    item?: ItemListStateItem;\n}\n\ninterface AlbumTracksTableProps {\n    isDark?: boolean;\n    serverId: string;\n    songs?: Array<{\n        discNumber: number;\n        duration: number;\n        id: string;\n        name: string;\n        trackNumber: number;\n    }>;\n}\n\ninterface TrackRowProps {\n    controls: ReturnType<typeof useDefaultItemListControls>;\n    internalState: ItemListStateActions;\n    player: ReturnType<typeof usePlayer>;\n    serverId: string;\n    song: NonNullable<AlbumTracksTableProps['songs']>[0];\n    songs: Song[];\n}\n\nconst CloseExpandedButton = () => {\n    const setGlobalExpanded = useSetGlobalExpanded();\n    return (\n        <ActionIcon\n            className={clsx(styles.closeButton)}\n            icon=\"x\"\n            iconProps={{\n                size: 'xl',\n            }}\n            onClick={() => setGlobalExpanded(null)}\n            radius=\"50%\"\n            size=\"sm\"\n            variant=\"default\"\n        />\n    );\n};\n\nconst TrackRow = ({ controls, internalState, player, serverId, song, songs }: TrackRowProps) => {\n    const rowId = internalState.extractRowId(song);\n    const isSelected = useItemSelectionState(internalState, rowId);\n    const isDraggingState = useItemDraggingState(internalState, rowId);\n\n    const songWithMetadata = {\n        ...song,\n        _serverId: serverId,\n        itemType: LibraryItem.SONG,\n    } as unknown as ItemListItem;\n\n    const {\n        isDraggedOver,\n        isDragging: isDraggingLocal,\n        ref: dragRef,\n    } = useDragDrop<HTMLDivElement>({\n        drag: {\n            getId: () => {\n                const draggedItems = getDraggedItems(\n                    songWithMetadata as unknown as Song,\n                    internalState,\n                );\n                return draggedItems.map((draggedItem) => draggedItem.id);\n            },\n            getItem: () => {\n                const draggedItems = getDraggedItems(\n                    songWithMetadata as unknown as Song,\n                    internalState,\n                );\n                return draggedItems;\n            },\n            itemType: LibraryItem.SONG,\n            onDragStart: () => {\n                const draggedItems = getDraggedItems(\n                    songWithMetadata as unknown as Song,\n                    internalState,\n                );\n                internalState.setDragging(draggedItems);\n            },\n            onDrop: () => {\n                internalState.setDragging([]);\n            },\n            operation: [DragOperation.ADD],\n            target: DragTargetMap[LibraryItem.SONG] || DragTarget.GENERIC,\n        },\n        isEnabled: true,\n    });\n\n    const isDragging = isDraggingState || isDraggingLocal;\n\n    const containerRef = useRef<HTMLDivElement>(null);\n    const mergedRef = useMergedRef(containerRef, dragRef);\n\n    const handleDoubleClick = useCallback(() => {\n        if (songs && song.id) {\n            player.addToQueueByData(songs, Play.NOW, song.id);\n        }\n    }, [player, songs, song.id]);\n\n    return (\n        <Text\n            className={clsx(styles['track-row'], {\n                [styles.dragging]: isDragging,\n                [styles.rowSelected]: isSelected,\n                [styles['dragged-over-bottom']]: isDraggedOver === 'bottom',\n                [styles['dragged-over-top']]: isDraggedOver === 'top',\n            })}\n            onClick={(e) =>\n                controls.onClick?.({\n                    event: e,\n                    internalState,\n                    item: songWithMetadata,\n                    itemType: LibraryItem.SONG,\n                })\n            }\n            onDoubleClick={handleDoubleClick}\n            ref={mergedRef}\n            size=\"sm\"\n        >\n            <span className={styles['track-number']}>\n                {song.discNumber} - {song.trackNumber}\n            </span>\n            <span className={styles['track-name']}>{song.name}</span>\n            <span className={styles['track-duration']}>{formatDuration(song.duration)}</span>\n        </Text>\n    );\n};\n\nconst AlbumTracksTable = ({ isDark, serverId, songs }: AlbumTracksTableProps) => {\n    const getDataFn = useCallback(() => songs || [], [songs]);\n\n    const extractRowId = useCallback((item: unknown) => {\n        if (item && typeof item === 'object' && 'id' in item) {\n            return (item as { id: string }).id;\n        }\n        return undefined;\n    }, []);\n\n    // Always use a local state for tracks - tracks are separate entities from albums\n    // and need their own selection state. The parentInternalState is for album-level operations.\n    const internalState = useItemListState(getDataFn, extractRowId);\n\n    const controls = useDefaultItemListControls();\n    const player = usePlayer();\n\n    const fullSongs = songs as Song[] | undefined;\n\n    return (\n        <div className={clsx(styles.tracks, { [styles.dark]: isDark })}>\n            <ScrollArea>\n                <div className={styles['tracks-list']}>\n                    {songs?.map((song) => (\n                        <TrackRow\n                            controls={controls}\n                            internalState={internalState}\n                            key={song.id}\n                            player={player}\n                            serverId={serverId}\n                            song={song}\n                            songs={fullSongs || []}\n                        />\n                    ))}\n                </div>\n            </ScrollArea>\n        </div>\n    );\n};\n\ninterface ExpandedAlbumListItemContentProps {\n    albumData: ExpandedAlbumData;\n}\n\nconst ExpandedAlbumListItemContent = ({ albumData }: ExpandedAlbumListItemContentProps) => {\n    const player = usePlayer();\n\n    const imageUrl = useItemImageUrl({\n        id: albumData.imageId || undefined,\n        itemType: LibraryItem.ALBUM,\n        type: 'itemCard',\n    });\n\n    const color = useFastAverageColor({\n        algorithm: 'sqrt',\n        id: albumData.id,\n        src: imageUrl,\n        srcLoaded: true,\n    });\n\n    const handlePlay = useCallback(\n        (playType: Play) => {\n            if (albumData.songs?.length) {\n                player.addToQueueByData(albumData.songs, playType);\n            }\n        },\n        [albumData.songs, player],\n    );\n\n    if (color.isLoading) {\n        return <Spinner container />;\n    }\n\n    const songs = albumData.songs ?? null;\n\n    return (\n        <motion.div\n            animate={{ opacity: 1 }}\n            className={styles.container}\n            exit={{ opacity: 0 }}\n            initial={{ opacity: 0 }}\n            style={{ backgroundColor: color.background }}\n        >\n            <div className={styles.expanded}>\n                <div className={styles.content}>\n                    <div className={styles.header}>\n                        <div className={styles.headerTitle}>\n                            <TextTitle\n                                className={clsx(styles.itemTitle, { [styles.dark]: color.isDark })}\n                                fw={700}\n                                order={4}\n                            >\n                                {albumData.name}\n                            </TextTitle>\n                            <CloseExpandedButton />\n                        </div>\n                        <Group\n                            className={clsx(styles.itemSubtitle, { [styles.dark]: color.isDark })}\n                            gap=\"xs\"\n                        >\n                            {albumData.albumArtists?.map((artist, index) => (\n                                <Fragment key={artist.id}>\n                                    <Text\n                                        className={clsx(styles.itemSubtitle, {\n                                            [styles.dark]: color.isDark,\n                                        })}\n                                    >\n                                        {artist.name}\n                                    </Text>\n                                    {index < (albumData.albumArtists?.length ?? 0) - 1 && (\n                                        <Separator />\n                                    )}\n                                </Fragment>\n                            ))}\n                        </Group>\n                    </div>\n                    <AlbumTracksTable\n                        isDark={color.isDark}\n                        serverId={albumData._serverId}\n                        songs={songs ?? undefined}\n                    />\n                </div>\n                <div className={styles.imageContainer}>\n                    <div\n                        className={styles.backgroundImage}\n                        style={{\n                            ['--bg-color' as string]: color?.background,\n                            backgroundImage: `url(${imageUrl})`,\n                        }}\n                    />\n                    {songs && songs.length > 0 && (\n                        <div className={styles.playButtonGroup}>\n                            <PlayButtonGroup onPlay={handlePlay} />\n                        </div>\n                    )}\n                </div>\n            </div>\n        </motion.div>\n    );\n};\n\nconst ExpandedAlbumListItemWithFetch = ({ item }: { item: ItemListStateItem }) => {\n    const { data } = useSuspenseQuery(\n        albumQueries.detail({\n            query: { id: item.id },\n            serverId: item._serverId,\n        }),\n    );\n\n    const albumData: ExpandedAlbumData = {\n        _serverId: item._serverId,\n        albumArtists: data?.albumArtists ?? [],\n        id: item.id,\n        imageId: item.imageId ?? data?.imageId ?? null,\n        name: data?.name ?? '',\n        songs: data?.songs ?? null,\n    };\n\n    return <ExpandedAlbumListItemContent albumData={albumData} />;\n};\n\nfunction itemToExpandedAlbumData(\n    item: ItemListStateItem & {\n        _playlistSongs?: Song[];\n        albumArtists?: RelatedArtist[];\n        name?: string;\n    },\n): ExpandedAlbumData | null {\n    const songs =\n        (item as { songs?: Song[] }).songs ?? (item as { _playlistSongs?: Song[] })._playlistSongs;\n    if (songs == null) return null;\n    return {\n        _serverId: item._serverId,\n        albumArtists: item.albumArtists ?? [],\n        id: item.id,\n        imageId: (item as { imageId?: null | string }).imageId ?? null,\n        name: (item as { name?: string }).name ?? '',\n        songs,\n    };\n}\n\nexport const ExpandedAlbumListItem = (props: ExpandedAlbumListItemProps) => {\n    if (props.album != null) {\n        return <ExpandedAlbumListItemContent albumData={props.album} />;\n    }\n\n    if (props.item != null) {\n        const albumData = itemToExpandedAlbumData(props.item);\n\n        if (albumData != null) {\n            return <ExpandedAlbumListItemContent albumData={albumData} />;\n        }\n\n        return (\n            <Suspense fallback={<Spinner container />}>\n                <ExpandedAlbumListItemWithFetch item={props.item} />\n            </Suspense>\n        );\n    }\n\n    return null;\n};\n"
  },
  {
    "path": "src/renderer/features/albums/components/jellyfin-album-filters.tsx",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { useCallback, useMemo } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport { getItemImageUrl } from '/@/renderer/components/item-image/item-image';\nimport { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album-list-filters';\nimport { artistsQueries } from '/@/renderer/features/artists/api/artists-api';\nimport { genresQueries } from '/@/renderer/features/genres/api/genres-api';\nimport {\n    ArtistMultiSelectRow,\n    GenreMultiSelectRow,\n} from '/@/renderer/features/shared/components/multi-select-rows';\nimport { TagFilters } from '/@/renderer/features/shared/components/tag-filter';\nimport { useCurrentServerId } from '/@/renderer/store';\nimport { useAppStore, useAppStoreActions } from '/@/renderer/store/app.store';\nimport { Divider } from '/@/shared/components/divider/divider';\nimport { Group } from '/@/shared/components/group/group';\nimport { VirtualMultiSelect } from '/@/shared/components/multi-select/virtual-multi-select';\nimport { NumberInput } from '/@/shared/components/number-input/number-input';\nimport { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { Text } from '/@/shared/components/text/text';\nimport { YesNoSelect } from '/@/shared/components/yes-no-select/yes-no-select';\nimport { useDebouncedCallback } from '/@/shared/hooks/use-debounced-callback';\nimport {\n    AlbumArtistListSort,\n    GenreListSort,\n    LibraryItem,\n    SortOrder,\n} from '/@/shared/types/domain-types';\n\ninterface JellyfinAlbumFiltersProps {\n    disableArtistFilter?: boolean;\n    disableGenreFilter?: boolean;\n}\n\nexport const JellyfinAlbumFilters = ({\n    disableArtistFilter,\n    disableGenreFilter,\n}: JellyfinAlbumFiltersProps) => {\n    const { t } = useTranslation();\n    const serverId = useCurrentServerId();\n\n    const {\n        query,\n        setAlbumArtist,\n        setCompilation,\n        setCustom,\n        setFavorite,\n        setGenreId,\n        setMaxYear,\n        setMinYear,\n    } = useAlbumListFilters();\n\n    const genreListQuery = useQuery(\n        genresQueries.list({\n            options: {\n                gcTime: 1000 * 60 * 2,\n                staleTime: 1000 * 60 * 1,\n            },\n            query: {\n                sortBy: GenreListSort.NAME,\n                sortOrder: SortOrder.ASC,\n                startIndex: 0,\n            },\n            serverId,\n        }),\n    );\n\n    const genreList = useMemo(() => {\n        if (!genreListQuery?.data) return [];\n        return genreListQuery.data.items.map((genre) => ({\n            albumCount: genre.albumCount,\n            label: genre.name,\n            songCount: genre.songCount,\n            value: genre.id,\n        }));\n    }, [genreListQuery.data]);\n\n    const yesNoFilter = useMemo(() => {\n        const filters = [\n            {\n                label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),\n                onChange: (favoriteValue?: boolean) => {\n                    setFavorite(favoriteValue ?? null);\n                },\n                value: query.favorite,\n            },\n        ];\n\n        if (query.artistIds?.length) {\n            filters.push({\n                label: t('filter.isCompilation', { postProcess: 'sentenceCase' }),\n                onChange: (compilationValue?: boolean) => {\n                    setCompilation(compilationValue ?? null);\n                },\n                value: query.compilation,\n            });\n        }\n        return filters;\n    }, [\n        t,\n        query.favorite,\n        query.artistIds?.length,\n        query.compilation,\n        setFavorite,\n        setCompilation,\n    ]);\n\n    const handleMinYearFilter = useMemo(\n        () => (e: number | string) => {\n            // Handle empty string, null, undefined, or invalid numbers as clearing\n            if (e === '' || e === null || e === undefined || isNaN(Number(e))) {\n                setMinYear(null);\n                return;\n            }\n\n            const year = typeof e === 'number' ? e : Number(e);\n            // If it's a valid number within range, set it; otherwise clear\n            if (!isNaN(year) && isFinite(year) && year >= 1700 && year <= 2300) {\n                setMinYear(year);\n            } else {\n                setMinYear(null);\n            }\n        },\n        [setMinYear],\n    );\n\n    const handleMaxYearFilter = useMemo(\n        () => (e: number | string) => {\n            // Handle empty string, null, undefined, or invalid numbers as clearing\n            if (e === '' || e === null || e === undefined || isNaN(Number(e))) {\n                setMaxYear(null);\n                return;\n            }\n\n            const year = typeof e === 'number' ? e : Number(e);\n            // If it's a valid number within range, set it; otherwise clear\n            if (!isNaN(year) && isFinite(year) && year >= 1700 && year <= 2300) {\n                setMaxYear(year);\n            } else {\n                setMaxYear(null);\n            }\n        },\n        [setMaxYear],\n    );\n\n    const handleGenresFilter = useCallback(\n        (e: null | string[]) => {\n            setGenreId(e && e.length > 0 ? e : null);\n        },\n        [setGenreId],\n    );\n\n    const albumArtistListQuery = useQuery(\n        artistsQueries.albumArtistList({\n            options: {\n                gcTime: 1000 * 60 * 2,\n                staleTime: 1000 * 60 * 1,\n            },\n            query: {\n                sortBy: AlbumArtistListSort.NAME,\n                sortOrder: SortOrder.ASC,\n                startIndex: 0,\n            },\n            serverId,\n        }),\n    );\n\n    const selectableAlbumArtists = useMemo(() => {\n        if (!albumArtistListQuery?.data?.items) return [];\n\n        return albumArtistListQuery?.data?.items?.map((artist) => ({\n            albumCount: artist.albumCount,\n            imageUrl: getItemImageUrl({\n                id: artist.id,\n                itemType: LibraryItem.ARTIST,\n                type: 'table',\n            }),\n            label: artist.name,\n            songCount: artist.songCount,\n            value: artist.id,\n        }));\n    }, [albumArtistListQuery.data?.items]);\n\n    const handleAlbumArtistFilter = useCallback(\n        (e: null | string[]) => {\n            setAlbumArtist(e ?? null);\n        },\n        [setAlbumArtist],\n    );\n\n    const debouncedHandleMinYearFilter = useDebouncedCallback(handleMinYearFilter, 300);\n    const debouncedHandleMaxYearFilter = useDebouncedCallback(handleMaxYearFilter, 300);\n\n    const artistSelectMode = useAppStore((state) => state.artistSelectMode);\n    const genreSelectMode = useAppStore((state) => state.genreSelectMode);\n    const { setArtistSelectMode, setGenreSelectMode } = useAppStoreActions();\n\n    const selectedArtistIds = useMemo(() => query.artistIds || [], [query.artistIds]);\n    const selectedGenreIds = useMemo(() => query.genreIds || [], [query.genreIds]);\n\n    const handleArtistSelectModeChange = useCallback(\n        (value: string) => {\n            const newMode = value as 'multi' | 'single';\n            setArtistSelectMode(newMode);\n\n            if (newMode === 'single' && selectedArtistIds.length > 1) {\n                setAlbumArtist([selectedArtistIds[0]]);\n            }\n        },\n        [selectedArtistIds, setAlbumArtist, setArtistSelectMode],\n    );\n\n    const handleGenreSelectModeChange = useCallback(\n        (value: string) => {\n            const newMode = value as 'multi' | 'single';\n            setGenreSelectMode(newMode);\n\n            if (newMode === 'single' && selectedGenreIds.length > 1) {\n                setGenreId([selectedGenreIds[0]]);\n            }\n        },\n        [selectedGenreIds, setGenreId, setGenreSelectMode],\n    );\n\n    const artistFilterLabel = useMemo(() => {\n        return (\n            <Group gap=\"xs\" justify=\"space-between\" w=\"100%\">\n                <Text fw={500} size=\"sm\">\n                    {t('entity.artist', { count: 2, postProcess: 'sentenceCase' })}\n                </Text>\n                <SegmentedControl\n                    data={[\n                        {\n                            label: t('common.filter_single', { postProcess: 'titleCase' }),\n                            value: 'single',\n                        },\n                        {\n                            label: t('common.filter_multiple', { postProcess: 'titleCase' }),\n                            value: 'multi',\n                        },\n                    ]}\n                    onChange={handleArtistSelectModeChange}\n                    size=\"xs\"\n                    value={artistSelectMode}\n                />\n            </Group>\n        );\n    }, [artistSelectMode, handleArtistSelectModeChange, t]);\n\n    const genreFilterLabel = useMemo(() => {\n        return (\n            <Group gap=\"xs\" justify=\"space-between\" w=\"100%\">\n                <Text fw={500} size=\"sm\">\n                    {t('entity.genre', { count: 2, postProcess: 'sentenceCase' })}\n                </Text>\n                <SegmentedControl\n                    data={[\n                        {\n                            label: t('common.filter_single', { postProcess: 'titleCase' }),\n                            value: 'single',\n                        },\n                        {\n                            label: t('common.filter_multiple', { postProcess: 'titleCase' }),\n                            value: 'multi',\n                        },\n                    ]}\n                    onChange={handleGenreSelectModeChange}\n                    size=\"xs\"\n                    value={genreSelectMode}\n                />\n            </Group>\n        );\n    }, [genreSelectMode, handleGenreSelectModeChange, t]);\n\n    return (\n        <Stack px=\"md\" py=\"md\">\n            {yesNoFilter.map((filter) => (\n                <YesNoSelect\n                    key={`jf-filter-${filter.label}`}\n                    label={filter.label}\n                    onChange={(e) => filter.onChange(e ? e === 'true' : undefined)}\n                    value={filter.value ? filter.value.toString() : undefined}\n                />\n            ))}\n            {!disableArtistFilter && (\n                <>\n                    <Divider my=\"md\" />\n                    <VirtualMultiSelect\n                        displayCountType=\"album\"\n                        height={300}\n                        isLoading={albumArtistListQuery.isFetching}\n                        label={artistFilterLabel}\n                        onChange={handleAlbumArtistFilter}\n                        options={selectableAlbumArtists}\n                        RowComponent={ArtistMultiSelectRow}\n                        singleSelect={artistSelectMode === 'single'}\n                        value={selectedArtistIds}\n                    />\n                </>\n            )}\n            {!disableGenreFilter && (\n                <>\n                    <Divider my=\"md\" />\n                    <VirtualMultiSelect\n                        displayCountType=\"album\"\n                        height={220}\n                        isLoading={genreListQuery.isFetching}\n                        label={genreFilterLabel}\n                        onChange={handleGenresFilter}\n                        options={genreList}\n                        RowComponent={GenreMultiSelectRow}\n                        singleSelect={genreSelectMode === 'single'}\n                        value={selectedGenreIds}\n                    />\n                </>\n            )}\n            <Divider my=\"md\" />\n            <Group grow>\n                <NumberInput\n                    hideControls={false}\n                    label={t('filter.fromYear', { postProcess: 'sentenceCase' })}\n                    max={2300}\n                    min={1700}\n                    onChange={(e) => debouncedHandleMinYearFilter(e)}\n                    required={!!query.minYear}\n                    value={query.minYear ?? undefined}\n                />\n                <NumberInput\n                    hideControls={false}\n                    label={t('filter.toYear', { postProcess: 'sentenceCase' })}\n                    max={2300}\n                    min={1700}\n                    onChange={(e) => debouncedHandleMaxYearFilter(e)}\n                    required={!!query.minYear}\n                    value={query.maxYear ?? undefined}\n                />\n            </Group>\n            <TagFilters query={query} setCustom={setCustom} type={LibraryItem.ALBUM} />\n        </Stack>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/albums/components/joined-artists.tsx",
    "content": "import { Fragment, memo } from 'react';\nimport { generatePath, Link } from 'react-router';\n\nimport { AppRoute } from '/@/renderer/router/routes';\nimport { Text, TextProps } from '/@/shared/components/text/text';\nimport { AlbumArtist, RelatedAlbumArtist, RelatedArtist } from '/@/shared/types/domain-types';\n\nexport const JOINED_ARTISTS_MUTED_PROPS = {\n    linkProps: { fw: 400, isMuted: true },\n    rootTextProps: { fw: 400, isMuted: true, size: 'sm' as const },\n} as const;\n\ninterface JoinedArtistsProps {\n    artistName: string;\n    artists: AlbumArtist[] | RelatedAlbumArtist[] | RelatedArtist[];\n    linkProps?: Partial<Omit<TextProps, 'children' | 'component' | 'to'>>;\n    readOnly?: boolean;\n    rootTextProps?: Partial<Omit<TextProps, 'children' | 'component'>>;\n}\n\nconst JoinedArtistsComponent = ({\n    artistName,\n    artists,\n    linkProps,\n    readOnly = false,\n    rootTextProps,\n}: JoinedArtistsProps) => {\n    const parts: (\n        | string\n        | {\n              artist: AlbumArtist | RelatedAlbumArtist | RelatedArtist;\n              end: number;\n              start: number;\n              text: string;\n          }\n    )[] = [];\n    const matches: Array<{\n        artist: AlbumArtist | RelatedAlbumArtist | RelatedArtist;\n        end: number;\n        name: string;\n        start: number;\n    }> = [];\n\n    for (const artist of artists) {\n        const name = artist.name;\n\n        // Avoid an infinite loop when `artist.name` is an empty string.\n        if (!name) continue;\n\n        const regex = new RegExp(escapeRegex(name), 'gi');\n        let match: null | RegExpExecArray = null;\n        while ((match = regex.exec(artistName)) !== null) {\n            matches.push({\n                artist,\n                end: match.index + match[0].length,\n                name: match[0],\n                start: match.index,\n            });\n        }\n    }\n\n    matches.sort((a, b) => {\n        const lengthDiff = b.end - b.start - (a.end - a.start);\n        if (lengthDiff !== 0) return lengthDiff;\n        return a.start - b.start;\n    });\n\n    const nonOverlappingMatches: typeof matches = [];\n    for (const match of matches) {\n        const overlaps = nonOverlappingMatches.some(\n            (existing) =>\n                (match.start >= existing.start && match.start < existing.end) ||\n                (match.end > existing.start && match.end <= existing.end) ||\n                (match.start <= existing.start && match.end >= existing.end),\n        );\n\n        if (!overlaps) {\n            nonOverlappingMatches.push(match);\n        }\n    }\n\n    nonOverlappingMatches.sort((a, b) => a.start - b.start);\n\n    let lastIndex = 0;\n    for (const match of nonOverlappingMatches) {\n        if (match.start > lastIndex) {\n            parts.push(artistName.substring(lastIndex, match.start));\n        }\n\n        parts.push({\n            artist: match.artist,\n            end: match.end,\n            start: match.start,\n            text: match.name,\n        });\n\n        lastIndex = match.end;\n    }\n\n    if (lastIndex < artistName?.length) {\n        parts.push(artistName.substring(lastIndex));\n    }\n\n    const hasArtistMatches = parts.some((part) => typeof part !== 'string');\n\n    // Find artists that were matched\n    const matchedArtistIds = new Set(nonOverlappingMatches.map((match) => match.artist.id));\n\n    // Find artists that are not present in the artist name\n    const unmatchedArtists = artists.filter(\n        (artist) => artist.name && !matchedArtistIds.has(artist.id),\n    );\n\n    // If no matches found and there are album artists, return the album artists\n    if (!hasArtistMatches && artists.length > 0) {\n        return (\n            <Text component=\"span\" {...rootTextProps}>\n                {artists.map((artist, index) => (\n                    <Fragment key={artist.id || `artist-${index}`}>\n                        {index > 0 && ', '}\n                        {artist.id && !readOnly ? (\n                            <Text\n                                component={Link}\n                                fw={500}\n                                isLink\n                                to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {\n                                    albumArtistId: artist.id,\n                                })}\n                                {...linkProps}\n                            >\n                                {artist.name}\n                            </Text>\n                        ) : (\n                            <Text component=\"span\" fw={500} {...linkProps}>\n                                {artist.name}\n                            </Text>\n                        )}\n                    </Fragment>\n                ))}\n            </Text>\n        );\n    }\n\n    // If no matches found and no albumArtists, return the original string\n    if (!hasArtistMatches) {\n        return (\n            <Text fw={400} isNoSelect {...rootTextProps}>\n                {artistName}\n            </Text>\n        );\n    }\n\n    return (\n        <Text component=\"span\" fw={400} {...rootTextProps}>\n            {parts.map((part, index) => {\n                if (typeof part === 'string') {\n                    return <Fragment key={index}>{part}</Fragment>;\n                }\n\n                const { artist, text } = part;\n\n                if (artist.id && !readOnly) {\n                    return (\n                        <Text\n                            component={Link}\n                            fw={500}\n                            isLink\n                            key={`${artist.id}-${index}`}\n                            to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {\n                                albumArtistId: artist.id,\n                            })}\n                            {...linkProps}\n                        >\n                            {text}\n                        </Text>\n                    );\n                }\n                return (\n                    <Text component=\"span\" fw={500} key={`${artist.name}-${index}`} {...linkProps}>\n                        {text}\n                    </Text>\n                );\n            })}\n            {unmatchedArtists.length > 0 && (\n                <>\n                    {', '}\n                    {unmatchedArtists.map((artist, index) => (\n                        <Fragment key={artist.id}>\n                            {index > 0 && ', '}\n                            {artist.id && !readOnly ? (\n                                <Text\n                                    component={Link}\n                                    fw={500}\n                                    isLink\n                                    to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {\n                                        albumArtistId: artist.id,\n                                    })}\n                                    {...linkProps}\n                                >\n                                    {artist.name}\n                                </Text>\n                            ) : artist.id ? (\n                                <Text component=\"span\" fw={500} {...linkProps}>\n                                    {artist.name}\n                                </Text>\n                            ) : (\n                                <Text component=\"span\" isMuted>\n                                    {artist.name}\n                                </Text>\n                            )}\n                        </Fragment>\n                    ))}\n                </>\n            )}\n        </Text>\n    );\n};\n\nexport const JoinedArtists = memo(JoinedArtistsComponent);\n\nfunction escapeRegex(str: string): string {\n    return str.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n}\n"
  },
  {
    "path": "src/renderer/features/albums/components/navidrome-album-filters.tsx",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { ChangeEvent, useCallback, useMemo } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport { getItemImageUrl } from '/@/renderer/components/item-image/item-image';\nimport { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album-list-filters';\nimport { artistsQueries } from '/@/renderer/features/artists/api/artists-api';\nimport { genresQueries } from '/@/renderer/features/genres/api/genres-api';\nimport {\n    ArtistMultiSelectRow,\n    GenreMultiSelectRow,\n} from '/@/renderer/features/shared/components/multi-select-rows';\nimport { TagFilters } from '/@/renderer/features/shared/components/tag-filter';\nimport { useCurrentServer } from '/@/renderer/store';\nimport { useAppStore, useAppStoreActions } from '/@/renderer/store/app.store';\nimport { Divider } from '/@/shared/components/divider/divider';\nimport { Group } from '/@/shared/components/group/group';\nimport { VirtualMultiSelect } from '/@/shared/components/multi-select/virtual-multi-select';\nimport { NumberInput } from '/@/shared/components/number-input/number-input';\nimport { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { Switch } from '/@/shared/components/switch/switch';\nimport { Text } from '/@/shared/components/text/text';\nimport { useDebouncedCallback } from '/@/shared/hooks/use-debounced-callback';\nimport {\n    AlbumArtistListSort,\n    GenreListSort,\n    LibraryItem,\n    SortOrder,\n} from '/@/shared/types/domain-types';\n\ninterface NavidromeAlbumFiltersProps {\n    disableArtistFilter?: boolean;\n    disableGenreFilter?: boolean;\n}\n\nexport const NavidromeAlbumFilters = ({\n    disableArtistFilter,\n    disableGenreFilter,\n}: NavidromeAlbumFiltersProps) => {\n    const { t } = useTranslation();\n    const server = useCurrentServer();\n    const serverId = server.id;\n\n    const artistSelectMode = useAppStore((state) => state.artistSelectMode);\n    const genreSelectMode = useAppStore((state) => state.genreSelectMode);\n    const { setArtistSelectMode, setGenreSelectMode } = useAppStoreActions();\n\n    const {\n        query,\n        setAlbumArtist,\n        setCompilation,\n        setCustom,\n        setFavorite,\n        setGenreId,\n        setHasRating,\n        setMaxYear,\n        setMinYear,\n        setRecentlyPlayed,\n    } = useAlbumListFilters();\n\n    const genreListQuery = useQuery(\n        genresQueries.list({\n            options: {\n                gcTime: 1000 * 60 * 2,\n                staleTime: 1000 * 60 * 1,\n            },\n            query: {\n                sortBy: GenreListSort.NAME,\n                sortOrder: SortOrder.ASC,\n                startIndex: 0,\n            },\n            serverId,\n        }),\n    );\n\n    const genreList = useMemo(() => {\n        if (!genreListQuery?.data) return [];\n        return genreListQuery.data.items.map((genre) => ({\n            albumCount: genre.albumCount,\n            label: genre.name,\n            songCount: genre.songCount,\n            value: genre.id,\n        }));\n    }, [genreListQuery.data]);\n\n    // Helper function to convert boolean/null to segment value\n    const booleanToSegmentValue = (value: boolean | null | undefined): string => {\n        if (value === true) return 'true';\n        if (value === false) return 'false';\n        return 'none';\n    };\n\n    // Helper function to convert segment value to boolean/null\n    const segmentValueToBoolean = (value: string): boolean | null => {\n        if (value === 'true') return true;\n        if (value === 'false') return false;\n        return null;\n    };\n\n    const segmentedControlData = useMemo(\n        () => [\n            {\n                label: t('common.none', { postProcess: 'titleCase' }),\n                value: 'none',\n            },\n            {\n                label: t('common.yes', { postProcess: 'titleCase' }),\n                value: 'true',\n            },\n            {\n                label: t('common.no', { postProcess: 'titleCase' }),\n                value: 'false',\n            },\n        ],\n        [t],\n    );\n\n    const toggleFilters = useMemo(\n        () => [\n            {\n                label: t('filter.isRecentlyPlayed', { postProcess: 'sentenceCase' }),\n                onChange: (e: ChangeEvent<HTMLInputElement>) => {\n                    const recentlyPlayed = e.currentTarget.checked ? true : undefined;\n                    setRecentlyPlayed(recentlyPlayed ?? null);\n                },\n                value: query.isRecentlyPlayed,\n            },\n        ],\n        [t, query.isRecentlyPlayed, setRecentlyPlayed],\n    );\n\n    const handleYearFilter = useMemo(\n        () => (e: number | string) => {\n            // Handle empty string, null, undefined, or invalid numbers as clearing\n\n            if (e === '' || e === null || e === undefined) {\n                setMinYear(null);\n                setMaxYear(null);\n                return;\n            }\n\n            const year = typeof e === 'number' ? e : Number(e);\n            // If it's a valid number, set it; otherwise clear\n            if (!isNaN(year) && isFinite(year) && year > 0) {\n                setMinYear(year);\n                setMaxYear(year);\n            } else {\n                setMinYear(null);\n                setMaxYear(null);\n            }\n        },\n        [setMinYear, setMaxYear],\n    );\n\n    const albumArtistListQuery = useQuery(\n        artistsQueries.albumArtistList({\n            options: {\n                gcTime: 1000 * 60 * 2,\n                staleTime: 1000 * 60 * 1,\n            },\n            query: {\n                sortBy: AlbumArtistListSort.NAME,\n                sortOrder: SortOrder.ASC,\n                startIndex: 0,\n            },\n            serverId,\n        }),\n    );\n\n    const selectableAlbumArtists = useMemo(() => {\n        if (!albumArtistListQuery?.data?.items) return [];\n\n        return albumArtistListQuery?.data?.items?.map((artist) => ({\n            albumCount: artist.albumCount,\n            imageUrl: getItemImageUrl({\n                id: artist.id,\n                itemType: LibraryItem.ARTIST,\n                type: 'table',\n            }),\n            label: artist.name,\n            songCount: artist.songCount,\n            value: artist.id,\n        }));\n    }, [albumArtistListQuery.data?.items]);\n\n    const debouncedHandleYearFilter = useDebouncedCallback(handleYearFilter, 300);\n\n    const handleGenreChange = useCallback(\n        (e: null | string[]) => {\n            if (e && e.length > 0) {\n                setGenreId(e);\n            } else {\n                setGenreId(null);\n            }\n        },\n        [setGenreId],\n    );\n\n    const selectedArtistIds = useMemo(() => query.artistIds || [], [query.artistIds]);\n    const selectedGenreIds = useMemo(() => query.genreIds || [], [query.genreIds]);\n\n    const handleAlbumArtistChange = useCallback(\n        (e: null | string[]) => {\n            if (e && e.length > 0) {\n                setAlbumArtist(e);\n            } else {\n                setAlbumArtist(null);\n            }\n        },\n        [setAlbumArtist],\n    );\n\n    const handleArtistSelectModeChange = useCallback(\n        (value: string) => {\n            const newMode = value as 'multi' | 'single';\n            setArtistSelectMode(newMode);\n\n            if (newMode === 'single' && selectedArtistIds.length > 1) {\n                setAlbumArtist([selectedArtistIds[0]]);\n            }\n        },\n        [selectedArtistIds, setAlbumArtist, setArtistSelectMode],\n    );\n\n    const handleGenreSelectModeChange = useCallback(\n        (value: string) => {\n            const newMode = value as 'multi' | 'single';\n            setGenreSelectMode(newMode);\n\n            if (newMode === 'single' && selectedGenreIds.length > 1) {\n                setGenreId([selectedGenreIds[0]]);\n            }\n        },\n        [selectedGenreIds, setGenreId, setGenreSelectMode],\n    );\n\n    const artistFilterLabel = useMemo(() => {\n        return (\n            <Group gap=\"xs\" justify=\"space-between\" w=\"100%\">\n                <Text fw={500} size=\"sm\">\n                    {t('entity.artist', { count: 2, postProcess: 'sentenceCase' })}\n                </Text>\n                <SegmentedControl\n                    data={[\n                        {\n                            label: t('common.filter_single', { postProcess: 'titleCase' }),\n                            value: 'single',\n                        },\n                        {\n                            label: t('common.filter_multiple', { postProcess: 'titleCase' }),\n                            value: 'multi',\n                        },\n                    ]}\n                    onChange={handleArtistSelectModeChange}\n                    size=\"xs\"\n                    value={artistSelectMode}\n                />\n            </Group>\n        );\n    }, [artistSelectMode, handleArtistSelectModeChange, t]);\n\n    const genreFilterLabel = useMemo(() => {\n        return (\n            <Group gap=\"xs\" justify=\"space-between\" w=\"100%\">\n                <Text fw={500} size=\"sm\">\n                    {t('entity.genre', { count: 2, postProcess: 'sentenceCase' })}\n                </Text>\n                <SegmentedControl\n                    data={[\n                        {\n                            label: t('common.filter_single', { postProcess: 'titleCase' }),\n                            value: 'single',\n                        },\n                        {\n                            label: t('common.filter_multiple', { postProcess: 'titleCase' }),\n                            value: 'multi',\n                        },\n                    ]}\n                    onChange={handleGenreSelectModeChange}\n                    size=\"xs\"\n                    value={genreSelectMode}\n                />\n            </Group>\n        );\n    }, [genreSelectMode, handleGenreSelectModeChange, t]);\n\n    return (\n        <Stack px=\"md\" py=\"md\">\n            <Stack gap=\"xs\">\n                <Text size=\"sm\" weight={500}>\n                    {t('filter.isFavorited', { postProcess: 'sentenceCase' })}\n                </Text>\n                <SegmentedControl\n                    data={segmentedControlData}\n                    onChange={(value) => {\n                        setFavorite(segmentValueToBoolean(value));\n                    }}\n                    size=\"sm\"\n                    value={booleanToSegmentValue(query.favorite)}\n                    w=\"100%\"\n                />\n            </Stack>\n            <Stack gap=\"xs\">\n                <Text size=\"sm\" weight={500}>\n                    {t('filter.isRated', { postProcess: 'sentenceCase' })}\n                </Text>\n                <SegmentedControl\n                    data={segmentedControlData}\n                    onChange={(value) => {\n                        setHasRating(segmentValueToBoolean(value));\n                    }}\n                    size=\"sm\"\n                    value={booleanToSegmentValue(query.hasRating)}\n                    w=\"100%\"\n                />\n            </Stack>\n            <Stack gap=\"xs\">\n                <Text size=\"sm\" weight={500}>\n                    {t('filter.isCompilation', { postProcess: 'sentenceCase' })}\n                </Text>\n                <SegmentedControl\n                    data={segmentedControlData}\n                    onChange={(value) => {\n                        setCompilation(segmentValueToBoolean(value));\n                    }}\n                    size=\"sm\"\n                    value={booleanToSegmentValue(query.compilation)}\n                    w=\"100%\"\n                />\n            </Stack>\n            {toggleFilters.map((filter) => (\n                <Group justify=\"space-between\" key={`nd-filter-${filter.label}`}>\n                    <Text>{filter.label}</Text>\n                    <Switch checked={filter?.value ?? false} onChange={filter.onChange} />\n                </Group>\n            ))}\n            {!disableArtistFilter && (\n                <>\n                    <Divider my=\"md\" />\n                    <VirtualMultiSelect\n                        displayCountType=\"album\"\n                        height={300}\n                        isLoading={albumArtistListQuery.isFetching}\n                        label={artistFilterLabel}\n                        onChange={handleAlbumArtistChange}\n                        options={selectableAlbumArtists}\n                        RowComponent={ArtistMultiSelectRow}\n                        singleSelect={artistSelectMode === 'single'}\n                        value={selectedArtistIds}\n                    />\n                </>\n            )}\n            {!disableGenreFilter && (\n                <>\n                    <Divider my=\"md\" />\n                    <VirtualMultiSelect\n                        displayCountType=\"album\"\n                        height={220}\n                        isLoading={genreListQuery.isFetching}\n                        label={genreFilterLabel}\n                        onChange={handleGenreChange}\n                        options={genreList}\n                        RowComponent={GenreMultiSelectRow}\n                        singleSelect={genreSelectMode === 'single'}\n                        value={selectedGenreIds}\n                    />\n                </>\n            )}\n            <Divider my=\"md\" />\n            <NumberInput\n                hideControls={false}\n                label={t('common.year', { postProcess: 'titleCase' })}\n                max={5000}\n                min={0}\n                onChange={(e) => debouncedHandleYearFilter(e)}\n                value={query.minYear ?? undefined}\n            />\n            <TagFilters query={query} setCustom={setCustom} type={LibraryItem.ALBUM} />\n        </Stack>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/albums/components/subsonic-album-filters.tsx",
    "content": "import { useQuery, useSuspenseQuery } from '@tanstack/react-query';\nimport { ChangeEvent, useCallback, useMemo } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport { getItemImageUrl } from '/@/renderer/components/item-image/item-image';\nimport { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album-list-filters';\nimport { artistsQueries } from '/@/renderer/features/artists/api/artists-api';\nimport { genresQueries } from '/@/renderer/features/genres/api/genres-api';\nimport {\n    ArtistMultiSelectRow,\n    GenreMultiSelectRow,\n} from '/@/renderer/features/shared/components/multi-select-rows';\nimport { useCurrentServerId } from '/@/renderer/store';\nimport { useAppStore, useAppStoreActions } from '/@/renderer/store/app.store';\nimport { Divider } from '/@/shared/components/divider/divider';\nimport { Group } from '/@/shared/components/group/group';\nimport { VirtualMultiSelect } from '/@/shared/components/multi-select/virtual-multi-select';\nimport { NumberInput } from '/@/shared/components/number-input/number-input';\nimport { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { Switch } from '/@/shared/components/switch/switch';\nimport { Text } from '/@/shared/components/text/text';\nimport { useDebouncedCallback } from '/@/shared/hooks/use-debounced-callback';\nimport {\n    AlbumArtistListSort,\n    GenreListSort,\n    LibraryItem,\n    SortOrder,\n} from '/@/shared/types/domain-types';\n\ninterface SubsonicAlbumFiltersProps {\n    disableArtistFilter?: boolean;\n    disableGenreFilter?: boolean;\n}\n\nexport const SubsonicAlbumFilters = ({\n    disableArtistFilter,\n    disableGenreFilter,\n}: SubsonicAlbumFiltersProps) => {\n    const { t } = useTranslation();\n\n    const serverId = useCurrentServerId();\n\n    const { query, setAlbumArtist, setFavorite, setGenreId, setMaxYear, setMinYear } =\n        useAlbumListFilters();\n\n    const albumArtistListQuery = useSuspenseQuery(\n        artistsQueries.albumArtistList({\n            options: {\n                gcTime: 1000 * 60 * 2,\n                staleTime: 1000 * 60 * 1,\n            },\n            query: {\n                sortBy: AlbumArtistListSort.NAME,\n                sortOrder: SortOrder.ASC,\n                startIndex: 0,\n            },\n            serverId,\n        }),\n    );\n\n    const items = albumArtistListQuery?.data?.items;\n\n    const selectableAlbumArtists = useMemo(() => {\n        if (!items) return [];\n\n        return items.map((artist) => ({\n            albumCount: artist.albumCount,\n            imageUrl: getItemImageUrl({\n                id: artist.id,\n                itemType: LibraryItem.ARTIST,\n                type: 'table',\n            }),\n            label: artist.name,\n            songCount: artist.songCount,\n            value: artist.id,\n        }));\n    }, [items]);\n\n    const hasFavorite = query.favorite === true;\n    const hasArtist = query.artistIds && query.artistIds.length > 0;\n    const hasGenre = query.genreIds && query.genreIds.length > 0;\n    const hasYear = query.minYear !== undefined || query.maxYear !== undefined;\n\n    const isFavoriteDisabled = hasArtist || hasGenre || hasYear;\n    const isArtistDisabled = hasFavorite || hasGenre || hasYear;\n    const isGenreDisabled = hasFavorite || hasArtist || hasYear;\n    const isYearDisabled = hasFavorite || hasArtist || hasGenre;\n\n    const handleAlbumArtistFilter = useCallback(\n        (e: null | string[]) => {\n            if (isArtistDisabled && e !== null) return;\n            setAlbumArtist(e ?? null);\n        },\n        [isArtistDisabled, setAlbumArtist],\n    );\n\n    const genreListQuery = useQuery(\n        genresQueries.list({\n            options: {\n                gcTime: 1000 * 60 * 2,\n                staleTime: 1000 * 60 * 1,\n            },\n            query: {\n                sortBy: GenreListSort.NAME,\n                sortOrder: SortOrder.ASC,\n                startIndex: 0,\n            },\n            serverId,\n        }),\n    );\n\n    const genreList = useMemo(() => {\n        if (!genreListQuery?.data) return [];\n        return genreListQuery.data.items.map((genre) => ({\n            albumCount: genre.albumCount,\n            label: genre.name,\n            songCount: genre.songCount,\n            value: genre.id,\n        }));\n    }, [genreListQuery.data]);\n\n    const selectedGenreIds = useMemo(() => query.genreIds || [], [query.genreIds]);\n\n    const handleGenresFilter = useCallback(\n        (e: null | string[]) => {\n            if (isGenreDisabled && e !== null && e.length > 0) return; // Prevent setting if disabled\n            if (e && e.length > 0) {\n                setGenreId([e[0]]);\n            } else {\n                setGenreId(null);\n            }\n        },\n        [isGenreDisabled, setGenreId],\n    );\n\n    const genreFilterLabel = useMemo(() => {\n        return (\n            <Text fw={500} size=\"sm\">\n                {t('entity.genre', { count: 1, postProcess: 'sentenceCase' })}\n            </Text>\n        );\n    }, [t]);\n\n    const toggleFilters = useMemo(\n        () => [\n            {\n                label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),\n                onChange: (e: ChangeEvent<HTMLInputElement>) => {\n                    if (isFavoriteDisabled && e.target.checked) return; // Prevent setting if disabled\n                    const favoriteValue = e.target.checked ? true : undefined;\n                    setFavorite(favoriteValue ?? null);\n                },\n                value: query.favorite,\n            },\n        ],\n        [isFavoriteDisabled, query.favorite, setFavorite, t],\n    );\n\n    const handleMinYearFilter = useMemo(\n        () => (e: number | string) => {\n            if (isYearDisabled) {\n                const isEmpty = e === '' || e === null || e === undefined || isNaN(Number(e));\n                if (!isEmpty) return;\n            }\n\n            // Handle empty string, null, undefined, or invalid numbers as clearing\n            if (e === '' || e === null || e === undefined || isNaN(Number(e))) {\n                setMinYear(null);\n                return;\n            }\n\n            const year = typeof e === 'number' ? e : Number(e);\n            // If it's a valid number, set it; otherwise clear\n            if (!isNaN(year) && isFinite(year) && year > 0) {\n                setMinYear(year);\n            } else {\n                setMinYear(null);\n            }\n        },\n        [isYearDisabled, setMinYear],\n    );\n\n    const handleMaxYearFilter = useMemo(\n        () => (e: number | string) => {\n            if (isYearDisabled) {\n                const isEmpty = e === '' || e === null || e === undefined || isNaN(Number(e));\n                if (!isEmpty) return;\n            }\n\n            // Handle empty string, null, undefined, or invalid numbers as clearing\n            if (e === '' || e === null || e === undefined || isNaN(Number(e))) {\n                setMaxYear(null);\n                return;\n            }\n\n            const year = typeof e === 'number' ? e : Number(e);\n            // If it's a valid number, set it; otherwise clear\n            if (!isNaN(year) && isFinite(year) && year > 0) {\n                setMaxYear(year);\n            } else {\n                setMaxYear(null);\n            }\n        },\n        [isYearDisabled, setMaxYear],\n    );\n\n    const debouncedHandleMinYearFilter = useDebouncedCallback(handleMinYearFilter, 300);\n    const debouncedHandleMaxYearFilter = useDebouncedCallback(handleMaxYearFilter, 300);\n\n    const artistSelectMode = useAppStore((state) => state.artistSelectMode);\n    const { setArtistSelectMode } = useAppStoreActions();\n\n    const selectedArtistIds = useMemo(() => query.artistIds || [], [query.artistIds]);\n\n    const handleArtistSelectModeChange = useCallback(\n        (value: string) => {\n            const newMode = value as 'multi' | 'single';\n            setArtistSelectMode(newMode);\n\n            if (newMode === 'single' && selectedArtistIds.length > 1) {\n                setAlbumArtist([selectedArtistIds[0]]);\n            }\n        },\n        [selectedArtistIds, setAlbumArtist, setArtistSelectMode],\n    );\n\n    const artistFilterLabel = useMemo(() => {\n        return (\n            <Group gap=\"xs\" justify=\"space-between\" w=\"100%\">\n                <Text fw={500} size=\"sm\">\n                    {t('entity.artist', { count: 2, postProcess: 'sentenceCase' })}\n                </Text>\n                <SegmentedControl\n                    data={[\n                        {\n                            label: t('common.filter_single', { postProcess: 'titleCase' }),\n                            value: 'single',\n                        },\n                        {\n                            label: t('common.filter_multiple', { postProcess: 'titleCase' }),\n                            value: 'multi',\n                        },\n                    ]}\n                    disabled={isArtistDisabled}\n                    onChange={handleArtistSelectModeChange}\n                    size=\"xs\"\n                    value={artistSelectMode}\n                />\n            </Group>\n        );\n    }, [artistSelectMode, handleArtistSelectModeChange, isArtistDisabled, t]);\n\n    return (\n        <Stack px=\"md\" py=\"md\">\n            {toggleFilters.map((filter) => (\n                <Group justify=\"space-between\" key={`ss-filter-${filter.label}`}>\n                    <Text>{filter.label}</Text>\n                    <Switch\n                        checked={filter.value ?? false}\n                        disabled={isFavoriteDisabled}\n                        onChange={filter.onChange}\n                    />\n                </Group>\n            ))}\n            {!disableArtistFilter && (\n                <>\n                    <Divider my=\"md\" />\n                    <VirtualMultiSelect\n                        disabled={isArtistDisabled}\n                        displayCountType=\"album\"\n                        height={300}\n                        isLoading={albumArtistListQuery.isFetching}\n                        label={artistFilterLabel}\n                        onChange={handleAlbumArtistFilter}\n                        options={selectableAlbumArtists}\n                        RowComponent={ArtistMultiSelectRow}\n                        singleSelect={artistSelectMode === 'single'}\n                        value={selectedArtistIds}\n                    />\n                </>\n            )}\n            {!disableGenreFilter && (\n                <>\n                    <Divider my=\"md\" />\n                    <VirtualMultiSelect\n                        disabled={isGenreDisabled}\n                        displayCountType=\"album\"\n                        height={220}\n                        isLoading={genreListQuery.isFetching}\n                        label={genreFilterLabel}\n                        onChange={handleGenresFilter}\n                        options={genreList}\n                        RowComponent={GenreMultiSelectRow}\n                        singleSelect={true}\n                        value={selectedGenreIds}\n                    />\n                </>\n            )}\n            <Divider my=\"md\" />\n            <Group grow>\n                <NumberInput\n                    disabled={isYearDisabled}\n                    hideControls={false}\n                    label={t('filter.fromYear', { postProcess: 'sentenceCase' })}\n                    max={5000}\n                    min={0}\n                    onChange={(e) => debouncedHandleMinYearFilter(e)}\n                    value={query.minYear ?? undefined}\n                />\n                <NumberInput\n                    disabled={isYearDisabled}\n                    hideControls={false}\n                    label={t('filter.toYear', { postProcess: 'sentenceCase' })}\n                    max={5000}\n                    min={0}\n                    onChange={(e) => debouncedHandleMaxYearFilter(e)}\n                    value={query.maxYear ?? undefined}\n                />\n            </Group>\n        </Stack>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/albums/hooks/use-album-list-filters.ts",
    "content": "import { useCallback, useMemo } from 'react';\nimport { useSearchParams } from 'react-router';\n\nimport { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-search-term-filter';\nimport { useSortByFilter } from '/@/renderer/features/shared/hooks/use-sort-by-filter';\nimport { useSortOrderFilter } from '/@/renderer/features/shared/hooks/use-sort-order-filter';\nimport { FILTER_KEYS } from '/@/renderer/features/shared/utils';\nimport {\n    parseArrayParam,\n    parseBooleanParam,\n    parseCustomFiltersParam,\n    parseIntParam,\n    setMultipleSearchParams,\n    setSearchParam,\n} from '/@/renderer/utils/query-params';\nimport { AlbumListSort, SortOrder } from '/@/shared/types/domain-types';\nimport { ItemListKey } from '/@/shared/types/types';\n\nexport const useAlbumListFilters = (listKey?: ItemListKey) => {\n    const resolvedListKey = listKey ?? ItemListKey.ALBUM;\n\n    const { sortBy } = useSortByFilter<AlbumListSort>(AlbumListSort.NAME, resolvedListKey);\n\n    const { sortOrder } = useSortOrderFilter(SortOrder.ASC, resolvedListKey);\n\n    const { searchTerm, setSearchTerm } = useSearchTermFilter('');\n\n    const [searchParams, setSearchParams] = useSearchParams();\n\n    const genreId = useMemo(\n        () => parseArrayParam(searchParams, FILTER_KEYS.ALBUM.GENRE_ID),\n        [searchParams],\n    );\n\n    const albumArtist = useMemo(\n        () => parseArrayParam(searchParams, FILTER_KEYS.ALBUM.ARTIST_IDS),\n        [searchParams],\n    );\n\n    const minYear = useMemo(\n        () => parseIntParam(searchParams, FILTER_KEYS.ALBUM.MIN_YEAR),\n        [searchParams],\n    );\n\n    const maxYear = useMemo(\n        () => parseIntParam(searchParams, FILTER_KEYS.ALBUM.MAX_YEAR),\n        [searchParams],\n    );\n\n    const favorite = useMemo(\n        () => parseBooleanParam(searchParams, FILTER_KEYS.ALBUM.FAVORITE),\n        [searchParams],\n    );\n\n    const compilation = useMemo(\n        () => parseBooleanParam(searchParams, FILTER_KEYS.ALBUM.COMPILATION),\n        [searchParams],\n    );\n\n    const hasRating = useMemo(\n        () => parseBooleanParam(searchParams, FILTER_KEYS.ALBUM.HAS_RATING),\n        [searchParams],\n    );\n\n    const recentlyPlayed = useMemo(\n        () => parseBooleanParam(searchParams, FILTER_KEYS.ALBUM.RECENTLY_PLAYED),\n        [searchParams],\n    );\n\n    const custom = useMemo(\n        () => parseCustomFiltersParam(searchParams, FILTER_KEYS.ALBUM._CUSTOM),\n        [searchParams],\n    );\n\n    const setGenreId = useCallback(\n        (value: null | string[]) => {\n            setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.GENRE_ID, value), {\n                replace: true,\n            });\n        },\n        [setSearchParams],\n    );\n\n    const setAlbumArtist = useCallback(\n        (value: null | string[]) => {\n            setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.ARTIST_IDS, value), {\n                replace: true,\n            });\n        },\n        [setSearchParams],\n    );\n\n    const setMinYear = useCallback(\n        (value: null | number) => {\n            setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.MIN_YEAR, value), {\n                replace: true,\n            });\n        },\n        [setSearchParams],\n    );\n\n    const setMaxYear = useCallback(\n        (value: null | number) => {\n            setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.MAX_YEAR, value), {\n                replace: true,\n            });\n        },\n        [setSearchParams],\n    );\n\n    const setFavorite = useCallback(\n        (value: boolean | null) => {\n            setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.FAVORITE, value), {\n                replace: true,\n            });\n        },\n        [setSearchParams],\n    );\n\n    const setCompilation = useCallback(\n        (value: boolean | null) => {\n            setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.COMPILATION, value), {\n                replace: true,\n            });\n        },\n        [setSearchParams],\n    );\n\n    const setHasRating = useCallback(\n        (value: boolean | null) => {\n            setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.HAS_RATING, value), {\n                replace: true,\n            });\n        },\n        [setSearchParams],\n    );\n\n    const setRecentlyPlayed = useCallback(\n        (value: boolean | null) => {\n            setSearchParams(\n                (prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.RECENTLY_PLAYED, value),\n                {\n                    replace: true,\n                },\n            );\n        },\n        [setSearchParams],\n    );\n\n    const setCustom = useCallback(\n        (value: null | Record<string, any>) => {\n            setSearchParams(\n                (prev) => {\n                    const previousValue = prev.get(FILTER_KEYS.ALBUM._CUSTOM);\n\n                    const newCustom = {\n                        ...(previousValue ? JSON.parse(previousValue) : {}),\n                        ...value,\n                    };\n\n                    const filteredNewCustom = Object.fromEntries(\n                        Object.entries(newCustom).filter(\n                            ([, value]) => value !== null && value !== undefined,\n                        ),\n                    );\n\n                    prev.set(FILTER_KEYS.ALBUM._CUSTOM, JSON.stringify(filteredNewCustom));\n                    return prev;\n                },\n                {\n                    replace: true,\n                },\n            );\n        },\n        [setSearchParams],\n    );\n\n    const clear = useCallback(() => {\n        setSearchParams(\n            (prev) =>\n                setMultipleSearchParams(\n                    prev,\n                    {\n                        [FILTER_KEYS.ALBUM._CUSTOM]: null,\n                        [FILTER_KEYS.ALBUM.ARTIST_IDS]: null,\n                        [FILTER_KEYS.ALBUM.COMPILATION]: null,\n                        [FILTER_KEYS.ALBUM.FAVORITE]: null,\n                        [FILTER_KEYS.ALBUM.GENRE_ID]: null,\n                        [FILTER_KEYS.ALBUM.HAS_RATING]: null,\n                        [FILTER_KEYS.ALBUM.MAX_YEAR]: null,\n                        [FILTER_KEYS.ALBUM.MIN_YEAR]: null,\n                        [FILTER_KEYS.ALBUM.RECENTLY_PLAYED]: null,\n                        [FILTER_KEYS.SHARED.SEARCH_TERM]: null,\n                    },\n                    new Set([FILTER_KEYS.ALBUM._CUSTOM]),\n                ),\n            { replace: true },\n        );\n    }, [setSearchParams]);\n\n    const query = useMemo(\n        () => ({\n            [FILTER_KEYS.ALBUM._CUSTOM]: custom ?? undefined,\n            [FILTER_KEYS.ALBUM.ARTIST_IDS]: albumArtist ?? undefined,\n            [FILTER_KEYS.ALBUM.COMPILATION]: compilation ?? undefined,\n            [FILTER_KEYS.ALBUM.FAVORITE]: favorite ?? undefined,\n            [FILTER_KEYS.ALBUM.GENRE_ID]: genreId ?? undefined,\n            [FILTER_KEYS.ALBUM.HAS_RATING]: hasRating ?? undefined,\n            [FILTER_KEYS.ALBUM.MAX_YEAR]: maxYear ?? undefined,\n            [FILTER_KEYS.ALBUM.MIN_YEAR]: minYear ?? undefined,\n            [FILTER_KEYS.ALBUM.RECENTLY_PLAYED]: recentlyPlayed ?? undefined,\n            [FILTER_KEYS.SHARED.SEARCH_TERM]: searchTerm ?? undefined,\n            [FILTER_KEYS.SHARED.SORT_BY]: sortBy ?? undefined,\n            [FILTER_KEYS.SHARED.SORT_ORDER]: sortOrder ?? undefined,\n        }),\n        [\n            custom,\n            albumArtist,\n            compilation,\n            favorite,\n            genreId,\n            hasRating,\n            maxYear,\n            minYear,\n            recentlyPlayed,\n            searchTerm,\n            sortBy,\n            sortOrder,\n        ],\n    );\n\n    return {\n        clear,\n        query,\n        setAlbumArtist,\n        setCompilation,\n        setCustom,\n        setFavorite,\n        setGenreId,\n        setHasRating,\n        setMaxYear,\n        setMinYear,\n        setRecentlyPlayed,\n        setSearchTerm,\n    };\n};\n"
  },
  {
    "path": "src/renderer/features/albums/routes/album-detail-route.tsx",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { useRef } from 'react';\nimport { useLocation, useParams } from 'react-router';\n\nimport { useItemImageUrl } from '/@/renderer/components/item-image/item-image';\nimport { NativeScrollArea } from '/@/renderer/components/native-scroll-area/native-scroll-area';\nimport { albumQueries } from '/@/renderer/features/albums/api/album-api';\nimport { AlbumDetailContent } from '/@/renderer/features/albums/components/album-detail-content';\nimport { AlbumDetailHeader } from '/@/renderer/features/albums/components/album-detail-header';\nimport { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';\nimport {\n    LibraryBackgroundImage,\n    LibraryBackgroundOverlay,\n} from '/@/renderer/features/shared/components/library-background-overlay';\nimport { LibraryContainer } from '/@/renderer/features/shared/components/library-container';\nimport { LibraryHeaderBar } from '/@/renderer/features/shared/components/library-header-bar';\nimport { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';\nimport { useFastAverageColor } from '/@/renderer/hooks';\nimport { useAlbumBackground, useCurrentServer } from '/@/renderer/store';\nimport { Spinner } from '/@/shared/components/spinner/spinner';\nimport { LibraryItem } from '/@/shared/types/domain-types';\n\nconst AlbumDetailRoute = () => {\n    const scrollAreaRef = useRef<HTMLDivElement>(null);\n    const headerRef = useRef<HTMLDivElement>(null);\n    const { albumBackground, albumBackgroundBlur } = useAlbumBackground();\n\n    const { albumId } = useParams() as { albumId: string };\n    const server = useCurrentServer();\n\n    const location = useLocation();\n\n    const detailQuery = useQuery({\n        ...albumQueries.detail({ query: { id: albumId }, serverId: server?.id }),\n        placeholderData: location.state?.item,\n    });\n\n    const imageUrl =\n        useItemImageUrl({\n            id: detailQuery?.data?.imageId || undefined,\n            itemType: LibraryItem.ALBUM,\n            type: 'itemCard',\n        }) || '';\n\n    const { background: backgroundColor, isLoading: isColorLoading } = useFastAverageColor({\n        id: albumId,\n        src: imageUrl,\n        srcLoaded: true,\n    });\n\n    const background = backgroundColor;\n\n    const showBlurredImage = albumBackground;\n\n    if (isColorLoading) {\n        return <Spinner container />;\n    }\n\n    return (\n        <AnimatedPage key={`album-detail-${albumId}`}>\n            <NativeScrollArea\n                pageHeaderProps={{\n                    backgroundColor: backgroundColor || undefined,\n                    children: (\n                        <LibraryHeaderBar>\n                            <LibraryHeaderBar.PlayButton\n                                ids={[albumId]}\n                                itemType={LibraryItem.ALBUM}\n                                variant=\"default\"\n                            />\n                            <LibraryHeaderBar.Title>\n                                {detailQuery?.data?.name}\n                            </LibraryHeaderBar.Title>\n                        </LibraryHeaderBar>\n                    ),\n                    offset: 200,\n                    target: headerRef,\n                }}\n                ref={scrollAreaRef}\n            >\n                {showBlurredImage ? (\n                    <LibraryBackgroundImage\n                        blur={albumBackgroundBlur}\n                        headerRef={headerRef}\n                        imageUrl={imageUrl}\n                    />\n                ) : (\n                    <LibraryBackgroundOverlay backgroundColor={background} headerRef={headerRef} />\n                )}\n                <LibraryContainer>\n                    <AlbumDetailHeader ref={headerRef as React.Ref<HTMLDivElement>} />\n                    <AlbumDetailContent />\n                </LibraryContainer>\n            </NativeScrollArea>\n        </AnimatedPage>\n    );\n};\n\nconst AlbumDetailRouteWithBoundary = () => {\n    return (\n        <PageErrorBoundary>\n            <AlbumDetailRoute />\n        </PageErrorBoundary>\n    );\n};\n\nexport default AlbumDetailRouteWithBoundary;\n"
  },
  {
    "path": "src/renderer/features/albums/routes/album-list-route.tsx",
    "content": "import { useMemo, useState } from 'react';\nimport { useParams } from 'react-router';\n\nimport { ListContext } from '/@/renderer/context/list-context';\nimport { AlbumListContent } from '/@/renderer/features/albums/components/album-list-content';\nimport { AlbumListHeader } from '/@/renderer/features/albums/components/album-list-header';\nimport { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';\nimport { ListWithSidebarContainer } from '/@/renderer/features/shared/components/list-with-sidebar-container';\nimport { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';\nimport { usePageSidebar } from '/@/renderer/store/app.store';\nimport { AlbumListQuery } from '/@/shared/types/domain-types';\nimport { ItemListKey } from '/@/shared/types/types';\n\nconst getPageKey = (options: { albumArtistId?: string; genreId?: string }) => {\n    if (options.albumArtistId) {\n        return ItemListKey.ALBUM_ARTIST_ALBUM;\n    }\n\n    if (options.genreId) {\n        return ItemListKey.GENRE_ALBUM;\n    }\n\n    return ItemListKey.ALBUM;\n};\n\nconst AlbumListRoute = () => {\n    const { albumArtistId, genreId } = useParams();\n    const pageKey = getPageKey({ albumArtistId, genreId });\n\n    const [itemCount, setItemCount] = useState<number | undefined>(undefined);\n    const [isSidebarOpen, setIsSidebarOpen] = usePageSidebar(pageKey);\n\n    const customFilters: Partial<AlbumListQuery> = useMemo(() => {\n        if (albumArtistId) {\n            return {\n                artistIds: [albumArtistId],\n            };\n        }\n\n        if (genreId) {\n            return {\n                genreIds: [genreId],\n            };\n        }\n\n        return {};\n    }, [albumArtistId, genreId]);\n\n    const providerValue = useMemo(() => {\n        return {\n            customFilters,\n            id: albumArtistId ?? genreId,\n            isSidebarOpen,\n            itemCount,\n            pageKey,\n            setIsSidebarOpen,\n            setItemCount,\n        };\n    }, [\n        albumArtistId,\n        customFilters,\n        genreId,\n        isSidebarOpen,\n        itemCount,\n        pageKey,\n        setIsSidebarOpen,\n    ]);\n\n    return (\n        <AnimatedPage>\n            <ListContext.Provider value={providerValue}>\n                <AlbumListHeader />\n                <ListWithSidebarContainer>\n                    <AlbumListContent />\n                </ListWithSidebarContainer>\n            </ListContext.Provider>\n        </AnimatedPage>\n    );\n};\n\nconst AlbumListRouteWithBoundary = () => {\n    return (\n        <PageErrorBoundary>\n            <AlbumListRoute />\n        </PageErrorBoundary>\n    );\n};\n\nexport default AlbumListRouteWithBoundary;\n"
  },
  {
    "path": "src/renderer/features/albums/routes/dummy-album-detail-route.module.css",
    "content": ".detail-container {\n    display: flex;\n    flex-direction: column;\n    gap: 2rem;\n    padding: 1rem 2rem 5rem;\n    overflow: hidden;\n}\n"
  },
  {
    "path": "src/renderer/features/albums/routes/dummy-album-detail-route.tsx",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { Fragment } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { generatePath, Link, useParams } from 'react-router';\n\nimport styles from './dummy-album-detail-route.module.css';\n\nimport { api } from '/@/renderer/api';\nimport { queryKeys } from '/@/renderer/api/query-keys';\nimport { useItemImageUrl } from '/@/renderer/components/item-image/item-image';\nimport { usePlayer } from '/@/renderer/features/player/context/player-context';\nimport { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';\nimport { LibraryContainer } from '/@/renderer/features/shared/components/library-container';\nimport { LibraryHeader } from '/@/renderer/features/shared/components/library-header';\nimport { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';\nimport { DefaultPlayButton } from '/@/renderer/features/shared/components/play-button';\nimport { useCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation';\nimport { useDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation';\nimport { useFastAverageColor } from '/@/renderer/hooks';\nimport { queryClient } from '/@/renderer/lib/react-query';\nimport { AppRoute } from '/@/renderer/router/routes';\nimport { useCurrentServer } from '/@/renderer/store';\nimport { usePlayButtonBehavior } from '/@/renderer/store/settings.store';\nimport { formatDurationString } from '/@/renderer/utils';\nimport { replaceURLWithHTMLLinks } from '/@/renderer/utils/linkify';\nimport { ActionIcon } from '/@/shared/components/action-icon/action-icon';\nimport { Button } from '/@/shared/components/button/button';\nimport { Center } from '/@/shared/components/center/center';\nimport { Group } from '/@/shared/components/group/group';\nimport { Icon } from '/@/shared/components/icon/icon';\nimport { Spoiler } from '/@/shared/components/spoiler/spoiler';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { Text } from '/@/shared/components/text/text';\nimport { LibraryItem, SongDetailResponse } from '/@/shared/types/domain-types';\n\nconst DummyAlbumDetailRoute = () => {\n    const { t } = useTranslation();\n\n    const { albumId } = useParams() as { albumId: string };\n    const server = useCurrentServer();\n    const queryKey = queryKeys.songs.detail(server?.id || '', albumId);\n    const detailQuery = useQuery({\n        queryFn: ({ signal }) => {\n            return api.controller.getSongDetail({\n                apiClientProps: { serverId: server?.id || '', signal },\n                query: { id: albumId },\n            });\n        },\n        queryKey,\n    });\n\n    const { background, colorId } = useFastAverageColor({\n        id: albumId,\n        src: detailQuery.data?.imageUrl,\n        srcLoaded: !detailQuery.isLoading,\n    });\n    const { addToQueueByFetch } = usePlayer();\n    const playButtonBehavior = usePlayButtonBehavior();\n\n    const createFavoriteMutation = useCreateFavorite({});\n    const deleteFavoriteMutation = useDeleteFavorite({});\n\n    const handleFavorite = async () => {\n        if (!detailQuery?.data) return;\n\n        const wasFavorite = detailQuery.data.userFavorite;\n\n        try {\n            if (wasFavorite) {\n                await deleteFavoriteMutation.mutateAsync({\n                    apiClientProps: { serverId: detailQuery.data._serverId },\n                    query: {\n                        id: [detailQuery.data.id],\n                        type: LibraryItem.SONG,\n                    },\n                });\n            } else {\n                await createFavoriteMutation.mutateAsync({\n                    apiClientProps: { serverId: detailQuery.data._serverId },\n                    query: {\n                        id: [detailQuery.data.id],\n                        type: LibraryItem.SONG,\n                    },\n                });\n            }\n\n            queryClient.setQueryData<SongDetailResponse>(queryKey, {\n                ...detailQuery.data,\n                userFavorite: !wasFavorite,\n            });\n        } catch (error) {\n            console.error(error);\n        }\n    };\n\n    const showGenres = detailQuery?.data?.genres ? detailQuery?.data?.genres.length !== 0 : false;\n    const comment = detailQuery?.data?.comment;\n\n    const handlePlay = () => {\n        if (!server?.id) return;\n        addToQueueByFetch(server.id, [albumId], LibraryItem.SONG, playButtonBehavior);\n    };\n\n    const metadataItems = [\n        {\n            id: 'releaseYear',\n            secondary: false,\n            value: detailQuery?.data?.releaseYear,\n        },\n        {\n            id: 'duration',\n            secondary: false,\n            value: detailQuery?.data?.duration && formatDurationString(detailQuery.data.duration),\n        },\n    ];\n\n    const imageUrl = useItemImageUrl({\n        id: detailQuery?.data?.imageId || undefined,\n        itemType: LibraryItem.ALBUM,\n        type: 'header',\n    });\n\n    return (\n        <AnimatedPage key={`dummy-album-detail-${albumId}`}>\n            <LibraryContainer>\n                <Stack>\n                    <LibraryHeader\n                        imageUrl={imageUrl}\n                        item={{\n                            explicitStatus: detailQuery?.data?.explicitStatus ?? null,\n                            imageId: detailQuery?.data?.imageId,\n                            imageUrl: detailQuery?.data?.imageUrl,\n                            route: AppRoute.LIBRARY_SONGS,\n                            type: LibraryItem.SONG,\n                        }}\n                        loading={!background || colorId !== albumId}\n                        title={detailQuery?.data?.name || ''}\n                    >\n                        <Stack gap=\"sm\">\n                            <Group gap=\"sm\">\n                                {metadataItems.map((item, index) => (\n                                    <Fragment key={`item-${item.id}-${index}`}>\n                                        {index > 0 && <Text isNoSelect>•</Text>}\n                                        <Text isMuted={item.secondary}>{item.value}</Text>\n                                    </Fragment>\n                                ))}\n                            </Group>\n                            <Group\n                                gap=\"md\"\n                                mah=\"4rem\"\n                                style={{\n                                    overflow: 'hidden',\n                                    WebkitBoxOrient: 'vertical',\n                                    WebkitLineClamp: 2,\n                                }}\n                            >\n                                {detailQuery?.data?.albumArtists.map((artist) => (\n                                    <Text\n                                        component={Link}\n                                        fw={600}\n                                        isLink\n                                        key={`artist-${artist.id}`}\n                                        size=\"md\"\n                                        to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {\n                                            albumArtistId: artist.id,\n                                        })}\n                                        variant=\"subtle\"\n                                    >\n                                        {artist.name}\n                                    </Text>\n                                ))}\n                            </Group>\n                        </Stack>\n                    </LibraryHeader>\n                </Stack>\n                <div className={styles.detailContainer}>\n                    <section>\n                        <Group gap=\"sm\" justify=\"space-between\">\n                            <Group>\n                                <DefaultPlayButton onClick={() => handlePlay()} />\n                                <ActionIcon\n                                    icon=\"favorite\"\n                                    iconProps={{\n                                        fill: detailQuery?.data?.userFavorite\n                                            ? 'primary'\n                                            : undefined,\n                                    }}\n                                    loading={\n                                        createFavoriteMutation.isPending ||\n                                        deleteFavoriteMutation.isPending\n                                    }\n                                    onClick={handleFavorite}\n                                    variant=\"subtle\"\n                                />\n                                <ActionIcon\n                                    icon=\"ellipsisHorizontal\"\n                                    onClick={() => {\n                                        if (!detailQuery?.data) return;\n                                    }}\n                                    variant=\"subtle\"\n                                />\n                            </Group>\n                        </Group>\n                    </section>\n                    {showGenres && (\n                        <section>\n                            <Group gap=\"sm\">\n                                {detailQuery?.data?.genres?.map((genre) => (\n                                    <Button\n                                        component={Link}\n                                        key={`genre-${genre.id}`}\n                                        radius={0}\n                                        size=\"compact-md\"\n                                        to={generatePath(AppRoute.LIBRARY_GENRES_DETAIL, {\n                                            genreId: genre.id,\n                                        })}\n                                        variant=\"outline\"\n                                    >\n                                        {genre.name}\n                                    </Button>\n                                ))}\n                            </Group>\n                        </section>\n                    )}\n                    {comment && (\n                        <section>\n                            <Spoiler maxHeight={75}>\n                                <Text pb=\"md\">{replaceURLWithHTMLLinks(comment)}</Text>\n                            </Spoiler>\n                        </section>\n                    )}\n                    <section>\n                        <Center>\n                            <Group mr={5}>\n                                <Icon fill=\"error\" icon=\"error\" size={30} />\n                            </Group>\n                            <h2>{t('error.badAlbum', { postProcess: 'sentenceCase' })}</h2>\n                        </Center>\n                    </section>\n                </div>\n            </LibraryContainer>\n        </AnimatedPage>\n    );\n};\n\nconst DummyAlbumDetailRouteWithBoundary = () => {\n    return (\n        <PageErrorBoundary>\n            <DummyAlbumDetailRoute />\n        </PageErrorBoundary>\n    );\n};\n\nexport default DummyAlbumDetailRouteWithBoundary;\n"
  },
  {
    "path": "src/renderer/features/analytics/hooks/use-analytics-disabled.ts",
    "content": "export const isAnalyticsDisabled = () => {\n    const isSettingOptOut = localStorage.getItem('umami.disabled') === '1';\n    const isDevMode = process.env.NODE_ENV === 'development';\n    const isEnvOptOut =\n        window && (window.ANALYTICS_DISABLED === true || window.ANALYTICS_DISABLED === 'true');\n\n    return isSettingOptOut || isDevMode || isEnvOptOut;\n};\n"
  },
  {
    "path": "src/renderer/features/analytics/hooks/use-app-tracker.ts",
    "content": "import { mutationOptions, useMutation } from '@tanstack/react-query';\nimport dayjs from 'dayjs';\nimport utc from 'dayjs/plugin/utc';\nimport isElectron from 'is-electron';\nimport { useEffect, useRef } from 'react';\n\ndayjs.extend(utc);\n\nimport packageJson from '../../../../../package.json';\n\nimport { isAnalyticsDisabled } from '/@/renderer/features/analytics/hooks/use-analytics-disabled';\nimport {\n    PlayerbarSliderType,\n    SideQueueType,\n    useAuthStore,\n    usePlayerStore,\n    useSettingsStore,\n} from '/@/renderer/store';\nimport { LogCategory, logFn } from '/@/renderer/utils/logger';\nimport { logMsg } from '/@/renderer/utils/logger-message';\nimport { LyricSource, ServerType } from '/@/shared/types/domain-types';\nimport { FontType, Platform, PlayerStyle, PlayerType } from '/@/shared/types/types';\n\nconst utils = isElectron() ? window.api.utils : null;\nlet appTrackerInFlight = false;\nlet appTrackerLastSentDate: null | string = null;\n\nconst getVersion = (): AppTrackerProperties['_version'] => {\n    return packageJson.version;\n};\n\nconst getPlatform = (): AppTrackerProperties['_platform'] => {\n    if (!isElectron()) {\n        return Platform.WEB;\n    }\n\n    if (utils?.isWindows()) {\n        return Platform.WINDOWS;\n    }\n\n    if (utils?.isMacOS()) {\n        return Platform.MACOS;\n    }\n\n    if (utils?.isLinux()) {\n        return Platform.LINUX;\n    }\n\n    return 'unknown';\n};\n\ntype AppTrackerProperties = PlayerProperties &\n    SettingsProperties & {\n        _platform: 'unknown' | Platform;\n        _server: 'unknown' | ServerType;\n        _version: string;\n    };\n\ntype PlayerProperties = {\n    'player.mediaSession': boolean;\n    'player.style': PlayerStyle;\n    'player.transcoding': boolean;\n    'player.type': PlayerType;\n    'player.webAudio': boolean;\n};\n\ntype SettingsProperties = {\n    'settings.albumBackground': boolean;\n    'settings.artistBackground': boolean;\n    'settings.autoDJ': boolean;\n    'settings.autoDJItemCount': number;\n    'settings.autoDJTiming': number;\n    'settings.customCss': boolean;\n    'settings.disableAutoUpdate': boolean;\n    'settings.discord': boolean;\n    'settings.exitToTray': boolean;\n    'settings.followSystemTheme': boolean;\n    'settings.fontType': FontType;\n    'settings.globalHotkeys': boolean;\n    'settings.homeFeature': boolean;\n    'settings.language': string;\n    'settings.lyrics.enableAutoTranslation': boolean;\n    'settings.lyrics.enableNeteaseTranslation': boolean;\n    'settings.lyrics.fetch': boolean;\n    'settings.lyrics.sources.genius': boolean;\n    'settings.lyrics.sources.lrclib': boolean;\n    'settings.lyrics.sources.netease': boolean;\n    'settings.minimizeToTray': boolean;\n    'settings.nativeAspectRatio': boolean;\n    'settings.playerbarSliderType': PlayerbarSliderType;\n    'settings.preventSleepOnPlayback': boolean;\n    'settings.releaseChannel': string;\n    'settings.resume': boolean;\n    'settings.scrobble.enabled': boolean;\n    'settings.scrobble.notify': boolean;\n    'settings.showLyricsInSidebar': boolean;\n    'settings.showVisualizerInSidebar': boolean;\n    'settings.sideQueueType': SideQueueType;\n    'settings.skipButtons': boolean;\n    'settings.startMinimized': boolean;\n    'settings.theme': string;\n    'settings.themeDark': string;\n    'settings.themeLight': string;\n    'settings.tray': boolean;\n    'settings.useThemeAccentColor': boolean;\n    'settings.useThemePrimaryShade': boolean;\n    'settings.windowBarStyle': Platform;\n    'settings.zoomFactor': number;\n};\n\nconst getPlayerProperties = (): Pick<\n    AppTrackerProperties,\n    | 'player.mediaSession'\n    | 'player.style'\n    | 'player.transcoding'\n    | 'player.type'\n    | 'player.webAudio'\n> => {\n    const player = usePlayerStore.getState();\n    const playbackSettings = useSettingsStore.getState().playback;\n\n    return {\n        'player.mediaSession': ignoreWeb(playbackSettings.mediaSession),\n        'player.style': player.player.transitionType,\n        'player.transcoding': playbackSettings.transcode.enabled,\n        'player.type': ignoreWeb(playbackSettings.type),\n        'player.webAudio': ignoreWeb(playbackSettings.webAudio),\n    } as any;\n};\n\nfunction ignoreWeb<T>(value: T): T | undefined {\n    return isElectron() ? value : undefined;\n}\n\nconst getSettingsProperties = (): SettingsProperties => {\n    const settings = useSettingsStore.getState();\n\n    return {\n        'settings.albumBackground': settings.general.albumBackground,\n        // 'settings.albumBackgroundBlur': settings.general.albumBackgroundBlur,\n        'settings.artistBackground': settings.general.artistBackground,\n        // 'settings.artistBackgroundBlur': settings.general.artistBackgroundBlur,\n        'settings.autoDJ': settings.autoDJ.enabled,\n        'settings.autoDJItemCount': settings.autoDJ.itemCount,\n        'settings.autoDJTiming': settings.autoDJ.timing,\n        'settings.customCss': settings.css.enabled,\n        'settings.disableAutoUpdate': ignoreWeb(settings.window.disableAutoUpdate),\n        'settings.discord': ignoreWeb(settings.discord.enabled),\n        'settings.exitToTray': ignoreWeb(settings.window.exitToTray),\n        'settings.followSystemTheme': settings.general.followSystemTheme,\n        'settings.fontType': settings.font.type,\n        'settings.globalHotkeys': settings.hotkeys.globalMediaHotkeys,\n        'settings.homeFeature': settings.general.homeFeature,\n        'settings.language': settings.general.language,\n        // 'settings.lastFM': settings.general.lastFM,\n        'settings.lyrics.enableAutoTranslation': ignoreWeb(settings.lyrics.enableAutoTranslation),\n        'settings.lyrics.enableNeteaseTranslation': ignoreWeb(\n            settings.lyrics.enableNeteaseTranslation,\n        ),\n        'settings.lyrics.fetch': ignoreWeb(settings.lyrics.fetch),\n        'settings.lyrics.sources.genius': ignoreWeb(\n            settings.lyrics.sources.includes(LyricSource.GENIUS),\n        ),\n        'settings.lyrics.sources.lrclib': ignoreWeb(\n            settings.lyrics.sources.includes(LyricSource.LRCLIB),\n        ),\n        'settings.lyrics.sources.netease': ignoreWeb(\n            settings.lyrics.sources.includes(LyricSource.NETEASE),\n        ),\n        'settings.lyrics.sources.simpmusic': ignoreWeb(\n            settings.lyrics.sources.includes(LyricSource.SIMPMUSIC),\n        ),\n        'settings.minimizeToTray': ignoreWeb(settings.window.minimizeToTray),\n        // 'settings.musicBrainz': settings.general.musicBrainz,\n        'settings.nativeAspectRatio': settings.general.nativeAspectRatio,\n        'settings.playerbarSliderType': settings.general.playerbarSlider\n            .type as PlayerbarSliderType,\n        // 'settings.playerbarWaveformAlign': settings.general.playerbarSlider.barAlign as BarAlign,\n        // 'settings.playerbarWaveformBarWidth': settings.general.playerbarSlider.barWidth,\n        // 'settings.playerbarWaveformGap': settings.general.playerbarSlider.barGap,\n        // 'settings.playerbarWaveformRadius': settings.general.playerbarSlider.barRadius,\n        'settings.preventSleepOnPlayback': ignoreWeb(settings.window.preventSleepOnPlayback),\n        'settings.releaseChannel': ignoreWeb(settings.window.releaseChannel),\n        'settings.resume': settings.general.resume,\n        'settings.scrobble.enabled': settings.playback.scrobble.enabled,\n        'settings.scrobble.notify': ignoreWeb(settings.playback.scrobble.notify),\n        'settings.showLyricsInSidebar': settings.general.showLyricsInSidebar,\n        'settings.showVisualizerInSidebar': settings.general.showVisualizerInSidebar,\n        'settings.sideQueueType': settings.general.sideQueueType,\n        // 'settings.skipBackwardSeconds': settings.general.skipButtons.skipBackwardSeconds,\n        'settings.skipButtons': settings.general.skipButtons.enabled,\n        // 'settings.skipForwardSeconds': settings.general.skipButtons.skipForwardSeconds,\n        'settings.startMinimized': ignoreWeb(settings.window.startMinimized),\n        'settings.theme': settings.general.theme,\n        'settings.themeDark': settings.general.themeDark,\n        'settings.themeLight': settings.general.themeLight,\n        'settings.tray': ignoreWeb(settings.window.tray),\n        'settings.useThemeAccentColor': settings.general.useThemeAccentColor,\n        'settings.useThemePrimaryShade': settings.general.useThemePrimaryShade,\n        'settings.windowBarStyle': ignoreWeb(settings.window.windowBarStyle),\n        'settings.zoomFactor': ignoreWeb(settings.general.zoomFactor),\n    } as any;\n};\n\nconst getServer = (): 'unknown' | ServerType => {\n    const auth = useAuthStore.getState();\n\n    const currentServer = auth.currentServer;\n\n    if (currentServer) {\n        return currentServer.type;\n    }\n\n    const serverList = auth.serverList;\n    const server = Object.values(serverList)[0];\n\n    if (server) {\n        return server.type;\n    }\n\n    return 'unknown';\n};\n\nexport const useAppTracker = () => {\n    const { mutate: trackAppMutation } = useMutation(appTrackerMutation);\n    const { mutate: trackAppViewMutation } = useMutation(appViewMutation);\n    const hasRunOnMountRef = useRef(false);\n\n    useEffect(() => {\n        if (!window.umami || isAnalyticsDisabled()) {\n            return;\n        }\n\n        const waitForServer = async (): Promise<void> => {\n            if (useAuthStore.getState().currentServer) {\n                return;\n            }\n\n            const pollInterval = 1000 * 60;\n\n            while (!useAuthStore.getState().currentServer) {\n                await new Promise((resolve) => setTimeout(resolve, pollInterval));\n            }\n        };\n\n        const getProperties = () => {\n            const platform = getPlatform();\n            const version = getVersion();\n            const server = getServer();\n            const playerProperties = getPlayerProperties();\n            const settingsProperties = getSettingsProperties();\n\n            const properties: AppTrackerProperties = {\n                _platform: platform,\n                _server: server,\n                _version: version,\n                ...playerProperties,\n                ...settingsProperties,\n            };\n\n            return properties;\n        };\n\n        const checkAndTrack = () => {\n            // Prevent multiple simultaneous requests\n            if (appTrackerInFlight) {\n                return;\n            }\n\n            const lastSentDate = localStorage.getItem('analytics_app_tracker_timestamp');\n            const lastTrackedDate = appTrackerLastSentDate ?? lastSentDate;\n            const todayUTC = dayjs.utc().format('YYYY-MM-DD');\n\n            // Only send if it's a new day in UTC (ensures once per 24 hours)\n            if (lastTrackedDate !== todayUTC) {\n                appTrackerInFlight = true;\n                const properties = getProperties();\n                logFn.info(logMsg[LogCategory.ANALYTICS].appTracked, {\n                    category: LogCategory.ANALYTICS,\n                    meta: { properties, todayUTC },\n                });\n\n                trackAppViewMutation(undefined, {\n                    onError: () => {},\n                });\n\n                trackAppMutation(properties, {\n                    onError: () => {},\n                    onSettled: () => {\n                        appTrackerInFlight = false;\n                    },\n                    onSuccess: () => {\n                        // Only update timestamp on success to ensure we only send once per 24 hours\n                        const utcDate = dayjs.utc().format('YYYY-MM-DD');\n                        appTrackerLastSentDate = utcDate;\n                        localStorage.setItem('analytics_app_tracker_timestamp', utcDate);\n\n                        logFn.debug(logMsg[LogCategory.ANALYTICS].appTracked, {\n                            category: LogCategory.ANALYTICS,\n                            meta: { properties },\n                        });\n                    },\n                });\n            }\n        };\n\n        // Check immediately on mount\n        if (!hasRunOnMountRef.current) {\n            waitForServer().then(() => {\n                checkAndTrack();\n            });\n            hasRunOnMountRef.current = true;\n        }\n\n        const interval = setInterval(checkAndTrack, 1000 * 60 * 60);\n\n        return () => clearInterval(interval);\n    }, [trackAppMutation, trackAppViewMutation]);\n};\n\n// Sends the app event to the analytics server which includes usage data\nconst appTrackerMutation = mutationOptions({\n    mutationFn: (properties: AppTrackerProperties) => {\n        try {\n            window.umami?.track((props) => ({\n                data: properties,\n                language: props.language,\n                name: 'app',\n                screen: props.screen,\n                website: props.website,\n            }));\n            return Promise.resolve();\n        } catch (error) {\n            return Promise.reject(error);\n        }\n    },\n    mutationKey: ['analytics', 'settings-tracker'],\n    onSuccess: () => {},\n    retry: false,\n    throwOnError: false,\n});\n\n// Sends a view event to the analytics server which only includes language, screen, and website\n// and triggers a page view event\nconst appViewMutation = mutationOptions({\n    mutationFn: () => {\n        try {\n            window.umami?.track((props) => ({\n                language: props.language,\n                screen: props.screen,\n                website: props.website,\n            }));\n            return Promise.resolve();\n        } catch (error) {\n            return Promise.reject(error);\n        }\n    },\n    mutationKey: ['analytics', 'app-view'],\n    onSuccess: () => {},\n    retry: false,\n    throwOnError: false,\n});\n"
  },
  {
    "path": "src/renderer/features/analytics/hooks/use-page-tracker.ts",
    "content": "import { mutationOptions, useMutation } from '@tanstack/react-query';\nimport { useEffect, useMemo } from 'react';\nimport { useLocation } from 'react-router';\n\nimport { isAnalyticsDisabled } from '/@/renderer/features/analytics/hooks/use-analytics-disabled';\nimport { getRoutePattern } from '/@/renderer/features/analytics/utils/get-route-pattern';\nimport { LogCategory, logFn } from '/@/renderer/utils/logger';\nimport { logMsg } from '/@/renderer/utils/logger-message';\n\nconst trackPageView = (routePattern: string) => {\n    window.umami?.track((props) => ({\n        language: props.language,\n        url: routePattern,\n        website: props.website,\n    }));\n};\n\nexport const usePageTracker = () => {\n    const location = useLocation();\n    const routePattern = useMemo(() => getRoutePattern(location.pathname), [location.pathname]);\n\n    const { mutate: trackPageViewMutation } = useMutation(pageTrackerMutation);\n\n    useEffect(() => {\n        if (!window.umami || isAnalyticsDisabled()) {\n            return;\n        }\n\n        trackPageViewMutation(routePattern, {\n            onSettled: () => {\n                logFn.debug(logMsg[LogCategory.ANALYTICS].pageViewTracked, {\n                    category: LogCategory.ANALYTICS,\n                    meta: { route: routePattern },\n                });\n            },\n        });\n    }, [routePattern, trackPageViewMutation]);\n};\n\nconst pageTrackerMutation = mutationOptions({\n    gcTime: 0,\n    mutationFn: (routePattern: string) => {\n        try {\n            trackPageView(routePattern);\n            return Promise.resolve();\n        } catch (error) {\n            return Promise.reject(error);\n        }\n    },\n    mutationKey: ['analytics', 'page-tracker'],\n\n    retry: false,\n    throwOnError: false,\n});\n"
  },
  {
    "path": "src/renderer/features/analytics/utils/get-route-pattern.ts",
    "content": "import { matchPath } from 'react-router';\n\nimport { AppRoute } from '/@/renderer/router/routes';\n\nexport const getRoutePattern = (pathname: string): string => {\n    const routePatterns = Object.values(AppRoute);\n\n    const sortedRoutes = routePatterns.sort((a, b) => b.split('/').length - a.split('/').length);\n\n    for (const pattern of sortedRoutes) {\n        const match = matchPath(\n            {\n                caseSensitive: false,\n                end: true,\n                path: pattern,\n            },\n            pathname,\n        );\n\n        if (match) {\n            return pattern;\n        }\n    }\n\n    // Fallback to the default route if no pattern matches\n    return '/';\n};\n"
  },
  {
    "path": "src/renderer/features/artists/api/artists-api.ts",
    "content": "import { queryOptions } from '@tanstack/react-query';\n\nimport { api } from '/@/renderer/api';\nimport { controller } from '/@/renderer/api/controller';\nimport { queryKeys } from '/@/renderer/api/query-keys';\nimport { getOptimizedListCount } from '/@/renderer/api/utils-list-count';\nimport { QueryHookArgs } from '/@/renderer/lib/react-query';\nimport {\n    AlbumArtistDetailQuery,\n    AlbumArtistInfoQuery,\n    AlbumArtistListQuery,\n    ArtistListQuery,\n    ListCountQuery,\n    SongListSort,\n    SortOrder,\n    TopSongListQuery,\n} from '/@/shared/types/domain-types';\n\nexport const artistsQueries = {\n    albumArtistDetail: (args: QueryHookArgs<AlbumArtistDetailQuery>) => {\n        return queryOptions({\n            queryFn: ({ signal }) => {\n                return api.controller.getAlbumArtistDetail({\n                    apiClientProps: { serverId: args.serverId, signal },\n                    query: args.query,\n                });\n            },\n            queryKey: queryKeys.albumArtists.detail(args.serverId, args.query),\n            ...args.options,\n        });\n    },\n    albumArtistInfo: (args: QueryHookArgs<AlbumArtistInfoQuery>) => {\n        return queryOptions({\n            queryFn: ({ signal }) => {\n                return (\n                    api.controller.getAlbumArtistInfo?.({\n                        apiClientProps: { serverId: args.serverId, signal },\n                        query: args.query,\n                    }) ?? Promise.resolve(null)\n                );\n            },\n            queryKey: queryKeys.albumArtists.info(args.serverId, args.query),\n            ...args.options,\n        });\n    },\n    albumArtistList: (args: QueryHookArgs<AlbumArtistListQuery>) => {\n        return queryOptions({\n            queryFn: ({ signal }) => {\n                return api.controller.getAlbumArtistList({\n                    apiClientProps: { serverId: args.serverId, signal },\n                    query: args.query,\n                });\n            },\n            queryKey: queryKeys.albumArtists.list(args.serverId, args.query),\n            ...args.options,\n        });\n    },\n    albumArtistListCount: (args: QueryHookArgs<ListCountQuery<AlbumArtistListQuery>>) => {\n        return queryOptions({\n            gcTime: 1000 * 60 * 60,\n            queryFn: async ({ client, signal }) => {\n                const optimizedCount = await getOptimizedListCount<\n                    ListCountQuery<AlbumArtistListQuery>,\n                    AlbumArtistListQuery,\n                    { totalRecordCount: null | number }\n                >({\n                    client,\n                    listQueryFn: controller.getAlbumArtistList,\n                    listQueryKeyFn: queryKeys.albumArtists.list,\n                    query: args.query,\n                    serverId: args.serverId,\n                    signal,\n                });\n\n                if (optimizedCount !== null) {\n                    return optimizedCount;\n                }\n\n                return api.controller.getAlbumArtistListCount({\n                    apiClientProps: { serverId: args.serverId, signal },\n                    query: args.query,\n                });\n            },\n            queryKey: queryKeys.albumArtists.count(\n                args.serverId,\n                Object.keys(args.query).length === 0 ? undefined : args.query,\n            ),\n            staleTime: 1000 * 60 * 60,\n            ...args.options,\n        });\n    },\n    artistList: (args: QueryHookArgs<ArtistListQuery>) => {\n        return queryOptions({\n            queryFn: ({ signal }) => {\n                return api.controller.getArtistList({\n                    apiClientProps: { serverId: args.serverId, signal },\n                    query: args.query,\n                });\n            },\n            queryKey: queryKeys.artists.list(args.serverId, args.query),\n            ...args.options,\n        });\n    },\n    artistListCount: (args: QueryHookArgs<ListCountQuery<ArtistListQuery>>) => {\n        return queryOptions({\n            gcTime: 1000 * 60 * 60,\n            queryFn: async ({ client, signal }) => {\n                const optimizedCount = await getOptimizedListCount<\n                    ListCountQuery<ArtistListQuery>,\n                    ArtistListQuery,\n                    { totalRecordCount: null | number }\n                >({\n                    client,\n                    listQueryFn: controller.getArtistList,\n                    listQueryKeyFn: queryKeys.artists.list,\n                    query: args.query,\n                    serverId: args.serverId,\n                    signal,\n                });\n\n                if (optimizedCount !== null) {\n                    return optimizedCount;\n                }\n\n                return api.controller\n                    .getArtistList({\n                        apiClientProps: { serverId: args.serverId, signal },\n                        query: { ...args.query, limit: 1, startIndex: 0 },\n                    })\n                    .then((result) => result?.totalRecordCount ?? 0);\n            },\n            queryKey: queryKeys.artists.count(\n                args.serverId,\n                Object.keys(args.query).length === 0 ? undefined : args.query,\n            ),\n            staleTime: 1000 * 60 * 60,\n            ...args.options,\n        });\n    },\n    favoriteSongs: (args: QueryHookArgs<{ artistId: string }>) => {\n        return queryOptions({\n            queryFn: ({ signal }) => {\n                return api.controller.getSongList({\n                    apiClientProps: { serverId: args.serverId, signal },\n                    query: {\n                        artistIds: [args.query.artistId],\n                        favorite: true,\n                        limit: -1,\n                        sortBy: SongListSort.RELEASE_DATE,\n                        sortOrder: SortOrder.ASC,\n                        startIndex: 0,\n                    },\n                });\n            },\n            queryKey: queryKeys.albumArtists.favoriteSongs(args.serverId, args.query.artistId),\n        });\n    },\n    topSongs: (args: QueryHookArgs<TopSongListQuery>) => {\n        return queryOptions({\n            queryFn: ({ signal }) => {\n                return api.controller.getTopSongs({\n                    apiClientProps: { serverId: args.serverId, signal },\n                    query: args.query,\n                });\n            },\n            queryKey: queryKeys.albumArtists.topSongs(args.serverId, args.query),\n            ...args.options,\n        });\n    },\n};\n"
  },
  {
    "path": "src/renderer/features/artists/components/album-artist-detail-content.module.css",
    "content": ".content-container {\n    position: relative;\n    z-index: 0;\n    container-type: inline-size;\n}\n\n.detail-container {\n    display: flex;\n    flex-direction: column;\n    gap: var(--theme-spacing-2xl);\n    padding: 1rem 2rem 5rem;\n}\n\n.album-section-container {\n    display: flex;\n    flex-direction: column;\n    gap: var(--theme-spacing-4xl);\n    width: 100%;\n    min-width: 0;\n}\n\n.album-section-title {\n    display: grid;\n    grid-template-columns: auto 1fr;\n    gap: var(--theme-spacing-md);\n    align-items: center;\n    margin-bottom: var(--theme-spacing-md);\n}\n\n.album-section-divider-container {\n    display: flex;\n    gap: var(--theme-spacing-md);\n    align-items: center;\n    width: 100%;\n}\n\n.album-section-divider {\n    flex: 1;\n    height: 2px;\n    background: var(--theme-colors-border);\n}\n\n.similar-artists-title {\n    display: grid;\n    grid-template-columns: auto 1fr;\n    gap: var(--theme-spacing-md);\n    align-items: center;\n    width: 100%;\n    min-width: 0;\n}\n\n.album-grid {\n    display: flex;\n    flex-wrap: wrap;\n    gap: var(--theme-spacing-md);\n    width: 100%;\n}\n\n.album-grid-item {\n    flex: 1 1\n        calc((100% - (var(--items-per-row) - 1) * var(--theme-spacing-md)) / var(--items-per-row));\n    min-width: 0;\n    max-width: calc(\n        (100% - (var(--items-per-row) - 1) * var(--theme-spacing-md)) / var(--items-per-row)\n    );\n}\n"
  },
  {
    "path": "src/renderer/features/artists/components/album-artist-detail-content.tsx",
    "content": "import {\n    useQuery,\n    useQueryClient,\n    useSuspenseQuery,\n    UseSuspenseQueryResult,\n} from '@tanstack/react-query';\nimport { motion } from 'motion/react';\nimport { memo, Suspense, useCallback, useMemo, useRef, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { createSearchParams, generatePath, Link, useLocation, useParams } from 'react-router';\n\nimport styles from './album-artist-detail-content.module.css';\n\nimport { queryKeys } from '/@/renderer/api/query-keys';\nimport { DataRow, MemoizedItemCard } from '/@/renderer/components/item-card/item-card';\nimport { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls';\nimport { useGridRows } from '/@/renderer/components/item-list/helpers/use-grid-rows';\nimport { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder';\nimport { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize';\nimport { SONG_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns';\nimport { ItemTableList } from '/@/renderer/components/item-list/item-table-list/item-table-list';\nimport { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';\nimport { ItemControls } from '/@/renderer/components/item-list/types';\nimport { artistsQueries } from '/@/renderer/features/artists/api/artists-api';\nimport { AlbumArtistGridCarousel } from '/@/renderer/features/artists/components/album-artist-grid-carousel';\nimport { useIsPlayerFetching, usePlayer } from '/@/renderer/features/player/context/player-context';\nimport {\n    ListConfigMenu,\n    SONG_DISPLAY_TYPES,\n} from '/@/renderer/features/shared/components/list-config-menu';\nimport {\n    CLIENT_SIDE_ALBUM_FILTERS,\n    CLIENT_SIDE_SONG_FILTERS,\n    ListSortByDropdownControlled,\n} from '/@/renderer/features/shared/components/list-sort-by-dropdown';\nimport { ListSortOrderToggleButtonControlled } from '/@/renderer/features/shared/components/list-sort-order-toggle-button';\nimport {\n    LONG_PRESS_PLAY_BEHAVIOR,\n    PlayTooltip,\n} from '/@/renderer/features/shared/components/play-button-group';\nimport { usePlayButtonClick } from '/@/renderer/features/shared/hooks/use-play-button-click';\nimport { searchLibraryItems } from '/@/renderer/features/shared/utils';\nimport { songsQueries } from '/@/renderer/features/songs/api/songs-api';\nimport { useContainerQuery } from '/@/renderer/hooks';\nimport { useGenreRoute } from '/@/renderer/hooks/use-genre-route';\nimport { AppRoute } from '/@/renderer/router/routes';\nimport {\n    ArtistItem,\n    useAppStore,\n    useCurrentServer,\n    useCurrentServerId,\n    usePlayerSong,\n} from '/@/renderer/store';\nimport {\n    useArtistItems,\n    useArtistRadioCount,\n    useExternalLinks,\n    useSettingsStore,\n} from '/@/renderer/store/settings.store';\nimport { sanitize } from '/@/renderer/utils/sanitize';\nimport { sortAlbumList, sortSongList } from '/@/shared/api/utils';\nimport { ActionIcon, ActionIconGroup } from '/@/shared/components/action-icon/action-icon';\nimport { Badge } from '/@/shared/components/badge/badge';\nimport { Button } from '/@/shared/components/button/button';\nimport { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu';\nimport { Grid } from '/@/shared/components/grid/grid';\nimport { Group } from '/@/shared/components/group/group';\nimport { Icon } from '/@/shared/components/icon/icon';\nimport { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control';\nimport { Skeleton } from '/@/shared/components/skeleton/skeleton';\nimport { Spinner } from '/@/shared/components/spinner/spinner';\nimport { Spoiler } from '/@/shared/components/spoiler/spoiler';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { TextInput } from '/@/shared/components/text-input/text-input';\nimport { TextTitle } from '/@/shared/components/text-title/text-title';\nimport { Text } from '/@/shared/components/text/text';\nimport { useDebouncedValue } from '/@/shared/hooks/use-debounced-value';\nimport { useHotkeys } from '/@/shared/hooks/use-hotkeys';\nimport { useLocalStorage } from '/@/shared/hooks/use-local-storage';\nimport {\n    Album,\n    AlbumArtist,\n    AlbumArtistDetailResponse,\n    AlbumListResponse,\n    AlbumListSort,\n    LibraryItem,\n    RelatedArtist,\n    ServerType,\n    Song,\n    SongListSort,\n    SortOrder,\n} from '/@/shared/types/domain-types';\nimport { ItemListKey, ListDisplayType, Play } from '/@/shared/types/types';\n\ninterface AlbumArtistActionButtonsProps {\n    artistDiscographyLink: string;\n    artistSongsLink: string;\n    onArtistRadio?: () => void;\n}\n\nconst AlbumArtistActionButtons = ({\n    artistDiscographyLink,\n    artistSongsLink,\n    onArtistRadio,\n}: AlbumArtistActionButtonsProps) => {\n    const { t } = useTranslation();\n    const isPlayerFetching = useIsPlayerFetching();\n\n    return (\n        <>\n            <Group gap=\"lg\">\n                <Button\n                    component={Link}\n                    p={0}\n                    size=\"compact-md\"\n                    to={artistDiscographyLink}\n                    variant=\"transparent\"\n                >\n                    {String(t('page.albumArtistDetail.viewDiscography')).toUpperCase()}\n                </Button>\n                <Button\n                    component={Link}\n                    p={0}\n                    size=\"compact-md\"\n                    to={artistSongsLink}\n                    variant=\"transparent\"\n                >\n                    {String(t('page.albumArtistDetail.viewAllTracks')).toUpperCase()}\n                </Button>\n                {onArtistRadio && (\n                    <Button\n                        disabled={isPlayerFetching}\n                        leftSection={\n                            isPlayerFetching ? (\n                                <Spinner color=\"white\" size={16} />\n                            ) : (\n                                <Icon icon=\"radio\" size=\"lg\" />\n                            )\n                        }\n                        onClick={onArtistRadio}\n                        p={0}\n                        size=\"compact-md\"\n                        variant=\"transparent\"\n                    >\n                        {String(\n                            t('player.artistRadio', {\n                                postProcess: 'sentenceCase',\n                            }),\n                        ).toUpperCase()}\n                    </Button>\n                )}\n            </Group>\n        </>\n    );\n};\n\ninterface AlbumArtistMetadataGenresProps {\n    genres?: Array<{ id: string; name: string }>;\n    order?: number;\n}\n\nconst AlbumArtistMetadataGenres = ({ genres, order }: AlbumArtistMetadataGenresProps) => {\n    const { t } = useTranslation();\n    const genrePath = useGenreRoute();\n\n    if (!genres || genres.length === 0) return null;\n\n    return (\n        <Grid.Col order={order} span={12}>\n            <Stack gap=\"xs\">\n                <Text fw={600} isNoSelect size=\"sm\" tt=\"uppercase\">\n                    {t('entity.genre', {\n                        count: genres.length,\n                    })}\n                </Text>\n                <Group gap=\"sm\">\n                    {genres.map((genre) => (\n                        <Button\n                            component={Link}\n                            key={`genre-${genre.id}`}\n                            radius=\"md\"\n                            size=\"compact-md\"\n                            to={generatePath(genrePath, {\n                                albumArtistId: null,\n                                albumId: null,\n                                artistId: null,\n                                genreId: genre.id,\n                                itemType: null,\n                                playlistId: null,\n                            })}\n                            variant=\"outline\"\n                        >\n                            {genre.name}\n                        </Button>\n                    ))}\n                </Group>\n            </Stack>\n        </Grid.Col>\n    );\n};\n\ninterface AlbumArtistMetadataBiographyProps {\n    artistName?: string;\n    order?: number;\n    routeId: string;\n}\n\nconst AlbumArtistMetadataBiography = ({\n    artistName,\n    order,\n    routeId,\n}: AlbumArtistMetadataBiographyProps) => {\n    const { t } = useTranslation();\n    const server = useCurrentServer();\n\n    const artistInfoQuery = useQuery({\n        ...artistsQueries.albumArtistInfo({\n            query: { id: routeId, limit: 10 },\n            serverId: server?.id,\n        }),\n        enabled: Boolean(server?.id && routeId),\n    });\n\n    const detailQuery = useQuery({\n        ...artistsQueries.albumArtistDetail({\n            query: { id: routeId },\n            serverId: server?.id,\n        }),\n        enabled: Boolean(server?.id && routeId),\n    });\n\n    const biography = artistInfoQuery.data?.biography || detailQuery.data?.biography;\n    const isLoading = !biography && (artistInfoQuery.isLoading || detailQuery.isLoading);\n\n    const sanitizedBiography = biography ? sanitize(biography) : '';\n\n    if (isLoading) {\n        return (\n            <Grid.Col order={order} span={12}>\n                <section style={{ maxWidth: '1280px' }}>\n                    <TextTitle fw={700} order={3}>\n                        {t('page.albumArtistDetail.about', {\n                            artist: artistName,\n                        })}\n                    </TextTitle>\n                    <Stack gap=\"xs\">\n                        <Skeleton enableAnimation height=\"1rem\" width=\"100%\" />\n                        <Skeleton enableAnimation height=\"1rem\" width=\"98%\" />\n                        <Skeleton enableAnimation height=\"1rem\" width=\"60%\" />\n                    </Stack>\n                </section>\n            </Grid.Col>\n        );\n    }\n\n    if (!biography) {\n        return null;\n    }\n\n    return (\n        <Grid.Col order={order} span={12}>\n            <section style={{ maxWidth: '1280px' }}>\n                <TextTitle fw={700} order={3}>\n                    {t('page.albumArtistDetail.about', {\n                        artist: artistName,\n                    })}\n                </TextTitle>\n                <Spoiler>\n                    <Text dangerouslySetInnerHTML={{ __html: sanitizedBiography }} />\n                </Spoiler>\n            </section>\n        </Grid.Col>\n    );\n};\n\nconst TABLE_ROW_HEIGHT = {\n    compact: 40,\n    default: 64,\n    large: 88,\n} as const;\n\nconst TABLE_HEADER_HEIGHT = 40;\n\ninterface SongTableListContainerProps {\n    children: React.ReactNode;\n    enableHeader?: boolean;\n    itemCount: number;\n    maxRows?: number;\n    tableSize?: 'compact' | 'default' | 'large';\n}\n\nfunction getTableRowHeight(size: 'compact' | 'default' | 'large' | undefined): number {\n    return size ? TABLE_ROW_HEIGHT[size] : TABLE_ROW_HEIGHT.default;\n}\n\nconst SongTableListContainer = ({\n    children,\n    enableHeader = true,\n    itemCount,\n    maxRows = 5,\n    tableSize = 'default',\n}: SongTableListContainerProps) => {\n    const rowHeight = getTableRowHeight(tableSize);\n    const headerOffset = enableHeader ? TABLE_HEADER_HEIGHT : 0;\n    const height = headerOffset + rowHeight * Math.min(itemCount, maxRows);\n    return <div style={{ height }}>{children}</div>;\n};\n\ninterface AlbumArtistMetadataTopSongsProps {\n    detailQuery: ReturnType<typeof useSuspenseQuery<AlbumArtistDetailResponse>>;\n    order?: number;\n    routeId: string;\n}\n\nconst AlbumArtistMetadataTopSongsContent = ({\n    detailQuery,\n    order,\n    routeId,\n}: AlbumArtistMetadataTopSongsProps) => {\n    const { t } = useTranslation();\n    const [searchTerm, setSearchTerm] = useState('');\n    const [debouncedSearchTerm] = useDebouncedValue(searchTerm, 300);\n    const [topSongsQueryType, setTopSongsQueryType] = useLocalStorage<'community' | 'personal'>({\n        defaultValue: 'community',\n        key: 'album-artist-top-songs-query-type',\n    });\n    const tableConfig = useSettingsStore((state) => state.lists[ItemListKey.SONG]?.table);\n    const currentSong = usePlayerSong();\n    const player = usePlayer();\n    const serverId = useCurrentServerId();\n    const server = useCurrentServer();\n\n    const canStartQuery = server?.type === ServerType.JELLYFIN || !!detailQuery.data?.name;\n\n    const topSongsQuery = useQuery({\n        ...artistsQueries.topSongs({\n            query: {\n                artist: detailQuery.data?.name || '',\n                artistId: routeId,\n                type: topSongsQueryType,\n            },\n            serverId: serverId,\n        }),\n        enabled: canStartQuery,\n    });\n\n    const songs = useMemo(() => topSongsQuery.data?.items || [], [topSongsQuery.data?.items]);\n\n    const columns = useMemo(() => {\n        return tableConfig?.columns || [];\n    }, [tableConfig?.columns]);\n\n    const filteredSongs = useMemo(() => {\n        return searchLibraryItems(songs, debouncedSearchTerm, LibraryItem.SONG);\n    }, [songs, debouncedSearchTerm]);\n\n    const { handleColumnReordered } = useItemListColumnReorder({\n        itemListKey: ItemListKey.SONG,\n    });\n\n    const { handleColumnResized } = useItemListColumnResize({\n        itemListKey: ItemListKey.SONG,\n    });\n\n    const overrideControls: Partial<ItemControls> = useMemo(() => {\n        return {\n            onDoubleClick: ({ index, internalState, item, meta }) => {\n                if (!item) {\n                    return;\n                }\n\n                const playType = (meta?.playType as Play) || Play.NOW;\n                const items = internalState?.getData() as Song[];\n\n                if (index !== undefined) {\n                    player.addToQueueByData(items, playType, item.id);\n                }\n            },\n        };\n    }, [player]);\n\n    const handlePlay = useCallback(\n        (playType: Play) => {\n            if (songs.length === 0) return;\n            player.addToQueueByData(songs, playType);\n        },\n        [songs, player],\n    );\n\n    const handlePlayNext = usePlayButtonClick({\n        onClick: () => handlePlay(Play.NEXT),\n        onLongPress: () => handlePlay(LONG_PRESS_PLAY_BEHAVIOR[Play.NEXT]),\n    });\n    const handlePlayNow = usePlayButtonClick({\n        onClick: () => handlePlay(Play.NOW),\n        onLongPress: () => handlePlay(LONG_PRESS_PLAY_BEHAVIOR[Play.NOW]),\n    });\n    const handlePlayLast = usePlayButtonClick({\n        onClick: () => handlePlay(Play.LAST),\n        onLongPress: () => handlePlay(LONG_PRESS_PLAY_BEHAVIOR[Play.LAST]),\n    });\n\n    const isLoading = topSongsQuery.isLoading || !topSongsQuery.data;\n\n    if (!isLoading && !tableConfig) return null;\n    if (!isLoading && songs.length === 0) return null;\n\n    const currentSongId = currentSong?.id;\n\n    return (\n        <Grid.Col order={order} span={12}>\n            <section>\n                <Stack gap=\"md\">\n                    <div className={styles.albumSectionTitle}>\n                        <Group>\n                            <TextTitle fw={700} order={3}>\n                                {t('page.albumArtistDetail.topSongs', {\n                                    postProcess: 'sentenceCase',\n                                })}\n                            </TextTitle>\n                            {!isLoading && <Badge>{songs.length}</Badge>}\n                        </Group>\n                        <div className={styles.albumSectionDividerContainer}>\n                            <div className={styles.albumSectionDivider} />\n                            <Button\n                                component={Link}\n                                size=\"compact-md\"\n                                to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL_TOP_SONGS, {\n                                    albumArtistId: routeId,\n                                })}\n                                uppercase\n                                variant=\"subtle\"\n                            >\n                                {t('page.albumArtistDetail.viewAll', {\n                                    postProcess: 'sentenceCase',\n                                })}\n                            </Button>\n                            {songs.length > 0 && (\n                                <ActionIconGroup>\n                                    <PlayTooltip type={Play.NOW}>\n                                        <ActionIcon\n                                            icon=\"mediaPlay\"\n                                            iconProps={{ size: 'md' }}\n                                            size=\"xs\"\n                                            variant=\"subtle\"\n                                            {...handlePlayNow.handlers}\n                                            {...handlePlayNow.props}\n                                            disabled={isLoading}\n                                        />\n                                    </PlayTooltip>\n                                    <PlayTooltip type={Play.NEXT}>\n                                        <ActionIcon\n                                            icon=\"mediaPlayNext\"\n                                            iconProps={{ size: 'md' }}\n                                            size=\"xs\"\n                                            variant=\"subtle\"\n                                            {...handlePlayNext.handlers}\n                                            {...handlePlayNext.props}\n                                            disabled={isLoading}\n                                        />\n                                    </PlayTooltip>\n                                    <PlayTooltip type={Play.LAST}>\n                                        <ActionIcon\n                                            icon=\"mediaPlayLast\"\n                                            iconProps={{ size: 'md' }}\n                                            size=\"xs\"\n                                            variant=\"subtle\"\n                                            {...handlePlayLast.handlers}\n                                            {...handlePlayLast.props}\n                                            disabled={isLoading}\n                                        />\n                                    </PlayTooltip>\n                                </ActionIconGroup>\n                            )}\n                        </div>\n                    </div>\n                    {isLoading ? (\n                        <Group justify=\"center\" py=\"md\">\n                            <Spinner container />\n                        </Group>\n                    ) : tableConfig ? (\n                        <>\n                            <Group gap=\"sm\" w=\"100%\">\n                                <TextInput\n                                    flex={1}\n                                    leftSection={<Icon icon=\"search\" />}\n                                    onChange={(e) => setSearchTerm(e.target.value)}\n                                    placeholder={t('common.search', {\n                                        postProcess: 'sentenceCase',\n                                    })}\n                                    radius=\"xl\"\n                                    rightSection={\n                                        searchTerm ? (\n                                            <ActionIcon\n                                                icon=\"x\"\n                                                onClick={() => setSearchTerm('')}\n                                                size=\"sm\"\n                                                variant=\"transparent\"\n                                            />\n                                        ) : null\n                                    }\n                                    styles={{\n                                        input: {\n                                            background: 'transparent',\n                                            border: '1px solid rgba(255, 255, 255, 0.05)',\n                                        },\n                                    }}\n                                    value={searchTerm}\n                                />\n                                <SegmentedControl\n                                    data={[\n                                        {\n                                            label: t('page.albumArtistDetail.topSongsCommunity', {\n                                                postProcess: 'sentenceCase',\n                                            }),\n                                            value: 'community',\n                                        },\n                                        {\n                                            label: t('page.albumArtistDetail.topSongsPersonal', {\n                                                postProcess: 'sentenceCase',\n                                            }),\n                                            value: 'personal',\n                                        },\n                                    ]}\n                                    onChange={(value) =>\n                                        setTopSongsQueryType(value as 'community' | 'personal')\n                                    }\n                                    size=\"xs\"\n                                    value={topSongsQueryType}\n                                />\n                                <ListConfigMenu\n                                    displayTypes={[\n                                        { hidden: true, value: ListDisplayType.GRID },\n                                        ...SONG_DISPLAY_TYPES,\n                                    ]}\n                                    listKey={ItemListKey.SONG}\n                                    optionsConfig={{\n                                        table: {\n                                            itemsPerPage: { hidden: true },\n                                            pagination: { hidden: true },\n                                        },\n                                    }}\n                                    tableColumnsData={SONG_TABLE_COLUMNS}\n                                />\n                            </Group>\n                            <SongTableListContainer\n                                enableHeader={tableConfig.enableHeader}\n                                itemCount={filteredSongs.length}\n                                maxRows={5}\n                                tableSize={tableConfig.size}\n                            >\n                                <ItemTableList\n                                    activeRowId={currentSongId}\n                                    autoFitColumns={tableConfig.autoFitColumns}\n                                    CellComponent={ItemTableListColumn}\n                                    columns={columns}\n                                    data={filteredSongs}\n                                    enableAlternateRowColors={tableConfig.enableAlternateRowColors}\n                                    enableDrag\n                                    enableDragScroll={false}\n                                    enableExpansion={false}\n                                    enableHeader={tableConfig.enableHeader}\n                                    enableHorizontalBorders={tableConfig.enableHorizontalBorders}\n                                    enableRowHoverHighlight={tableConfig.enableRowHoverHighlight}\n                                    enableSelection\n                                    enableSelectionDialog={false}\n                                    enableVerticalBorders={tableConfig.enableVerticalBorders}\n                                    itemType={LibraryItem.SONG}\n                                    onColumnReordered={handleColumnReordered}\n                                    onColumnResized={handleColumnResized}\n                                    overrideControls={overrideControls}\n                                    size={tableConfig.size}\n                                />\n                            </SongTableListContainer>\n                        </>\n                    ) : null}\n                </Stack>\n            </section>\n        </Grid.Col>\n    );\n};\n\nconst AlbumArtistMetadataTopSongs = ({\n    detailQuery,\n    order,\n    routeId,\n}: AlbumArtistMetadataTopSongsProps) => {\n    const server = useCurrentServer();\n\n    const location = useLocation();\n    const artistName = location.state?.item?.name || detailQuery.data?.name;\n\n    const canStartQuery = server?.type === ServerType.JELLYFIN || !!artistName;\n\n    return (\n        <Suspense fallback={null}>\n            {canStartQuery ? (\n                <AlbumArtistMetadataTopSongsContent\n                    detailQuery={detailQuery}\n                    order={order}\n                    routeId={routeId}\n                />\n            ) : null}\n        </Suspense>\n    );\n};\n\ninterface AlbumArtistMetadataFavoriteSongsProps {\n    order?: number;\n    routeId: string;\n}\n\nconst AlbumArtistMetadataFavoriteSongs = ({\n    order,\n    routeId,\n}: AlbumArtistMetadataFavoriteSongsProps) => {\n    const { t } = useTranslation();\n    const [searchTerm, setSearchTerm] = useState('');\n    const [debouncedSearchTerm] = useDebouncedValue(searchTerm, 300);\n    const albumArtistDetailFavoriteSongsSort = useAppStore(\n        (state) => state.albumArtistDetailFavoriteSongsSort,\n    );\n    const setAlbumArtistDetailFavoriteSongsSort = useAppStore(\n        (state) => state.actions.setAlbumArtistDetailFavoriteSongsSort,\n    );\n    const sortBy = albumArtistDetailFavoriteSongsSort.sortBy;\n    const sortOrder = albumArtistDetailFavoriteSongsSort.sortOrder;\n    const tableConfig = useSettingsStore((state) => state.lists[ItemListKey.SONG]?.table);\n    const currentSong = usePlayerSong();\n    const player = usePlayer();\n    const serverId = useCurrentServerId();\n\n    const favoriteSongsQuery = useQuery({\n        ...artistsQueries.favoriteSongs({\n            query: {\n                artistId: routeId,\n            },\n            serverId: serverId,\n        }),\n    });\n\n    const songs = useMemo(\n        () => favoriteSongsQuery.data?.items || [],\n        [favoriteSongsQuery.data?.items],\n    );\n\n    const columns = useMemo(() => {\n        return tableConfig?.columns || [];\n    }, [tableConfig?.columns]);\n\n    const filteredSongs = useMemo(() => {\n        return sortSongList(\n            searchLibraryItems(songs, debouncedSearchTerm, LibraryItem.SONG),\n            sortBy,\n            sortOrder,\n        );\n    }, [songs, debouncedSearchTerm, sortBy, sortOrder]);\n\n    const { handleColumnReordered } = useItemListColumnReorder({\n        itemListKey: ItemListKey.SONG,\n    });\n\n    const { handleColumnResized } = useItemListColumnResize({\n        itemListKey: ItemListKey.SONG,\n    });\n\n    const overrideControls: Partial<ItemControls> = useMemo(() => {\n        return {\n            onDoubleClick: ({ index, internalState, item, meta }) => {\n                if (!item) {\n                    return;\n                }\n\n                const playType = (meta?.playType as Play) || Play.NOW;\n                const items = internalState?.getData() as Song[];\n\n                if (index !== undefined) {\n                    player.addToQueueByData(items, playType, item.id);\n                }\n            },\n        };\n    }, [player]);\n\n    const handlePlay = useCallback(\n        (playType: Play) => {\n            if (songs.length === 0) return;\n            player.addToQueueByData(songs, playType);\n        },\n        [songs, player],\n    );\n\n    const handlePlayNext = usePlayButtonClick({\n        onClick: () => handlePlay(Play.NEXT),\n        onLongPress: () => handlePlay(LONG_PRESS_PLAY_BEHAVIOR[Play.NEXT]),\n    });\n    const handlePlayNow = usePlayButtonClick({\n        onClick: () => handlePlay(Play.NOW),\n        onLongPress: () => handlePlay(LONG_PRESS_PLAY_BEHAVIOR[Play.NOW]),\n    });\n    const handlePlayLast = usePlayButtonClick({\n        onClick: () => handlePlay(Play.LAST),\n        onLongPress: () => handlePlay(LONG_PRESS_PLAY_BEHAVIOR[Play.LAST]),\n    });\n\n    const isLoading = favoriteSongsQuery.isLoading || !favoriteSongsQuery.data;\n\n    if (!isLoading && !tableConfig) return null;\n    if (!isLoading && songs.length === 0) return null;\n\n    const currentSongId = currentSong?.id;\n\n    return (\n        <Grid.Col order={order} span={12}>\n            <section>\n                <Stack gap=\"md\">\n                    <div className={styles.albumSectionTitle}>\n                        <Group>\n                            <TextTitle fw={700} order={3}>\n                                {t('page.albumArtistDetail.favoriteSongs', {\n                                    postProcess: 'sentenceCase',\n                                })}\n                            </TextTitle>\n                            {!isLoading && <Badge>{songs.length}</Badge>}\n                        </Group>\n                        <div className={styles.albumSectionDividerContainer}>\n                            <div className={styles.albumSectionDivider} />\n                            <Button\n                                component={Link}\n                                size=\"compact-md\"\n                                to={generatePath(\n                                    AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL_FAVORITE_SONGS,\n                                    {\n                                        albumArtistId: routeId,\n                                    },\n                                )}\n                                uppercase\n                                variant=\"subtle\"\n                            >\n                                {t('page.albumArtistDetail.viewAll', {\n                                    postProcess: 'sentenceCase',\n                                })}\n                            </Button>\n                            {songs.length > 0 && (\n                                <ActionIconGroup>\n                                    <PlayTooltip type={Play.NOW}>\n                                        <ActionIcon\n                                            icon=\"mediaPlay\"\n                                            iconProps={{ size: 'md' }}\n                                            size=\"xs\"\n                                            variant=\"subtle\"\n                                            {...handlePlayNow.handlers}\n                                            {...handlePlayNow.props}\n                                            disabled={isLoading}\n                                        />\n                                    </PlayTooltip>\n                                    <PlayTooltip type={Play.NEXT}>\n                                        <ActionIcon\n                                            icon=\"mediaPlayNext\"\n                                            iconProps={{ size: 'md' }}\n                                            size=\"xs\"\n                                            variant=\"subtle\"\n                                            {...handlePlayNext.handlers}\n                                            {...handlePlayNext.props}\n                                            disabled={isLoading}\n                                        />\n                                    </PlayTooltip>\n                                    <PlayTooltip type={Play.LAST}>\n                                        <ActionIcon\n                                            icon=\"mediaPlayLast\"\n                                            iconProps={{ size: 'md' }}\n                                            size=\"xs\"\n                                            variant=\"subtle\"\n                                            {...handlePlayLast.handlers}\n                                            {...handlePlayLast.props}\n                                            disabled={isLoading}\n                                        />\n                                    </PlayTooltip>\n                                </ActionIconGroup>\n                            )}\n                        </div>\n                    </div>\n                    {isLoading ? (\n                        <Group justify=\"center\" py=\"md\">\n                            <Spinner />\n                        </Group>\n                    ) : tableConfig ? (\n                        <>\n                            <Group gap=\"sm\" w=\"100%\">\n                                <TextInput\n                                    flex={1}\n                                    leftSection={<Icon icon=\"search\" />}\n                                    onChange={(e) => setSearchTerm(e.target.value)}\n                                    placeholder={t('common.search', {\n                                        postProcess: 'sentenceCase',\n                                    })}\n                                    radius=\"xl\"\n                                    rightSection={\n                                        searchTerm ? (\n                                            <ActionIcon\n                                                icon=\"x\"\n                                                onClick={() => setSearchTerm('')}\n                                                size=\"sm\"\n                                                variant=\"transparent\"\n                                            />\n                                        ) : null\n                                    }\n                                    styles={{\n                                        input: {\n                                            background: 'transparent',\n                                            border: '1px solid rgba(255, 255, 255, 0.05)',\n                                        },\n                                    }}\n                                    value={searchTerm}\n                                />\n                                <ListSortByDropdownControlled\n                                    filters={CLIENT_SIDE_SONG_FILTERS}\n                                    itemType={LibraryItem.SONG}\n                                    setSortBy={(value) =>\n                                        setAlbumArtistDetailFavoriteSongsSort(\n                                            value as SongListSort,\n                                            sortOrder,\n                                        )\n                                    }\n                                    sortBy={sortBy}\n                                />\n                                <ListSortOrderToggleButtonControlled\n                                    setSortOrder={(value) =>\n                                        setAlbumArtistDetailFavoriteSongsSort(\n                                            sortBy,\n                                            value as SortOrder,\n                                        )\n                                    }\n                                    sortOrder={sortOrder}\n                                />\n                                <ListConfigMenu\n                                    displayTypes={[\n                                        { hidden: true, value: ListDisplayType.GRID },\n                                        ...SONG_DISPLAY_TYPES,\n                                    ]}\n                                    listKey={ItemListKey.SONG}\n                                    optionsConfig={{\n                                        table: {\n                                            itemsPerPage: { hidden: true },\n                                            pagination: { hidden: true },\n                                        },\n                                    }}\n                                    tableColumnsData={SONG_TABLE_COLUMNS}\n                                />\n                            </Group>\n                            <SongTableListContainer\n                                enableHeader={tableConfig.enableHeader}\n                                itemCount={filteredSongs.length}\n                                maxRows={5}\n                                tableSize={tableConfig.size}\n                            >\n                                <ItemTableList\n                                    activeRowId={currentSongId}\n                                    autoFitColumns={tableConfig.autoFitColumns}\n                                    CellComponent={ItemTableListColumn}\n                                    columns={columns}\n                                    data={filteredSongs}\n                                    enableAlternateRowColors={tableConfig.enableAlternateRowColors}\n                                    enableDrag\n                                    enableDragScroll={false}\n                                    enableExpansion={false}\n                                    enableHeader={tableConfig.enableHeader}\n                                    enableHorizontalBorders={tableConfig.enableHorizontalBorders}\n                                    enableRowHoverHighlight={tableConfig.enableRowHoverHighlight}\n                                    enableSelection\n                                    enableSelectionDialog={false}\n                                    enableVerticalBorders={tableConfig.enableVerticalBorders}\n                                    itemType={LibraryItem.SONG}\n                                    onColumnReordered={handleColumnReordered}\n                                    onColumnResized={handleColumnResized}\n                                    overrideControls={overrideControls}\n                                    size={tableConfig.size}\n                                />\n                            </SongTableListContainer>\n                        </>\n                    ) : null}\n                </Stack>\n            </section>\n        </Grid.Col>\n    );\n};\n\ninterface AlbumArtistMetadataExternalLinksProps {\n    artistName?: string;\n    externalLinks: boolean;\n    lastFM: boolean;\n    listenBrainz: boolean;\n    mbzId?: null | string;\n    musicBrainz: boolean;\n    nativeSpotify: boolean;\n    order?: number;\n    qobuz: boolean;\n    spotify: boolean;\n}\n\nconst getListenBrainzUrl = (mbzId: null | string, artistName?: string) => {\n    if (mbzId) {\n        return `https://listenbrainz.org/artist/${mbzId}`;\n    }\n\n    if (artistName) {\n        return `https://listenbrainz.org/search/?search_term=${encodeURIComponent(artistName)}`;\n    }\n\n    return null;\n};\n\nconst getQobuzUrl = (artistName?: string) => {\n    if (artistName) {\n        return `https://www.qobuz.com/us-en/search/artists/${encodeURIComponent(artistName)}`;\n    }\n\n    return null;\n};\n\nconst AlbumArtistMetadataExternalLinks = ({\n    artistName,\n    externalLinks,\n    lastFM,\n    listenBrainz,\n    mbzId,\n    musicBrainz,\n    nativeSpotify,\n    order,\n    qobuz,\n    spotify,\n}: AlbumArtistMetadataExternalLinksProps) => {\n    const { t } = useTranslation();\n    const listenBrainzUrl = getListenBrainzUrl(mbzId || null, artistName);\n    const qobuzUrl = getQobuzUrl(artistName);\n\n    if (!externalLinks || (!lastFM && !listenBrainz && !musicBrainz && !qobuz && !spotify)) {\n        return null;\n    }\n\n    return (\n        <Grid.Col order={order} span={12}>\n            <Stack gap=\"xs\">\n                <Text fw={600} isNoSelect size=\"sm\" tt=\"uppercase\">\n                    {t('common.externalLinks', {\n                        postProcess: 'sentenceCase',\n                    })}\n                </Text>\n                <Group gap=\"xs\">\n                    {lastFM && (\n                        <ActionIcon\n                            component=\"a\"\n                            href={`https://www.last.fm/music/${encodeURIComponent(artistName || '')}`}\n                            icon=\"brandLastfm\"\n                            iconProps={{\n                                size: '2xl',\n                            }}\n                            rel=\"noopener noreferrer\"\n                            target=\"_blank\"\n                            tooltip={{\n                                label: t('action.openIn.lastfm'),\n                            }}\n                            variant=\"subtle\"\n                        />\n                    )}\n                    {mbzId && musicBrainz ? (\n                        <ActionIcon\n                            component=\"a\"\n                            href={`https://musicbrainz.org/artist/${mbzId}`}\n                            icon=\"brandMusicBrainz\"\n                            iconProps={{\n                                size: '2xl',\n                            }}\n                            rel=\"noopener noreferrer\"\n                            target=\"_blank\"\n                            tooltip={{\n                                label: t('action.openIn.musicbrainz'),\n                            }}\n                            variant=\"subtle\"\n                        />\n                    ) : null}\n                    {listenBrainz && listenBrainzUrl && (\n                        <ActionIcon\n                            component=\"a\"\n                            href={listenBrainzUrl}\n                            icon=\"brandListenBrainz\"\n                            iconProps={{\n                                size: '2xl',\n                            }}\n                            rel=\"noopener noreferrer\"\n                            target=\"_blank\"\n                            tooltip={{\n                                label: t('action.openIn.listenbrainz'),\n                            }}\n                            variant=\"subtle\"\n                        />\n                    )}\n                    {qobuz && qobuzUrl && (\n                        <ActionIcon\n                            component=\"a\"\n                            href={qobuzUrl}\n                            icon=\"brandQobuz\"\n                            iconProps={{\n                                size: '2xl',\n                            }}\n                            rel=\"noopener noreferrer\"\n                            target=\"_blank\"\n                            tooltip={{\n                                label: t('action.openIn.qobuz'),\n                            }}\n                            variant=\"subtle\"\n                        />\n                    )}\n                    {spotify && (\n                        <ActionIcon\n                            component=\"a\"\n                            href={\n                                nativeSpotify\n                                    ? `spotify:search:${encodeURIComponent(artistName || '')}`\n                                    : `https://open.spotify.com/search/${encodeURIComponent(artistName || '')}`\n                            }\n                            icon=\"brandSpotify\"\n                            iconProps={{\n                                size: '2xl',\n                            }}\n                            rel=\"noopener noreferrer\"\n                            target={nativeSpotify ? undefined : '_blank'}\n                            tooltip={{\n                                label: t('action.openIn.spotify'),\n                            }}\n                            variant=\"subtle\"\n                        />\n                    )}\n                </Group>\n            </Stack>\n        </Grid.Col>\n    );\n};\n\ninterface AlbumArtistMetadataSimilarArtistsProps {\n    order?: number;\n    routeId: string;\n}\n\nconst AlbumArtistMetadataSimilarArtists = ({\n    order,\n    routeId,\n}: AlbumArtistMetadataSimilarArtistsProps) => {\n    const { t } = useTranslation();\n    const server = useCurrentServer();\n    const serverId = useCurrentServerId();\n\n    const artistInfoQuery = useQuery({\n        ...artistsQueries.albumArtistInfo({\n            query: { id: routeId, limit: 10 },\n            serverId: server?.id,\n        }),\n        enabled: Boolean(server?.id && routeId),\n    });\n\n    const relatedArtists = artistInfoQuery.data?.similarArtists ?? null;\n\n    const similarArtists = useMemo(() => {\n        if (!relatedArtists || relatedArtists.length === 0) {\n            return [];\n        }\n\n        return relatedArtists.map(\n            (relatedArtist: RelatedArtist): AlbumArtist => ({\n                _itemType: LibraryItem.ALBUM_ARTIST,\n                _serverId: serverId || '',\n                _serverType: (server?.type as ServerType) || ServerType.JELLYFIN,\n                albumCount: null,\n                biography: null,\n                duration: null,\n                genres: [],\n                id: relatedArtist.id,\n                imageId: relatedArtist.imageId,\n                imageUrl: relatedArtist.imageUrl,\n                lastPlayedAt: null,\n                mbz: null,\n                name: relatedArtist.name,\n                playCount: null,\n                similarArtists: null,\n                songCount: null,\n                userFavorite: relatedArtist.userFavorite,\n                userRating: relatedArtist.userRating,\n            }),\n        );\n    }, [relatedArtists, server?.type, serverId]);\n\n    const carouselTitle = useMemo(\n        () => (\n            <div className={styles.similarArtistsTitle}>\n                <TextTitle fw={700} order={3}>\n                    {t('page.albumArtistDetail.relatedArtists', {\n                        postProcess: 'sentenceCase',\n                    })}\n                </TextTitle>\n                <div className={styles.albumSectionDividerContainer}>\n                    <div className={styles.albumSectionDivider} />\n                </div>\n            </div>\n        ),\n        [t],\n    );\n\n    if (!artistInfoQuery.isLoading && similarArtists.length === 0) {\n        return null;\n    }\n\n    return (\n        <Grid.Col order={order} span={12}>\n            <AlbumArtistGridCarousel\n                data={similarArtists}\n                excludeIds={[routeId]}\n                isLoading={artistInfoQuery.isLoading}\n                rowCount={1}\n                title={carouselTitle}\n            />\n        </Grid.Col>\n    );\n};\n\ninterface AlbumArtistDetailContentProps {\n    albumsQuery: UseSuspenseQueryResult<AlbumListResponse, Error>;\n    detailQuery: UseSuspenseQueryResult<AlbumArtistDetailResponse, Error>;\n}\n\nexport const AlbumArtistDetailContent = ({\n    albumsQuery,\n    detailQuery,\n}: AlbumArtistDetailContentProps) => {\n    const artistItems = useArtistItems();\n    const artistRadioCount = useArtistRadioCount();\n    const { externalLinks, lastFM, listenBrainz, musicBrainz, nativeSpotify, qobuz, spotify } =\n        useExternalLinks();\n    const { albumArtistId, artistId } = useParams() as {\n        albumArtistId?: string;\n        artistId?: string;\n    };\n    const routeId = (artistId || albumArtistId) as string;\n    const server = useCurrentServer();\n    const { addToQueueByData } = usePlayer();\n    const queryClient = useQueryClient();\n\n    const [enabledItem, itemOrder] = useMemo(() => {\n        const enabled: { [key in ArtistItem]?: boolean } = {};\n        const order: { [key in ArtistItem]?: number } = {};\n\n        for (const [idx, item] of artistItems.entries()) {\n            enabled[item.id] = !item.disabled;\n            order[item.id] = idx + 1;\n        }\n\n        return [enabled, order];\n    }, [artistItems]);\n\n    const artistDiscographyLink = useMemo(\n        () =>\n            `${generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL_DISCOGRAPHY, {\n                albumArtistId: routeId,\n            })}?${createSearchParams({\n                artistId: routeId,\n                artistName: detailQuery.data?.name || '',\n            })}`,\n        [routeId, detailQuery.data?.name],\n    );\n\n    const artistSongsLink = useMemo(\n        () =>\n            `${generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL_SONGS, {\n                albumArtistId: routeId,\n            })}?${createSearchParams({\n                artistId: routeId,\n                artistName: detailQuery.data?.name || '',\n            })}`,\n        [routeId, detailQuery.data?.name],\n    );\n\n    const mbzId = detailQuery.data?.mbz;\n\n    const handleArtistRadio = useCallback(async () => {\n        if (!server?.id || !routeId) return;\n\n        try {\n            const artistRadioSongs = await queryClient.fetchQuery({\n                ...songsQueries.artistRadio({\n                    query: {\n                        artistId: routeId,\n                        count: artistRadioCount,\n                    },\n                    serverId: server.id,\n                }),\n                queryKey: queryKeys.player.fetch({ artistId: routeId }),\n            });\n            if (artistRadioSongs && artistRadioSongs.length > 0) {\n                addToQueueByData(artistRadioSongs, Play.NOW);\n            }\n        } catch (error) {\n            console.error('Failed to load artist radio:', error);\n        }\n    }, [addToQueueByData, artistRadioCount, queryClient, routeId, server.id]);\n\n    // Calculate order for genres and external links (show before other sections)\n    // Use a very low order number to ensure they appear first\n    const genresOrder = 0;\n    const externalLinksOrder = 0.5;\n\n    return (\n        <div className={styles.contentContainer}>\n            <div className={styles.detailContainer}>\n                <AlbumArtistActionButtons\n                    artistDiscographyLink={artistDiscographyLink}\n                    artistSongsLink={artistSongsLink}\n                    onArtistRadio={handleArtistRadio}\n                />\n                <Grid gutter=\"2xl\">\n                    <AlbumArtistMetadataGenres\n                        genres={detailQuery.data?.genres}\n                        order={genresOrder}\n                    />\n                    {externalLinks &&\n                        (lastFM || listenBrainz || musicBrainz || qobuz || spotify) && (\n                            <AlbumArtistMetadataExternalLinks\n                                artistName={detailQuery.data?.name}\n                                externalLinks={externalLinks}\n                                lastFM={lastFM}\n                                listenBrainz={listenBrainz}\n                                mbzId={mbzId}\n                                musicBrainz={musicBrainz}\n                                nativeSpotify={nativeSpotify}\n                                order={externalLinksOrder}\n                                qobuz={qobuz}\n                                spotify={spotify}\n                            />\n                        )}\n                    {enabledItem.biography && (\n                        <AlbumArtistMetadataBiography\n                            artistName={detailQuery.data?.name}\n                            order={itemOrder.biography}\n                            routeId={routeId}\n                        />\n                    )}\n                    <ArtistAlbums albumsQuery={albumsQuery} order={itemOrder.recentAlbums} />\n                    {enabledItem.similarArtists && (\n                        <AlbumArtistMetadataSimilarArtists\n                            order={itemOrder.similarArtists}\n                            routeId={routeId}\n                        />\n                    )}\n                    {enabledItem.topSongs && (\n                        <AlbumArtistMetadataTopSongs\n                            detailQuery={detailQuery}\n                            order={itemOrder.topSongs}\n                            routeId={routeId}\n                        />\n                    )}\n                    {enabledItem.favoriteSongs && (\n                        <AlbumArtistMetadataFavoriteSongs\n                            order={itemOrder.favoriteSongs}\n                            routeId={routeId}\n                        />\n                    )}\n                </Grid>\n            </div>\n        </div>\n    );\n};\n\ninterface AlbumSectionProps {\n    albums: Album[];\n    controls: ItemControls;\n    enableExpansion?: boolean;\n    itemsPerRow: number;\n    releaseType: string;\n    rows: DataRow[] | undefined;\n    title: React.ReactNode | string;\n}\n\nconst MAX_SECTION_CARDS = 100;\n\nconst getItemsPerRow = (cq: ReturnType<typeof useContainerQuery>) => {\n    // Match grid carousel breakpoints: is3xl: 8, is2xl: 7, isXl: 6, isLg: 5, isMd: 4, isSm: 3, default: 2\n    if (cq.is3xl) return 8;\n    if (cq.is2xl) return 7;\n    if (cq.isXl) return 6;\n    if (cq.isLg) return 5;\n    if (cq.isMd) return 4;\n    if (cq.isSm) return 3;\n    if (cq.isXs) return 2;\n    return 2;\n};\n\nconst AlbumSection = memo(function AlbumSection({\n    albums,\n    controls,\n    enableExpansion,\n    itemsPerRow,\n    releaseType,\n    rows,\n    title,\n}: AlbumSectionProps) {\n    const { t } = useTranslation();\n    const albumCount = albums.length;\n    const [showAll, setShowAll] = useState(false);\n    const player = usePlayer();\n    const serverId = useCurrentServerId();\n\n    const displayedAlbums = showAll ? albums : albums.slice(0, MAX_SECTION_CARDS);\n    const hasMoreAlbums = albums.length > MAX_SECTION_CARDS;\n\n    const handlePlay = useCallback(\n        (playType: Play) => {\n            if (albums.length === 0) return;\n            const albumIds = albums.map((album) => album.id);\n            player.addToQueueByFetch(serverId, albumIds, LibraryItem.ALBUM, playType);\n        },\n        [albums, player, serverId],\n    );\n\n    const handlePlayNext = usePlayButtonClick({\n        onClick: () => {\n            handlePlay(Play.NEXT);\n        },\n        onLongPress: () => {\n            handlePlay(LONG_PRESS_PLAY_BEHAVIOR[Play.NEXT]);\n        },\n    });\n\n    const handlePlayNow = usePlayButtonClick({\n        onClick: () => {\n            handlePlay(Play.NOW);\n        },\n        onLongPress: () => {\n            handlePlay(LONG_PRESS_PLAY_BEHAVIOR[Play.NOW]);\n        },\n    });\n\n    const handlePlayLast = usePlayButtonClick({\n        onClick: () => {\n            handlePlay(Play.LAST);\n        },\n        onLongPress: () => {\n            handlePlay(LONG_PRESS_PLAY_BEHAVIOR[Play.LAST]);\n        },\n    });\n\n    const DisplayedAlbumsMemo = useMemo(() => {\n        return displayedAlbums.map((album) => (\n            <motion.div\n                className={styles.albumGridItem}\n                key={album.id}\n                layoutId={`${releaseType}-${album.id}`}\n            >\n                <MemoizedItemCard\n                    controls={controls}\n                    data={album}\n                    enableDrag\n                    enableExpansion={enableExpansion ?? true}\n                    itemType={LibraryItem.ALBUM}\n                    rows={rows}\n                    type=\"poster\"\n                    withControls\n                />\n            </motion.div>\n        ));\n    }, [controls, displayedAlbums, enableExpansion, releaseType, rows]);\n\n    return (\n        <Stack gap=\"md\">\n            <div className={styles.albumSectionTitle}>\n                <Group gap=\"md\">\n                    <TextTitle fw={700} order={3}>\n                        {title}\n                    </TextTitle>\n                    <Badge variant=\"default\">{albumCount}</Badge>\n                </Group>\n                <div className={styles.albumSectionDividerContainer}>\n                    <div className={styles.albumSectionDivider} />\n                    {albumCount > 0 && (\n                        <ActionIconGroup>\n                            <PlayTooltip type={Play.NOW}>\n                                <ActionIcon\n                                    icon=\"mediaPlay\"\n                                    iconProps={{\n                                        size: 'md',\n                                    }}\n                                    size=\"xs\"\n                                    variant=\"subtle\"\n                                    {...handlePlayNow.handlers}\n                                    {...handlePlayNow.props}\n                                />\n                            </PlayTooltip>\n                            <PlayTooltip type={Play.NEXT}>\n                                <ActionIcon\n                                    icon=\"mediaPlayNext\"\n                                    iconProps={{\n                                        size: 'md',\n                                    }}\n                                    size=\"xs\"\n                                    variant=\"subtle\"\n                                    {...handlePlayNext.handlers}\n                                    {...handlePlayNext.props}\n                                />\n                            </PlayTooltip>\n                            <PlayTooltip type={Play.LAST}>\n                                <ActionIcon\n                                    icon=\"mediaPlayLast\"\n                                    iconProps={{\n                                        size: 'md',\n                                    }}\n                                    size=\"xs\"\n                                    variant=\"subtle\"\n                                    {...handlePlayLast.handlers}\n                                    {...handlePlayLast.props}\n                                />\n                            </PlayTooltip>\n                        </ActionIconGroup>\n                    )}\n                </div>\n            </div>\n            <div\n                className={styles.albumGrid}\n                style={\n                    {\n                        '--items-per-row': itemsPerRow,\n                    } as React.CSSProperties\n                }\n            >\n                {DisplayedAlbumsMemo}\n            </div>\n            {hasMoreAlbums && !showAll && (\n                <Group justify=\"center\" w=\"100%\">\n                    <Button onClick={() => setShowAll(true)} variant=\"subtle\">\n                        {t('action.viewMore', { postProcess: 'sentenceCase' })}\n                    </Button>\n                </Group>\n            )}\n        </Stack>\n    );\n});\n\nimport { useArtistAlbumsGrouped } from '/@/renderer/features/artists/hooks/use-artist-albums-grouped';\n\ninterface ArtistAlbumsProps {\n    albumsQuery: UseSuspenseQueryResult<AlbumListResponse, Error>;\n    order?: number;\n}\n\nconst ArtistAlbums = ({ albumsQuery, order }: ArtistAlbumsProps) => {\n    const { t } = useTranslation();\n    const [searchTerm, setSearchTerm] = useState('');\n    const [debouncedSearchTerm] = useDebouncedValue(searchTerm, 300);\n    const albumArtistDetailSort = useAppStore((state) => state.albumArtistDetailSort);\n    const setAlbumArtistDetailSort = useAppStore((state) => state.actions.setAlbumArtistDetailSort);\n    const sortBy = albumArtistDetailSort.sortBy;\n    const sortOrder = albumArtistDetailSort.sortOrder;\n\n    const { albumArtistId, artistId } = useParams() as {\n        albumArtistId?: string;\n        artistId?: string;\n    };\n    const routeId = (artistId || albumArtistId) as string;\n\n    const rows = useGridRows(LibraryItem.ALBUM, ItemListKey.ALBUM);\n\n    const filteredAndSortedAlbums = useMemo(() => {\n        const albums = albumsQuery.data?.items || [];\n        const searched = searchLibraryItems(albums, debouncedSearchTerm, LibraryItem.ALBUM);\n        return sortAlbumList(searched, sortBy, sortOrder);\n    }, [albumsQuery.data?.items, debouncedSearchTerm, sortBy, sortOrder]);\n\n    const controls = useDefaultItemListControls();\n\n    const { releaseTypeEntries } = useArtistAlbumsGrouped(filteredAndSortedAlbums, routeId);\n\n    const cq = useContainerQuery({\n        '2xl': 1280,\n        '3xl': 1440,\n        lg: 960,\n        md: 720,\n        sm: 520,\n        xl: 1152,\n        xs: 360,\n    });\n\n    const binding = useSettingsStore((state) => state.hotkeys.bindings.localSearch);\n    const searchInputRef = useRef<HTMLInputElement>(null);\n\n    useHotkeys([\n        [\n            binding.hotkey,\n            () => {\n                searchInputRef.current?.focus();\n            },\n        ],\n    ]);\n\n    const itemsPerRow = getItemsPerRow(cq);\n\n    const ReleaseTypeEntriesMemo = useMemo(() => {\n        return releaseTypeEntries.map(({ albums, displayName, releaseType }) => (\n            <AlbumSection\n                albums={albums}\n                controls={controls}\n                enableExpansion\n                itemsPerRow={itemsPerRow}\n                key={releaseType}\n                releaseType={releaseType}\n                rows={rows}\n                title={displayName}\n            />\n        ));\n    }, [releaseTypeEntries, itemsPerRow, controls, rows]);\n\n    return (\n        <Grid.Col order={order} span={12}>\n            <Stack gap=\"md\">\n                <Group gap=\"sm\" w=\"100%\">\n                    <TextInput\n                        flex={1}\n                        leftSection={<Icon icon=\"search\" />}\n                        onChange={(e) => setSearchTerm(e.target.value)}\n                        placeholder={t('common.search', { postProcess: 'sentenceCase' })}\n                        radius=\"xl\"\n                        ref={searchInputRef}\n                        rightSection={\n                            searchTerm ? (\n                                <ActionIcon\n                                    icon=\"x\"\n                                    onClick={() => setSearchTerm('')}\n                                    size=\"sm\"\n                                    variant=\"transparent\"\n                                />\n                            ) : null\n                        }\n                        styles={{\n                            input: {\n                                background: 'transparent',\n                                border: '1px solid rgba(255, 255, 255, 0.05)',\n                            },\n                        }}\n                        value={searchTerm}\n                    />\n                    <ListSortByDropdownControlled\n                        filters={CLIENT_SIDE_ALBUM_FILTERS}\n                        itemType={LibraryItem.ALBUM}\n                        setSortBy={(value) =>\n                            setAlbumArtistDetailSort(value as AlbumListSort, sortOrder)\n                        }\n                        sortBy={sortBy}\n                    />\n                    <ListSortOrderToggleButtonControlled\n                        setSortOrder={(value) =>\n                            setAlbumArtistDetailSort(sortBy, value as SortOrder)\n                        }\n                        sortOrder={sortOrder}\n                    />\n                    <GroupingTypeSelector />\n                </Group>\n                {releaseTypeEntries.length > 0 && (\n                    <div className={styles.albumSectionContainer} ref={cq.ref}>\n                        {cq.isCalculated && <>{ReleaseTypeEntriesMemo}</>}\n                    </div>\n                )}\n            </Stack>\n        </Grid.Col>\n    );\n};\n\nfunction GroupingTypeSelector() {\n    const { t } = useTranslation();\n    const groupingType = useAppStore((state) => state.albumArtistDetailSort.groupingType);\n    const setAlbumArtistDetailGroupingType = useAppStore(\n        (state) => state.actions.setAlbumArtistDetailGroupingType,\n    );\n\n    return (\n        <DropdownMenu>\n            <DropdownMenu.Target>\n                <ActionIcon icon=\"settings\" variant=\"subtle\" />\n            </DropdownMenu.Target>\n            <DropdownMenu.Dropdown>\n                <DropdownMenu.Item\n                    isSelected={groupingType === 'all'}\n                    onClick={() => setAlbumArtistDetailGroupingType('all')}\n                >\n                    {t('page.albumArtistDetail.groupingTypeAll', {\n                        postProcess: 'sentenceCase',\n                    })}\n                </DropdownMenu.Item>\n                <DropdownMenu.Item\n                    isSelected={groupingType === 'primary'}\n                    onClick={() => setAlbumArtistDetailGroupingType('primary')}\n                >\n                    {t('page.albumArtistDetail.groupingTypePrimary', {\n                        postProcess: 'sentenceCase',\n                    })}\n                </DropdownMenu.Item>\n            </DropdownMenu.Dropdown>\n        </DropdownMenu>\n    );\n}\n"
  },
  {
    "path": "src/renderer/features/artists/components/album-artist-detail-discography-list.tsx",
    "content": ""
  },
  {
    "path": "src/renderer/features/artists/components/album-artist-detail-favorite-songs-list-header-filters.tsx",
    "content": "import { ListSearchInput } from '/@/renderer/features/shared/components/list-search-input';\nimport {\n    CLIENT_SIDE_SONG_FILTERS,\n    ListSortByDropdownControlled,\n} from '/@/renderer/features/shared/components/list-sort-by-dropdown';\nimport { ListSortOrderToggleButtonControlled } from '/@/renderer/features/shared/components/list-sort-order-toggle-button';\nimport { useAppStore } from '/@/renderer/store/app.store';\nimport { Divider } from '/@/shared/components/divider/divider';\nimport { Flex } from '/@/shared/components/flex/flex';\nimport { Group } from '/@/shared/components/group/group';\nimport { LibraryItem, SongListSort, SortOrder } from '/@/shared/types/domain-types';\n\nexport const AlbumArtistDetailFavoriteSongsListHeaderFilters = () => {\n    const albumArtistDetailFavoriteSongsSort = useAppStore(\n        (state) => state.albumArtistDetailFavoriteSongsSort,\n    );\n    const setAlbumArtistDetailFavoriteSongsSort = useAppStore(\n        (state) => state.actions.setAlbumArtistDetailFavoriteSongsSort,\n    );\n    const sortBy = albumArtistDetailFavoriteSongsSort.sortBy;\n    const sortOrder = albumArtistDetailFavoriteSongsSort.sortOrder;\n\n    return (\n        <Flex justify=\"space-between\">\n            <Group gap=\"sm\" w=\"100%\">\n                <ListSortByDropdownControlled\n                    filters={CLIENT_SIDE_SONG_FILTERS}\n                    itemType={LibraryItem.SONG}\n                    setSortBy={(value) =>\n                        setAlbumArtistDetailFavoriteSongsSort(value as SongListSort, sortOrder)\n                    }\n                    sortBy={sortBy}\n                />\n                <Divider orientation=\"vertical\" />\n                <ListSortOrderToggleButtonControlled\n                    setSortOrder={(value) =>\n                        setAlbumArtistDetailFavoriteSongsSort(sortBy, value as SortOrder)\n                    }\n                    sortOrder={sortOrder}\n                />\n                <Divider orientation=\"vertical\" />\n                <ListSearchInput />\n            </Group>\n        </Flex>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/artists/components/album-artist-detail-favorite-songs-list-header.tsx",
    "content": "import { useTranslation } from 'react-i18next';\n\nimport { PageHeader } from '/@/renderer/components/page-header/page-header';\nimport { LibraryHeaderBar } from '/@/renderer/features/shared/components/library-header-bar';\nimport { Badge } from '/@/shared/components/badge/badge';\nimport { SpinnerIcon } from '/@/shared/components/spinner/spinner';\nimport { LibraryItem, Song } from '/@/shared/types/domain-types';\n\ninterface AlbumArtistDetailFavoriteSongsListHeaderProps {\n    data: Song[];\n    itemCount?: number;\n    title: string;\n}\n\nexport const AlbumArtistDetailFavoriteSongsListHeader = ({\n    data,\n    itemCount,\n    title,\n}: AlbumArtistDetailFavoriteSongsListHeaderProps) => {\n    const { t } = useTranslation();\n\n    return (\n        <PageHeader>\n            <LibraryHeaderBar ignoreMaxWidth>\n                <LibraryHeaderBar.PlayButton itemType={LibraryItem.SONG} songs={data} />\n                <LibraryHeaderBar.Title order={2}>\n                    {t('page.albumArtistDetail.favoriteSongsFrom', {\n                        postProcess: 'titleCase',\n                        title,\n                    })}\n                </LibraryHeaderBar.Title>\n                <Badge>\n                    {itemCount === null || itemCount === undefined ? <SpinnerIcon /> : itemCount}\n                </Badge>\n            </LibraryHeaderBar>\n        </PageHeader>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/artists/components/album-artist-detail-header.module.css",
    "content": ".metadata-group {\n    justify-content: center;\n    width: 100%;\n\n    @container (min-width: $mantine-breakpoint-sm) {\n        justify-content: flex-start;\n    }\n}\n"
  },
  {
    "path": "src/renderer/features/artists/components/album-artist-detail-header.tsx",
    "content": "import { useQuery, useSuspenseQuery, UseSuspenseQueryResult } from '@tanstack/react-query';\nimport { forwardRef, Fragment, useCallback, useMemo } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { useParams } from 'react-router';\n\nimport styles from './album-artist-detail-header.module.css';\n\nimport { useItemImageUrl } from '/@/renderer/components/item-image/item-image';\nimport { artistsQueries } from '/@/renderer/features/artists/api/artists-api';\nimport { getArtistAlbumsGrouped } from '/@/renderer/features/artists/hooks/use-artist-albums-grouped';\nimport { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';\nimport { usePlayer } from '/@/renderer/features/player/context/player-context';\nimport {\n    LibraryHeader,\n    LibraryHeaderMenu,\n} from '/@/renderer/features/shared/components/library-header';\nimport { useSetFavorite } from '/@/renderer/features/shared/hooks/use-set-favorite';\nimport { useSetRating } from '/@/renderer/features/shared/hooks/use-set-rating';\nimport { AppRoute } from '/@/renderer/router/routes';\nimport { useAppStore, useCurrentServer, useShowRatings } from '/@/renderer/store';\nimport { useArtistReleaseTypeItems, usePlayButtonBehavior } from '/@/renderer/store/settings.store';\nimport { formatDurationString } from '/@/renderer/utils';\nimport { SEPARATOR_STRING, sortAlbumList } from '/@/shared/api/utils';\nimport { Group } from '/@/shared/components/group/group';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { Text } from '/@/shared/components/text/text';\nimport { AlbumListResponse, LibraryItem, ServerType } from '/@/shared/types/domain-types';\nimport { Play } from '/@/shared/types/types';\n\ninterface AlbumArtistDetailHeaderProps {\n    albumsQuery: UseSuspenseQueryResult<AlbumListResponse, Error>;\n}\n\nexport const AlbumArtistDetailHeader = forwardRef<HTMLDivElement, AlbumArtistDetailHeaderProps>(\n    ({ albumsQuery }, ref) => {\n        const { albumArtistId, artistId } = useParams() as {\n            albumArtistId?: string;\n            artistId?: string;\n        };\n        const routeId = (artistId || albumArtistId) as string;\n        const server = useCurrentServer();\n        const showRatings = useShowRatings();\n        const { t } = useTranslation();\n        const detailQuery = useSuspenseQuery(\n            artistsQueries.albumArtistDetail({\n                query: { id: routeId },\n                serverId: server?.id,\n            }),\n        );\n\n        const albumCount = detailQuery.data?.albumCount;\n        const songCount = detailQuery.data?.songCount;\n        const duration = detailQuery.data?.duration;\n        const durationEnabled = duration !== null && duration !== undefined;\n\n        const metadataItems = [\n            {\n                enabled: albumCount !== null && albumCount !== undefined,\n                id: 'albumCount',\n                secondary: false,\n                value: t('entity.albumWithCount', { count: albumCount || 0 }),\n            },\n            {\n                enabled: songCount !== null && songCount !== undefined,\n                id: 'songCount',\n                secondary: false,\n                value: t('entity.trackWithCount', { count: songCount || 0 }),\n            },\n            {\n                enabled: durationEnabled,\n                id: 'duration',\n                secondary: true,\n                value: durationEnabled && formatDurationString(duration),\n            },\n        ];\n\n        const { addToQueueByFetch } = usePlayer();\n        const playButtonBehavior = usePlayButtonBehavior();\n        const setFavorite = useSetFavorite();\n        const setRating = useSetRating();\n\n        const albumArtistDetailSort = useAppStore((state) => state.albumArtistDetailSort);\n        const sortBy = albumArtistDetailSort.sortBy;\n        const sortOrder = albumArtistDetailSort.sortOrder;\n        const groupingType = albumArtistDetailSort.groupingType;\n        const artistReleaseTypeItems = useArtistReleaseTypeItems();\n\n        const handlePlay = useCallback(\n            (type?: Play) => {\n                if (!server?.id || !routeId) return;\n\n                const albums = albumsQuery.data?.items || [];\n                const sortedAlbums = sortAlbumList(albums, sortBy, sortOrder);\n\n                const { flatSortedAlbums } = getArtistAlbumsGrouped(\n                    sortedAlbums,\n                    routeId,\n                    groupingType,\n                    artistReleaseTypeItems,\n                    t,\n                );\n\n                const albumIds = flatSortedAlbums.map((album) => album.id);\n                if (albumIds.length === 0) return;\n                addToQueueByFetch(\n                    server.id,\n                    albumIds,\n                    LibraryItem.ALBUM,\n                    type || playButtonBehavior,\n                );\n            },\n            [\n                addToQueueByFetch,\n                playButtonBehavior,\n                routeId,\n                server.id,\n                albumsQuery.data?.items,\n                sortBy,\n                sortOrder,\n                groupingType,\n                artistReleaseTypeItems,\n                t,\n            ],\n        );\n\n        const handleFavorite = useCallback(() => {\n            if (!detailQuery.data) return;\n            setFavorite(\n                detailQuery.data._serverId,\n                [detailQuery.data.id],\n                LibraryItem.ALBUM_ARTIST,\n                !detailQuery.data.userFavorite,\n            );\n        }, [detailQuery.data, setFavorite]);\n\n        const handleUpdateRating = useCallback(\n            (rating: number) => {\n                if (!detailQuery.data) return;\n\n                if (detailQuery.data.userRating === rating) {\n                    return setRating(\n                        detailQuery.data._serverId,\n                        [detailQuery.data.id],\n                        LibraryItem.ALBUM_ARTIST,\n                        0,\n                    );\n                }\n\n                return setRating(\n                    detailQuery.data._serverId,\n                    [detailQuery.data.id],\n                    LibraryItem.ALBUM_ARTIST,\n                    rating,\n                );\n            },\n            [detailQuery.data, setRating],\n        );\n\n        const handleMoreOptions = useCallback(\n            (e: React.MouseEvent<HTMLButtonElement>) => {\n                if (!detailQuery.data) return;\n                ContextMenuController.call({\n                    cmd: { items: [detailQuery.data], type: LibraryItem.ALBUM_ARTIST },\n                    event: e,\n                });\n            },\n            [detailQuery.data],\n        );\n\n        const imageUrl = useItemImageUrl({\n            id: detailQuery.data?.imageId || undefined,\n            imageUrl: detailQuery.data?.imageUrl,\n            itemType: LibraryItem.ALBUM_ARTIST,\n            type: 'itemCard',\n        });\n\n        const artistInfoQuery = useQuery({\n            ...artistsQueries.albumArtistInfo({\n                query: { id: routeId, limit: 10 },\n                serverId: server?.id,\n            }),\n            enabled: Boolean(server?.id && routeId),\n        });\n\n        const showRating = showRatings && detailQuery?.data?._serverType === ServerType.NAVIDROME;\n\n        const selectedImageUrl = useMemo(() => {\n            return detailQuery.data?.imageUrl || imageUrl;\n        }, [detailQuery.data?.imageUrl, imageUrl]);\n\n        const alternateImageUrl = artistInfoQuery.data?.imageUrl;\n\n        return (\n            <LibraryHeader\n                imageUrl={alternateImageUrl || selectedImageUrl}\n                item={{\n                    imageId: detailQuery.data?.imageId,\n                    imageUrl: alternateImageUrl || detailQuery.data?.imageUrl,\n                    route: AppRoute.LIBRARY_ALBUM_ARTISTS,\n                    type: LibraryItem.ALBUM_ARTIST,\n                }}\n                ref={ref}\n                title={detailQuery.data?.name || ''}\n            >\n                <Stack gap=\"md\" w=\"100%\">\n                    <Group className={styles.metadataGroup}>\n                        {metadataItems\n                            .filter((i) => i.enabled)\n                            .map((item, index) => (\n                                <Fragment key={`item-${item.id}-${index}`}>\n                                    {index > 0 && (\n                                        <Text isMuted isNoSelect>\n                                            {SEPARATOR_STRING}\n                                        </Text>\n                                    )}\n                                    <Text isMuted={item.secondary}>{item.value}</Text>\n                                </Fragment>\n                            ))}\n                    </Group>\n                    <LibraryHeaderMenu\n                        favorite={detailQuery.data?.userFavorite}\n                        onFavorite={handleFavorite}\n                        onMore={handleMoreOptions}\n                        onPlay={(type) => handlePlay(type)}\n                        onRating={showRating ? handleUpdateRating : undefined}\n                        onShuffle={() => handlePlay(Play.SHUFFLE)}\n                        rating={detailQuery.data?.userRating || 0}\n                    />\n                </Stack>\n            </LibraryHeader>\n        );\n    },\n);\n"
  },
  {
    "path": "src/renderer/features/artists/components/album-artist-detail-top-songs-list-header.tsx",
    "content": "import { useTranslation } from 'react-i18next';\n\nimport { PageHeader } from '/@/renderer/components/page-header/page-header';\nimport { LibraryHeaderBar } from '/@/renderer/features/shared/components/library-header-bar';\nimport { Badge } from '/@/shared/components/badge/badge';\nimport { SpinnerIcon } from '/@/shared/components/spinner/spinner';\nimport { LibraryItem, Song } from '/@/shared/types/domain-types';\n\ninterface AlbumArtistDetailTopSongsListHeaderProps {\n    data: Song[];\n    itemCount?: number;\n    title: string;\n}\n\nexport const AlbumArtistDetailTopSongsListHeader = ({\n    data,\n    itemCount,\n    title,\n}: AlbumArtistDetailTopSongsListHeaderProps) => {\n    const { t } = useTranslation();\n\n    return (\n        <PageHeader>\n            <LibraryHeaderBar ignoreMaxWidth>\n                <LibraryHeaderBar.PlayButton itemType={LibraryItem.SONG} songs={data} />\n                <LibraryHeaderBar.Title order={2}>\n                    {t('page.albumArtistDetail.topSongsFrom', {\n                        postProcess: 'titleCase',\n                        title,\n                    })}\n                </LibraryHeaderBar.Title>\n                <Badge>\n                    {itemCount === null || itemCount === undefined ? <SpinnerIcon /> : itemCount}\n                </Badge>\n            </LibraryHeaderBar>\n        </PageHeader>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/artists/components/album-artist-grid-carousel.tsx",
    "content": "import { useMemo } from 'react';\n\nimport {\n    GridCarousel,\n    GridCarouselSkeletonFallback,\n} from '/@/renderer/components/grid-carousel/grid-carousel-v2';\nimport { MemoizedItemCard } from '/@/renderer/components/item-card/item-card';\nimport { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls';\nimport { useGridRows } from '/@/renderer/components/item-list/helpers/use-grid-rows';\nimport { AlbumArtist, LibraryItem } from '/@/shared/types/domain-types';\nimport { ItemListKey } from '/@/shared/types/types';\n\ninterface AlbumArtistGridCarouselProps {\n    data: AlbumArtist[];\n    excludeIds?: string[];\n    isLoading?: boolean;\n    rowCount?: number;\n    title: React.ReactNode | string;\n}\n\nexport function AlbumArtistGridCarousel(props: AlbumArtistGridCarouselProps) {\n    const { data, excludeIds, isLoading = false, rowCount = 1, title } = props;\n    const rows = useGridRows(LibraryItem.ALBUM_ARTIST, ItemListKey.ALBUM_ARTIST);\n    const controls = useDefaultItemListControls();\n\n    const cards = useMemo(() => {\n        const filteredItems = excludeIds\n            ? data.filter((albumArtist) => !excludeIds.includes(albumArtist.id))\n            : data;\n\n        return filteredItems.map((albumArtist: AlbumArtist) => ({\n            content: (\n                <MemoizedItemCard\n                    controls={controls}\n                    data={albumArtist}\n                    enableDrag\n                    isRound\n                    itemType={LibraryItem.ALBUM_ARTIST}\n                    rows={rows}\n                    type=\"poster\"\n                    withControls\n                />\n            ),\n            id: albumArtist.id,\n        }));\n    }, [data, excludeIds, controls, rows]);\n\n    if (isLoading) {\n        return (\n            <GridCarouselSkeletonFallback\n                placeholderItemType={LibraryItem.ALBUM_ARTIST}\n                placeholderRound\n                placeholderRows={rows}\n                rowCount={rowCount}\n                title={title}\n            />\n        );\n    }\n\n    const handleNextPage = () => {};\n    const handlePrevPage = () => {};\n\n    if (cards.length === 0) {\n        return null;\n    }\n\n    return (\n        <GridCarousel\n            cards={cards}\n            onNextPage={handleNextPage}\n            onPrevPage={handlePrevPage}\n            rowCount={rowCount}\n            title={title}\n        />\n    );\n}\n"
  },
  {
    "path": "src/renderer/features/artists/components/album-artist-infinite-carousel.tsx",
    "content": "import { QueryFunctionContext, useSuspenseInfiniteQuery } from '@tanstack/react-query';\nimport { Suspense, useCallback, useMemo } from 'react';\n\nimport { api } from '/@/renderer/api';\nimport { queryKeys } from '/@/renderer/api/query-keys';\nimport {\n    GridCarousel,\n    GridCarouselSkeletonFallback,\n    useGridCarouselContainerQuery,\n} from '/@/renderer/components/grid-carousel/grid-carousel-v2';\nimport { DataRow, MemoizedItemCard } from '/@/renderer/components/item-card/item-card';\nimport { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls';\nimport { useGridRows } from '/@/renderer/components/item-list/helpers/use-grid-rows';\nimport { useCurrentServerId } from '/@/renderer/store';\nimport {\n    AlbumArtist,\n    AlbumArtistListQuery,\n    AlbumArtistListResponse,\n    AlbumArtistListSort,\n    LibraryItem,\n    SortOrder,\n} from '/@/shared/types/domain-types';\nimport { ItemListKey } from '/@/shared/types/types';\n\ninterface AlbumArtistCarouselProps {\n    containerQuery?: ReturnType<typeof useGridCarouselContainerQuery>;\n    excludeIds?: string[];\n    query?: Partial<Omit<AlbumArtistListQuery, 'startIndex'>>;\n    queryKey?: QueryFunctionContext['queryKey'];\n    rowCount?: number;\n    sortBy: AlbumArtistListSort;\n    sortOrder: SortOrder;\n    title: React.ReactNode | string;\n}\n\nconst BaseAlbumArtistInfiniteCarousel = (props: AlbumArtistCarouselProps & { rows: DataRow[] }) => {\n    const {\n        containerQuery,\n        excludeIds,\n        query: additionalQuery,\n        queryKey,\n        rowCount = 1,\n        rows,\n        sortBy,\n        sortOrder,\n        title,\n    } = props;\n    const {\n        data: albumArtists,\n        fetchNextPage,\n        hasNextPage,\n        isFetchingNextPage,\n        refetch,\n    } = useAlbumArtistListInfinite(sortBy, sortOrder, 20, additionalQuery, queryKey);\n\n    const controls = useDefaultItemListControls();\n\n    const cards = useMemo(() => {\n        // Flatten all pages and filter excluded IDs\n        const allItems =\n            albumArtists?.pages.flatMap((page: AlbumArtistListResponse) => page.items) || [];\n        const filteredItems = excludeIds\n            ? allItems.filter((albumArtist) => !excludeIds.includes(albumArtist.id))\n            : allItems;\n\n        return filteredItems.map((albumArtist: AlbumArtist) => ({\n            content: (\n                <MemoizedItemCard\n                    controls={controls}\n                    data={albumArtist}\n                    enableDrag\n                    imageFetchPriority=\"low\"\n                    itemType={LibraryItem.ALBUM_ARTIST}\n                    rows={rows}\n                    type=\"poster\"\n                    withControls\n                />\n            ),\n            id: albumArtist.id,\n        }));\n    }, [albumArtists, controls, excludeIds, rows]);\n\n    const handleNextPage = useCallback(() => {}, []);\n\n    const handlePrevPage = useCallback(() => {}, []);\n\n    const handleRefresh = useCallback(() => {\n        refetch();\n    }, [refetch]);\n\n    const firstPageItems = excludeIds\n        ? albumArtists?.pages[0]?.items.filter(\n              (albumArtist) => !excludeIds.includes(albumArtist.id),\n          ) || []\n        : albumArtists?.pages[0]?.items || [];\n\n    if (firstPageItems.length === 0) {\n        return null;\n    }\n\n    return (\n        <GridCarousel\n            cards={cards}\n            containerQuery={containerQuery}\n            hasNextPage={hasNextPage}\n            isFetchingNextPage={isFetchingNextPage}\n            loadNextPage={fetchNextPage}\n            onNextPage={handleNextPage}\n            onPrevPage={handlePrevPage}\n            onRefresh={handleRefresh}\n            placeholderItemType={LibraryItem.ALBUM_ARTIST}\n            placeholderRows={rows}\n            rowCount={rowCount}\n            title={title}\n        />\n    );\n};\n\nexport const AlbumArtistInfiniteCarousel = (props: AlbumArtistCarouselProps) => {\n    const rows = useGridRows(LibraryItem.ALBUM_ARTIST, ItemListKey.ALBUM_ARTIST);\n\n    return (\n        <Suspense\n            fallback={\n                <GridCarouselSkeletonFallback\n                    containerQuery={props.containerQuery}\n                    placeholderItemType={LibraryItem.ALBUM_ARTIST}\n                    placeholderRows={rows}\n                    title={props.title}\n                />\n            }\n        >\n            <BaseAlbumArtistInfiniteCarousel {...props} rows={rows} />\n        </Suspense>\n    );\n};\n\nfunction useAlbumArtistListInfinite(\n    sortBy: AlbumArtistListSort,\n    sortOrder: SortOrder,\n    itemLimit: number,\n    additionalQuery?: Partial<Omit<AlbumArtistListQuery, 'startIndex'>>,\n    overrideQueryKey?: QueryFunctionContext['queryKey'],\n) {\n    const serverId = useCurrentServerId();\n\n    const defaultQueryKey = queryKeys.albumArtists.infiniteList(serverId, {\n        sortBy,\n        sortOrder,\n        ...additionalQuery,\n    });\n\n    const query = useSuspenseInfiniteQuery<AlbumArtistListResponse>({\n        getNextPageParam: (lastPage, _allPages, lastPageParam) => {\n            if (lastPage.items.length < itemLimit) {\n                return undefined;\n            }\n\n            const nextPageParam = Number(lastPageParam) + itemLimit;\n\n            return String(nextPageParam);\n        },\n        initialPageParam: '0',\n        queryFn: ({ pageParam, signal }) => {\n            return api.controller.getAlbumArtistList({\n                apiClientProps: { serverId, signal },\n                query: {\n                    limit: itemLimit,\n                    sortBy,\n                    sortOrder,\n                    startIndex: Number(pageParam),\n                    ...additionalQuery,\n                },\n            });\n        },\n        queryKey: overrideQueryKey || defaultQueryKey,\n    });\n\n    return query;\n}\n"
  },
  {
    "path": "src/renderer/features/artists/components/album-artist-list-content.tsx",
    "content": "import { lazy, Suspense, useMemo } from 'react';\n\nimport { useAlbumArtistListFilters } from '/@/renderer/features/artists/hooks/use-album-artist-list-filters';\nimport { ItemListSettings, useCurrentServer, useListSettings } from '/@/renderer/store';\nimport { Spinner } from '/@/shared/components/spinner/spinner';\nimport { AlbumArtistListQuery } from '/@/shared/types/domain-types';\nimport { ItemListKey, ListDisplayType, ListPaginationType } from '/@/shared/types/types';\n\nconst AlbumArtistListInfiniteGrid = lazy(() =>\n    import('/@/renderer/features/artists/components/album-artist-list-infinite-grid').then(\n        (module) => ({\n            default: module.AlbumArtistListInfiniteGrid,\n        }),\n    ),\n);\n\nconst AlbumArtistListPaginatedGrid = lazy(() =>\n    import('/@/renderer/features/artists/components/album-artist-list-paginated-grid').then(\n        (module) => ({\n            default: module.AlbumArtistListPaginatedGrid,\n        }),\n    ),\n);\n\nconst AlbumArtistListInfiniteTable = lazy(() =>\n    import('/@/renderer/features/artists/components/album-artist-list-infinite-table').then(\n        (module) => ({\n            default: module.AlbumArtistListInfiniteTable,\n        }),\n    ),\n);\n\nconst AlbumArtistListPaginatedTable = lazy(() =>\n    import('/@/renderer/features/artists/components/album-artist-list-paginated-table').then(\n        (module) => ({\n            default: module.AlbumArtistListPaginatedTable,\n        }),\n    ),\n);\n\nexport const AlbumArtistListContent = () => {\n    const { display, grid, itemsPerPage, pagination, table } = useListSettings(\n        ItemListKey.ALBUM_ARTIST,\n    );\n\n    return (\n        <Suspense fallback={<Spinner container />}>\n            <AlbumArtistListView\n                display={display}\n                grid={grid}\n                itemsPerPage={itemsPerPage}\n                pagination={pagination}\n                table={table}\n            />\n        </Suspense>\n    );\n};\n\nexport type OverrideAlbumArtistListQuery = Omit<AlbumArtistListQuery, 'limit' | 'startIndex'>;\n\nexport const AlbumArtistListView = ({\n    display,\n    grid,\n    itemsPerPage,\n    overrideQuery,\n    pagination,\n    table,\n}: ItemListSettings & { overrideQuery?: OverrideAlbumArtistListQuery }) => {\n    const server = useCurrentServer();\n\n    const { query } = useAlbumArtistListFilters();\n\n    const mergedQuery = useMemo(() => {\n        if (!overrideQuery) {\n            return query;\n        }\n\n        return {\n            ...query,\n            ...overrideQuery,\n            sortBy: overrideQuery.sortBy || query.sortBy,\n            sortOrder: overrideQuery.sortOrder || query.sortOrder,\n        };\n    }, [query, overrideQuery]);\n\n    switch (display) {\n        case ListDisplayType.GRID: {\n            switch (pagination) {\n                case ListPaginationType.INFINITE: {\n                    return (\n                        <AlbumArtistListInfiniteGrid\n                            gap={grid.itemGap}\n                            itemsPerPage={itemsPerPage}\n                            itemsPerRow={grid.itemsPerRowEnabled ? grid.itemsPerRow : undefined}\n                            query={mergedQuery}\n                            serverId={server.id}\n                            size={grid.size}\n                        />\n                    );\n                }\n                case ListPaginationType.PAGINATED: {\n                    return (\n                        <AlbumArtistListPaginatedGrid\n                            gap={grid.itemGap}\n                            itemsPerPage={itemsPerPage}\n                            itemsPerRow={grid.itemsPerRowEnabled ? grid.itemsPerRow : undefined}\n                            query={mergedQuery}\n                            serverId={server.id}\n                            size={grid.size}\n                        />\n                    );\n                }\n                default:\n                    return null;\n            }\n        }\n        case ListDisplayType.TABLE: {\n            switch (pagination) {\n                case ListPaginationType.INFINITE: {\n                    return (\n                        <AlbumArtistListInfiniteTable\n                            autoFitColumns={table.autoFitColumns}\n                            columns={table.columns}\n                            enableAlternateRowColors={table.enableAlternateRowColors}\n                            enableHeader={table.enableHeader}\n                            enableHorizontalBorders={table.enableHorizontalBorders}\n                            enableRowHoverHighlight={table.enableRowHoverHighlight}\n                            enableVerticalBorders={table.enableVerticalBorders}\n                            itemsPerPage={itemsPerPage}\n                            query={mergedQuery}\n                            serverId={server.id}\n                            size={table.size}\n                        />\n                    );\n                }\n                case ListPaginationType.PAGINATED: {\n                    return (\n                        <AlbumArtistListPaginatedTable\n                            autoFitColumns={table.autoFitColumns}\n                            columns={table.columns}\n                            enableAlternateRowColors={table.enableAlternateRowColors}\n                            enableHeader={table.enableHeader}\n                            enableHorizontalBorders={table.enableHorizontalBorders}\n                            enableRowHoverHighlight={table.enableRowHoverHighlight}\n                            enableVerticalBorders={table.enableVerticalBorders}\n                            itemsPerPage={itemsPerPage}\n                            query={mergedQuery}\n                            serverId={server.id}\n                            size={table.size}\n                        />\n                    );\n                }\n                default:\n                    return null;\n            }\n        }\n    }\n\n    return null;\n};\n"
  },
  {
    "path": "src/renderer/features/artists/components/album-artist-list-header-filters.tsx",
    "content": "import { ALBUM_ARTIST_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns';\nimport { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';\nimport { ListDisplayTypeToggleButton } from '/@/renderer/features/shared/components/list-display-type-toggle-button';\nimport { ListRefreshButton } from '/@/renderer/features/shared/components/list-refresh-button';\nimport { ListSortByDropdown } from '/@/renderer/features/shared/components/list-sort-by-dropdown';\nimport { ListSortOrderToggleButton } from '/@/renderer/features/shared/components/list-sort-order-toggle-button';\nimport { Divider } from '/@/shared/components/divider/divider';\nimport { Flex } from '/@/shared/components/flex/flex';\nimport { Group } from '/@/shared/components/group/group';\nimport { AlbumArtistListSort, LibraryItem, SortOrder } from '/@/shared/types/domain-types';\nimport { ItemListKey } from '/@/shared/types/types';\n\nexport const AlbumArtistListHeaderFilters = () => {\n    return (\n        <Flex justify=\"space-between\">\n            <Group gap=\"sm\" w=\"100%\">\n                <ListSortByDropdown\n                    defaultSortByValue={AlbumArtistListSort.NAME}\n                    itemType={LibraryItem.ALBUM_ARTIST}\n                    listKey={ItemListKey.ALBUM_ARTIST}\n                />\n                <Divider orientation=\"vertical\" />\n                <ListSortOrderToggleButton\n                    defaultSortOrder={SortOrder.ASC}\n                    listKey={ItemListKey.ALBUM_ARTIST}\n                />\n                <ListRefreshButton listKey={ItemListKey.ALBUM_ARTIST} />\n            </Group>\n            <Group gap=\"sm\" wrap=\"nowrap\">\n                <ListDisplayTypeToggleButton listKey={ItemListKey.ALBUM_ARTIST} />\n                <ListConfigMenu\n                    listKey={ItemListKey.ALBUM_ARTIST}\n                    tableColumnsData={ALBUM_ARTIST_TABLE_COLUMNS}\n                />\n            </Group>\n        </Flex>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/artists/components/album-artist-list-header.tsx",
    "content": "import { useTranslation } from 'react-i18next';\n\nimport { useIsFetchingItemListCount } from '/@/renderer/components/item-list/helpers/use-is-fetching-item-list';\nimport { PageHeader } from '/@/renderer/components/page-header/page-header';\nimport { useListContext } from '/@/renderer/context/list-context';\nimport { AlbumArtistListHeaderFilters } from '/@/renderer/features/artists/components/album-artist-list-header-filters';\nimport { useAlbumArtistListFilters } from '/@/renderer/features/artists/hooks/use-album-artist-list-filters';\nimport { FilterBar } from '/@/renderer/features/shared/components/filter-bar';\nimport { LibraryHeaderBar } from '/@/renderer/features/shared/components/library-header-bar';\nimport { ListSearchInput } from '/@/renderer/features/shared/components/list-search-input';\nimport { Group } from '/@/shared/components/group/group';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { LibraryItem } from '/@/shared/types/domain-types';\n\ninterface AlbumArtistListHeaderProps {\n    title?: string;\n}\n\nexport const AlbumArtistListHeader = ({ title }: AlbumArtistListHeaderProps) => {\n    const { t } = useTranslation();\n\n    const pageTitle = title || t('page.albumArtistList.title', { postProcess: 'titleCase' });\n\n    return (\n        <Stack gap={0}>\n            <PageHeader>\n                <LibraryHeaderBar ignoreMaxWidth>\n                    <PlayButton />\n                    <LibraryHeaderBar.Title>{pageTitle}</LibraryHeaderBar.Title>\n                    <AlbumArtistListHeaderBadge />\n                </LibraryHeaderBar>\n                <Group>\n                    <ListSearchInput />\n                </Group>\n            </PageHeader>\n            <FilterBar>\n                <AlbumArtistListHeaderFilters />\n            </FilterBar>\n        </Stack>\n    );\n};\n\nconst AlbumArtistListHeaderBadge = () => {\n    const { itemCount } = useListContext();\n\n    const isFetching = useIsFetchingItemListCount({\n        itemType: LibraryItem.ALBUM_ARTIST,\n    });\n\n    return <LibraryHeaderBar.Badge isLoading={isFetching}>{itemCount}</LibraryHeaderBar.Badge>;\n};\n\nconst PlayButton = () => {\n    const { query } = useAlbumArtistListFilters();\n\n    return <LibraryHeaderBar.PlayButton itemType={LibraryItem.ALBUM_ARTIST} listQuery={query} />;\n};\n"
  },
  {
    "path": "src/renderer/features/artists/components/album-artist-list-infinite-grid.tsx",
    "content": "import { UseSuspenseQueryOptions } from '@tanstack/react-query';\n\nimport { api } from '/@/renderer/api';\nimport { useItemListInfiniteLoader } from '/@/renderer/components/item-list/helpers/item-list-infinite-loader';\nimport { useGridRows } from '/@/renderer/components/item-list/helpers/use-grid-rows';\nimport { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist';\nimport { ItemGridList } from '/@/renderer/components/item-list/item-grid-list/item-grid-list';\nimport { ItemListGridComponentProps } from '/@/renderer/components/item-list/types';\nimport { artistsQueries } from '/@/renderer/features/artists/api/artists-api';\nimport { useGeneralSettings } from '/@/renderer/store';\nimport {\n    AlbumArtistListQuery,\n    AlbumArtistListSort,\n    LibraryItem,\n    SortOrder,\n} from '/@/shared/types/domain-types';\nimport { ItemListKey } from '/@/shared/types/types';\n\ninterface AlbumArtistListInfiniteGridProps\n    extends ItemListGridComponentProps<AlbumArtistListQuery> {}\n\nexport const AlbumArtistListInfiniteGrid = ({\n    gap = 'md',\n    itemsPerPage = 100,\n    itemsPerRow,\n    query = {\n        sortBy: AlbumArtistListSort.NAME,\n        sortOrder: SortOrder.ASC,\n    },\n    saveScrollOffset = true,\n    serverId,\n    size,\n}: AlbumArtistListInfiniteGridProps) => {\n    const listCountQuery = artistsQueries.albumArtistListCount({\n        query: { ...query, limit: itemsPerPage },\n        serverId: serverId,\n    }) as UseSuspenseQueryOptions<number, Error, number, readonly unknown[]>;\n\n    const listQueryFn = api.controller.getAlbumArtistList;\n\n    const { dataVersion, getItem, getItemIndex, itemCount, loadedItems, onRangeChanged } =\n        useItemListInfiniteLoader({\n            eventKey: ItemListKey.ALBUM_ARTIST,\n            itemsPerPage,\n            itemType: LibraryItem.ALBUM_ARTIST,\n            listCountQuery,\n            listQueryFn,\n            query,\n            serverId,\n        });\n\n    const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({\n        enabled: saveScrollOffset,\n    });\n\n    const rows = useGridRows(LibraryItem.ALBUM_ARTIST, ItemListKey.ALBUM_ARTIST, size);\n    const { enableGridMultiSelect } = useGeneralSettings();\n\n    return (\n        <ItemGridList\n            data={loadedItems}\n            dataVersion={dataVersion}\n            enableMultiSelect={enableGridMultiSelect}\n            gap={gap}\n            getItem={getItem}\n            getItemIndex={getItemIndex}\n            initialTop={{\n                to: scrollOffset ?? 0,\n                type: 'offset',\n            }}\n            itemCount={itemCount}\n            itemsPerRow={itemsPerRow}\n            itemType={LibraryItem.ALBUM_ARTIST}\n            onRangeChanged={onRangeChanged}\n            onScrollEnd={handleOnScrollEnd}\n            rows={rows}\n            size={size}\n        />\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/artists/components/album-artist-list-infinite-table.tsx",
    "content": "import { UseSuspenseQueryOptions } from '@tanstack/react-query';\n\nimport { api } from '/@/renderer/api';\nimport { useItemListInfiniteLoader } from '/@/renderer/components/item-list/helpers/item-list-infinite-loader';\nimport { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder';\nimport { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize';\nimport { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist';\nimport { ItemTableList } from '/@/renderer/components/item-list/item-table-list/item-table-list';\nimport { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';\nimport { ItemListTableComponentProps } from '/@/renderer/components/item-list/types';\nimport { artistsQueries } from '/@/renderer/features/artists/api/artists-api';\nimport {\n    AlbumArtistListQuery,\n    AlbumArtistListSort,\n    LibraryItem,\n    SortOrder,\n} from '/@/shared/types/domain-types';\nimport { ItemListKey } from '/@/shared/types/types';\n\ninterface AlbumArtistListInfiniteTableProps\n    extends ItemListTableComponentProps<AlbumArtistListQuery> {}\n\nexport const AlbumArtistListInfiniteTable = ({\n    autoFitColumns = false,\n    columns,\n    enableAlternateRowColors = false,\n    enableHeader = true,\n    enableHorizontalBorders = false,\n    enableRowHoverHighlight = true,\n    enableSelection = true,\n    enableVerticalBorders = false,\n    itemsPerPage = 100,\n    query = {\n        sortBy: AlbumArtistListSort.NAME,\n        sortOrder: SortOrder.ASC,\n    },\n    saveScrollOffset = true,\n    serverId,\n    size = 'default',\n}: AlbumArtistListInfiniteTableProps) => {\n    const listCountQuery = artistsQueries.albumArtistListCount({\n        query: { ...query, limit: itemsPerPage },\n        serverId: serverId,\n    }) as UseSuspenseQueryOptions<number, Error, number, readonly unknown[]>;\n\n    const listQueryFn = api.controller.getAlbumArtistList;\n\n    const { getItem, getItemIndex, itemCount, loadedItems, onRangeChanged } =\n        useItemListInfiniteLoader({\n            eventKey: ItemListKey.ALBUM_ARTIST,\n            itemsPerPage,\n            itemType: LibraryItem.ALBUM_ARTIST,\n            listCountQuery,\n            listQueryFn,\n            query,\n            serverId,\n        });\n\n    const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({\n        enabled: saveScrollOffset,\n    });\n\n    const { handleColumnReordered } = useItemListColumnReorder({\n        itemListKey: ItemListKey.ALBUM_ARTIST,\n    });\n\n    const { handleColumnResized } = useItemListColumnResize({\n        itemListKey: ItemListKey.ALBUM_ARTIST,\n    });\n\n    return (\n        <ItemTableList\n            autoFitColumns={autoFitColumns}\n            CellComponent={ItemTableListColumn}\n            columns={columns}\n            data={loadedItems}\n            enableAlternateRowColors={enableAlternateRowColors}\n            enableExpansion={false}\n            enableHeader={enableHeader}\n            enableHorizontalBorders={enableHorizontalBorders}\n            enableRowHoverHighlight={enableRowHoverHighlight}\n            enableSelection={enableSelection}\n            enableVerticalBorders={enableVerticalBorders}\n            getItem={getItem}\n            getItemIndex={getItemIndex}\n            initialTop={{\n                to: scrollOffset ?? 0,\n                type: 'offset',\n            }}\n            itemCount={itemCount}\n            itemType={LibraryItem.ALBUM_ARTIST}\n            onColumnReordered={handleColumnReordered}\n            onColumnResized={handleColumnResized}\n            onRangeChanged={onRangeChanged}\n            onScrollEnd={handleOnScrollEnd}\n            size={size}\n        />\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/artists/components/album-artist-list-paginated-grid.tsx",
    "content": "import { UseSuspenseQueryOptions } from '@tanstack/react-query';\n\nimport { api } from '/@/renderer/api';\nimport { useItemListPaginatedLoader } from '/@/renderer/components/item-list/helpers/item-list-paginated-loader';\nimport { useGridRows } from '/@/renderer/components/item-list/helpers/use-grid-rows';\nimport { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist';\nimport { ItemGridList } from '/@/renderer/components/item-list/item-grid-list/item-grid-list';\nimport { ItemListWithPagination } from '/@/renderer/components/item-list/item-list-pagination/item-list-pagination';\nimport { useItemListPagination } from '/@/renderer/components/item-list/item-list-pagination/use-item-list-pagination';\nimport { ItemListGridComponentProps } from '/@/renderer/components/item-list/types';\nimport { artistsQueries } from '/@/renderer/features/artists/api/artists-api';\nimport { useGeneralSettings } from '/@/renderer/store';\nimport {\n    AlbumArtistListQuery,\n    AlbumArtistListSort,\n    LibraryItem,\n    SortOrder,\n} from '/@/shared/types/domain-types';\nimport { ItemListKey } from '/@/shared/types/types';\n\ninterface AlbumArtistListPaginatedGridProps\n    extends ItemListGridComponentProps<AlbumArtistListQuery> {}\n\nexport const AlbumArtistListPaginatedGrid = ({\n    gap = 'md',\n    itemsPerPage = 100,\n    itemsPerRow,\n    query = {\n        sortBy: AlbumArtistListSort.NAME,\n        sortOrder: SortOrder.ASC,\n    },\n    saveScrollOffset = true,\n    serverId,\n    size,\n}: AlbumArtistListPaginatedGridProps) => {\n    const { currentPage, onChange } = useItemListPagination();\n\n    const listCountQuery = artistsQueries.albumArtistListCount({\n        query: { ...query, limit: itemsPerPage },\n        serverId: serverId,\n    }) as UseSuspenseQueryOptions<number, Error, number, readonly unknown[]>;\n\n    const listQueryFn = api.controller.getAlbumArtistList;\n\n    const { data, pageCount, totalItemCount } = useItemListPaginatedLoader({\n        currentPage,\n        eventKey: ItemListKey.ALBUM_ARTIST,\n        itemsPerPage,\n        itemType: LibraryItem.ALBUM_ARTIST,\n        listCountQuery,\n        listQueryFn,\n        query,\n        serverId,\n    });\n\n    const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({\n        enabled: saveScrollOffset,\n    });\n\n    const rows = useGridRows(LibraryItem.ALBUM_ARTIST, ItemListKey.ALBUM_ARTIST, size);\n    const { enableGridMultiSelect } = useGeneralSettings();\n\n    return (\n        <ItemListWithPagination\n            currentPage={currentPage}\n            itemsPerPage={itemsPerPage}\n            onChange={onChange}\n            pageCount={pageCount}\n            totalItemCount={totalItemCount}\n        >\n            <ItemGridList\n                currentPage={currentPage}\n                data={data || []}\n                enableMultiSelect={enableGridMultiSelect}\n                gap={gap}\n                initialTop={{\n                    to: scrollOffset ?? 0,\n                    type: 'offset',\n                }}\n                itemsPerRow={itemsPerRow}\n                itemType={LibraryItem.ALBUM_ARTIST}\n                onScrollEnd={handleOnScrollEnd}\n                rows={rows}\n                size={size}\n            />\n        </ItemListWithPagination>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/artists/components/album-artist-list-paginated-table.tsx",
    "content": "import { UseSuspenseQueryOptions } from '@tanstack/react-query';\n\nimport { api } from '/@/renderer/api';\nimport { useItemListPaginatedLoader } from '/@/renderer/components/item-list/helpers/item-list-paginated-loader';\nimport { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder';\nimport { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize';\nimport { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist';\nimport { ItemListWithPagination } from '/@/renderer/components/item-list/item-list-pagination/item-list-pagination';\nimport { useItemListPagination } from '/@/renderer/components/item-list/item-list-pagination/use-item-list-pagination';\nimport { ItemTableList } from '/@/renderer/components/item-list/item-table-list/item-table-list';\nimport { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';\nimport { ItemListTableComponentProps } from '/@/renderer/components/item-list/types';\nimport { artistsQueries } from '/@/renderer/features/artists/api/artists-api';\nimport {\n    AlbumArtistListQuery,\n    AlbumArtistListSort,\n    LibraryItem,\n    SortOrder,\n} from '/@/shared/types/domain-types';\nimport { ItemListKey } from '/@/shared/types/types';\n\ninterface AlbumArtistListPaginatedTableProps\n    extends ItemListTableComponentProps<AlbumArtistListQuery> {}\n\nexport const AlbumArtistListPaginatedTable = ({\n    autoFitColumns = false,\n    columns,\n    enableAlternateRowColors = false,\n    enableHeader = true,\n    enableHorizontalBorders = false,\n    enableRowHoverHighlight = true,\n    enableSelection = true,\n    enableVerticalBorders = false,\n    itemsPerPage = 100,\n    query = {\n        sortBy: AlbumArtistListSort.NAME,\n        sortOrder: SortOrder.ASC,\n    },\n    saveScrollOffset = true,\n    serverId,\n    size = 'default',\n}: AlbumArtistListPaginatedTableProps) => {\n    const { currentPage, onChange } = useItemListPagination();\n\n    const listCountQuery = artistsQueries.albumArtistListCount({\n        query: { ...query, limit: itemsPerPage },\n        serverId: serverId,\n    }) as UseSuspenseQueryOptions<number, Error, number, readonly unknown[]>;\n\n    const listQueryFn = api.controller.getAlbumArtistList;\n\n    const { data, pageCount, totalItemCount } = useItemListPaginatedLoader({\n        currentPage,\n        eventKey: ItemListKey.ALBUM_ARTIST,\n        itemsPerPage,\n        itemType: LibraryItem.ALBUM_ARTIST,\n        listCountQuery,\n        listQueryFn,\n        query,\n        serverId,\n    });\n\n    const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({\n        enabled: saveScrollOffset,\n    });\n\n    const { handleColumnReordered } = useItemListColumnReorder({\n        itemListKey: ItemListKey.ALBUM_ARTIST,\n    });\n\n    const { handleColumnResized } = useItemListColumnResize({\n        itemListKey: ItemListKey.ALBUM_ARTIST,\n    });\n\n    const startRowIndex = currentPage * itemsPerPage;\n\n    return (\n        <ItemListWithPagination\n            currentPage={currentPage}\n            itemsPerPage={itemsPerPage}\n            onChange={onChange}\n            pageCount={pageCount}\n            totalItemCount={totalItemCount}\n        >\n            <ItemTableList\n                autoFitColumns={autoFitColumns}\n                CellComponent={ItemTableListColumn}\n                columns={columns}\n                data={data || []}\n                enableAlternateRowColors={enableAlternateRowColors}\n                enableExpansion={false}\n                enableHeader={enableHeader}\n                enableHorizontalBorders={enableHorizontalBorders}\n                enableRowHoverHighlight={enableRowHoverHighlight}\n                enableSelection={enableSelection}\n                enableVerticalBorders={enableVerticalBorders}\n                initialTop={{\n                    to: scrollOffset ?? 0,\n                    type: 'offset',\n                }}\n                itemType={LibraryItem.ALBUM_ARTIST}\n                onColumnReordered={handleColumnReordered}\n                onColumnResized={handleColumnResized}\n                onScrollEnd={handleOnScrollEnd}\n                size={size}\n                startRowIndex={startRowIndex}\n            />\n        </ItemListWithPagination>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/artists/components/artist-list-content.tsx",
    "content": "import { lazy, Suspense, useMemo } from 'react';\n\nimport { useArtistListFilters } from '/@/renderer/features/artists/hooks/use-artist-list-filters';\nimport { ItemListSettings, useCurrentServer, useListSettings } from '/@/renderer/store';\nimport { Spinner } from '/@/shared/components/spinner/spinner';\nimport { ArtistListQuery } from '/@/shared/types/domain-types';\nimport { ItemListKey, ListDisplayType, ListPaginationType } from '/@/shared/types/types';\n\nconst ArtistListInfiniteGrid = lazy(() =>\n    import('/@/renderer/features/artists/components/artist-list-infinite-grid').then((module) => ({\n        default: module.ArtistListInfiniteGrid,\n    })),\n);\n\nconst ArtistListPaginatedGrid = lazy(() =>\n    import('/@/renderer/features/artists/components/artist-list-paginated-grid').then((module) => ({\n        default: module.ArtistListPaginatedGrid,\n    })),\n);\n\nconst ArtistListInfiniteTable = lazy(() =>\n    import('/@/renderer/features/artists/components/artist-list-infinite-table').then((module) => ({\n        default: module.ArtistListInfiniteTable,\n    })),\n);\n\nconst ArtistListPaginatedTable = lazy(() =>\n    import('/@/renderer/features/artists/components/artist-list-paginated-table').then(\n        (module) => ({\n            default: module.ArtistListPaginatedTable,\n        }),\n    ),\n);\n\nexport const ArtistListContent = () => {\n    const { display, grid, itemsPerPage, pagination, table } = useListSettings(ItemListKey.ARTIST);\n\n    return (\n        <Suspense fallback={<Spinner container />}>\n            <ArtistListView\n                display={display}\n                grid={grid}\n                itemsPerPage={itemsPerPage}\n                pagination={pagination}\n                table={table}\n            />\n        </Suspense>\n    );\n};\n\nexport type OverrideArtistListQuery = Omit<ArtistListQuery, 'limit' | 'startIndex'>;\n\nexport const ArtistListView = ({\n    display,\n    grid,\n    itemsPerPage,\n    overrideQuery,\n    pagination,\n    table,\n}: ItemListSettings & { overrideQuery?: OverrideArtistListQuery }) => {\n    const server = useCurrentServer();\n\n    const { query } = useArtistListFilters();\n\n    const mergedQuery = useMemo(() => {\n        if (!overrideQuery) {\n            return query;\n        }\n\n        return {\n            ...query,\n            ...overrideQuery,\n            sortBy: overrideQuery.sortBy || query.sortBy,\n            sortOrder: overrideQuery.sortOrder || query.sortOrder,\n        };\n    }, [query, overrideQuery]);\n\n    switch (display) {\n        case ListDisplayType.GRID: {\n            switch (pagination) {\n                case ListPaginationType.INFINITE: {\n                    return (\n                        <ArtistListInfiniteGrid\n                            gap={grid.itemGap}\n                            itemsPerPage={itemsPerPage}\n                            itemsPerRow={grid.itemsPerRowEnabled ? grid.itemsPerRow : undefined}\n                            query={mergedQuery}\n                            serverId={server.id}\n                            size={grid.size}\n                        />\n                    );\n                }\n                case ListPaginationType.PAGINATED: {\n                    return (\n                        <ArtistListPaginatedGrid\n                            gap={grid.itemGap}\n                            itemsPerPage={itemsPerPage}\n                            itemsPerRow={grid.itemsPerRowEnabled ? grid.itemsPerRow : undefined}\n                            query={mergedQuery}\n                            serverId={server.id}\n                            size={grid.size}\n                        />\n                    );\n                }\n                default:\n                    return null;\n            }\n        }\n        case ListDisplayType.TABLE: {\n            switch (pagination) {\n                case ListPaginationType.INFINITE: {\n                    return (\n                        <ArtistListInfiniteTable\n                            autoFitColumns={table.autoFitColumns}\n                            columns={table.columns}\n                            enableAlternateRowColors={table.enableAlternateRowColors}\n                            enableHeader={table.enableHeader}\n                            enableHorizontalBorders={table.enableHorizontalBorders}\n                            enableRowHoverHighlight={table.enableRowHoverHighlight}\n                            enableVerticalBorders={table.enableVerticalBorders}\n                            itemsPerPage={itemsPerPage}\n                            query={mergedQuery}\n                            serverId={server.id}\n                            size={table.size}\n                        />\n                    );\n                }\n                case ListPaginationType.PAGINATED: {\n                    return (\n                        <ArtistListPaginatedTable\n                            autoFitColumns={table.autoFitColumns}\n                            columns={table.columns}\n                            enableAlternateRowColors={table.enableAlternateRowColors}\n                            enableHeader={table.enableHeader}\n                            enableHorizontalBorders={table.enableHorizontalBorders}\n                            enableRowHoverHighlight={table.enableRowHoverHighlight}\n                            enableVerticalBorders={table.enableVerticalBorders}\n                            itemsPerPage={itemsPerPage}\n                            query={mergedQuery}\n                            serverId={server.id}\n                            size={table.size}\n                        />\n                    );\n                }\n                default:\n                    return null;\n            }\n        }\n    }\n\n    return null;\n};\n"
  },
  {
    "path": "src/renderer/features/artists/components/artist-list-header-filters.tsx",
    "content": "import { useQuery } from '@tanstack/react-query';\n\nimport { ALBUM_ARTIST_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns';\nimport { sharedQueries } from '/@/renderer/features/shared/api/shared-api';\nimport { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';\nimport { ListDisplayTypeToggleButton } from '/@/renderer/features/shared/components/list-display-type-toggle-button';\nimport { ListRefreshButton } from '/@/renderer/features/shared/components/list-refresh-button';\nimport { ListSelectFilter } from '/@/renderer/features/shared/components/list-select-filter';\nimport { ListSortByDropdown } from '/@/renderer/features/shared/components/list-sort-by-dropdown';\nimport { ListSortOrderToggleButton } from '/@/renderer/features/shared/components/list-sort-order-toggle-button';\nimport { FILTER_KEYS } from '/@/renderer/features/shared/utils';\nimport { useCurrentServer } from '/@/renderer/store';\nimport { Divider } from '/@/shared/components/divider/divider';\nimport { Flex } from '/@/shared/components/flex/flex';\nimport { Group } from '/@/shared/components/group/group';\nimport { ArtistListSort, LibraryItem, SortOrder } from '/@/shared/types/domain-types';\nimport { ItemListKey } from '/@/shared/types/types';\n\nexport const ArtistListHeaderFilters = () => {\n    const server = useCurrentServer();\n\n    const rolesQuery = useQuery(sharedQueries.roles({ query: {}, serverId: server.id }));\n\n    return (\n        <Flex justify=\"space-between\">\n            <Group gap=\"sm\" w=\"100%\">\n                <ListSortByDropdown\n                    defaultSortByValue={ArtistListSort.NAME}\n                    itemType={LibraryItem.ARTIST}\n                    listKey={ItemListKey.ARTIST}\n                />\n                <Divider orientation=\"vertical\" />\n                <ListSortOrderToggleButton\n                    defaultSortOrder={SortOrder.ASC}\n                    listKey={ItemListKey.ARTIST}\n                />\n                {rolesQuery.data && rolesQuery.data.length > 0 && (\n                    <>\n                        <Divider orientation=\"vertical\" />\n                        <ListSelectFilter\n                            data={rolesQuery.data}\n                            filterKey={FILTER_KEYS.ARTIST.ROLE}\n                            listKey={ItemListKey.ARTIST}\n                        />\n                    </>\n                )}\n                <ListRefreshButton listKey={ItemListKey.ARTIST} />\n            </Group>\n            <Group gap=\"sm\" wrap=\"nowrap\">\n                <ListDisplayTypeToggleButton listKey={ItemListKey.ARTIST} />\n                <ListConfigMenu\n                    listKey={ItemListKey.ARTIST}\n                    tableColumnsData={ALBUM_ARTIST_TABLE_COLUMNS}\n                />\n            </Group>\n        </Flex>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/artists/components/artist-list-header.tsx",
    "content": "import { useTranslation } from 'react-i18next';\n\nimport { useIsFetchingItemListCount } from '/@/renderer/components/item-list/helpers/use-is-fetching-item-list';\nimport { PageHeader } from '/@/renderer/components/page-header/page-header';\nimport { useListContext } from '/@/renderer/context/list-context';\nimport { ArtistListHeaderFilters } from '/@/renderer/features/artists/components/artist-list-header-filters';\nimport { useArtistListFilters } from '/@/renderer/features/artists/hooks/use-artist-list-filters';\nimport { FilterBar } from '/@/renderer/features/shared/components/filter-bar';\nimport { LibraryHeaderBar } from '/@/renderer/features/shared/components/library-header-bar';\nimport { ListSearchInput } from '/@/renderer/features/shared/components/list-search-input';\nimport { Group } from '/@/shared/components/group/group';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { LibraryItem } from '/@/shared/types/domain-types';\n\ninterface ArtistListHeaderProps {\n    title?: string;\n}\n\nexport const ArtistListHeader = ({ title }: ArtistListHeaderProps) => {\n    const { t } = useTranslation();\n\n    const pageTitle = title || t('entity.artist', { count: 2, postProcess: 'titleCase' });\n\n    return (\n        <Stack gap={0}>\n            <PageHeader>\n                <LibraryHeaderBar ignoreMaxWidth>\n                    <PlayButton />\n                    <LibraryHeaderBar.Title>{pageTitle}</LibraryHeaderBar.Title>\n                    <ArtistListHeaderBadge />\n                </LibraryHeaderBar>\n                <Group>\n                    <ListSearchInput />\n                </Group>\n            </PageHeader>\n            <FilterBar>\n                <ArtistListHeaderFilters />\n            </FilterBar>\n        </Stack>\n    );\n};\n\nconst ArtistListHeaderBadge = () => {\n    const { itemCount } = useListContext();\n\n    const isFetching = useIsFetchingItemListCount({\n        itemType: LibraryItem.ARTIST,\n    });\n\n    return <LibraryHeaderBar.Badge isLoading={isFetching}>{itemCount}</LibraryHeaderBar.Badge>;\n};\n\nconst PlayButton = () => {\n    const { query } = useArtistListFilters();\n\n    return <LibraryHeaderBar.PlayButton itemType={LibraryItem.ARTIST} listQuery={query} />;\n};\n"
  },
  {
    "path": "src/renderer/features/artists/components/artist-list-infinite-grid.tsx",
    "content": "import { UseSuspenseQueryOptions } from '@tanstack/react-query';\n\nimport { api } from '/@/renderer/api';\nimport { useItemListInfiniteLoader } from '/@/renderer/components/item-list/helpers/item-list-infinite-loader';\nimport { useGridRows } from '/@/renderer/components/item-list/helpers/use-grid-rows';\nimport { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist';\nimport { ItemGridList } from '/@/renderer/components/item-list/item-grid-list/item-grid-list';\nimport { ItemListGridComponentProps } from '/@/renderer/components/item-list/types';\nimport { artistsQueries } from '/@/renderer/features/artists/api/artists-api';\nimport { useGeneralSettings } from '/@/renderer/store';\nimport {\n    ArtistListQuery,\n    ArtistListSort,\n    LibraryItem,\n    SortOrder,\n} from '/@/shared/types/domain-types';\nimport { ItemListKey } from '/@/shared/types/types';\n\ninterface ArtistListInfiniteGridProps extends ItemListGridComponentProps<ArtistListQuery> {}\n\nexport const ArtistListInfiniteGrid = ({\n    gap = 'md',\n    itemsPerPage = 100,\n    itemsPerRow,\n    query = {\n        sortBy: ArtistListSort.NAME,\n        sortOrder: SortOrder.ASC,\n    },\n    saveScrollOffset = true,\n    serverId,\n    size,\n}: ArtistListInfiniteGridProps) => {\n    const listCountQuery = artistsQueries.artistListCount({\n        query: { ...query, limit: itemsPerPage },\n        serverId: serverId,\n    }) as UseSuspenseQueryOptions<number, Error, number, readonly unknown[]>;\n\n    const listQueryFn = api.controller.getArtistList;\n\n    const { dataVersion, getItem, getItemIndex, itemCount, loadedItems, onRangeChanged } =\n        useItemListInfiniteLoader({\n            eventKey: ItemListKey.ARTIST,\n            itemsPerPage,\n            itemType: LibraryItem.ARTIST,\n            listCountQuery,\n            listQueryFn,\n            query,\n            serverId,\n        });\n\n    const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({\n        enabled: saveScrollOffset,\n    });\n\n    const rows = useGridRows(LibraryItem.ARTIST, ItemListKey.ARTIST, size);\n    const { enableGridMultiSelect } = useGeneralSettings();\n\n    return (\n        <ItemGridList\n            data={loadedItems}\n            dataVersion={dataVersion}\n            enableMultiSelect={enableGridMultiSelect}\n            gap={gap}\n            getItem={getItem}\n            getItemIndex={getItemIndex}\n            initialTop={{\n                to: scrollOffset ?? 0,\n                type: 'offset',\n            }}\n            itemCount={itemCount}\n            itemsPerRow={itemsPerRow}\n            itemType={LibraryItem.ARTIST}\n            onRangeChanged={onRangeChanged}\n            onScrollEnd={handleOnScrollEnd}\n            rows={rows}\n            size={size}\n        />\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/artists/components/artist-list-infinite-table.tsx",
    "content": "import { UseSuspenseQueryOptions } from '@tanstack/react-query';\n\nimport { api } from '/@/renderer/api';\nimport { useItemListInfiniteLoader } from '/@/renderer/components/item-list/helpers/item-list-infinite-loader';\nimport { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder';\nimport { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize';\nimport { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist';\nimport { ItemTableList } from '/@/renderer/components/item-list/item-table-list/item-table-list';\nimport { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';\nimport { ItemListTableComponentProps } from '/@/renderer/components/item-list/types';\nimport { artistsQueries } from '/@/renderer/features/artists/api/artists-api';\nimport {\n    ArtistListQuery,\n    ArtistListSort,\n    LibraryItem,\n    SortOrder,\n} from '/@/shared/types/domain-types';\nimport { ItemListKey } from '/@/shared/types/types';\n\ninterface ArtistListInfiniteTableProps extends ItemListTableComponentProps<ArtistListQuery> {}\n\nexport const ArtistListInfiniteTable = ({\n    autoFitColumns = false,\n    columns,\n    enableAlternateRowColors = false,\n    enableHeader = true,\n    enableHorizontalBorders = false,\n    enableRowHoverHighlight = true,\n    enableSelection = true,\n    enableVerticalBorders = false,\n    itemsPerPage = 100,\n    query = {\n        sortBy: ArtistListSort.NAME,\n        sortOrder: SortOrder.ASC,\n    },\n    saveScrollOffset = true,\n    serverId,\n    size = 'default',\n}: ArtistListInfiniteTableProps) => {\n    const listCountQuery = artistsQueries.artistListCount({\n        query: { ...query, limit: itemsPerPage },\n        serverId: serverId,\n    }) as UseSuspenseQueryOptions<number, Error, number, readonly unknown[]>;\n\n    const listQueryFn = api.controller.getArtistList;\n\n    const { getItem, getItemIndex, itemCount, loadedItems, onRangeChanged } =\n        useItemListInfiniteLoader({\n            eventKey: ItemListKey.ARTIST,\n            itemsPerPage,\n            itemType: LibraryItem.ARTIST,\n            listCountQuery,\n            listQueryFn,\n            query,\n            serverId,\n        });\n\n    const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({\n        enabled: saveScrollOffset,\n    });\n\n    const { handleColumnReordered } = useItemListColumnReorder({\n        itemListKey: ItemListKey.ARTIST,\n    });\n\n    const { handleColumnResized } = useItemListColumnResize({\n        itemListKey: ItemListKey.ARTIST,\n    });\n\n    return (\n        <ItemTableList\n            autoFitColumns={autoFitColumns}\n            CellComponent={ItemTableListColumn}\n            columns={columns}\n            data={loadedItems}\n            enableAlternateRowColors={enableAlternateRowColors}\n            enableExpansion={false}\n            enableHeader={enableHeader}\n            enableHorizontalBorders={enableHorizontalBorders}\n            enableRowHoverHighlight={enableRowHoverHighlight}\n            enableSelection={enableSelection}\n            enableVerticalBorders={enableVerticalBorders}\n            getItem={getItem}\n            getItemIndex={getItemIndex}\n            initialTop={{\n                to: scrollOffset ?? 0,\n                type: 'offset',\n            }}\n            itemCount={itemCount}\n            itemType={LibraryItem.ARTIST}\n            onColumnReordered={handleColumnReordered}\n            onColumnResized={handleColumnResized}\n            onRangeChanged={onRangeChanged}\n            onScrollEnd={handleOnScrollEnd}\n            size={size}\n        />\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/artists/components/artist-list-paginated-grid.tsx",
    "content": "import { UseSuspenseQueryOptions } from '@tanstack/react-query';\n\nimport { api } from '/@/renderer/api';\nimport { useItemListPaginatedLoader } from '/@/renderer/components/item-list/helpers/item-list-paginated-loader';\nimport { useGridRows } from '/@/renderer/components/item-list/helpers/use-grid-rows';\nimport { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist';\nimport { ItemGridList } from '/@/renderer/components/item-list/item-grid-list/item-grid-list';\nimport { ItemListWithPagination } from '/@/renderer/components/item-list/item-list-pagination/item-list-pagination';\nimport { useItemListPagination } from '/@/renderer/components/item-list/item-list-pagination/use-item-list-pagination';\nimport { ItemListGridComponentProps } from '/@/renderer/components/item-list/types';\nimport { artistsQueries } from '/@/renderer/features/artists/api/artists-api';\nimport { useGeneralSettings } from '/@/renderer/store';\nimport {\n    ArtistListQuery,\n    ArtistListSort,\n    LibraryItem,\n    SortOrder,\n} from '/@/shared/types/domain-types';\nimport { ItemListKey } from '/@/shared/types/types';\n\ninterface ArtistListPaginatedGridProps extends ItemListGridComponentProps<ArtistListQuery> {}\n\nexport const ArtistListPaginatedGrid = ({\n    gap = 'md',\n    itemsPerPage = 100,\n    itemsPerRow,\n    query = {\n        sortBy: ArtistListSort.NAME,\n        sortOrder: SortOrder.ASC,\n    },\n    saveScrollOffset = true,\n    serverId,\n    size,\n}: ArtistListPaginatedGridProps) => {\n    const { currentPage, onChange } = useItemListPagination();\n\n    const listCountQuery = artistsQueries.artistListCount({\n        query: { ...query, limit: itemsPerPage },\n        serverId: serverId,\n    }) as UseSuspenseQueryOptions<number, Error, number, readonly unknown[]>;\n\n    const listQueryFn = api.controller.getArtistList;\n\n    const { data, pageCount, totalItemCount } = useItemListPaginatedLoader({\n        currentPage,\n        eventKey: ItemListKey.ARTIST,\n        itemsPerPage,\n        itemType: LibraryItem.ARTIST,\n        listCountQuery,\n        listQueryFn,\n        query,\n        serverId,\n    });\n\n    const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({\n        enabled: saveScrollOffset,\n    });\n\n    const rows = useGridRows(LibraryItem.ARTIST, ItemListKey.ARTIST, size);\n    const { enableGridMultiSelect } = useGeneralSettings();\n\n    return (\n        <ItemListWithPagination\n            currentPage={currentPage}\n            itemsPerPage={itemsPerPage}\n            onChange={onChange}\n            pageCount={pageCount}\n            totalItemCount={totalItemCount}\n        >\n            <ItemGridList\n                currentPage={currentPage}\n                data={data || []}\n                enableMultiSelect={enableGridMultiSelect}\n                gap={gap}\n                initialTop={{\n                    to: scrollOffset ?? 0,\n                    type: 'offset',\n                }}\n                itemsPerRow={itemsPerRow}\n                itemType={LibraryItem.ARTIST}\n                onScrollEnd={handleOnScrollEnd}\n                rows={rows}\n                size={size}\n            />\n        </ItemListWithPagination>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/artists/components/artist-list-paginated-table.tsx",
    "content": "import { UseSuspenseQueryOptions } from '@tanstack/react-query';\n\nimport { api } from '/@/renderer/api';\nimport { useItemListPaginatedLoader } from '/@/renderer/components/item-list/helpers/item-list-paginated-loader';\nimport { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder';\nimport { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize';\nimport { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist';\nimport { ItemListWithPagination } from '/@/renderer/components/item-list/item-list-pagination/item-list-pagination';\nimport { useItemListPagination } from '/@/renderer/components/item-list/item-list-pagination/use-item-list-pagination';\nimport { ItemTableList } from '/@/renderer/components/item-list/item-table-list/item-table-list';\nimport { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';\nimport { ItemListTableComponentProps } from '/@/renderer/components/item-list/types';\nimport { artistsQueries } from '/@/renderer/features/artists/api/artists-api';\nimport {\n    ArtistListQuery,\n    ArtistListSort,\n    LibraryItem,\n    SortOrder,\n} from '/@/shared/types/domain-types';\nimport { ItemListKey } from '/@/shared/types/types';\n\ninterface ArtistListPaginatedTableProps extends ItemListTableComponentProps<ArtistListQuery> {}\n\nexport const ArtistListPaginatedTable = ({\n    autoFitColumns = false,\n    columns,\n    enableAlternateRowColors = false,\n    enableHeader = true,\n    enableHorizontalBorders = false,\n    enableRowHoverHighlight = true,\n    enableSelection = true,\n    enableVerticalBorders = false,\n    itemsPerPage = 100,\n    query = {\n        sortBy: ArtistListSort.NAME,\n        sortOrder: SortOrder.ASC,\n    },\n    saveScrollOffset = true,\n    serverId,\n    size = 'default',\n}: ArtistListPaginatedTableProps) => {\n    const { currentPage, onChange } = useItemListPagination();\n\n    const listCountQuery = artistsQueries.artistListCount({\n        query: { ...query, limit: itemsPerPage },\n        serverId: serverId,\n    }) as UseSuspenseQueryOptions<number, Error, number, readonly unknown[]>;\n\n    const listQueryFn = api.controller.getArtistList;\n\n    const { data, pageCount, totalItemCount } = useItemListPaginatedLoader({\n        currentPage,\n        eventKey: ItemListKey.ARTIST,\n        itemsPerPage,\n        itemType: LibraryItem.ARTIST,\n        listCountQuery,\n        listQueryFn,\n        query,\n        serverId,\n    });\n\n    const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({\n        enabled: saveScrollOffset,\n    });\n\n    const { handleColumnReordered } = useItemListColumnReorder({\n        itemListKey: ItemListKey.ARTIST,\n    });\n\n    const { handleColumnResized } = useItemListColumnResize({\n        itemListKey: ItemListKey.ARTIST,\n    });\n\n    const startRowIndex = currentPage * itemsPerPage;\n\n    return (\n        <ItemListWithPagination\n            currentPage={currentPage}\n            itemsPerPage={itemsPerPage}\n            onChange={onChange}\n            pageCount={pageCount}\n            totalItemCount={totalItemCount}\n        >\n            <ItemTableList\n                autoFitColumns={autoFitColumns}\n                CellComponent={ItemTableListColumn}\n                columns={columns}\n                data={data || []}\n                enableAlternateRowColors={enableAlternateRowColors}\n                enableExpansion={false}\n                enableHeader={enableHeader}\n                enableHorizontalBorders={enableHorizontalBorders}\n                enableRowHoverHighlight={enableRowHoverHighlight}\n                enableSelection={enableSelection}\n                enableVerticalBorders={enableVerticalBorders}\n                initialTop={{\n                    to: scrollOffset ?? 0,\n                    type: 'offset',\n                }}\n                itemType={LibraryItem.ARTIST}\n                onColumnReordered={handleColumnReordered}\n                onColumnResized={handleColumnResized}\n                onScrollEnd={handleOnScrollEnd}\n                size={size}\n                startRowIndex={startRowIndex}\n            />\n        </ItemListWithPagination>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/artists/hooks/use-album-artist-list-filters.ts",
    "content": "import { useCallback } from 'react';\nimport { useSearchParams } from 'react-router';\n\nimport { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-search-term-filter';\nimport { useSortByFilter } from '/@/renderer/features/shared/hooks/use-sort-by-filter';\nimport { useSortOrderFilter } from '/@/renderer/features/shared/hooks/use-sort-order-filter';\nimport { FILTER_KEYS } from '/@/renderer/features/shared/utils';\nimport { setMultipleSearchParams } from '/@/renderer/utils/query-params';\nimport { AlbumArtistListSort } from '/@/shared/types/domain-types';\nimport { ItemListKey } from '/@/shared/types/types';\n\nexport const useAlbumArtistListFilters = () => {\n    const { sortBy } = useSortByFilter<AlbumArtistListSort>(null, ItemListKey.ALBUM_ARTIST);\n\n    const { sortOrder } = useSortOrderFilter(null, ItemListKey.ALBUM_ARTIST);\n\n    const { searchTerm, setSearchTerm } = useSearchTermFilter('');\n\n    const [, setSearchParams] = useSearchParams();\n\n    const clear = useCallback(() => {\n        setSearchParams(\n            (prev) =>\n                setMultipleSearchParams(prev, {\n                    [FILTER_KEYS.SHARED.SEARCH_TERM]: null,\n                }),\n            { replace: true },\n        );\n    }, [setSearchParams]);\n\n    const query = {\n        [FILTER_KEYS.SHARED.SEARCH_TERM]: searchTerm ?? undefined,\n        [FILTER_KEYS.SHARED.SORT_BY]: sortBy ?? undefined,\n        [FILTER_KEYS.SHARED.SORT_ORDER]: sortOrder ?? undefined,\n    };\n\n    return {\n        clear,\n        query,\n        setSearchTerm,\n    };\n};\n"
  },
  {
    "path": "src/renderer/features/artists/hooks/use-artist-albums-grouped.ts",
    "content": "import { useMemo } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport { ArtistReleaseTypeItem, useAppStore } from '/@/renderer/store';\nimport { useArtistReleaseTypeItems } from '/@/renderer/store/settings.store';\nimport { titleCase } from '/@/renderer/utils';\nimport { SEPARATOR_STRING } from '/@/shared/api/utils';\nimport { Album } from '/@/shared/types/domain-types';\n\nconst collator = new Intl.Collator();\n\nexport type GroupingType = 'all' | 'primary';\n\nconst PRIMARY_RELEASE_TYPES = ['album', 'broadcast', 'ep', 'other', 'single'];\n\nexport const groupAlbumsByReleaseType = (\n    albums: Album[],\n    routeId: string,\n    groupingType: GroupingType = 'primary',\n): Record<string, Album[]> => {\n    if (groupingType === 'all') {\n        // Group by all individual release types\n        const grouped = albums.reduce(\n            (acc, album) => {\n                // Priority 1: Appears on - artist is not an album artist\n                const isAlbumArtist = album.albumArtists?.some((artist) => artist.id === routeId);\n                if (!isAlbumArtist) {\n                    const appearsOnKey = 'appears-on';\n                    if (!acc[appearsOnKey]) {\n                        acc[appearsOnKey] = [];\n                    }\n                    acc[appearsOnKey].push(album);\n                    return acc;\n                }\n\n                // Priority 2: Compilations\n                if (album.isCompilation) {\n                    const compilationKey = 'compilation';\n                    if (!acc[compilationKey]) {\n                        acc[compilationKey] = [];\n                    }\n                    acc[compilationKey].push(album);\n                    return acc;\n                }\n\n                // Group by all release types\n                const releaseTypes = album.releaseTypes || [];\n                if (releaseTypes.length > 0) {\n                    // Sort release types: primaries first (alphabetically), then secondaries (alphabetically)\n                    const normalizedTypes = releaseTypes.map((type) => type.toLowerCase());\n                    const primaryTypes = normalizedTypes\n                        .filter((type) => PRIMARY_RELEASE_TYPES.includes(type))\n                        .sort();\n                    const secondaryTypes = normalizedTypes\n                        .filter((type) => !PRIMARY_RELEASE_TYPES.includes(type))\n                        .sort();\n                    const sortedTypes = [...primaryTypes, ...secondaryTypes];\n\n                    const combinedKey = sortedTypes.join('/');\n                    if (!acc[combinedKey]) {\n                        acc[combinedKey] = [];\n                    }\n                    acc[combinedKey].push(album);\n                } else {\n                    // If no release types, use \"album\" as fallback\n                    const albumKey = 'album';\n                    if (!acc[albumKey]) {\n                        acc[albumKey] = [];\n                    }\n                    acc[albumKey].push(album);\n                }\n\n                return acc;\n            },\n            {} as Record<string, Album[]>,\n        );\n\n        return grouped;\n    }\n\n    // Group by primary release types\n    const grouped = albums.reduce(\n        (acc, album) => {\n            // Priority 1: Appears on - artist is not an album artist\n            const isAlbumArtist = album.albumArtists?.some((artist) => artist.id === routeId);\n            if (!isAlbumArtist) {\n                const appearsOnKey = 'appears-on';\n                if (!acc[appearsOnKey]) {\n                    acc[appearsOnKey] = [];\n                }\n                acc[appearsOnKey].push(album);\n                return acc;\n            }\n\n            const releaseTypes = album.releaseTypes || [];\n            const normalizedTypes = releaseTypes.map((type) => type.toLowerCase());\n\n            let matchedType: null | string = null;\n\n            if (normalizedTypes.includes('album')) {\n                matchedType = 'album';\n            } else if (normalizedTypes.includes('single')) {\n                matchedType = 'single';\n            } else if (normalizedTypes.includes('ep')) {\n                matchedType = 'ep';\n            } else if (normalizedTypes.includes('broadcast')) {\n                matchedType = 'broadcast';\n            } else if (normalizedTypes.includes('other')) {\n                matchedType = 'other';\n            } else {\n                matchedType = 'album';\n            }\n\n            const releaseTypeKey = matchedType;\n            if (!acc[releaseTypeKey]) {\n                acc[releaseTypeKey] = [];\n            }\n            acc[releaseTypeKey].push(album);\n            return acc;\n        },\n        {} as Record<string, Album[]>,\n    );\n\n    return grouped;\n};\n\nexport const releaseTypeToEnumMap: Record<string, ArtistReleaseTypeItem> = {\n    album: ArtistReleaseTypeItem.RELEASE_TYPE_ALBUM,\n    'appears-on': ArtistReleaseTypeItem.APPEARS_ON,\n    audiobook: ArtistReleaseTypeItem.RELEASE_TYPE_AUDIOBOOK,\n    'audio drama': ArtistReleaseTypeItem.RELEASE_TYPE_AUDIO_DRAMA,\n    broadcast: ArtistReleaseTypeItem.RELEASE_TYPE_BROADCAST,\n    compilation: ArtistReleaseTypeItem.RELEASE_TYPE_COMPILATION,\n    demo: ArtistReleaseTypeItem.RELEASE_TYPE_DEMO,\n    'dj-mix': ArtistReleaseTypeItem.RELEASE_TYPE_DJ_MIX,\n    ep: ArtistReleaseTypeItem.RELEASE_TYPE_EP,\n    'field recording': ArtistReleaseTypeItem.RELEASE_TYPE_FIELD_RECORDING,\n    interview: ArtistReleaseTypeItem.RELEASE_TYPE_INTERVIEW,\n    live: ArtistReleaseTypeItem.RELEASE_TYPE_LIVE,\n    'mixtape/street': ArtistReleaseTypeItem.RELEASE_TYPE_MIXTAPE_STREET,\n    other: ArtistReleaseTypeItem.RELEASE_TYPE_OTHER,\n    remix: ArtistReleaseTypeItem.RELEASE_TYPE_REMIX,\n    single: ArtistReleaseTypeItem.RELEASE_TYPE_SINGLE,\n    soundtrack: ArtistReleaseTypeItem.RELEASE_TYPE_SOUNDTRACK,\n    spokenword: ArtistReleaseTypeItem.RELEASE_TYPE_SPOKENWORD,\n};\n\nexport const getArtistAlbumsGrouped = (\n    albums: Album[],\n    routeId: string,\n    groupingType: GroupingType,\n    artistReleaseTypeItems: { disabled: boolean; id: string }[],\n    t: (key: string, options?: any) => string,\n) => {\n    const albumsByReleaseType = groupAlbumsByReleaseType(albums, routeId, groupingType);\n\n    const enabledReleaseTypeEnums = new Set(\n        artistReleaseTypeItems.filter((item) => !item.disabled).map((item) => item.id),\n    );\n\n    const priorityMap = new Map<string, number>();\n    artistReleaseTypeItems\n        .filter((item) => !item.disabled)\n        .forEach((item, index) => {\n            const releaseTypeKey = Object.keys(releaseTypeToEnumMap).find(\n                (key) => releaseTypeToEnumMap[key] === item.id,\n            );\n            if (releaseTypeKey) {\n                priorityMap.set(releaseTypeKey, index);\n            }\n        });\n\n    const getDisplayNameForType = (releaseType: string): string => {\n        switch (releaseType) {\n            case 'album':\n                return t('releaseType.primary.album', {\n                    postProcess: 'sentenceCase',\n                });\n            case 'appears-on':\n                return t('page.albumArtistDetail.appearsOn', {\n                    postProcess: 'sentenceCase',\n                });\n            case 'audiobook':\n                return t('releaseType.secondary.audiobook', {\n                    postProcess: 'sentenceCase',\n                });\n            case 'audio drama':\n                return t('releaseType.secondary.audioDrama', {\n                    postProcess: 'sentenceCase',\n                });\n            case 'broadcast':\n                return t('releaseType.primary.broadcast', {\n                    postProcess: 'sentenceCase',\n                });\n            case 'compilation':\n                return t('releaseType.secondary.compilation', {\n                    postProcess: 'sentenceCase',\n                });\n            case 'demo':\n                return t('releaseType.secondary.demo', {\n                    postProcess: 'sentenceCase',\n                });\n            case 'dj-mix':\n                return t('releaseType.secondary.djMix', {\n                    postProcess: 'sentenceCase',\n                });\n            case 'ep':\n                return t('releaseType.primary.ep', {\n                    postProcess: 'upperCase',\n                });\n            case 'field recording':\n                return t('releaseType.secondary.fieldRecording', {\n                    postProcess: 'sentenceCase',\n                });\n            case 'interview':\n                return t('releaseType.secondary.interview', {\n                    postProcess: 'sentenceCase',\n                });\n            case 'live':\n                return t('releaseType.secondary.live', {\n                    postProcess: 'sentenceCase',\n                });\n            case 'mixtape/street':\n                return t('releaseType.secondary.mixtape', {\n                    postProcess: 'sentenceCase',\n                });\n            case 'other':\n                return t('releaseType.primary.other', {\n                    postProcess: 'sentenceCase',\n                });\n            case 'remix':\n                return t('releaseType.secondary.remix', {\n                    postProcess: 'sentenceCase',\n                });\n            case 'single':\n                return t('releaseType.primary.single', {\n                    postProcess: 'sentenceCase',\n                });\n            case 'soundtrack':\n                return t('releaseType.secondary.soundtrack', {\n                    postProcess: 'sentenceCase',\n                });\n            case 'spokenword':\n                return t('releaseType.secondary.spokenWord', {\n                    postProcess: 'sentenceCase',\n                });\n            default:\n                return titleCase(releaseType);\n        }\n    };\n\n    const getPriority = (releaseType: string) => {\n        if (releaseType.includes('/')) {\n            const types = releaseType.split('/');\n            // Check if there's a primary type in the joined types\n            const primaryTypes = types.filter((type) => PRIMARY_RELEASE_TYPES.includes(type));\n\n            if (primaryTypes.length > 0) {\n                // Use the primary type's priority (first primary if multiple)\n                const primaryPriority = priorityMap.get(primaryTypes[0]) ?? 999;\n                return primaryPriority;\n            } else {\n                // Only secondary types - use minimum priority from settings\n                const priorities = types\n                    .map((type) => priorityMap.get(type) ?? 999)\n                    .filter((p) => p !== 999);\n                return priorities.length > 0 ? Math.min(...priorities) : 999;\n            }\n        }\n        return priorityMap.get(releaseType) ?? 999;\n    };\n\n    const getSecondaryTypePriorityKey = (releaseType: string): string => {\n        if (releaseType.includes('/')) {\n            const types = releaseType.split('/');\n            const secondaryTypes = types.filter((type) => !PRIMARY_RELEASE_TYPES.includes(type));\n\n            if (secondaryTypes.length > 0) {\n                const priorities = secondaryTypes\n                    .map((type) => priorityMap.get(type) ?? 999)\n                    .filter((p) => p !== 999)\n                    .sort((a, b) => a - b);\n\n                return priorities.map((p) => String(p).padStart(3, '0')).join(',');\n            }\n        }\n        return '';\n    };\n\n    const isReleaseTypeEnabled = (releaseType: string): boolean => {\n        if (releaseType.includes('/')) {\n            const types = releaseType.split('/');\n            return types.some((type) => {\n                const enumValue = releaseTypeToEnumMap[type];\n                return enumValue ? enabledReleaseTypeEnums.has(enumValue) : false;\n            });\n        }\n        const enumValue = releaseTypeToEnumMap[releaseType];\n        return enumValue ? enabledReleaseTypeEnums.has(enumValue) : false;\n    };\n\n    const releaseTypeEntries = Object.entries(albumsByReleaseType)\n        .filter(([releaseType]) => isReleaseTypeEnabled(releaseType))\n        .map(([releaseType, albums]) => {\n            let displayName: React.ReactNode | string;\n\n            if (releaseType.includes('/')) {\n                const types = releaseType.split('/');\n                const displayNames = types.map((type) => getDisplayNameForType(type));\n                displayName = displayNames.join(SEPARATOR_STRING);\n            } else {\n                displayName = getDisplayNameForType(releaseType);\n            }\n\n            return { albums, displayName, releaseType };\n        })\n        .sort((a, b) => {\n            const priorityA = getPriority(a.releaseType);\n            const priorityB = getPriority(b.releaseType);\n\n            if (priorityA !== priorityB) {\n                return priorityA - priorityB;\n            }\n\n            const isCombinedA = a.releaseType.includes('/');\n            const isCombinedB = b.releaseType.includes('/');\n\n            if (isCombinedA && isCombinedB) {\n                const secondaryKeyA = getSecondaryTypePriorityKey(a.releaseType);\n                const secondaryKeyB = getSecondaryTypePriorityKey(b.releaseType);\n\n                if (secondaryKeyA && secondaryKeyB) {\n                    return collator.compare(secondaryKeyA, secondaryKeyB);\n                }\n            }\n\n            return collator.compare(a.releaseType, b.releaseType);\n        });\n\n    const flatSortedAlbums = releaseTypeEntries.flatMap((entry) => entry.albums);\n\n    return { flatSortedAlbums, releaseTypeEntries };\n};\n\nexport const useArtistAlbumsGrouped = (albums: Album[], routeId: string) => {\n    const { t } = useTranslation();\n    const artistReleaseTypeItems = useArtistReleaseTypeItems();\n    const albumArtistDetailSort = useAppStore((state) => state.albumArtistDetailSort);\n    const groupingType = albumArtistDetailSort.groupingType;\n\n    return useMemo(() => {\n        return getArtistAlbumsGrouped(albums, routeId, groupingType, artistReleaseTypeItems, t);\n    }, [albums, routeId, groupingType, artistReleaseTypeItems, t]);\n};\n"
  },
  {
    "path": "src/renderer/features/artists/hooks/use-artist-list-filters.ts",
    "content": "import { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-search-term-filter';\nimport { useSelectFilter } from '/@/renderer/features/shared/hooks/use-select-filter';\nimport { useSortByFilter } from '/@/renderer/features/shared/hooks/use-sort-by-filter';\nimport { useSortOrderFilter } from '/@/renderer/features/shared/hooks/use-sort-order-filter';\nimport { FILTER_KEYS } from '/@/renderer/features/shared/utils';\nimport { ArtistListSort } from '/@/shared/types/domain-types';\nimport { ItemListKey } from '/@/shared/types/types';\n\nexport const useArtistListFilters = () => {\n    const { sortBy } = useSortByFilter<ArtistListSort>(null, ItemListKey.ARTIST);\n\n    const { sortOrder } = useSortOrderFilter(null, ItemListKey.ARTIST);\n\n    const { searchTerm, setSearchTerm } = useSearchTermFilter('');\n\n    const { value: role } = useSelectFilter(FILTER_KEYS.ARTIST.ROLE, '', ItemListKey.ARTIST);\n\n    const query = {\n        [FILTER_KEYS.ARTIST.ROLE]: role ?? undefined,\n        [FILTER_KEYS.SHARED.SEARCH_TERM]: searchTerm ?? undefined,\n        [FILTER_KEYS.SHARED.SORT_BY]: sortBy ?? undefined,\n        [FILTER_KEYS.SHARED.SORT_ORDER]: sortOrder ?? undefined,\n    };\n\n    return {\n        query,\n        setSearchTerm,\n    };\n};\n"
  },
  {
    "path": "src/renderer/features/artists/routes/album-artist-detail-favorite-songs-list-route.tsx",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { useMemo } from 'react';\nimport { useParams } from 'react-router';\n\nimport { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder';\nimport { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize';\nimport { ItemTableList } from '/@/renderer/components/item-list/item-table-list/item-table-list';\nimport { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';\nimport { ItemControls } from '/@/renderer/components/item-list/types';\nimport { ListContext } from '/@/renderer/context/list-context';\nimport { artistsQueries } from '/@/renderer/features/artists/api/artists-api';\nimport { AlbumArtistDetailFavoriteSongsListHeader } from '/@/renderer/features/artists/components/album-artist-detail-favorite-songs-list-header';\nimport { AlbumArtistDetailFavoriteSongsListHeaderFilters } from '/@/renderer/features/artists/components/album-artist-detail-favorite-songs-list-header-filters';\nimport { usePlayer } from '/@/renderer/features/player/context/player-context';\nimport { applyClientSideSongFilters } from '/@/renderer/features/playlists/hooks/use-playlist-track-list';\nimport { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';\nimport { FilterBar } from '/@/renderer/features/shared/components/filter-bar';\nimport { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';\nimport { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-search-term-filter';\nimport { FILTER_KEYS, searchLibraryItems } from '/@/renderer/features/shared/utils';\nimport { usePlayerSong } from '/@/renderer/store';\nimport { useAppStore } from '/@/renderer/store/app.store';\nimport { useCurrentServer } from '/@/renderer/store/auth.store';\nimport { useSettingsStore } from '/@/renderer/store/settings.store';\nimport { sortSongList } from '/@/shared/api/utils';\nimport { LibraryItem, Song } from '/@/shared/types/domain-types';\nimport { ItemListKey, Play } from '/@/shared/types/types';\n\nconst AlbumArtistDetailFavoriteSongsListRoute = () => {\n    const { albumArtistId, artistId } = useParams() as {\n        albumArtistId?: string;\n        artistId?: string;\n    };\n    const routeId = (artistId || albumArtistId) as string;\n    const server = useCurrentServer();\n    const pageKey = LibraryItem.SONG;\n\n    const detailQuery = useQuery(\n        artistsQueries.albumArtistDetail({\n            query: { id: routeId },\n            serverId: server?.id,\n        }),\n    );\n\n    const favoriteSongsQuery = useQuery(\n        artistsQueries.favoriteSongs({\n            options: { enabled: !!detailQuery?.data?.name },\n            query: { artistId: routeId },\n            serverId: server?.id,\n        }),\n    );\n\n    const songs = useMemo(\n        () => favoriteSongsQuery?.data?.items || [],\n        [favoriteSongsQuery?.data?.items],\n    );\n\n    const albumArtistDetailFavoriteSongsSort = useAppStore(\n        (state) => state.albumArtistDetailFavoriteSongsSort,\n    );\n    const sortBy = albumArtistDetailFavoriteSongsSort.sortBy;\n    const sortOrder = albumArtistDetailFavoriteSongsSort.sortOrder;\n\n    const { searchTerm } = useSearchTermFilter();\n\n    const sortedSongs = useMemo(() => {\n        const filtered = applyClientSideSongFilters(songs, {\n            [FILTER_KEYS.SHARED.SEARCH_TERM]: searchTerm,\n        });\n        const searched = searchTerm\n            ? searchLibraryItems(filtered, searchTerm, LibraryItem.SONG)\n            : filtered;\n        return sortSongList(searched, sortBy, sortOrder);\n    }, [songs, sortBy, sortOrder, searchTerm]);\n\n    const itemCount = sortedSongs.length;\n\n    const tableConfig = useSettingsStore((state) => state.lists[ItemListKey.SONG]?.table);\n    const currentSong = usePlayerSong();\n    const player = usePlayer();\n\n    const columns = useMemo(() => {\n        return tableConfig?.columns || [];\n    }, [tableConfig?.columns]);\n\n    const { handleColumnReordered } = useItemListColumnReorder({\n        itemListKey: ItemListKey.SONG,\n    });\n\n    const { handleColumnResized } = useItemListColumnResize({\n        itemListKey: ItemListKey.SONG,\n    });\n\n    const overrideControls: Partial<ItemControls> = useMemo(() => {\n        return {\n            onDoubleClick: ({ index, internalState, item, meta }) => {\n                if (!item) {\n                    return;\n                }\n\n                const playType = (meta?.playType as Play) || Play.NOW;\n                const items = internalState?.getData() as Song[];\n\n                if (index !== undefined) {\n                    player.addToQueueByData(items, playType, item.id);\n                }\n            },\n        };\n    }, [player]);\n\n    const providerValue = useMemo(() => {\n        return {\n            id: routeId,\n            pageKey,\n        };\n    }, [routeId, pageKey]);\n\n    const currentSongId = currentSong?.id;\n\n    if (!tableConfig || columns.length === 0) {\n        return (\n            <AnimatedPage>\n                <ListContext.Provider value={providerValue}>\n                    <AlbumArtistDetailFavoriteSongsListHeader\n                        data={sortedSongs}\n                        itemCount={itemCount}\n                        title={detailQuery?.data?.name || 'Unknown'}\n                    />\n                </ListContext.Provider>\n            </AnimatedPage>\n        );\n    }\n\n    return (\n        <AnimatedPage>\n            <ListContext.Provider value={providerValue}>\n                <AlbumArtistDetailFavoriteSongsListHeader\n                    data={sortedSongs}\n                    itemCount={itemCount}\n                    title={detailQuery?.data?.name || 'Unknown'}\n                />\n                <FilterBar>\n                    <AlbumArtistDetailFavoriteSongsListHeaderFilters />\n                </FilterBar>\n                <ItemTableList\n                    activeRowId={currentSongId}\n                    autoFitColumns={tableConfig.autoFitColumns}\n                    CellComponent={ItemTableListColumn}\n                    columns={columns}\n                    data={sortedSongs}\n                    enableAlternateRowColors={tableConfig.enableAlternateRowColors}\n                    enableDrag\n                    enableExpansion={false}\n                    enableHeader={tableConfig.enableHeader}\n                    enableHorizontalBorders={tableConfig.enableHorizontalBorders}\n                    enableRowHoverHighlight={tableConfig.enableRowHoverHighlight}\n                    enableSelection\n                    enableSelectionDialog={false}\n                    enableVerticalBorders={tableConfig.enableVerticalBorders}\n                    itemType={LibraryItem.SONG}\n                    onColumnReordered={handleColumnReordered}\n                    onColumnResized={handleColumnResized}\n                    overrideControls={overrideControls}\n                    size={tableConfig.size}\n                />\n            </ListContext.Provider>\n        </AnimatedPage>\n    );\n};\n\nconst AlbumArtistDetailTopSongsListRouteWithBoundary = () => {\n    return (\n        <PageErrorBoundary>\n            <AlbumArtistDetailFavoriteSongsListRoute />\n        </PageErrorBoundary>\n    );\n};\n\nexport default AlbumArtistDetailTopSongsListRouteWithBoundary;\n"
  },
  {
    "path": "src/renderer/features/artists/routes/album-artist-detail-route.tsx",
    "content": "import { useSuspenseQueries } from '@tanstack/react-query';\nimport { Suspense, useRef } from 'react';\nimport { useParams } from 'react-router';\n\nimport { useItemImageUrl } from '/@/renderer/components/item-image/item-image';\nimport { NativeScrollArea } from '/@/renderer/components/native-scroll-area/native-scroll-area';\nimport { albumQueries } from '/@/renderer/features/albums/api/album-api';\nimport { artistsQueries } from '/@/renderer/features/artists/api/artists-api';\nimport { AlbumArtistDetailContent } from '/@/renderer/features/artists/components/album-artist-detail-content';\nimport { AlbumArtistDetailHeader } from '/@/renderer/features/artists/components/album-artist-detail-header';\nimport { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';\nimport {\n    LibraryBackgroundImage,\n    LibraryBackgroundOverlay,\n} from '/@/renderer/features/shared/components/library-background-overlay';\nimport { LibraryContainer } from '/@/renderer/features/shared/components/library-container';\nimport { LibraryHeaderBar } from '/@/renderer/features/shared/components/library-header-bar';\nimport { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';\nimport { useFastAverageColor } from '/@/renderer/hooks';\nimport { useArtistBackground, useCurrentServer, useCurrentServerId } from '/@/renderer/store';\nimport { Spinner } from '/@/shared/components/spinner/spinner';\nimport { AlbumListSort, LibraryItem, SortOrder } from '/@/shared/types/domain-types';\n\nconst AlbumArtistDetailRouteContent = () => {\n    const scrollAreaRef = useRef<HTMLDivElement>(null);\n    const headerRef = useRef<HTMLDivElement>(null);\n    const server = useCurrentServer();\n    const serverId = useCurrentServerId();\n    const { artistBackground, artistBackgroundBlur } = useArtistBackground();\n\n    const { albumArtistId, artistId } = useParams() as {\n        albumArtistId?: string;\n        artistId?: string;\n    };\n\n    const routeId = (artistId || albumArtistId) as string;\n\n    const [detailQuery, albumsQuery] = useSuspenseQueries({\n        queries: [\n            artistsQueries.albumArtistDetail({ query: { id: routeId }, serverId: server?.id }),\n            albumQueries.list({\n                query: {\n                    artistIds: [routeId],\n                    limit: -1,\n                    sortBy: AlbumListSort.RELEASE_DATE,\n                    sortOrder: SortOrder.DESC,\n                    startIndex: 0,\n                },\n                serverId,\n            }),\n        ],\n    });\n\n    const imageUrl = useItemImageUrl({\n        id: detailQuery.data?.imageId || undefined,\n        imageUrl: detailQuery.data?.imageUrl,\n        itemType: LibraryItem.ALBUM_ARTIST,\n        type: 'header',\n    });\n\n    const libraryBackgroundImageUrl = useItemImageUrl({\n        id: detailQuery.data?.imageId || undefined,\n        imageUrl: detailQuery.data?.imageUrl,\n        itemType: LibraryItem.ALBUM_ARTIST,\n        type: 'itemCard',\n    });\n\n    const selectedImageUrl = imageUrl || detailQuery.data?.imageUrl;\n\n    const { background: backgroundColor } = useFastAverageColor({\n        id: artistId,\n        src: selectedImageUrl,\n        srcLoaded: true,\n    });\n\n    const background = backgroundColor;\n\n    const showBlurredImage = artistBackground;\n\n    // if (isColorLoading) {\n    //     return <Spinner container />;\n    // }\n\n    return (\n        <AnimatedPage key={`album-artist-detail-${routeId}`}>\n            <NativeScrollArea\n                pageHeaderProps={{\n                    backgroundColor: backgroundColor || undefined,\n                    children: (\n                        <LibraryHeaderBar>\n                            <LibraryHeaderBar.PlayButton\n                                ids={[routeId]}\n                                itemType={LibraryItem.ALBUM_ARTIST}\n                                variant=\"default\"\n                            />\n                            <LibraryHeaderBar.Title>\n                                {detailQuery.data?.name}\n                            </LibraryHeaderBar.Title>\n                        </LibraryHeaderBar>\n                    ),\n                    offset: 200,\n                    target: headerRef,\n                }}\n                ref={scrollAreaRef}\n            >\n                {showBlurredImage ? (\n                    <LibraryBackgroundImage\n                        blur={artistBackgroundBlur}\n                        headerRef={headerRef}\n                        imageUrl={libraryBackgroundImageUrl || ''}\n                    />\n                ) : (\n                    <LibraryBackgroundOverlay backgroundColor={background} headerRef={headerRef} />\n                )}\n                <LibraryContainer>\n                    <AlbumArtistDetailHeader\n                        albumsQuery={albumsQuery}\n                        ref={headerRef as React.Ref<HTMLDivElement>}\n                    />\n                    <AlbumArtistDetailContent albumsQuery={albumsQuery} detailQuery={detailQuery} />\n                </LibraryContainer>\n            </NativeScrollArea>\n        </AnimatedPage>\n    );\n};\n\nconst AlbumArtistDetailRoute = () => {\n    const { albumArtistId, artistId } = useParams() as {\n        albumArtistId?: string;\n        artistId?: string;\n    };\n    const routeId = (artistId || albumArtistId) as string;\n\n    return (\n        <Suspense fallback={<Spinner container />} key={`album-artist-detail-suspense-${routeId}`}>\n            <AlbumArtistDetailRouteContent />\n        </Suspense>\n    );\n};\n\nconst AlbumArtistDetailRouteWithBoundary = () => {\n    return (\n        <PageErrorBoundary>\n            <AlbumArtistDetailRoute />\n        </PageErrorBoundary>\n    );\n};\n\nexport default AlbumArtistDetailRouteWithBoundary;\n"
  },
  {
    "path": "src/renderer/features/artists/routes/album-artist-detail-top-songs-list-route.tsx",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { useMemo } from 'react';\nimport { useParams } from 'react-router';\n\nimport { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder';\nimport { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize';\nimport { ItemTableList } from '/@/renderer/components/item-list/item-table-list/item-table-list';\nimport { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';\nimport { ItemControls } from '/@/renderer/components/item-list/types';\nimport { ListContext } from '/@/renderer/context/list-context';\nimport { artistsQueries } from '/@/renderer/features/artists/api/artists-api';\nimport { AlbumArtistDetailTopSongsListHeader } from '/@/renderer/features/artists/components/album-artist-detail-top-songs-list-header';\nimport { usePlayer } from '/@/renderer/features/player/context/player-context';\nimport { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';\nimport { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';\nimport { usePlayerSong } from '/@/renderer/store';\nimport { useCurrentServer } from '/@/renderer/store/auth.store';\nimport { useSettingsStore } from '/@/renderer/store/settings.store';\nimport { useLocalStorage } from '/@/shared/hooks/use-local-storage';\nimport { LibraryItem, Song } from '/@/shared/types/domain-types';\nimport { ItemListKey, Play } from '/@/shared/types/types';\n\nconst AlbumArtistDetailTopSongsListRoute = () => {\n    const { albumArtistId, artistId } = useParams() as {\n        albumArtistId?: string;\n        artistId?: string;\n    };\n    const routeId = (artistId || albumArtistId) as string;\n    const server = useCurrentServer();\n    const pageKey = LibraryItem.SONG;\n\n    const [topSongsQueryType] = useLocalStorage<'community' | 'personal'>({\n        defaultValue: 'community',\n        key: 'album-artist-top-songs-query-type',\n    });\n\n    const detailQuery = useQuery(\n        artistsQueries.albumArtistDetail({\n            query: { id: routeId },\n            serverId: server?.id,\n        }),\n    );\n\n    const topSongsQuery = useQuery(\n        artistsQueries.topSongs({\n            options: { enabled: !!detailQuery?.data?.name },\n            query: {\n                artist: detailQuery?.data?.name || '',\n                artistId: routeId,\n                type: topSongsQueryType,\n            },\n            serverId: server?.id,\n        }),\n    );\n\n    const itemCount = topSongsQuery?.data?.items?.length || 0;\n    const songs = useMemo(() => topSongsQuery?.data?.items || [], [topSongsQuery?.data?.items]);\n\n    const tableConfig = useSettingsStore((state) => state.lists[ItemListKey.SONG]?.table);\n    const currentSong = usePlayerSong();\n    const player = usePlayer();\n\n    const columns = useMemo(() => {\n        return tableConfig?.columns || [];\n    }, [tableConfig?.columns]);\n\n    const { handleColumnReordered } = useItemListColumnReorder({\n        itemListKey: ItemListKey.SONG,\n    });\n\n    const { handleColumnResized } = useItemListColumnResize({\n        itemListKey: ItemListKey.SONG,\n    });\n\n    const overrideControls: Partial<ItemControls> = useMemo(() => {\n        return {\n            onDoubleClick: ({ index, internalState, item, meta }) => {\n                if (!item) {\n                    return;\n                }\n\n                const playType = (meta?.playType as Play) || Play.NOW;\n                const items = internalState?.getData() as Song[];\n\n                if (index !== undefined) {\n                    player.addToQueueByData(items, playType, item.id);\n                }\n            },\n        };\n    }, [player]);\n\n    const providerValue = useMemo(() => {\n        return {\n            id: routeId,\n            pageKey,\n        };\n    }, [routeId, pageKey]);\n\n    const currentSongId = currentSong?.id;\n\n    if (!tableConfig || columns.length === 0) {\n        return (\n            <AnimatedPage>\n                <ListContext.Provider value={providerValue}>\n                    <AlbumArtistDetailTopSongsListHeader\n                        data={songs}\n                        itemCount={itemCount}\n                        title={detailQuery?.data?.name || 'Unknown'}\n                    />\n                </ListContext.Provider>\n            </AnimatedPage>\n        );\n    }\n\n    return (\n        <AnimatedPage>\n            <ListContext.Provider value={providerValue}>\n                <AlbumArtistDetailTopSongsListHeader\n                    data={songs}\n                    itemCount={itemCount}\n                    title={detailQuery?.data?.name || 'Unknown'}\n                />\n                <ItemTableList\n                    activeRowId={currentSongId}\n                    autoFitColumns={tableConfig.autoFitColumns}\n                    CellComponent={ItemTableListColumn}\n                    columns={columns}\n                    data={songs}\n                    enableAlternateRowColors={tableConfig.enableAlternateRowColors}\n                    enableDrag\n                    enableExpansion={false}\n                    enableHeader={tableConfig.enableHeader}\n                    enableHorizontalBorders={tableConfig.enableHorizontalBorders}\n                    enableRowHoverHighlight={tableConfig.enableRowHoverHighlight}\n                    enableSelection\n                    enableSelectionDialog={false}\n                    enableVerticalBorders={tableConfig.enableVerticalBorders}\n                    itemType={LibraryItem.SONG}\n                    onColumnReordered={handleColumnReordered}\n                    onColumnResized={handleColumnResized}\n                    overrideControls={overrideControls}\n                    size={tableConfig.size}\n                />\n            </ListContext.Provider>\n        </AnimatedPage>\n    );\n};\n\nconst AlbumArtistDetailTopSongsListRouteWithBoundary = () => {\n    return (\n        <PageErrorBoundary>\n            <AlbumArtistDetailTopSongsListRoute />\n        </PageErrorBoundary>\n    );\n};\n\nexport default AlbumArtistDetailTopSongsListRouteWithBoundary;\n"
  },
  {
    "path": "src/renderer/features/artists/routes/album-artist-list-route.tsx",
    "content": "import { useMemo, useState } from 'react';\n\nimport { ListContext } from '/@/renderer/context/list-context';\nimport { AlbumArtistListContent } from '/@/renderer/features/artists/components/album-artist-list-content';\nimport { AlbumArtistListHeader } from '/@/renderer/features/artists/components/album-artist-list-header';\nimport { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';\nimport { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';\nimport { ItemListKey } from '/@/shared/types/types';\n\nconst AlbumArtistListRoute = () => {\n    const pageKey = ItemListKey.ALBUM_ARTIST;\n\n    const [itemCount, setItemCount] = useState<number | undefined>(undefined);\n\n    const providerValue = useMemo(() => {\n        return {\n            id: undefined,\n            itemCount,\n            pageKey,\n            setItemCount,\n        };\n    }, [itemCount, pageKey, setItemCount]);\n\n    return (\n        <AnimatedPage>\n            <ListContext.Provider value={providerValue}>\n                <AlbumArtistListHeader />\n                <AlbumArtistListContent />\n            </ListContext.Provider>\n        </AnimatedPage>\n    );\n};\n\nconst AlbumArtistListRouteWithBoundary = () => {\n    return (\n        <PageErrorBoundary>\n            <AlbumArtistListRoute />\n        </PageErrorBoundary>\n    );\n};\n\nexport default AlbumArtistListRouteWithBoundary;\n"
  },
  {
    "path": "src/renderer/features/artists/routes/artist-list-route.tsx",
    "content": "import { useMemo, useState } from 'react';\n\nimport { ListContext } from '/@/renderer/context/list-context';\nimport { ArtistListContent } from '/@/renderer/features/artists/components/artist-list-content';\nimport { ArtistListHeader } from '/@/renderer/features/artists/components/artist-list-header';\nimport { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';\nimport { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';\nimport { ItemListKey } from '/@/shared/types/types';\n\nconst ArtistListRoute = () => {\n    const pageKey = ItemListKey.ARTIST;\n\n    const [itemCount, setItemCount] = useState<number | undefined>(undefined);\n\n    const providerValue = useMemo(() => {\n        return {\n            id: undefined,\n            itemCount,\n            pageKey,\n            setItemCount,\n        };\n    }, [itemCount, pageKey, setItemCount]);\n\n    return (\n        <AnimatedPage>\n            <ListContext.Provider value={providerValue}>\n                <ArtistListHeader />\n                <ArtistListContent />\n            </ListContext.Provider>\n        </AnimatedPage>\n    );\n};\n\nconst ArtistListRouteWithBoundary = () => {\n    return (\n        <PageErrorBoundary>\n            <ArtistListRoute />\n        </PageErrorBoundary>\n    );\n};\n\nexport default ArtistListRouteWithBoundary;\n"
  },
  {
    "path": "src/renderer/features/context-menu/actions/add-to-playlist-action.tsx",
    "content": "import { openContextModal } from '@mantine/modals';\nimport { useQuery, useQueryClient } from '@tanstack/react-query';\nimport Fuse from 'fuse.js';\nimport { useCallback, useMemo, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport { api } from '/@/renderer/api';\nimport { queryKeys } from '/@/renderer/api/query-keys';\nimport {\n    getAlbumArtistSongsById,\n    getAlbumSongsById,\n    getGenreSongsById,\n    getPlaylistSongsById,\n    getSongsByFolder,\n} from '/@/renderer/features/player/utils';\nimport { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';\nimport { useRecentPlaylists } from '/@/renderer/features/playlists/hooks/use-recent-playlists';\nimport { useAddToPlaylist } from '/@/renderer/features/playlists/mutations/add-to-playlist-mutation';\nimport { useCurrentServer, useCurrentServerId } from '/@/renderer/store';\nimport { Checkbox } from '/@/shared/components/checkbox/checkbox';\nimport { ContextMenu } from '/@/shared/components/context-menu/context-menu';\nimport { Icon } from '/@/shared/components/icon/icon';\nimport { Spinner } from '/@/shared/components/spinner/spinner';\nimport { TextInput } from '/@/shared/components/text-input/text-input';\nimport { toast } from '/@/shared/components/toast/toast';\nimport { Tooltip } from '/@/shared/components/tooltip/tooltip';\nimport { useLocalStorage } from '/@/shared/hooks/use-local-storage';\nimport { LibraryItem, PlaylistListSort, SortOrder } from '/@/shared/types/domain-types';\n\ninterface AddToPlaylistActionProps {\n    items: string[];\n    itemType: LibraryItem;\n}\n\nexport const AddToPlaylistAction = ({ items, itemType }: AddToPlaylistActionProps) => {\n    const { t } = useTranslation();\n    const server = useCurrentServer();\n    const serverId = useCurrentServerId();\n    const queryClient = useQueryClient();\n    const [searchTerm, setSearchTerm] = useState('');\n    const [skipDuplicates, setSkipDuplicates] = useLocalStorage({\n        defaultValue: true,\n        key: 'playlist-skip-duplicate',\n    });\n    const addToPlaylistMutation = useAddToPlaylist({});\n\n    const playlistsQuery = useQuery(\n        playlistsQueries.list({\n            query: {\n                excludeSmartPlaylists: true,\n                sortBy: PlaylistListSort.NAME,\n                sortOrder: SortOrder.ASC,\n                startIndex: 0,\n            },\n            serverId: server?.id,\n        }),\n    );\n\n    const { recentPlaylistId } = useRecentPlaylists(serverId);\n\n    const playlists = playlistsQuery.data?.items;\n\n    const fuse = useMemo(() => {\n        if (!playlists) return null;\n\n        return new Fuse(playlists, {\n            fieldNormWeight: 1,\n            ignoreLocation: true,\n            keys: ['name'],\n            threshold: 0.3,\n        });\n    }, [playlists]);\n\n    const recentPlaylist = useMemo(() => {\n        if (!playlists || !recentPlaylistId) return null;\n\n        const playlist = playlists.find((p) => p.id === recentPlaylistId);\n        if (!playlist) return null;\n\n        if (searchTerm && fuse) {\n            const results = fuse.search(searchTerm);\n            const found = results.find((result) => result.item.id === recentPlaylistId);\n            if (!found) return null;\n        }\n\n        return playlist;\n    }, [playlists, recentPlaylistId, searchTerm, fuse]);\n\n    const filteredPlaylists = useMemo(() => {\n        if (!playlists) return [];\n        if (!searchTerm || !fuse) {\n            // Exclude recent playlist from the list if it exists\n            return recentPlaylistId\n                ? playlists.filter((p) => p.id !== recentPlaylistId)\n                : playlists;\n        }\n\n        const results = fuse.search(searchTerm);\n        const filtered = results.map((result) => result.item);\n        // Exclude recent playlist from the filtered results if it exists\n        return recentPlaylistId ? filtered.filter((p) => p.id !== recentPlaylistId) : filtered;\n    }, [playlists, searchTerm, fuse, recentPlaylistId]);\n\n    const getSongsByAlbum = useCallback(\n        async (albumId: string) => {\n            return getAlbumSongsById({\n                id: [albumId],\n                queryClient,\n                serverId,\n            });\n        },\n        [queryClient, serverId],\n    );\n\n    const getSongsByArtist = useCallback(\n        async (artistId: string) => {\n            return getAlbumArtistSongsById({\n                id: [artistId],\n                queryClient,\n                serverId,\n            });\n        },\n        [queryClient, serverId],\n    );\n\n    const getSongsByGenre = useCallback(\n        async (genreIds: string[]) => {\n            return getGenreSongsById({\n                id: genreIds,\n                queryClient,\n                serverId,\n            });\n        },\n        [queryClient, serverId],\n    );\n\n    const getSongsByPlaylist = useCallback(\n        async (playlistId: string) => {\n            return getPlaylistSongsById({\n                id: playlistId,\n                queryClient,\n                serverId,\n            });\n        },\n        [queryClient, serverId],\n    );\n\n    const getSongsByFolderLocal = useCallback(\n        async (folderId: string) => {\n            if (!server) return null;\n\n            const songsResponse = await getSongsByFolder({\n                id: [folderId],\n                queryClient,\n                serverId: server.id,\n            });\n\n            return {\n                items: songsResponse.items.map((song) => song.id),\n                startIndex: 0,\n                totalRecordCount: songsResponse.items.length,\n            };\n        },\n        [queryClient, server],\n    );\n\n    const handleAddToPlaylist = useCallback(\n        async (playlistId: string) => {\n            if (items.length === 0 || !serverId) return;\n\n            try {\n                let allSongIds: string[] = [];\n\n                if (itemType === LibraryItem.SONG || itemType === LibraryItem.PLAYLIST_SONG) {\n                    allSongIds = items;\n                } else if (itemType === LibraryItem.ALBUM) {\n                    for (const id of items) {\n                        const songs = await getSongsByAlbum(id);\n                        allSongIds.push(...(songs?.items?.map((song) => song.id) || []));\n                    }\n                } else if (\n                    itemType === LibraryItem.ALBUM_ARTIST ||\n                    itemType === LibraryItem.ARTIST\n                ) {\n                    for (const id of items) {\n                        const songs = await getSongsByArtist(id);\n                        allSongIds.push(...(songs?.items?.map((song) => song.id) || []));\n                    }\n                } else if (itemType === LibraryItem.GENRE) {\n                    const songs = await getSongsByGenre(items);\n                    allSongIds.push(...(songs?.items?.map((song) => song.id) || []));\n                } else if (itemType === LibraryItem.PLAYLIST) {\n                    for (const id of items) {\n                        const songs = await getSongsByPlaylist(id);\n                        allSongIds.push(...(songs?.items?.map((song) => song.id) || []));\n                    }\n                } else if (itemType === LibraryItem.FOLDER) {\n                    for (const id of items) {\n                        const songs = await getSongsByFolderLocal(id);\n                        allSongIds.push(...(songs?.items || []));\n                    }\n                }\n\n                if (allSongIds.length === 0) {\n                    toast.success({\n                        message: t('form.addToPlaylist.success', {\n                            message: 0,\n                            numOfPlaylists: 1,\n                            postProcess: 'sentenceCase',\n                        }),\n                    });\n                    return;\n                }\n\n                let songsToAdd: string[] = allSongIds;\n\n                if (skipDuplicates) {\n                    const queryKey = queryKeys.playlists.songList(serverId, playlistId);\n\n                    const playlistSongsRes = await queryClient.fetchQuery({\n                        queryFn: ({ signal }) => {\n                            return api.controller.getPlaylistSongList({\n                                apiClientProps: {\n                                    serverId,\n                                    signal,\n                                },\n                                query: {\n                                    id: playlistId,\n                                },\n                            });\n                        },\n                        queryKey,\n                    });\n\n                    const playlistSongIds = playlistSongsRes?.items?.map((song) => song.id);\n                    const uniqueSongIds: string[] = [];\n\n                    for (const songId of allSongIds) {\n                        if (!playlistSongIds?.includes(songId)) {\n                            uniqueSongIds.push(songId);\n                        }\n                    }\n\n                    songsToAdd = uniqueSongIds;\n                }\n\n                if (songsToAdd.length === 0) {\n                    toast.success({\n                        message: t('form.addToPlaylist.success', {\n                            message: 0,\n                            numOfPlaylists: 1,\n                            postProcess: 'sentenceCase',\n                        }),\n                    });\n                    return;\n                }\n\n                addToPlaylistMutation.mutate(\n                    {\n                        apiClientProps: { serverId },\n                        body: {\n                            songId: songsToAdd,\n                        },\n                        query: {\n                            id: playlistId,\n                        },\n                    },\n                    {\n                        onError: (err) => {\n                            toast.error({\n                                message: err.message,\n                                title: t('error.genericError', { postProcess: 'sentenceCase' }),\n                            });\n                        },\n                        onSuccess: () => {},\n                    },\n                );\n\n                toast.success({\n                    message: t('form.addToPlaylist.success', {\n                        message: songsToAdd.length,\n                        numOfPlaylists: 1,\n                        postProcess: 'sentenceCase',\n                    }),\n                });\n            } catch (error) {\n                toast.error({\n                    message: (error as Error).message,\n                    title: t('error.genericError', { postProcess: 'sentenceCase' }),\n                });\n            }\n        },\n        [\n            addToPlaylistMutation,\n            getSongsByAlbum,\n            getSongsByArtist,\n            getSongsByFolderLocal,\n            getSongsByGenre,\n            getSongsByPlaylist,\n            itemType,\n            items,\n            queryClient,\n            serverId,\n            skipDuplicates,\n            t,\n        ],\n    );\n\n    const handleOpenModal = useCallback(() => {\n        const modalProps: {\n            albumId?: string[];\n            artistId?: string[];\n            folderId?: string[];\n            genreId?: string[];\n            initialSelectedIds?: string[];\n            playlistId?: string[];\n            songId?: string[];\n        } = {};\n\n        switch (itemType) {\n            case LibraryItem.ALBUM:\n                modalProps.albumId = items;\n                break;\n            case LibraryItem.ALBUM_ARTIST:\n            case LibraryItem.ARTIST:\n                modalProps.artistId = items;\n                break;\n            case LibraryItem.FOLDER:\n                modalProps.folderId = items;\n                break;\n            case LibraryItem.GENRE:\n                modalProps.genreId = items;\n                break;\n            case LibraryItem.PLAYLIST:\n                modalProps.playlistId = items;\n                break;\n            case LibraryItem.PLAYLIST_SONG:\n            case LibraryItem.QUEUE_SONG:\n            case LibraryItem.SONG:\n                modalProps.songId = items;\n                break;\n            default:\n                return;\n        }\n\n        openContextModal({\n            innerProps: {\n                ...modalProps,\n            },\n            modalKey: 'addToPlaylist',\n            size: 'lg',\n            title: t('page.contextMenu.addToPlaylist', { postProcess: 'sentenceCase' }),\n        });\n    }, [itemType, items, t]);\n\n    if (items.length === 0) return null;\n\n    const searchInput = (\n        <TextInput\n            autoFocus\n            leftSection={<Icon icon=\"search\" />}\n            onChange={(e) => setSearchTerm(e.target.value)}\n            onKeyDown={(e) => e.stopPropagation()}\n            onPointerDown={(e) => e.stopPropagation()}\n            pb=\"xs\"\n            placeholder={t('common.search', { postProcess: 'sentenceCase' })}\n            rightSection={\n                <Tooltip\n                    label={t('form.addToPlaylist.input', {\n                        context: 'skipDuplicates',\n                        postProcess: 'titleCase',\n                    })}\n                >\n                    <Checkbox\n                        checked={skipDuplicates}\n                        onChange={(e) => {\n                            setSkipDuplicates(e.target.checked);\n                            e.stopPropagation();\n                        }}\n                        onClick={(e) => e.stopPropagation()}\n                        size=\"sm\"\n                    />\n                </Tooltip>\n            }\n            size=\"sm\"\n            value={searchTerm}\n        />\n    );\n\n    return (\n        <ContextMenu.Submenu isCloseDisabled>\n            <ContextMenu.SubmenuTarget>\n                <ContextMenu.Item\n                    leftIcon=\"playlist\"\n                    onSelect={handleOpenModal}\n                    rightIcon=\"arrowRightS\"\n                >\n                    {t('page.contextMenu.addToPlaylist', { postProcess: 'sentenceCase' })}\n                </ContextMenu.Item>\n            </ContextMenu.SubmenuTarget>\n            <ContextMenu.SubmenuContent stickyContent={searchInput}>\n                {playlistsQuery.isLoading && (\n                    <ContextMenu.Item disabled>\n                        <Spinner container />\n                    </ContextMenu.Item>\n                )}\n                {playlistsQuery.isError && (\n                    <ContextMenu.Item disabled>\n                        {t('error.genericError', { postProcess: 'sentenceCase' })}\n                    </ContextMenu.Item>\n                )}\n                {recentPlaylist && (\n                    <>\n                        <ContextMenu.Item\n                            key={recentPlaylist.id}\n                            onSelect={() => handleAddToPlaylist(recentPlaylist.id)}\n                        >\n                            {recentPlaylist.name}\n                        </ContextMenu.Item>\n                        {filteredPlaylists.length > 0 && <ContextMenu.Divider />}\n                    </>\n                )}\n                {filteredPlaylists.length === 0 && !playlistsQuery.isLoading && (\n                    <ContextMenu.Item disabled>\n                        {t('common.noResultsFromQuery', { postProcess: 'sentenceCase' })}\n                    </ContextMenu.Item>\n                )}\n                {filteredPlaylists.map((playlist) => (\n                    <ContextMenu.Item\n                        key={playlist.id}\n                        onSelect={() => handleAddToPlaylist(playlist.id)}\n                    >\n                        {playlist.name}\n                    </ContextMenu.Item>\n                ))}\n            </ContextMenu.SubmenuContent>\n        </ContextMenu.Submenu>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/context-menu/actions/delete-playlist-action.tsx",
    "content": "import { closeAllModals, openModal } from '@mantine/modals';\nimport { useCallback } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { useNavigate } from 'react-router';\n\nimport { useDeletePlaylist } from '/@/renderer/features/playlists/mutations/delete-playlist-mutation';\nimport { AppRoute } from '/@/renderer/router/routes';\nimport { useCurrentServerId } from '/@/renderer/store';\nimport { ContextMenu } from '/@/shared/components/context-menu/context-menu';\nimport { ConfirmModal } from '/@/shared/components/modal/modal';\nimport { Text } from '/@/shared/components/text/text';\nimport { toast } from '/@/shared/components/toast/toast';\nimport { Playlist } from '/@/shared/types/domain-types';\n\ninterface DeletePlaylistActionProps {\n    disabled?: boolean;\n    items: Playlist[];\n}\n\nexport const DeletePlaylistAction = ({ disabled, items }: DeletePlaylistActionProps) => {\n    const { t } = useTranslation();\n    const navigate = useNavigate();\n    const serverId = useCurrentServerId();\n    const deletePlaylistMutation = useDeletePlaylist({});\n\n    const handleDeletePlaylist = useCallback(async () => {\n        if (items.length === 0 || !serverId) return;\n\n        try {\n            await Promise.all(\n                items.map((playlist) =>\n                    deletePlaylistMutation.mutateAsync({\n                        apiClientProps: { serverId },\n                        query: { id: playlist.id },\n                    }),\n                ),\n            );\n\n            navigate(AppRoute.PLAYLISTS, { replace: true });\n            toast.success({\n                message: t('action.deletePlaylist', { postProcess: 'sentenceCase' }),\n            });\n        } catch (err: any) {\n            toast.error({\n                message: err.message,\n                title: t('error.genericError', { postProcess: 'sentenceCase' }),\n            });\n        }\n\n        closeAllModals();\n    }, [deletePlaylistMutation, items, navigate, serverId, t]);\n\n    const openDeletePlaylistModal = useCallback(() => {\n        if (items.length === 0) return;\n\n        openModal({\n            children: (\n                <ConfirmModal onConfirm={handleDeletePlaylist}>\n                    <Text>{t('common.areYouSure', { postProcess: 'sentenceCase' })}</Text>\n                </ConfirmModal>\n            ),\n            title: t('form.deletePlaylist.title', { postProcess: 'sentenceCase' }),\n        });\n    }, [handleDeletePlaylist, items.length, t]);\n\n    if (items.length === 0) return null;\n\n    return (\n        <ContextMenu.Item disabled={disabled} leftIcon=\"remove\" onSelect={openDeletePlaylistModal}>\n            {t('action.deletePlaylist', { postProcess: 'sentenceCase' })}\n        </ContextMenu.Item>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/context-menu/actions/download-action.tsx",
    "content": "import isElectron from 'is-electron';\nimport { useCallback } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport { api } from '/@/renderer/api';\nimport { useCurrentServer } from '/@/renderer/store';\nimport { ContextMenu } from '/@/shared/components/context-menu/context-menu';\n\ninterface DownloadActionProps {\n    ids: string[];\n}\n\nconst utils = isElectron() ? window.api.utils : null;\n\nexport const DownloadAction = ({ ids }: DownloadActionProps) => {\n    const { t } = useTranslation();\n    const server = useCurrentServer();\n\n    const onSelect = useCallback(async () => {\n        try {\n            for (const id of ids) {\n                const downloadUrl = api.controller.getDownloadUrl({\n                    apiClientProps: { serverId: server.id },\n                    query: { id },\n                });\n\n                if (isElectron()) {\n                    utils?.download(downloadUrl);\n                } else {\n                    window.open(downloadUrl, '_blank');\n                }\n            }\n        } catch (error) {\n            console.error('Failed to download items:', error);\n        }\n    }, [ids, server]);\n\n    return (\n        <ContextMenu.Item disabled={ids.length > 1} leftIcon=\"download\" onSelect={onSelect}>\n            {t('page.contextMenu.download', { postProcess: 'sentenceCase' })}\n        </ContextMenu.Item>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/context-menu/actions/edit-playlist-action.tsx",
    "content": "import { useCallback } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport { openUpdatePlaylistModal } from '/@/renderer/features/playlists/components/update-playlist-modal';\nimport { ContextMenu } from '/@/shared/components/context-menu/context-menu';\nimport { Playlist } from '/@/shared/types/domain-types';\n\ninterface EditPlaylistActionProps {\n    disabled?: boolean;\n    items: Playlist[];\n}\n\nexport const EditPlaylistAction = ({ disabled, items }: EditPlaylistActionProps) => {\n    const { t } = useTranslation();\n\n    const handleEditPlaylist = useCallback(async () => {\n        if (items.length === 0) return;\n\n        const playlist = items[0];\n\n        openUpdatePlaylistModal({\n            playlist,\n        });\n    }, [items]);\n\n    if (items.length === 0 || items.length > 1) return null;\n\n    return (\n        <ContextMenu.Item disabled={disabled} leftIcon=\"edit\" onSelect={handleEditPlaylist}>\n            {t('action.editPlaylist', { postProcess: 'sentenceCase' })}\n        </ContextMenu.Item>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/context-menu/actions/get-info-action.tsx",
    "content": "import { openModal } from '@mantine/modals';\nimport { useCallback } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport {\n    ItemDetailsModal,\n    ItemDetailsModalProps,\n} from '/@/renderer/features/item-details/components/item-details-modal';\nimport { useCurrentServer } from '/@/renderer/store';\nimport { ContextMenu } from '/@/shared/components/context-menu/context-menu';\n\ninterface GetInfoActionProps {\n    disabled?: boolean;\n    items: ItemDetailsModalProps['item'][];\n}\n\nexport const GetInfoAction = ({ disabled, items }: GetInfoActionProps) => {\n    const { t } = useTranslation();\n    const server = useCurrentServer();\n\n    const onSelect = useCallback(async () => {\n        if (!server || items.length === 0) return;\n\n        const filteredItems = items.filter(\n            (item): item is NonNullable<typeof item> => item !== undefined,\n        );\n\n        if (filteredItems.length === 0) return;\n\n        openModal({\n            children: <ItemDetailsModal items={filteredItems} />,\n            size: 'lg',\n            styles: {\n                body: { paddingBottom: 'var(--theme-spacing-xl)' },\n            },\n            title:\n                filteredItems.length === 1\n                    ? filteredItems[0]?.name ||\n                      t('page.contextMenu.showDetails', { postProcess: 'sentenceCase' })\n                    : t('page.contextMenu.showDetails', { postProcess: 'sentenceCase' }),\n        });\n    }, [items, server, t]);\n\n    return (\n        <ContextMenu.Item disabled={disabled} leftIcon=\"info\" onSelect={onSelect}>\n            {t('page.contextMenu.showDetails', { postProcess: 'sentenceCase' })}\n        </ContextMenu.Item>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/context-menu/actions/go-to-action.tsx",
    "content": "import { useCallback, useMemo } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { generatePath, useNavigate } from 'react-router';\n\nimport { AppRoute } from '/@/renderer/router/routes';\nimport { ContextMenu } from '/@/shared/components/context-menu/context-menu';\nimport {\n    Album,\n    AlbumArtist,\n    Artist,\n    LibraryItem,\n    QueueSong,\n    Song,\n} from '/@/shared/types/domain-types';\n\ninterface GoToActionProps {\n    items: Album[] | AlbumArtist[] | Artist[] | QueueSong[] | Song[];\n}\n\nexport const GoToAction = ({ items }: GoToActionProps) => {\n    const { t } = useTranslation();\n    const navigate = useNavigate();\n\n    const { albumArtists, albumId } = useMemo(() => {\n        const firstItem = items[0];\n\n        if (firstItem._itemType === LibraryItem.ALBUM) {\n            return {\n                albumArtists: firstItem.albumArtists || [],\n                albumId: firstItem.id,\n            };\n        } else if (firstItem._itemType === LibraryItem.SONG) {\n            return {\n                albumArtists: firstItem.albumArtists || [],\n                albumId: firstItem.albumId,\n            };\n        } else if (\n            firstItem._itemType === LibraryItem.ARTIST ||\n            firstItem._itemType === LibraryItem.ALBUM_ARTIST\n        ) {\n            return {\n                albumArtists: [{ id: firstItem.id, name: firstItem.name }],\n                albumId: null,\n            };\n        }\n\n        return {\n            albumArtists: [],\n            albumId: null,\n        };\n    }, [items]);\n\n    const handleGoToAlbum = useCallback(() => {\n        if (!albumId) return;\n        navigate(generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, { albumId }));\n    }, [albumId, navigate]);\n\n    const handleGoToAlbumArtist = useCallback(\n        (albumArtistId: string) => {\n            navigate(generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, { albumArtistId }));\n        },\n        [navigate],\n    );\n\n    const hasAlbum = !!albumId;\n\n    return (\n        <ContextMenu.Submenu disabled={items.length !== 1}>\n            <ContextMenu.SubmenuTarget>\n                <ContextMenu.Item\n                    leftIcon=\"externalLink\"\n                    onSelect={(e) => e.preventDefault()}\n                    rightIcon=\"arrowRightS\"\n                >\n                    {t('page.contextMenu.goTo', { postProcess: 'sentenceCase' })}\n                </ContextMenu.Item>\n            </ContextMenu.SubmenuTarget>\n            <ContextMenu.SubmenuContent>\n                {hasAlbum && (\n                    <ContextMenu.Item leftIcon=\"album\" onSelect={handleGoToAlbum}>\n                        {t('page.contextMenu.goToAlbum', { postProcess: 'sentenceCase' })}\n                    </ContextMenu.Item>\n                )}\n                {albumArtists.map((albumArtist) => (\n                    <ContextMenu.Item\n                        key={albumArtist.id}\n                        leftIcon=\"artist\"\n                        onSelect={() => handleGoToAlbumArtist(albumArtist.id)}\n                    >\n                        {`${t('page.contextMenu.goTo', { postProcess: 'sentenceCase' })} ${albumArtist.name}`}\n                    </ContextMenu.Item>\n                ))}\n            </ContextMenu.SubmenuContent>\n        </ContextMenu.Submenu>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/context-menu/actions/move-queue-items-action.tsx",
    "content": "import { useCallback } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport { usePlayer } from '/@/renderer/features/player/context/player-context';\nimport { ContextMenu } from '/@/shared/components/context-menu/context-menu';\nimport { QueueSong } from '/@/shared/types/domain-types';\n\ninterface MoveQueueItemsActionProps {\n    items: QueueSong[];\n}\n\nexport const MoveQueueItemsAction = ({ items }: MoveQueueItemsActionProps) => {\n    const { t } = useTranslation();\n    const player = usePlayer();\n\n    const handleMoveToTop = useCallback(() => {\n        player.moveSelectedToTop(items);\n    }, [items, player]);\n\n    const handleMoveToNext = useCallback(() => {\n        player.moveSelectedToNext(items);\n    }, [items, player]);\n\n    const handleMoveToBottom = useCallback(() => {\n        player.moveSelectedToBottom(items);\n    }, [items, player]);\n\n    return (\n        <ContextMenu.Submenu>\n            <ContextMenu.SubmenuTarget>\n                <ContextMenu.Item\n                    leftIcon=\"dragVertical\"\n                    onSelect={(e) => e.preventDefault()}\n                    rightIcon=\"arrowRightS\"\n                >\n                    {t('page.contextMenu.moveItems', { postProcess: 'sentenceCase' })}\n                </ContextMenu.Item>\n            </ContextMenu.SubmenuTarget>\n            <ContextMenu.SubmenuContent>\n                <ContextMenu.Item leftIcon=\"arrowUpToLine\" onSelect={handleMoveToTop}>\n                    {t('page.contextMenu.moveToTop', { postProcess: 'sentenceCase' })}\n                </ContextMenu.Item>\n                <ContextMenu.Item leftIcon=\"mediaPlayNext\" onSelect={handleMoveToNext}>\n                    {t('page.contextMenu.moveToNext', { postProcess: 'sentenceCase' })}\n                </ContextMenu.Item>\n                <ContextMenu.Item leftIcon=\"arrowDownToLine\" onSelect={handleMoveToBottom}>\n                    {t('page.contextMenu.moveToBottom', { postProcess: 'sentenceCase' })}\n                </ContextMenu.Item>\n            </ContextMenu.SubmenuContent>\n        </ContextMenu.Submenu>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/context-menu/actions/play-action.tsx",
    "content": "import { useCallback } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport { usePlayer } from '/@/renderer/features/player/context/player-context';\nimport { useCurrentServerId, usePlayButtonBehavior } from '/@/renderer/store';\nimport { ContextMenu } from '/@/shared/components/context-menu/context-menu';\nimport { LibraryItem, Song } from '/@/shared/types/domain-types';\nimport { Play } from '/@/shared/types/types';\n\ninterface PlayActionProps {\n    ids: string[];\n    itemType: LibraryItem;\n    songs?: Song[];\n}\n\nexport const PlayAction = ({ ids, itemType, songs }: PlayActionProps) => {\n    const { t } = useTranslation();\n    const player = usePlayer();\n    const serverId = useCurrentServerId();\n\n    const handlePlay = useCallback(\n        (playType: Play) => {\n            if (ids.length === 0 || !serverId) return;\n\n            if (\n                itemType === LibraryItem.SONG ||\n                itemType === LibraryItem.PLAYLIST_SONG ||\n                itemType === LibraryItem.QUEUE_SONG\n            ) {\n                player.addToQueueByData(songs || [], playType);\n            } else {\n                player.addToQueueByFetch(serverId, ids, itemType, playType);\n            }\n        },\n        [ids, itemType, player, serverId, songs],\n    );\n\n    const handlePlayNow = useCallback(() => {\n        handlePlay(Play.NOW);\n    }, [handlePlay]);\n\n    const handlePlayNext = useCallback(() => {\n        handlePlay(Play.NEXT);\n    }, [handlePlay]);\n\n    const handlePlayLast = useCallback(() => {\n        handlePlay(Play.LAST);\n    }, [handlePlay]);\n\n    const handlePlayShuffled = useCallback(() => {\n        handlePlay(Play.SHUFFLE);\n    }, [handlePlay]);\n\n    const handlePlayNextShuffled = useCallback(() => {\n        handlePlay(Play.NEXT_SHUFFLE);\n    }, [handlePlay]);\n\n    const handlePlayLastShuffled = useCallback(() => {\n        handlePlay(Play.LAST_SHUFFLE);\n    }, [handlePlay]);\n\n    const playButtonBehavior = usePlayButtonBehavior();\n\n    const defaultPlayAction = useCallback(() => {\n        handlePlay(playButtonBehavior);\n    }, [handlePlay, playButtonBehavior]);\n\n    if (ids.length === 0) return null;\n\n    return (\n        <ContextMenu.Submenu>\n            <ContextMenu.SubmenuTarget>\n                <ContextMenu.Item\n                    leftIcon=\"mediaPlay\"\n                    onSelect={defaultPlayAction}\n                    rightIcon=\"arrowRightS\"\n                >\n                    {t('player.play', { postProcess: 'sentenceCase' })}\n                </ContextMenu.Item>\n            </ContextMenu.SubmenuTarget>\n            <ContextMenu.SubmenuContent>\n                <ContextMenu.Item leftIcon=\"mediaPlay\" onSelect={handlePlayNow}>\n                    {t('player.play', { postProcess: 'sentenceCase' })}\n                </ContextMenu.Item>\n                <ContextMenu.Item leftIcon=\"mediaPlayNext\" onSelect={handlePlayNext}>\n                    {t('player.addNext', { postProcess: 'sentenceCase' })}\n                </ContextMenu.Item>\n                <ContextMenu.Item leftIcon=\"mediaPlayLast\" onSelect={handlePlayLast}>\n                    {t('player.addLast', { postProcess: 'sentenceCase' })}\n                </ContextMenu.Item>\n                <ContextMenu.Divider />\n                <ContextMenu.Item leftIcon=\"mediaShuffle\" onSelect={handlePlayShuffled}>\n                    {t('player.shuffle', { postProcess: 'sentenceCase' })}\n                </ContextMenu.Item>\n                <ContextMenu.Item leftIcon=\"mediaPlayNext\" onSelect={handlePlayNextShuffled}>\n                    {t('player.addNextShuffled', { postProcess: 'sentenceCase' })}\n                </ContextMenu.Item>\n                <ContextMenu.Item leftIcon=\"mediaPlayLast\" onSelect={handlePlayLastShuffled}>\n                    {t('player.addLastShuffled', { postProcess: 'sentenceCase' })}\n                </ContextMenu.Item>\n            </ContextMenu.SubmenuContent>\n        </ContextMenu.Submenu>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/context-menu/actions/play-album-radio-action.tsx",
    "content": "import { useQueryClient } from '@tanstack/react-query';\nimport { useCallback } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport { queryKeys } from '/@/renderer/api/query-keys';\nimport { usePlayer } from '/@/renderer/features/player/context/player-context';\nimport { songsQueries } from '/@/renderer/features/songs/api/songs-api';\nimport { useArtistRadioCount, useCurrentServerId, usePlayButtonBehavior } from '/@/renderer/store';\nimport { ContextMenu } from '/@/shared/components/context-menu/context-menu';\nimport { Album } from '/@/shared/types/domain-types';\nimport { Play } from '/@/shared/types/types';\n\ninterface PlayAlbumRadioActionProps {\n    album: Album;\n    disabled?: boolean;\n}\n\nexport const PlayAlbumRadioAction = ({ album, disabled }: PlayAlbumRadioActionProps) => {\n    const albumRadioCount = useArtistRadioCount(); // Reuse the same setting for album radio\n    const { t } = useTranslation();\n    const player = usePlayer();\n    const serverId = useCurrentServerId();\n    const queryClient = useQueryClient();\n    const playButtonBehavior = usePlayButtonBehavior();\n\n    const handlePlayAlbumRadio = useCallback(\n        async (playType: Play) => {\n            if (!serverId || !album) return;\n\n            try {\n                const albumRadioSongs = await queryClient.fetchQuery({\n                    ...songsQueries.albumRadio({\n                        query: {\n                            albumId: album.id,\n                            count: albumRadioCount,\n                        },\n                        serverId: serverId,\n                    }),\n                    queryKey: queryKeys.player.fetch({ albumId: album.id }),\n                });\n                if (albumRadioSongs && albumRadioSongs.length > 0) {\n                    player.addToQueueByData(albumRadioSongs, playType);\n                }\n            } catch (error) {\n                console.error('Failed to load album radio:', error);\n            }\n        },\n        [album, albumRadioCount, player, queryClient, serverId],\n    );\n\n    const handlePlayAlbumRadioNow = useCallback(() => {\n        handlePlayAlbumRadio(Play.NOW);\n    }, [handlePlayAlbumRadio]);\n\n    const handlePlayAlbumRadioNext = useCallback(() => {\n        handlePlayAlbumRadio(Play.NEXT);\n    }, [handlePlayAlbumRadio]);\n\n    const handlePlayAlbumRadioLast = useCallback(() => {\n        handlePlayAlbumRadio(Play.LAST);\n    }, [handlePlayAlbumRadio]);\n\n    const defaultPlayAlbumRadioAction = useCallback(() => {\n        handlePlayAlbumRadio(playButtonBehavior);\n    }, [handlePlayAlbumRadio, playButtonBehavior]);\n\n    return (\n        <ContextMenu.Submenu>\n            <ContextMenu.SubmenuTarget>\n                <ContextMenu.Item\n                    disabled={disabled}\n                    leftIcon=\"radio\"\n                    onSelect={defaultPlayAlbumRadioAction}\n                    rightIcon=\"arrowRightS\"\n                >\n                    {t('player.albumRadio', { postProcess: 'sentenceCase' })}\n                </ContextMenu.Item>\n            </ContextMenu.SubmenuTarget>\n            <ContextMenu.SubmenuContent>\n                <ContextMenu.Item leftIcon=\"mediaPlay\" onSelect={handlePlayAlbumRadioNow}>\n                    {t('player.play', { postProcess: 'sentenceCase' })}\n                </ContextMenu.Item>\n                <ContextMenu.Item leftIcon=\"mediaPlayNext\" onSelect={handlePlayAlbumRadioNext}>\n                    {t('player.addNext', { postProcess: 'sentenceCase' })}\n                </ContextMenu.Item>\n                <ContextMenu.Item leftIcon=\"mediaPlayLast\" onSelect={handlePlayAlbumRadioLast}>\n                    {t('player.addLast', { postProcess: 'sentenceCase' })}\n                </ContextMenu.Item>\n            </ContextMenu.SubmenuContent>\n        </ContextMenu.Submenu>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/context-menu/actions/play-artist-radio-action.tsx",
    "content": "import { useQueryClient } from '@tanstack/react-query';\nimport { useCallback } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport { queryKeys } from '/@/renderer/api/query-keys';\nimport { usePlayer } from '/@/renderer/features/player/context/player-context';\nimport { songsQueries } from '/@/renderer/features/songs/api/songs-api';\nimport { useArtistRadioCount, useCurrentServerId, usePlayButtonBehavior } from '/@/renderer/store';\nimport { ContextMenu } from '/@/shared/components/context-menu/context-menu';\nimport { AlbumArtist, Artist } from '/@/shared/types/domain-types';\nimport { Play } from '/@/shared/types/types';\n\ninterface PlayArtistRadioActionProps {\n    artist: AlbumArtist | Artist;\n    disabled?: boolean;\n}\n\nexport const PlayArtistRadioAction = ({ artist, disabled }: PlayArtistRadioActionProps) => {\n    const artistRadioCount = useArtistRadioCount();\n    const { t } = useTranslation();\n    const player = usePlayer();\n    const serverId = useCurrentServerId();\n    const queryClient = useQueryClient();\n    const playButtonBehavior = usePlayButtonBehavior();\n\n    const handlePlayArtistRadio = useCallback(\n        async (playType: Play) => {\n            if (!serverId || !artist) return;\n\n            try {\n                const artistRadioSongs = await queryClient.fetchQuery({\n                    ...songsQueries.artistRadio({\n                        query: {\n                            artistId: artist.id,\n                            count: artistRadioCount,\n                        },\n                        serverId: serverId,\n                    }),\n                    queryKey: queryKeys.player.fetch({ artistId: artist.id }),\n                });\n                if (artistRadioSongs && artistRadioSongs.length > 0) {\n                    player.addToQueueByData(artistRadioSongs, playType);\n                }\n            } catch (error) {\n                console.error('Failed to load track radio:', error);\n            }\n        },\n        [artist, artistRadioCount, player, queryClient, serverId],\n    );\n\n    const handlePlayArtistRadioNow = useCallback(() => {\n        handlePlayArtistRadio(Play.NOW);\n    }, [handlePlayArtistRadio]);\n\n    const handlePlayArtistRadioNext = useCallback(() => {\n        handlePlayArtistRadio(Play.NEXT);\n    }, [handlePlayArtistRadio]);\n\n    const handlePlayArtistRadioLast = useCallback(() => {\n        handlePlayArtistRadio(Play.LAST);\n    }, [handlePlayArtistRadio]);\n\n    const defaultPlayArtistRadioAction = useCallback(() => {\n        handlePlayArtistRadio(playButtonBehavior);\n    }, [handlePlayArtistRadio, playButtonBehavior]);\n\n    return (\n        <ContextMenu.Submenu>\n            <ContextMenu.SubmenuTarget>\n                <ContextMenu.Item\n                    disabled={disabled}\n                    leftIcon=\"radio\"\n                    onSelect={defaultPlayArtistRadioAction}\n                    rightIcon=\"arrowRightS\"\n                >\n                    {t('player.artistRadio', { postProcess: 'sentenceCase' })}\n                </ContextMenu.Item>\n            </ContextMenu.SubmenuTarget>\n            <ContextMenu.SubmenuContent>\n                <ContextMenu.Item leftIcon=\"mediaPlay\" onSelect={handlePlayArtistRadioNow}>\n                    {t('player.play', { postProcess: 'sentenceCase' })}\n                </ContextMenu.Item>\n                <ContextMenu.Item leftIcon=\"mediaPlayNext\" onSelect={handlePlayArtistRadioNext}>\n                    {t('player.addNext', { postProcess: 'sentenceCase' })}\n                </ContextMenu.Item>\n                <ContextMenu.Item leftIcon=\"mediaPlayLast\" onSelect={handlePlayArtistRadioLast}>\n                    {t('player.addLast', { postProcess: 'sentenceCase' })}\n                </ContextMenu.Item>\n            </ContextMenu.SubmenuContent>\n        </ContextMenu.Submenu>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/context-menu/actions/play-track-radio-action.tsx",
    "content": "import { useQueryClient } from '@tanstack/react-query';\nimport { useCallback } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport { queryKeys } from '/@/renderer/api/query-keys';\nimport { usePlayer } from '/@/renderer/features/player/context/player-context';\nimport { songsQueries } from '/@/renderer/features/songs/api/songs-api';\nimport { useCurrentServerId, usePlayButtonBehavior } from '/@/renderer/store';\nimport { ContextMenu } from '/@/shared/components/context-menu/context-menu';\nimport { Song } from '/@/shared/types/domain-types';\nimport { Play } from '/@/shared/types/types';\n\ninterface PlayTrackRadioActionProps {\n    disabled?: boolean;\n    song: Song;\n}\n\nexport const PlayTrackRadioAction = ({ disabled, song }: PlayTrackRadioActionProps) => {\n    const { t } = useTranslation();\n    const player = usePlayer();\n    const serverId = useCurrentServerId();\n    const queryClient = useQueryClient();\n    const playButtonBehavior = usePlayButtonBehavior();\n\n    const handlePlayTrackRadio = useCallback(\n        async (playType: Play) => {\n            if (!serverId || !song) return;\n\n            try {\n                const similarSongs = await queryClient.fetchQuery({\n                    ...songsQueries.similar({\n                        query: {\n                            songId: song.id,\n                        },\n                        serverId,\n                    }),\n                    queryKey: queryKeys.player.fetch({ similarSongs: song.id }),\n                });\n\n                if (similarSongs && similarSongs.length > 0) {\n                    player.addToQueueByData([song, ...similarSongs], playType);\n                }\n            } catch (error) {\n                console.error('Failed to load track radio:', error);\n            }\n        },\n        [player, queryClient, serverId, song],\n    );\n\n    const handlePlayTrackRadioNow = useCallback(() => {\n        handlePlayTrackRadio(Play.NOW);\n    }, [handlePlayTrackRadio]);\n\n    const handlePlayTrackRadioNext = useCallback(() => {\n        handlePlayTrackRadio(Play.NEXT);\n    }, [handlePlayTrackRadio]);\n\n    const handlePlayTrackRadioLast = useCallback(() => {\n        handlePlayTrackRadio(Play.LAST);\n    }, [handlePlayTrackRadio]);\n\n    const defaultPlayTrackRadioAction = useCallback(() => {\n        handlePlayTrackRadio(playButtonBehavior);\n    }, [handlePlayTrackRadio, playButtonBehavior]);\n\n    return (\n        <ContextMenu.Submenu>\n            <ContextMenu.SubmenuTarget>\n                <ContextMenu.Item\n                    disabled={disabled}\n                    leftIcon=\"radio\"\n                    onSelect={defaultPlayTrackRadioAction}\n                    rightIcon=\"arrowRightS\"\n                >\n                    {t('player.trackRadio', { postProcess: 'sentenceCase' })}\n                </ContextMenu.Item>\n            </ContextMenu.SubmenuTarget>\n            <ContextMenu.SubmenuContent>\n                <ContextMenu.Item leftIcon=\"mediaPlay\" onSelect={handlePlayTrackRadioNow}>\n                    {t('player.play', { postProcess: 'sentenceCase' })}\n                </ContextMenu.Item>\n                <ContextMenu.Item leftIcon=\"mediaPlayNext\" onSelect={handlePlayTrackRadioNext}>\n                    {t('player.addNext', { postProcess: 'sentenceCase' })}\n                </ContextMenu.Item>\n                <ContextMenu.Item leftIcon=\"mediaPlayLast\" onSelect={handlePlayTrackRadioLast}>\n                    {t('player.addLast', { postProcess: 'sentenceCase' })}\n                </ContextMenu.Item>\n            </ContextMenu.SubmenuContent>\n        </ContextMenu.Submenu>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/context-menu/actions/remove-from-playlist-action.tsx",
    "content": "import { closeAllModals, openModal } from '@mantine/modals';\nimport { useCallback, useMemo } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { useParams } from 'react-router';\n\nimport { useRemoveFromPlaylist } from '/@/renderer/features/playlists/mutations/remove-from-playlist-mutation';\nimport { useCurrentServerId } from '/@/renderer/store';\nimport { ContextMenu } from '/@/shared/components/context-menu/context-menu';\nimport { ConfirmModal } from '/@/shared/components/modal/modal';\nimport { Text } from '/@/shared/components/text/text';\nimport { toast } from '/@/shared/components/toast/toast';\nimport { Song } from '/@/shared/types/domain-types';\n\ninterface RemoveFromPlaylistActionProps {\n    items: Song[];\n}\n\nexport const RemoveFromPlaylistAction = ({ items }: RemoveFromPlaylistActionProps) => {\n    const { t } = useTranslation();\n    const serverId = useCurrentServerId();\n    const { playlistId } = useParams() as { playlistId?: string };\n    const removeFromPlaylistMutation = useRemoveFromPlaylist();\n\n    const { ids } = useMemo(() => {\n        const ids = items.map((item) => item.playlistItemId).filter((id) => id !== undefined);\n        return { ids };\n    }, [items]);\n\n    const handleRemoveFromPlaylist = useCallback(async () => {\n        if (ids.length === 0 || !serverId || !playlistId) return;\n\n        try {\n            await removeFromPlaylistMutation.mutateAsync({\n                apiClientProps: { serverId },\n                query: {\n                    id: playlistId,\n                    songId: ids,\n                },\n            });\n\n            toast.success({\n                message: t('action.removeFromPlaylist', { postProcess: 'sentenceCase' }),\n            });\n        } catch (err: any) {\n            toast.error({\n                message: err.message,\n                title: t('error.genericError', { postProcess: 'sentenceCase' }),\n            });\n        }\n\n        closeAllModals();\n    }, [ids, playlistId, removeFromPlaylistMutation, serverId, t]);\n\n    const openRemoveFromPlaylistModal = useCallback(() => {\n        if (ids.length === 0 || !playlistId) return;\n\n        openModal({\n            children: (\n                <ConfirmModal onConfirm={handleRemoveFromPlaylist}>\n                    <Text>{t('common.areYouSure', { postProcess: 'sentenceCase' })}</Text>\n                </ConfirmModal>\n            ),\n            title: t('action.removeFromPlaylist', { postProcess: 'sentenceCase' }),\n        });\n    }, [handleRemoveFromPlaylist, ids, playlistId, t]);\n\n    if (ids.length === 0 || !playlistId) return null;\n\n    return (\n        <ContextMenu.Item leftIcon=\"remove\" onSelect={openRemoveFromPlaylistModal}>\n            {t('action.removeFromPlaylist', { postProcess: 'sentenceCase' })}\n        </ContextMenu.Item>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/context-menu/actions/remove-from-queue-action.tsx",
    "content": "import { useCallback } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport { usePlayer } from '/@/renderer/features/player/context/player-context';\nimport { ContextMenu } from '/@/shared/components/context-menu/context-menu';\nimport { QueueSong } from '/@/shared/types/domain-types';\n\ninterface RemoveFromQueueActionProps {\n    items: QueueSong[];\n}\n\nexport const RemoveFromQueueAction = ({ items }: RemoveFromQueueActionProps) => {\n    const { t } = useTranslation();\n    const player = usePlayer();\n\n    const onSelect = useCallback(() => {\n        player.clearSelected(items);\n    }, [items, player]);\n\n    return (\n        <ContextMenu.Item leftIcon=\"remove\" onSelect={onSelect}>\n            {t('action.removeFromQueue', { postProcess: 'sentenceCase' })}\n        </ContextMenu.Item>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/context-menu/actions/set-favorite-action.tsx",
    "content": "import { useCallback } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport { useCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation';\nimport { useDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation';\nimport { useCurrentServerId } from '/@/renderer/store';\nimport { ContextMenu } from '/@/shared/components/context-menu/context-menu';\nimport { LibraryItem } from '/@/shared/types/domain-types';\n\ninterface SetFavoriteActionProps {\n    ids: string[];\n    itemType: LibraryItem;\n}\n\nexport const SetFavoriteAction = ({ ids, itemType }: SetFavoriteActionProps) => {\n    const { t } = useTranslation();\n    const serverId = useCurrentServerId();\n\n    const createFavoriteMutation = useCreateFavorite({});\n    const deleteFavoriteMutation = useDeleteFavorite({});\n\n    const handleAddToFavorites = useCallback(() => {\n        if (ids.length === 0 || !serverId) return;\n\n        createFavoriteMutation.mutate({\n            apiClientProps: { serverId },\n            query: {\n                id: ids,\n                type: itemType,\n            },\n        });\n    }, [createFavoriteMutation, ids, itemType, serverId]);\n\n    const handleRemoveFromFavorites = useCallback(() => {\n        if (ids.length === 0 || !serverId) return;\n\n        deleteFavoriteMutation.mutate({\n            apiClientProps: { serverId },\n            query: {\n                id: ids,\n                type: itemType,\n            },\n        });\n    }, [deleteFavoriteMutation, ids, itemType, serverId]);\n\n    return (\n        <ContextMenu.Submenu>\n            <ContextMenu.SubmenuTarget>\n                <ContextMenu.Item\n                    leftIcon=\"favorite\"\n                    onSelect={(e) => e.preventDefault()}\n                    rightIcon=\"arrowRightS\"\n                >\n                    {t('common.favorite', { postProcess: 'sentenceCase' })}\n                </ContextMenu.Item>\n            </ContextMenu.SubmenuTarget>\n            <ContextMenu.SubmenuContent>\n                <ContextMenu.Item leftIcon=\"favorite\" onSelect={handleAddToFavorites}>\n                    {t('action.addToFavorites', { postProcess: 'sentenceCase' })}\n                </ContextMenu.Item>\n                <ContextMenu.Item leftIcon=\"unfavorite\" onSelect={handleRemoveFromFavorites}>\n                    {t('action.removeFromFavorites', { postProcess: 'sentenceCase' })}\n                </ContextMenu.Item>\n            </ContextMenu.SubmenuContent>\n        </ContextMenu.Submenu>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/context-menu/actions/set-rating-action.tsx",
    "content": "import { useMemo } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport { useSetRating } from '/@/renderer/features/shared/hooks/use-set-rating';\nimport { useCurrentServer, useCurrentServerId, useShowRatings } from '/@/renderer/store';\nimport { ContextMenu } from '/@/shared/components/context-menu/context-menu';\nimport { Rating } from '/@/shared/components/rating/rating';\nimport { LibraryItem } from '/@/shared/types/domain-types';\nimport { ServerType } from '/@/shared/types/types';\n\ninterface SetRatingActionProps {\n    ids: string[];\n    itemType: LibraryItem;\n}\n\nexport const SetRatingAction = ({ ids, itemType }: SetRatingActionProps) => {\n    const { t } = useTranslation();\n    const server = useCurrentServer();\n    const serverId = useCurrentServerId();\n    const showRatings = useShowRatings();\n\n    const setRating = useSetRating();\n\n    const isRatingSupported = useMemo(() => {\n        return server?.type === ServerType.NAVIDROME || server?.type === ServerType.SUBSONIC;\n    }, [server?.type]);\n\n    const onRating = (rating: number) => {\n        setRating(serverId, ids, itemType, rating);\n    };\n\n    if (!showRatings || !isRatingSupported) {\n        return null;\n    }\n\n    return (\n        <ContextMenu.Submenu>\n            <ContextMenu.SubmenuTarget>\n                <ContextMenu.Item\n                    leftIcon=\"star\"\n                    onSelect={(e) => e.preventDefault()}\n                    rightIcon=\"arrowRightS\"\n                >\n                    {t('action.setRating', { postProcess: 'sentenceCase' })}\n                </ContextMenu.Item>\n            </ContextMenu.SubmenuTarget>\n            <ContextMenu.SubmenuContent>\n                <ContextMenu.Item onSelect={() => onRating(0)}>\n                    <Rating preventDefault={false} readOnly stopPropagation={false} value={0} />\n                </ContextMenu.Item>\n                <ContextMenu.Item onSelect={() => onRating(1)}>\n                    <Rating preventDefault={false} readOnly stopPropagation={false} value={1} />\n                </ContextMenu.Item>\n                <ContextMenu.Item onSelect={() => onRating(2)}>\n                    <Rating preventDefault={false} readOnly stopPropagation={false} value={2} />\n                </ContextMenu.Item>\n                <ContextMenu.Item onSelect={() => onRating(3)}>\n                    <Rating preventDefault={false} readOnly stopPropagation={false} value={3} />\n                </ContextMenu.Item>\n                <ContextMenu.Item onSelect={() => onRating(4)}>\n                    <Rating preventDefault={false} readOnly stopPropagation={false} value={4} />\n                </ContextMenu.Item>\n                <ContextMenu.Item onSelect={() => onRating(5)}>\n                    <Rating preventDefault={false} readOnly stopPropagation={false} value={5} />\n                </ContextMenu.Item>\n            </ContextMenu.SubmenuContent>\n        </ContextMenu.Submenu>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/context-menu/actions/share-action.tsx",
    "content": "import { openContextModal } from '@mantine/modals';\nimport { useCallback, useMemo } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport { ContextMenu } from '/@/shared/components/context-menu/context-menu';\nimport { LibraryItem } from '/@/shared/types/domain-types';\n\ninterface ShareActionProps {\n    ids: string[];\n    itemType: LibraryItem;\n}\n\nexport const ShareAction = ({ ids, itemType }: ShareActionProps) => {\n    const { t } = useTranslation();\n\n    const resourceType = useMemo(() => {\n        switch (itemType) {\n            case LibraryItem.ALBUM:\n                return 'album';\n            case LibraryItem.ALBUM_ARTIST:\n                return 'albumArtist';\n            case LibraryItem.FOLDER:\n                return 'folder';\n            case LibraryItem.PLAYLIST:\n                return 'playlist';\n            case LibraryItem.SONG:\n                return 'song';\n            default:\n                return 'song';\n        }\n    }, [itemType]);\n\n    const onSelect = useCallback(() => {\n        openContextModal({\n            innerProps: {\n                itemIds: ids,\n                resourceType,\n            },\n            modalKey: 'shareItem',\n            title: t('page.contextMenu.shareItem', { postProcess: 'titleCase' }),\n        });\n    }, [ids, resourceType, t]);\n\n    return (\n        <ContextMenu.Item leftIcon=\"share\" onSelect={onSelect}>\n            {t('page.contextMenu.shareItem', { postProcess: 'sentenceCase' })}\n        </ContextMenu.Item>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/context-menu/actions/show-in-file-explorer-action.tsx",
    "content": "import isElectron from 'is-electron';\nimport { useCallback } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport { ContextMenu } from '/@/shared/components/context-menu/context-menu';\nimport { toast } from '/@/shared/components/toast/toast';\nimport { QueueSong, Song } from '/@/shared/types/domain-types';\n\ninterface ShowInFileExplorerActionProps {\n    items: QueueSong[] | Song[];\n}\n\nconst utils = isElectron() ? window.api.utils : null;\n\nexport const ShowInFileExplorerAction = ({ items }: ShowInFileExplorerActionProps) => {\n    const { t } = useTranslation();\n\n    const onSelect = useCallback(async () => {\n        if (!utils) {\n            return;\n        }\n\n        const firstItem = items[0];\n        if (!firstItem?.path) {\n            return;\n        }\n\n        try {\n            await utils.openItem(firstItem.path);\n        } catch (error) {\n            toast.error({\n                message: (error as Error).message,\n                title: t('error.openError', {\n                    postProcess: 'sentenceCase',\n                }),\n            });\n        }\n    }, [items, t]);\n\n    if (!utils) {\n        return null;\n    }\n\n    const firstItem = items[0];\n    const hasPath = firstItem?.path !== null;\n    const isDisabled = items.length > 1 || !hasPath;\n\n    return (\n        <ContextMenu.Item disabled={isDisabled} leftIcon=\"folder\" onSelect={onSelect}>\n            {t('page.itemDetail.openFile', { postProcess: 'sentenceCase' })}\n        </ContextMenu.Item>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/context-menu/actions/shuffle-items-action.tsx",
    "content": "import { useCallback } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport { usePlayer } from '/@/renderer/features/player/context/player-context';\nimport { ContextMenu } from '/@/shared/components/context-menu/context-menu';\nimport { QueueSong } from '/@/shared/types/domain-types';\n\ninterface ShuffleItemsActionProps {\n    items: QueueSong[];\n}\n\nexport const ShuffleItemsAction = ({ items }: ShuffleItemsActionProps) => {\n    const { t } = useTranslation();\n    const player = usePlayer();\n\n    const handleShuffleSelected = useCallback(() => {\n        player.shuffleSelected(items);\n    }, [items, player]);\n\n    const handleShuffleAll = useCallback(() => {\n        player.shuffleAll();\n    }, [player]);\n\n    return (\n        <ContextMenu.Submenu>\n            <ContextMenu.SubmenuTarget>\n                <ContextMenu.Item\n                    leftIcon=\"mediaShuffle\"\n                    onSelect={(e) => e.preventDefault()}\n                    rightIcon=\"arrowRightS\"\n                >\n                    {t('action.shuffle', { postProcess: 'sentenceCase' })}\n                </ContextMenu.Item>\n            </ContextMenu.SubmenuTarget>\n            <ContextMenu.SubmenuContent>\n                <ContextMenu.Item onSelect={handleShuffleSelected}>\n                    {t('action.shuffleSelected', { postProcess: 'sentenceCase' })}\n                </ContextMenu.Item>\n                <ContextMenu.Item onSelect={handleShuffleAll}>\n                    {t('action.shuffleAll', { postProcess: 'sentenceCase' })}\n                </ContextMenu.Item>\n            </ContextMenu.SubmenuContent>\n        </ContextMenu.Submenu>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/context-menu/components/context-menu-preview.module.css",
    "content": ".container {\n    position: relative;\n    width: 100%;\n}\n\n.divider {\n    height: 1px;\n    margin: var(--theme-spacing-xs) 0;\n    background: none;\n    border: none;\n    border-top: 1px solid var(--theme-colors-border);\n}\n\n.preview {\n    display: flex;\n    align-items: center;\n    padding: var(--theme-spacing-sm) var(--theme-spacing-md);\n    border-radius: var(--theme-radius-sm);\n}\n\n.content {\n    display: flex;\n    gap: var(--theme-spacing-sm);\n    align-items: center;\n    width: 100%;\n}\n\n.image-container {\n    position: relative;\n    flex-shrink: 0;\n    width: 40px;\n    height: 40px;\n    overflow: hidden;\n    border-radius: var(--theme-radius-sm);\n    box-shadow: 0 2px 8px rgb(0 0 0 / 20%);\n}\n\n.image {\n    width: 100%;\n    height: 100%;\n    object-fit: cover;\n}\n\n.image-overlay {\n    position: absolute;\n    inset: 0;\n    pointer-events: none;\n    background: linear-gradient(135deg, rgb(255 255 255 / 10%) 0%, rgb(0 0 0 / 10%) 100%);\n}\n\n.icon-container {\n    display: flex;\n    flex-shrink: 0;\n    align-items: center;\n    justify-content: center;\n    width: 40px;\n    height: 40px;\n    background: linear-gradient(\n        135deg,\n        var(--theme-colors-surface) 0%,\n        var(--theme-colors-background) 100%\n    );\n    border: 1px solid var(--theme-colors-border);\n    border-radius: var(--theme-radius-sm);\n    box-shadow: 0 2px 8px rgb(0 0 0 / 20%);\n}\n\n.text-container {\n    display: flex;\n    flex: 1;\n    flex-direction: column;\n    gap: 2px;\n    min-width: 0;\n}\n\n.name {\n    overflow: hidden;\n    text-overflow: ellipsis;\n    font-size: var(--theme-font-size-sm);\n    font-weight: 600;\n    line-height: 1.4;\n    color: var(--theme-colors-foreground);\n    white-space: nowrap;\n}\n\n.count {\n    font-size: var(--theme-font-size-xs);\n    font-weight: 500;\n    line-height: 1.2;\n    color: var(--theme-colors-foreground-muted);\n}\n"
  },
  {
    "path": "src/renderer/features/context-menu/components/context-menu-preview.tsx",
    "content": "import { useTranslation } from 'react-i18next';\n\nimport styles from './context-menu-preview.module.css';\n\nimport { useItemImageUrl } from '/@/renderer/components/item-image/item-image';\nimport { Icon } from '/@/shared/components/icon/icon';\nimport { Text } from '/@/shared/components/text/text';\nimport { LibraryItem } from '/@/shared/types/domain-types';\n\ninterface ContextMenuPreviewProps {\n    items: unknown[];\n    itemType?: LibraryItem;\n}\n\nconst getItemName = (item: unknown): string => {\n    if (item && typeof item === 'object') {\n        if ('name' in item && typeof item.name === 'string') {\n            return item.name;\n        }\n        if ('title' in item && typeof item.title === 'string') {\n            return item.title;\n        }\n    }\n    return 'Item';\n};\n\nconst getItemImage = (item: unknown): null | string => {\n    if (item && typeof item === 'object') {\n        if ('imageId' in item && typeof item.imageId === 'string') {\n            return item.imageId;\n        }\n\n        if ('imageUrl' in item && typeof item.imageUrl === 'string') {\n            return item.imageUrl;\n        }\n    }\n    return null;\n};\n\nexport const ContextMenuPreview = ({ items, itemType }: ContextMenuPreviewProps) => {\n    const { t } = useTranslation();\n    const itemCount = items.length;\n    const firstItem = items[0];\n    const itemName = firstItem ? getItemName(firstItem) : 'Item';\n    const itemImage = firstItem ? getItemImage(firstItem) : null;\n    const isMultiple = itemCount > 1;\n\n    const imageUrl = useItemImageUrl({\n        id: (firstItem as { imageId?: string })?.imageId,\n        itemType: itemType || LibraryItem.SONG,\n        serverId: (firstItem as { _serverId?: string })?._serverId,\n        type: 'table',\n    });\n\n    if (itemCount === 0) {\n        return null;\n    }\n\n    return (\n        <div className={styles.container}>\n            <div className={styles.divider} />\n            <div className={styles.preview}>\n                <div className={styles.content}>\n                    {itemImage ? (\n                        <div className={styles.imageContainer}>\n                            <img alt={itemName} className={styles.image} src={imageUrl} />\n                            <div className={styles.imageOverlay} />\n                        </div>\n                    ) : (\n                        <div className={styles.iconContainer}>\n                            {itemType === LibraryItem.ALBUM && <Icon icon=\"album\" size=\"md\" />}\n                            {itemType === LibraryItem.SONG && <Icon icon=\"itemSong\" size=\"md\" />}\n                            {itemType === LibraryItem.ALBUM_ARTIST && (\n                                <Icon icon=\"artist\" size=\"md\" />\n                            )}\n                            {itemType === LibraryItem.ARTIST && <Icon icon=\"artist\" size=\"md\" />}\n                            {itemType === LibraryItem.PLAYLIST && (\n                                <Icon icon=\"playlist\" size=\"md\" />\n                            )}\n                            {itemType === LibraryItem.GENRE && <Icon icon=\"genre\" size=\"md\" />}\n                            {itemType === LibraryItem.FOLDER && <Icon icon=\"folder\" size=\"md\" />}\n                            {!itemType && <Icon icon=\"library\" size=\"md\" />}\n                        </div>\n                    )}\n                    <div className={styles.textContainer}>\n                        <Text className={styles.name} isNoSelect>\n                            {itemName}\n                        </Text>\n                        {isMultiple && (\n                            <Text className={styles.count} isNoSelect>\n                                +{t('common.itemsMore', { count: itemCount - 1 })}\n                            </Text>\n                        )}\n                    </div>\n                </div>\n            </div>\n        </div>\n    );\n};\n\nContextMenuPreview.displayName = 'ContextMenuPreview';\n"
  },
  {
    "path": "src/renderer/features/context-menu/context-menu-controller.tsx",
    "content": "import { useQueryClient } from '@tanstack/react-query';\nimport { useEffect, useRef } from 'react';\nimport { createCallable } from 'react-call';\nimport { useParams } from 'react-router';\n\nimport { AlbumArtistContextMenu } from '/@/renderer/features/context-menu/menus/album-artist-context-menu';\nimport { AlbumContextMenu } from '/@/renderer/features/context-menu/menus/album-context-menu';\nimport { ArtistContextMenu } from '/@/renderer/features/context-menu/menus/artist-context-menu';\nimport { FolderContextMenu } from '/@/renderer/features/context-menu/menus/folder-context-menu';\nimport { GenreContextMenu } from '/@/renderer/features/context-menu/menus/genre-context-menu';\nimport { PlaylistContextMenu } from '/@/renderer/features/context-menu/menus/playlist-context-menu';\nimport { PlaylistSongContextMenu } from '/@/renderer/features/context-menu/menus/playlist-song-context-menu';\nimport { QueueContextMenu } from '/@/renderer/features/context-menu/menus/queue-context-menu';\nimport { SongContextMenu } from '/@/renderer/features/context-menu/menus/song-context-menu';\nimport { ContextMenu } from '/@/shared/components/context-menu/context-menu';\nimport {\n    Album,\n    AlbumArtist,\n    Artist,\n    Folder,\n    Genre,\n    LibraryItem,\n    Playlist,\n    QueueSong,\n    Song,\n} from '/@/shared/types/domain-types';\n\ninterface ContextMenuControllerProps {\n    cmd: ContextMenuCommand;\n    event: React.MouseEvent<unknown>;\n}\n\nexport const ContextMenuController = createCallable<ContextMenuControllerProps, void>(\n    ({ call, cmd, event }) => {\n        const { libraryId } = useParams() as { libraryId: string };\n        const queryClient = useQueryClient();\n\n        const triggerRef = useRef<HTMLDivElement>(null);\n        const isExecuted = useRef<boolean>(false);\n\n        useEffect(() => {\n            if (isExecuted.current) {\n                return;\n            }\n\n            if (!triggerRef.current) {\n                return;\n            }\n\n            const handleContextMenu = () => {\n                event.preventDefault();\n\n                triggerRef.current?.dispatchEvent(\n                    new MouseEvent('contextmenu', {\n                        bubbles: true,\n                        clientX: event.clientX,\n                        clientY: event.clientY,\n                    }),\n                );\n            };\n\n            isExecuted.current = true;\n\n            handleContextMenu();\n        }, [call, cmd, event, event.clientX, event.clientY, libraryId, queryClient]);\n\n        return (\n            <ContextMenu>\n                <ContextMenu.Target>\n                    <div\n                        ref={triggerRef}\n                        style={{\n                            height: 0,\n                            left: 0,\n                            pointerEvents: 'none',\n                            position: 'absolute',\n                            top: 0,\n                            userSelect: 'none',\n                            width: 0,\n                        }}\n                    />\n                </ContextMenu.Target>\n                {cmd.type === LibraryItem.QUEUE_SONG && <QueueContextMenu {...cmd} />}\n                {cmd.type === LibraryItem.ALBUM && <AlbumContextMenu {...cmd} />}\n                {cmd.type === LibraryItem.ALBUM_ARTIST && <AlbumArtistContextMenu {...cmd} />}\n                {cmd.type === LibraryItem.ARTIST && <ArtistContextMenu {...cmd} />}\n                {cmd.type === LibraryItem.FOLDER && <FolderContextMenu {...cmd} />}\n                {cmd.type === LibraryItem.GENRE && <GenreContextMenu {...cmd} />}\n                {cmd.type === LibraryItem.PLAYLIST && <PlaylistContextMenu {...cmd} />}\n                {cmd.type === LibraryItem.PLAYLIST_SONG && <PlaylistSongContextMenu {...cmd} />}\n                {cmd.type === LibraryItem.SONG && <SongContextMenu {...cmd} />}\n            </ContextMenu>\n        );\n    },\n);\n\nexport type ContextMenuCommand =\n    | AlbumArtistContextMenuProps\n    | AlbumContextMenuProps\n    | ArtistContextMenuProps\n    | FolderContextMenuProps\n    | GenreContextMenuProps\n    | PlaylistContextMenuProps\n    | PlaylistSongContextMenuProps\n    | QueueSongContextMenuProps\n    | SongContextMenuProps;\n\ntype AlbumArtistContextMenuProps = {\n    items: AlbumArtist[];\n    type: LibraryItem.ALBUM_ARTIST;\n};\n\ntype AlbumContextMenuProps = {\n    items: Album[];\n    type: LibraryItem.ALBUM;\n};\n\ntype ArtistContextMenuProps = {\n    items: Artist[];\n    type: LibraryItem.ARTIST;\n};\n\ntype FolderContextMenuProps = {\n    items: Folder[];\n    type: LibraryItem.FOLDER;\n};\n\ntype GenreContextMenuProps = {\n    items: Genre[];\n    type: LibraryItem.GENRE;\n};\n\ntype PlaylistContextMenuProps = {\n    items: Playlist[];\n    type: LibraryItem.PLAYLIST;\n};\n\ntype PlaylistSongContextMenuProps = {\n    items: Song[];\n    type: LibraryItem.PLAYLIST_SONG;\n};\n\ntype QueueSongContextMenuProps = {\n    items: QueueSong[];\n    type: LibraryItem.QUEUE_SONG;\n};\n\ntype SongContextMenuProps = {\n    items: Song[];\n    type: LibraryItem.SONG;\n};\n"
  },
  {
    "path": "src/renderer/features/context-menu/menus/album-artist-context-menu.tsx",
    "content": "import { useMemo } from 'react';\n\nimport { AddToPlaylistAction } from '/@/renderer/features/context-menu/actions/add-to-playlist-action';\nimport { DownloadAction } from '/@/renderer/features/context-menu/actions/download-action';\nimport { GetInfoAction } from '/@/renderer/features/context-menu/actions/get-info-action';\nimport { GoToAction } from '/@/renderer/features/context-menu/actions/go-to-action';\nimport { PlayAction } from '/@/renderer/features/context-menu/actions/play-action';\nimport { PlayArtistRadioAction } from '/@/renderer/features/context-menu/actions/play-artist-radio-action';\nimport { SetFavoriteAction } from '/@/renderer/features/context-menu/actions/set-favorite-action';\nimport { SetRatingAction } from '/@/renderer/features/context-menu/actions/set-rating-action';\nimport { ShareAction } from '/@/renderer/features/context-menu/actions/share-action';\nimport { ContextMenuPreview } from '/@/renderer/features/context-menu/components/context-menu-preview';\nimport { ContextMenu } from '/@/shared/components/context-menu/context-menu';\nimport { AlbumArtist, LibraryItem } from '/@/shared/types/domain-types';\n\ninterface AlbumArtistContextMenuProps {\n    items: AlbumArtist[];\n    type: LibraryItem.ALBUM_ARTIST;\n}\n\nexport const AlbumArtistContextMenu = ({ items, type }: AlbumArtistContextMenuProps) => {\n    const { ids } = useMemo(() => {\n        const ids = items.map((item) => item.id);\n        return { ids };\n    }, [items]);\n\n    return (\n        <ContextMenu.Content\n            bottomStickyContent={<ContextMenuPreview items={items} itemType={type} />}\n        >\n            <PlayAction ids={ids} itemType={LibraryItem.ALBUM_ARTIST} />\n            <PlayArtistRadioAction artist={items[0]} disabled={items.length > 1} />\n            <ContextMenu.Divider />\n            <AddToPlaylistAction items={ids} itemType={LibraryItem.ALBUM_ARTIST} />\n            <ContextMenu.Divider />\n            <SetFavoriteAction ids={ids} itemType={LibraryItem.ALBUM_ARTIST} />\n            <SetRatingAction ids={ids} itemType={LibraryItem.ALBUM_ARTIST} />\n            <ContextMenu.Divider />\n            <DownloadAction ids={ids} />\n            <ShareAction ids={ids} itemType={LibraryItem.ALBUM_ARTIST} />\n            <ContextMenu.Divider />\n            <GoToAction items={items} />\n            <ContextMenu.Divider />\n            <GetInfoAction disabled={items.length === 0} items={items} />\n        </ContextMenu.Content>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/context-menu/menus/album-context-menu.tsx",
    "content": "import { useMemo } from 'react';\n\nimport { AddToPlaylistAction } from '/@/renderer/features/context-menu/actions/add-to-playlist-action';\nimport { DownloadAction } from '/@/renderer/features/context-menu/actions/download-action';\nimport { GetInfoAction } from '/@/renderer/features/context-menu/actions/get-info-action';\nimport { GoToAction } from '/@/renderer/features/context-menu/actions/go-to-action';\nimport { PlayAction } from '/@/renderer/features/context-menu/actions/play-action';\nimport { PlayAlbumRadioAction } from '/@/renderer/features/context-menu/actions/play-album-radio-action';\nimport { SetFavoriteAction } from '/@/renderer/features/context-menu/actions/set-favorite-action';\nimport { SetRatingAction } from '/@/renderer/features/context-menu/actions/set-rating-action';\nimport { ShareAction } from '/@/renderer/features/context-menu/actions/share-action';\nimport { ContextMenuPreview } from '/@/renderer/features/context-menu/components/context-menu-preview';\nimport { ContextMenu } from '/@/shared/components/context-menu/context-menu';\nimport { Album, LibraryItem } from '/@/shared/types/domain-types';\n\ninterface AlbumContextMenuProps {\n    items: Album[];\n    type: LibraryItem.ALBUM;\n}\n\nexport const AlbumContextMenu = ({ items, type }: AlbumContextMenuProps) => {\n    const { ids } = useMemo(() => {\n        const ids = items.map((item) => item.id);\n        return { ids };\n    }, [items]);\n\n    return (\n        <ContextMenu.Content\n            bottomStickyContent={<ContextMenuPreview items={items} itemType={type} />}\n        >\n            <PlayAction ids={ids} itemType={LibraryItem.ALBUM} />\n            <PlayAlbumRadioAction album={items[0]} disabled={items.length > 1} />\n            <ContextMenu.Divider />\n            <AddToPlaylistAction items={ids} itemType={LibraryItem.ALBUM} />\n            <ContextMenu.Divider />\n            <SetFavoriteAction ids={ids} itemType={LibraryItem.ALBUM} />\n            <SetRatingAction ids={ids} itemType={LibraryItem.ALBUM} />\n            <ContextMenu.Divider />\n            <DownloadAction ids={ids} />\n            <ShareAction ids={ids} itemType={LibraryItem.ALBUM} />\n            <ContextMenu.Divider />\n            <GoToAction items={items} />\n            <ContextMenu.Divider />\n            <GetInfoAction disabled={items.length === 0} items={items} />\n        </ContextMenu.Content>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/context-menu/menus/artist-context-menu.tsx",
    "content": "import { useMemo } from 'react';\n\nimport { AddToPlaylistAction } from '/@/renderer/features/context-menu/actions/add-to-playlist-action';\nimport { DownloadAction } from '/@/renderer/features/context-menu/actions/download-action';\nimport { GetInfoAction } from '/@/renderer/features/context-menu/actions/get-info-action';\nimport { GoToAction } from '/@/renderer/features/context-menu/actions/go-to-action';\nimport { PlayAction } from '/@/renderer/features/context-menu/actions/play-action';\nimport { PlayArtistRadioAction } from '/@/renderer/features/context-menu/actions/play-artist-radio-action';\nimport { SetFavoriteAction } from '/@/renderer/features/context-menu/actions/set-favorite-action';\nimport { SetRatingAction } from '/@/renderer/features/context-menu/actions/set-rating-action';\nimport { ShareAction } from '/@/renderer/features/context-menu/actions/share-action';\nimport { ContextMenuPreview } from '/@/renderer/features/context-menu/components/context-menu-preview';\nimport { ContextMenu } from '/@/shared/components/context-menu/context-menu';\nimport { Artist, LibraryItem } from '/@/shared/types/domain-types';\n\ninterface ArtistContextMenuProps {\n    items: Artist[];\n    type: LibraryItem.ARTIST;\n}\n\nexport const ArtistContextMenu = ({ items, type }: ArtistContextMenuProps) => {\n    const { ids } = useMemo(() => {\n        const ids = items.map((item) => item.id);\n        return { ids };\n    }, [items]);\n\n    return (\n        <ContextMenu.Content\n            bottomStickyContent={<ContextMenuPreview items={items} itemType={type} />}\n        >\n            <PlayAction ids={ids} itemType={LibraryItem.ARTIST} />\n            <PlayArtistRadioAction artist={items[0]} disabled={items.length > 1} />\n            <ContextMenu.Divider />\n            <AddToPlaylistAction items={ids} itemType={LibraryItem.ARTIST} />\n            <ContextMenu.Divider />\n            <SetFavoriteAction ids={ids} itemType={LibraryItem.ARTIST} />\n            <SetRatingAction ids={ids} itemType={LibraryItem.ARTIST} />\n            <ContextMenu.Divider />\n            <DownloadAction ids={ids} />\n            <ShareAction ids={ids} itemType={LibraryItem.ARTIST} />\n            <ContextMenu.Divider />\n            <GoToAction items={items} />\n            <ContextMenu.Divider />\n            <GetInfoAction disabled={items.length === 0} items={items} />\n        </ContextMenu.Content>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/context-menu/menus/folder-context-menu.tsx",
    "content": "import { useMemo } from 'react';\n\nimport { AddToPlaylistAction } from '/@/renderer/features/context-menu/actions/add-to-playlist-action';\nimport { DownloadAction } from '/@/renderer/features/context-menu/actions/download-action';\nimport { PlayAction } from '/@/renderer/features/context-menu/actions/play-action';\nimport { ShareAction } from '/@/renderer/features/context-menu/actions/share-action';\nimport { ContextMenuPreview } from '/@/renderer/features/context-menu/components/context-menu-preview';\nimport { ContextMenu } from '/@/shared/components/context-menu/context-menu';\nimport { Folder, LibraryItem } from '/@/shared/types/domain-types';\n\ninterface FolderContextMenuProps {\n    items: Folder[];\n    type: LibraryItem.FOLDER;\n}\n\nexport const FolderContextMenu = ({ items, type }: FolderContextMenuProps) => {\n    const { ids } = useMemo(() => {\n        const ids = items.map((item) => item.id);\n        return { ids };\n    }, [items]);\n\n    return (\n        <ContextMenu.Content\n            bottomStickyContent={<ContextMenuPreview items={items} itemType={type} />}\n        >\n            <PlayAction ids={ids} itemType={LibraryItem.FOLDER} />\n            <ContextMenu.Divider />\n            <AddToPlaylistAction items={ids} itemType={LibraryItem.FOLDER} />\n            <ContextMenu.Divider />\n            <DownloadAction ids={ids} />\n            <ShareAction ids={ids} itemType={LibraryItem.FOLDER} />\n        </ContextMenu.Content>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/context-menu/menus/genre-context-menu.tsx",
    "content": "import { useMemo } from 'react';\n\nimport { AddToPlaylistAction } from '/@/renderer/features/context-menu/actions/add-to-playlist-action';\nimport { PlayAction } from '/@/renderer/features/context-menu/actions/play-action';\nimport { ContextMenuPreview } from '/@/renderer/features/context-menu/components/context-menu-preview';\nimport { ContextMenu } from '/@/shared/components/context-menu/context-menu';\nimport { Genre, LibraryItem } from '/@/shared/types/domain-types';\n\ninterface GenreContextMenuProps {\n    items: Genre[];\n    type: LibraryItem.GENRE;\n}\n\nexport const GenreContextMenu = ({ items, type }: GenreContextMenuProps) => {\n    const { ids } = useMemo(() => {\n        const ids = items.map((item) => item.id);\n        return { ids };\n    }, [items]);\n\n    return (\n        <ContextMenu.Content\n            bottomStickyContent={<ContextMenuPreview items={items} itemType={type} />}\n        >\n            <PlayAction ids={ids} itemType={LibraryItem.ALBUM} />\n            <ContextMenu.Divider />\n            <AddToPlaylistAction items={ids} itemType={LibraryItem.ALBUM} />\n        </ContextMenu.Content>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/context-menu/menus/playlist-context-menu.tsx",
    "content": "import { useMemo } from 'react';\n\nimport { AddToPlaylistAction } from '/@/renderer/features/context-menu/actions/add-to-playlist-action';\nimport { DeletePlaylistAction } from '/@/renderer/features/context-menu/actions/delete-playlist-action';\nimport { EditPlaylistAction } from '/@/renderer/features/context-menu/actions/edit-playlist-action';\nimport { GetInfoAction } from '/@/renderer/features/context-menu/actions/get-info-action';\nimport { PlayAction } from '/@/renderer/features/context-menu/actions/play-action';\nimport { ContextMenuPreview } from '/@/renderer/features/context-menu/components/context-menu-preview';\nimport { usePermissions } from '/@/renderer/store';\nimport { ContextMenu } from '/@/shared/components/context-menu/context-menu';\nimport { LibraryItem, Playlist } from '/@/shared/types/domain-types';\n\ninterface PlaylistContextMenuProps {\n    items: Playlist[];\n    type: LibraryItem.PLAYLIST;\n}\n\nexport const PlaylistContextMenu = ({ items, type }: PlaylistContextMenuProps) => {\n    const { ids } = useMemo(() => {\n        const ids = items.map((item) => item.id);\n        return { ids };\n    }, [items]);\n\n    const { userId, ...permissions } = usePermissions();\n\n    const canEditPublic = permissions.playlists.editPublic;\n\n    const includesNonOwnedPublic = items.some((item) => item.public && item.ownerId !== userId);\n\n    const canEditPlaylist = canEditPublic || !includesNonOwnedPublic;\n    const canDeletePlaylist = canEditPublic || !includesNonOwnedPublic;\n\n    return (\n        <ContextMenu.Content\n            bottomStickyContent={<ContextMenuPreview items={items} itemType={type} />}\n        >\n            <PlayAction ids={ids} itemType={LibraryItem.PLAYLIST} />\n            <ContextMenu.Divider />\n            <AddToPlaylistAction items={ids} itemType={LibraryItem.PLAYLIST} />\n            <ContextMenu.Divider />\n            <GetInfoAction disabled={items.length === 0} items={items} />\n            <ContextMenu.Divider />\n            <EditPlaylistAction disabled={!canEditPlaylist} items={items} />\n            <DeletePlaylistAction disabled={!canDeletePlaylist} items={items} />\n        </ContextMenu.Content>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/context-menu/menus/playlist-song-context-menu.tsx",
    "content": "import { useMemo } from 'react';\n\nimport { AddToPlaylistAction } from '/@/renderer/features/context-menu/actions/add-to-playlist-action';\nimport { DownloadAction } from '/@/renderer/features/context-menu/actions/download-action';\nimport { GetInfoAction } from '/@/renderer/features/context-menu/actions/get-info-action';\nimport { GoToAction } from '/@/renderer/features/context-menu/actions/go-to-action';\nimport { PlayAction } from '/@/renderer/features/context-menu/actions/play-action';\nimport { PlayTrackRadioAction } from '/@/renderer/features/context-menu/actions/play-track-radio-action';\nimport { RemoveFromPlaylistAction } from '/@/renderer/features/context-menu/actions/remove-from-playlist-action';\nimport { SetFavoriteAction } from '/@/renderer/features/context-menu/actions/set-favorite-action';\nimport { SetRatingAction } from '/@/renderer/features/context-menu/actions/set-rating-action';\nimport { ShareAction } from '/@/renderer/features/context-menu/actions/share-action';\nimport { ShowInFileExplorerAction } from '/@/renderer/features/context-menu/actions/show-in-file-explorer-action';\nimport { ContextMenuPreview } from '/@/renderer/features/context-menu/components/context-menu-preview';\nimport { ContextMenu } from '/@/shared/components/context-menu/context-menu';\nimport { LibraryItem, Song } from '/@/shared/types/domain-types';\n\ninterface PlaylistSongContextMenuProps {\n    items: Song[];\n    type: LibraryItem.PLAYLIST_SONG;\n}\n\nexport const PlaylistSongContextMenu = ({ items, type }: PlaylistSongContextMenuProps) => {\n    const { ids } = useMemo(() => {\n        const ids = items.map((item) => item.id);\n        return { ids };\n    }, [items]);\n\n    return (\n        <ContextMenu.Content\n            bottomStickyContent={<ContextMenuPreview items={items} itemType={type} />}\n        >\n            <PlayAction ids={ids} itemType={type} songs={items} />\n            <PlayTrackRadioAction disabled={items.length > 1} song={items[0]} />\n            <ContextMenu.Divider />\n            <RemoveFromPlaylistAction items={items} />\n            <ContextMenu.Divider />\n            <AddToPlaylistAction items={ids} itemType={type} />\n            <ContextMenu.Divider />\n            <SetFavoriteAction ids={ids} itemType={type} />\n            <SetRatingAction ids={ids} itemType={type} />\n            <ContextMenu.Divider />\n            <DownloadAction ids={ids} />\n            <ShareAction ids={ids} itemType={type} />\n            <ContextMenu.Divider />\n            <GoToAction items={items} />\n            <ShowInFileExplorerAction items={items} />\n            <ContextMenu.Divider />\n            <GetInfoAction disabled={items.length === 0} items={items} />\n        </ContextMenu.Content>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/context-menu/menus/queue-context-menu.tsx",
    "content": "import { useMemo } from 'react';\n\nimport { AddToPlaylistAction } from '/@/renderer/features/context-menu/actions/add-to-playlist-action';\nimport { DownloadAction } from '/@/renderer/features/context-menu/actions/download-action';\nimport { GetInfoAction } from '/@/renderer/features/context-menu/actions/get-info-action';\nimport { GoToAction } from '/@/renderer/features/context-menu/actions/go-to-action';\nimport { MoveQueueItemsAction } from '/@/renderer/features/context-menu/actions/move-queue-items-action';\nimport { PlayTrackRadioAction } from '/@/renderer/features/context-menu/actions/play-track-radio-action';\nimport { RemoveFromQueueAction } from '/@/renderer/features/context-menu/actions/remove-from-queue-action';\nimport { SetFavoriteAction } from '/@/renderer/features/context-menu/actions/set-favorite-action';\nimport { SetRatingAction } from '/@/renderer/features/context-menu/actions/set-rating-action';\nimport { ShareAction } from '/@/renderer/features/context-menu/actions/share-action';\nimport { ShowInFileExplorerAction } from '/@/renderer/features/context-menu/actions/show-in-file-explorer-action';\nimport { ShuffleItemsAction } from '/@/renderer/features/context-menu/actions/shuffle-items-action';\nimport { ContextMenuPreview } from '/@/renderer/features/context-menu/components/context-menu-preview';\nimport { ContextMenu } from '/@/shared/components/context-menu/context-menu';\nimport { LibraryItem, QueueSong } from '/@/shared/types/domain-types';\n\ninterface QueueContextMenuProps {\n    items: QueueSong[];\n}\n\nexport const QueueContextMenu = ({ items }: QueueContextMenuProps) => {\n    const { ids } = useMemo(() => {\n        const ids = items.map((item) => item.id);\n        return { ids };\n    }, [items]);\n\n    return (\n        <ContextMenu.Content\n            bottomStickyContent={<ContextMenuPreview items={items} itemType={LibraryItem.SONG} />}\n        >\n            <RemoveFromQueueAction items={items} />\n            <ContextMenu.Divider />\n            <MoveQueueItemsAction items={items} />\n            <ShuffleItemsAction items={items} />\n            <ContextMenu.Divider />\n            <PlayTrackRadioAction disabled={items.length > 1} song={items[0]} />\n            <ContextMenu.Divider />\n            <AddToPlaylistAction items={ids} itemType={LibraryItem.SONG} />\n            <ContextMenu.Divider />\n            <SetFavoriteAction ids={ids} itemType={LibraryItem.SONG} />\n            <SetRatingAction ids={ids} itemType={LibraryItem.SONG} />\n            <ContextMenu.Divider />\n            <DownloadAction ids={ids} />\n            <ShareAction ids={ids} itemType={LibraryItem.SONG} />\n            <ContextMenu.Divider />\n            <GoToAction items={items} />\n            <ShowInFileExplorerAction items={items} />\n            <ContextMenu.Divider />\n            <GetInfoAction disabled={items.length === 0} items={items} />\n        </ContextMenu.Content>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/context-menu/menus/song-context-menu.tsx",
    "content": "import { useMemo } from 'react';\n\nimport { AddToPlaylistAction } from '/@/renderer/features/context-menu/actions/add-to-playlist-action';\nimport { DownloadAction } from '/@/renderer/features/context-menu/actions/download-action';\nimport { GetInfoAction } from '/@/renderer/features/context-menu/actions/get-info-action';\nimport { GoToAction } from '/@/renderer/features/context-menu/actions/go-to-action';\nimport { PlayAction } from '/@/renderer/features/context-menu/actions/play-action';\nimport { PlayTrackRadioAction } from '/@/renderer/features/context-menu/actions/play-track-radio-action';\nimport { SetFavoriteAction } from '/@/renderer/features/context-menu/actions/set-favorite-action';\nimport { SetRatingAction } from '/@/renderer/features/context-menu/actions/set-rating-action';\nimport { ShareAction } from '/@/renderer/features/context-menu/actions/share-action';\nimport { ShowInFileExplorerAction } from '/@/renderer/features/context-menu/actions/show-in-file-explorer-action';\nimport { ContextMenuPreview } from '/@/renderer/features/context-menu/components/context-menu-preview';\nimport { ContextMenu } from '/@/shared/components/context-menu/context-menu';\nimport { LibraryItem, Song } from '/@/shared/types/domain-types';\n\ninterface SongContextMenuProps {\n    items: Song[];\n    type: LibraryItem.SONG;\n}\n\nexport const SongContextMenu = ({ items, type }: SongContextMenuProps) => {\n    const { ids } = useMemo(() => {\n        const ids = items.map((item) => item.id);\n        return { ids };\n    }, [items]);\n\n    return (\n        <ContextMenu.Content\n            bottomStickyContent={<ContextMenuPreview items={items} itemType={type} />}\n        >\n            <PlayAction ids={ids} itemType={LibraryItem.SONG} songs={items} />\n            <PlayTrackRadioAction disabled={items.length > 1} song={items[0]} />\n            <ContextMenu.Divider />\n            <AddToPlaylistAction items={ids} itemType={LibraryItem.SONG} />\n            <ContextMenu.Divider />\n            <SetFavoriteAction ids={ids} itemType={LibraryItem.SONG} />\n            <SetRatingAction ids={ids} itemType={LibraryItem.SONG} />\n            <ContextMenu.Divider />\n            <DownloadAction ids={ids} />\n            <ShareAction ids={ids} itemType={LibraryItem.SONG} />\n            <ContextMenu.Divider />\n            <GoToAction items={items} />\n            <ShowInFileExplorerAction items={items} />\n            <ContextMenu.Divider />\n            <GetInfoAction disabled={items.length === 0} items={items} />\n        </ContextMenu.Content>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/discord-rpc/use-discord-rpc.ts",
    "content": "import { SetActivity, StatusDisplayType } from '@xhayper/discord-rpc';\nimport isElectron from 'is-electron';\nimport React, { useCallback, useEffect, useRef, useState } from 'react';\n\nimport { api } from '/@/renderer/api';\nimport { useItemImageUrl } from '/@/renderer/components/item-image/item-image';\nimport {\n    useIsRadioActive,\n    useRadioPlayer,\n} from '/@/renderer/features/radio/hooks/use-radio-player';\nimport {\n    DiscordDisplayType,\n    DiscordLinkType,\n    useAppStore,\n    useDiscordSettings,\n    useLastfmApiKey,\n    usePlayerSong,\n    usePlayerStore,\n    useSettingsStore,\n    useTimestampStoreBase,\n} from '/@/renderer/store';\nimport { sentenceCase } from '/@/renderer/utils';\nimport { LogCategory, logFn } from '/@/renderer/utils/logger';\nimport { logMsg } from '/@/renderer/utils/logger-message';\nimport { useDebouncedCallback } from '/@/shared/hooks/use-debounced-callback';\nimport { LibraryItem, QueueSong, ServerType } from '/@/shared/types/domain-types';\nimport { PlayerStatus } from '/@/shared/types/types';\n\nconst discordRpc = isElectron() ? window.api.discordRpc : null;\ntype ActivityState = [QueueSong | undefined, number, PlayerStatus];\n\nconst MAX_FIELD_LENGTH = 127;\nconst MAX_URL_LENGTH = 256;\n\nconst truncate = (field: string) =>\n    field.length <= MAX_FIELD_LENGTH ? field : field.substring(0, MAX_FIELD_LENGTH - 1) + '…';\n\nexport const useDiscordRpc = () => {\n    const discordSettings = useDiscordSettings();\n    const lastfmApiKey = useLastfmApiKey();\n    const privateMode = useAppStore((state) => state.privateMode);\n    const [lastUniqueId, setlastUniqueId] = useState('');\n\n    const isRadioActive = useIsRadioActive();\n    const { isPlaying: isRadioPlaying, metadata: radioMetadata, stationName } = useRadioPlayer();\n\n    const currentSong = usePlayerSong();\n    const imageUrl = useItemImageUrl({\n        id: currentSong?.imageId || undefined,\n        imageUrl: currentSong?.imageUrl,\n        itemType: LibraryItem.SONG,\n        type: 'table',\n        useRemoteUrl: true,\n    });\n\n    const imageUrlRef = useRef<null | string | undefined>(imageUrl);\n    const previousEnabledRef = useRef<boolean>(discordSettings.enabled);\n    const intervalRef = useRef<NodeJS.Timeout | null>(null);\n    const previousActivityStateRef = useRef<ActivityState | null>(null);\n\n    // Update imageUrl ref when it changes\n    useEffect(() => {\n        imageUrlRef.current = imageUrl;\n    }, [imageUrl]);\n\n    const setActivity = useCallback(\n        async (current: ActivityState, previous: ActivityState) => {\n            // Check if track changed by comparing with previous state\n            const song = current[0];\n            const previousSong = previous[0];\n            const trackChangedByState =\n                song && previousSong\n                    ? song._uniqueId !== previousSong._uniqueId\n                    : song !== previousSong;\n            const trackChanged = song ? lastUniqueId !== song._uniqueId : false;\n\n            const isPlayingRadio = isRadioActive && isRadioPlaying;\n            const hasTrackOrRadio = Boolean(current[0]) || isPlayingRadio;\n\n            if (\n                !hasTrackOrRadio || // No track and not playing radio\n                (current[2] === 'paused' && !discordSettings.showPaused) // Paused with show paused setting disabled\n            ) {\n                let reason: string;\n                if (!hasTrackOrRadio) {\n                    reason = current[0] ? 'no_track' : 'no_track_or_radio';\n                } else if (current[1] === 0 && !isPlayingRadio) {\n                    reason = 'start_of_track';\n                } else {\n                    reason = 'paused_with_show_paused_disabled';\n                }\n\n                logFn.debug(logMsg[LogCategory.EXTERNAL].discordRpcActivityCleared, {\n                    category: LogCategory.EXTERNAL,\n                    meta: {\n                        reason,\n                        status: current[2],\n                    },\n                });\n                return discordRpc?.clearActivity();\n            }\n\n            if (isPlayingRadio) {\n                const title = radioMetadata?.title || stationName || 'Radio';\n                const artist = radioMetadata?.artist || stationName || '';\n\n                const activity: SetActivity = {\n                    details: truncate(title),\n                    instance: false,\n                    largeImageKey: 'icon',\n                    largeImageText: truncate(stationName || 'Radio'),\n                    smallImageKey:\n                        current[2] === PlayerStatus.PLAYING\n                            ? discordSettings.showStateIcon\n                                ? 'playing'\n                                : undefined\n                            : 'paused',\n                    smallImageText:\n                        current[2] === PlayerStatus.PLAYING\n                            ? discordSettings.showStateIcon\n                                ? sentenceCase(current[2])\n                                : undefined\n                            : sentenceCase(current[2]),\n                    state: truncate(artist),\n                    statusDisplayType: StatusDisplayType.STATE,\n                    type: discordSettings.showAsListening ? 2 : 0,\n                };\n\n                const isConnected = await discordRpc?.isConnected();\n                if (!isConnected) {\n                    logFn.debug(logMsg[LogCategory.EXTERNAL].discordRpcInitialized, {\n                        category: LogCategory.EXTERNAL,\n                        meta: { clientId: discordSettings.clientId },\n                    });\n                    previousEnabledRef.current = true;\n                    await discordRpc?.initialize(discordSettings.clientId);\n                }\n\n                logFn.debug(logMsg[LogCategory.EXTERNAL].discordRpcSetActivity, {\n                    category: LogCategory.EXTERNAL,\n                    meta: {\n                        currentStatus: current[2],\n                        reason: 'radio',\n                        showAsListening: discordSettings.showAsListening,\n                        stationName: stationName || 'Radio',\n                        title,\n                    },\n                });\n                discordRpc?.setActivity(activity);\n                return;\n            }\n\n            if (!song) {\n                return;\n            }\n\n            /*\n                1. If the song has just started, update status\n                2. If we jump more then 1.2 seconds from last state, update status to match\n                3. If the current song id is completely different, update status\n                4. If the player state changed, update status\n            */\n            if (\n                previous[1] === 0 ||\n                Math.abs(current[1] - previous[1]) > 1.2 ||\n                trackChangedByState ||\n                trackChanged ||\n                current[2] !== previous[2]\n            ) {\n                if (trackChangedByState || trackChanged) {\n                    logFn.debug(logMsg[LogCategory.EXTERNAL].discordRpcTrackChanged, {\n                        category: LogCategory.EXTERNAL,\n                        meta: {\n                            artistName: song.artists?.[0]?.name,\n                            songId: song._uniqueId,\n                            songName: song.name,\n                        },\n                    });\n                    setlastUniqueId(song._uniqueId);\n                }\n\n                let reason: string;\n                if (trackChangedByState || trackChanged) {\n                    reason = 'track_changed';\n                } else if (previous[1] === 0) {\n                    reason = 'song_started';\n                } else if (Math.abs(current[1] - previous[1]) > 1.2) {\n                    reason = 'time_jump';\n                } else {\n                    reason = 'player_state_changed';\n                }\n\n                const start = Math.round(Date.now() - current[1] * 1000);\n                const end = Math.round(start + song.duration);\n\n                const artists = song?.artists.map((artist) => artist.name).join(', ');\n\n                const statusDisplayMap = {\n                    [DiscordDisplayType.ARTIST_NAME]: StatusDisplayType.STATE,\n                    [DiscordDisplayType.FEISHIN]: StatusDisplayType.NAME,\n                    [DiscordDisplayType.SONG_NAME]: StatusDisplayType.DETAILS,\n                };\n\n                const activity: SetActivity = {\n                    details: truncate((song?.name && song.name.padEnd(2, ' ')) || 'Idle'),\n                    instance: false,\n                    largeImageKey: undefined,\n                    largeImageText: truncate(\n                        (song?.album && song.album.padEnd(2, ' ')) || 'Unknown album',\n                    ),\n                    smallImageKey: undefined,\n                    smallImageText: undefined,\n                    state: truncate((artists && artists.padEnd(2, ' ')) || 'Unknown artist'),\n                    statusDisplayType: statusDisplayMap[discordSettings.displayType],\n                    // I would love to use the actual type as opposed to hardcoding to 2,\n                    // but manually installing the discord-types package appears to break things\n                    type: discordSettings.showAsListening ? 2 : 0,\n                };\n\n                if (\n                    (discordSettings.linkType == DiscordLinkType.LAST_FM ||\n                        discordSettings.linkType == DiscordLinkType.MBZ_LAST_FM) &&\n                    song?.artistName\n                ) {\n                    activity.stateUrl =\n                        'https://www.last.fm/music/' + encodeURIComponent(song.artists[0].name);\n\n                    const detailsUrl =\n                        'https://www.last.fm/music/' +\n                        encodeURIComponent(song.albumArtists[0].name) +\n                        '/' +\n                        encodeURIComponent(song.album || '_') +\n                        '/' +\n                        encodeURIComponent(song.name);\n\n                    // The details URL has a max length, only set it if it doesn't exceed it\n                    if (detailsUrl.length <= MAX_URL_LENGTH) {\n                        activity.detailsUrl = detailsUrl;\n                    }\n                }\n\n                if (\n                    discordSettings.linkType == DiscordLinkType.MBZ ||\n                    discordSettings.linkType == DiscordLinkType.MBZ_LAST_FM\n                ) {\n                    if (song?.mbzTrackId) {\n                        activity.detailsUrl = 'https://musicbrainz.org/track/' + song.mbzTrackId;\n                    } else if (song?.mbzRecordingId) {\n                        activity.detailsUrl =\n                            'https://musicbrainz.org/recording/' + song.mbzRecordingId;\n                    }\n                }\n\n                if (current[2] === PlayerStatus.PLAYING) {\n                    if (start && end) {\n                        activity.startTimestamp = start;\n                        activity.endTimestamp = end;\n                    }\n\n                    if (discordSettings.showStateIcon) {\n                        activity.smallImageKey = 'playing';\n                        activity.smallImageText = sentenceCase(current[2]);\n                    }\n                } else {\n                    activity.smallImageKey = 'paused';\n                    activity.smallImageText = sentenceCase(current[2]);\n                }\n\n                if (discordSettings.showServerImage && song) {\n                    if (song._uniqueId === currentSong?._uniqueId && imageUrlRef.current) {\n                        if (song._serverType === ServerType.JELLYFIN) {\n                            activity.largeImageKey = imageUrlRef.current;\n                        } else if (\n                            song._serverType === ServerType.NAVIDROME ||\n                            song._serverType === ServerType.SUBSONIC\n                        ) {\n                            try {\n                                const info = await api.controller.getAlbumInfo({\n                                    apiClientProps: { serverId: song._serverId },\n                                    query: { id: song.albumId },\n                                });\n\n                                if (info.imageUrl) {\n                                    activity.largeImageKey = info.imageUrl;\n                                }\n                            } catch {\n                                /* empty */\n                            }\n                        }\n                    }\n                }\n\n                if (\n                    activity.largeImageKey === undefined &&\n                    lastfmApiKey &&\n                    song?.album &&\n                    song?.albumArtists.length\n                ) {\n                    const albumInfo = await fetch(\n                        `https://ws.audioscrobbler.com/2.0/?method=album.getinfo&api_key=${lastfmApiKey}&artist=${encodeURIComponent(song.albumArtists[0].name)}&album=${encodeURIComponent(song.album)}&format=json`,\n                    );\n\n                    const albumInfoJson = await albumInfo.json();\n\n                    if (albumInfoJson.album?.image?.[3]['#text']) {\n                        activity.largeImageKey = albumInfoJson.album.image[3]['#text'];\n                    }\n                }\n\n                // Fall back to default icon if not set\n                if (!activity.largeImageKey) {\n                    activity.largeImageKey = 'icon';\n                }\n\n                // Initialize if needed\n                const isConnected = await discordRpc?.isConnected();\n                if (!isConnected) {\n                    logFn.debug(logMsg[LogCategory.EXTERNAL].discordRpcInitialized, {\n                        category: LogCategory.EXTERNAL,\n                        meta: {\n                            clientId: discordSettings.clientId,\n                        },\n                    });\n\n                    previousEnabledRef.current = true;\n\n                    await discordRpc?.initialize(discordSettings.clientId);\n                }\n\n                logFn.debug(logMsg[LogCategory.EXTERNAL].discordRpcSetActivity, {\n                    category: LogCategory.EXTERNAL,\n                    meta: {\n                        albumName: song.album,\n                        artistName: song.artists?.[0]?.name,\n                        currentStatus: current[2],\n                        currentTime: current[1],\n                        displayType: discordSettings.displayType,\n                        hasLargeImage: !!activity.largeImageKey,\n                        hasTimestamps: !!(activity.startTimestamp && activity.endTimestamp),\n                        previousStatus: previous[2],\n                        previousTime: previous[1],\n                        reason,\n                        showAsListening: discordSettings.showAsListening,\n                        songName: song.name,\n                        trackChanged: trackChangedByState || trackChanged,\n                    },\n                });\n                discordRpc?.setActivity(activity);\n            } else {\n                logFn.debug(logMsg[LogCategory.EXTERNAL].discordRpcUpdateSkipped, {\n                    category: LogCategory.EXTERNAL,\n                    meta: {\n                        currentStatus: current[2],\n                        currentTime: current[1],\n                        previousStatus: previous[2],\n                        previousTime: previous[1],\n                        timeDiff: Math.abs(current[1] - previous[1]),\n                        trackChanged: trackChangedByState || trackChanged,\n                    },\n                });\n            }\n        },\n        [\n            discordSettings.showAsListening,\n            discordSettings.showServerImage,\n            discordSettings.showStateIcon,\n            discordSettings.showPaused,\n            lastfmApiKey,\n            discordSettings.clientId,\n            discordSettings.displayType,\n            discordSettings.linkType,\n            lastUniqueId,\n            currentSong?._uniqueId,\n            isRadioActive,\n            isRadioPlaying,\n            radioMetadata?.artist,\n            radioMetadata?.title,\n            stationName,\n        ],\n    );\n\n    const debouncedSetActivity = useDebouncedCallback(setActivity, 500);\n\n    // Quit Discord RPC if it was enabled and is now disabled\n    useEffect(() => {\n        if ((!discordSettings.enabled || privateMode) && Boolean(previousEnabledRef.current)) {\n            logFn.info(logMsg[LogCategory.EXTERNAL].discordRpcQuit, {\n                category: LogCategory.EXTERNAL,\n                meta: {\n                    enabled: discordSettings.enabled,\n                    privateMode,\n                },\n            });\n\n            previousEnabledRef.current = false;\n\n            return discordRpc?.quit();\n        }\n    }, [discordSettings.clientId, privateMode, discordSettings.enabled]);\n\n    useEffect(() => {\n        if (!discordSettings.enabled || privateMode) {\n            return;\n        }\n\n        const getCurrentActivityState = (): ActivityState => {\n            const state = usePlayerStore.getState();\n            const currentSong = state.getCurrentSong();\n            const currentTime = useTimestampStoreBase.getState().timestamp;\n            const status = state.player.status;\n            return [currentSong, currentTime, status];\n        };\n\n        const resetInterval = () => {\n            if (intervalRef.current) {\n                clearInterval(intervalRef.current);\n            }\n            intervalRef.current = setInterval(() => {\n                const current = getCurrentActivityState();\n                const previous = previousActivityStateRef.current || current;\n                debouncedSetActivity(current, previous);\n                previousActivityStateRef.current = current;\n            }, 15000);\n        };\n\n        resetInterval();\n\n        const initialState = getCurrentActivityState();\n        let previousUniqueId = initialState[0]?._uniqueId || '';\n\n        previousActivityStateRef.current = initialState;\n\n        // Set activity immediately when Discord RPC is enabled\n        debouncedSetActivity(initialState, initialState);\n\n        const unsubSongChange = usePlayerStore.subscribe(\n            (state): ActivityState => {\n                const currentSong = state.getCurrentSong();\n                const currentTime = useTimestampStoreBase.getState().timestamp;\n                const status = state.player.status;\n\n                return [currentSong, currentTime, status];\n            },\n            (current, previous) => {\n                const currentUniqueId = current[0]?._uniqueId || '';\n                const trackChanged = previousUniqueId !== currentUniqueId;\n\n                if (trackChanged && current[0]) {\n                    resetInterval();\n                    previousUniqueId = currentUniqueId;\n                }\n\n                const activity: ActivityState = [\n                    current[0] as QueueSong,\n                    current[1] as number,\n                    current[2] as PlayerStatus,\n                ];\n\n                // Use the ref as the source of truth for previous state\n                const previousActivity: ActivityState =\n                    previousActivityStateRef.current ||\n                    (previous\n                        ? [\n                              previous[0] as QueueSong,\n                              previous[1] as number,\n                              previous[2] as PlayerStatus,\n                          ]\n                        : activity);\n\n                debouncedSetActivity(activity, previousActivity);\n\n                previousActivityStateRef.current = activity;\n            },\n        );\n\n        return () => {\n            unsubSongChange();\n            if (intervalRef.current) {\n                clearInterval(intervalRef.current);\n                intervalRef.current = null;\n            }\n        };\n    }, [\n        debouncedSetActivity,\n        discordSettings.clientId,\n        discordSettings.enabled,\n        privateMode,\n        setActivity,\n    ]);\n};\n\nconst DiscordRpcHookInner = () => {\n    useDiscordRpc();\n    return null;\n};\n\nexport const DiscordRpcHook = () => {\n    const isElectronEnv = isElectron();\n    const isDiscordRpcEnabled = useSettingsStore((state) => state.discord.enabled);\n    const isPrivateMode = useAppStore((state) => state.privateMode);\n    const discordRpc = isElectronEnv ? window.api.discordRpc : null;\n\n    if (!isElectronEnv || !discordRpc || !isDiscordRpcEnabled || isPrivateMode) {\n        return null;\n    }\n\n    return React.createElement(DiscordRpcHookInner);\n};\n"
  },
  {
    "path": "src/renderer/features/favorites/components/favorites-content.tsx",
    "content": "import { Suspense } from 'react';\n\nimport { useListContext } from '/@/renderer/context/list-context';\nimport {\n    AlbumListView,\n    OverrideAlbumListQuery,\n} from '/@/renderer/features/albums/components/album-list-content';\nimport {\n    AlbumArtistListView,\n    OverrideAlbumArtistListQuery,\n} from '/@/renderer/features/artists/components/album-artist-list-content';\nimport { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';\nimport {\n    OverrideSongListQuery,\n    SongListView,\n} from '/@/renderer/features/songs/components/song-list-content';\nimport { useListSettings } from '/@/renderer/store';\nimport { Spinner } from '/@/shared/components/spinner/spinner';\nimport { LibraryItem } from '/@/shared/types/domain-types';\nimport { ItemListKey } from '/@/shared/types/types';\n\ninterface FavoritesContentProps {\n    itemType: LibraryItem;\n}\n\nexport const FavoritesContent = ({ itemType }: FavoritesContentProps) => {\n    return (\n        <AnimatedPage>\n            <Suspense fallback={<Spinner container />}>\n                {itemType === LibraryItem.ALBUM && <AlbumFavorites />}\n                {itemType === LibraryItem.SONG && <SongFavorites />}\n                {itemType === LibraryItem.ALBUM_ARTIST && <ArtistFavorites />}\n            </Suspense>\n        </AnimatedPage>\n    );\n};\n\nconst AlbumFavorites = () => {\n    const { display, grid, itemsPerPage, pagination, table } = useListSettings(ItemListKey.ALBUM);\n    const { customFilters } = useListContext();\n\n    const albumQuery: OverrideAlbumListQuery = {\n        ...(customFilters as OverrideAlbumListQuery),\n    };\n\n    return (\n        <AlbumListView\n            display={display}\n            grid={grid}\n            itemsPerPage={itemsPerPage}\n            overrideQuery={albumQuery}\n            pagination={pagination}\n            table={table}\n        />\n    );\n};\n\nconst SongFavorites = () => {\n    const { display, grid, itemsPerPage, pagination, table } = useListSettings(ItemListKey.SONG);\n    const { customFilters } = useListContext();\n\n    const songQuery: OverrideSongListQuery = {\n        ...(customFilters as OverrideSongListQuery),\n    };\n\n    return (\n        <SongListView\n            display={display}\n            grid={grid}\n            itemsPerPage={itemsPerPage}\n            overrideQuery={songQuery}\n            pagination={pagination}\n            table={table}\n        />\n    );\n};\n\nconst ArtistFavorites = () => {\n    const { display, grid, itemsPerPage, pagination, table } = useListSettings(ItemListKey.ARTIST);\n    const { customFilters } = useListContext();\n\n    const albumArtistQuery: OverrideAlbumArtistListQuery = {\n        ...(customFilters as OverrideAlbumArtistListQuery),\n    };\n\n    return (\n        <AlbumArtistListView\n            display={display}\n            grid={grid}\n            itemsPerPage={itemsPerPage}\n            overrideQuery={albumArtistQuery}\n            pagination={pagination}\n            table={table}\n        />\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/favorites/components/favorites-header.tsx",
    "content": "import { useCallback, useMemo } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { useNavigate } from 'react-router';\n\nimport { PageHeader } from '/@/renderer/components/page-header/page-header';\nimport { useListContext } from '/@/renderer/context/list-context';\nimport { AlbumListHeaderFilters } from '/@/renderer/features/albums/components/album-list-header-filters';\nimport { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album-list-filters';\nimport { AlbumArtistListHeaderFilters } from '/@/renderer/features/artists/components/album-artist-list-header-filters';\nimport { useAlbumArtistListFilters } from '/@/renderer/features/artists/hooks/use-album-artist-list-filters';\nimport { FilterBar } from '/@/renderer/features/shared/components/filter-bar';\nimport { LibraryHeaderBar } from '/@/renderer/features/shared/components/library-header-bar';\nimport { ListSearchInput } from '/@/renderer/features/shared/components/list-search-input';\nimport { SongListHeaderFilters } from '/@/renderer/features/songs/components/song-list-header-filters';\nimport { useSongListFilters } from '/@/renderer/features/songs/hooks/use-song-list-filters';\nimport { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu';\nimport { Flex } from '/@/shared/components/flex/flex';\nimport { Group } from '/@/shared/components/group/group';\nimport { Icon } from '/@/shared/components/icon/icon';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { TextTitle } from '/@/shared/components/text-title/text-title';\nimport { Text } from '/@/shared/components/text/text';\nimport { LibraryItem } from '/@/shared/types/domain-types';\n\ninterface FavoritesHeaderProps {\n    itemType: LibraryItem;\n}\n\nexport const FavoritesHeader = ({ itemType }: FavoritesHeaderProps) => {\n    const { t } = useTranslation();\n    const { customFilters, itemCount } = useListContext();\n    const navigate = useNavigate();\n\n    const albumFilters = useAlbumListFilters();\n    const albumArtistFilters = useAlbumArtistListFilters();\n    const songFilters = useSongListFilters();\n\n    const playQuery = useMemo(() => {\n        let query = {};\n        switch (itemType) {\n            case LibraryItem.ALBUM:\n                query = albumFilters.query;\n                break;\n            case LibraryItem.ALBUM_ARTIST:\n                query = albumArtistFilters.query;\n                break;\n            case LibraryItem.SONG:\n                query = songFilters.query;\n                break;\n        }\n\n        return {\n            ...query,\n            ...(customFilters ?? {}),\n        };\n    }, [albumFilters.query, albumArtistFilters.query, songFilters.query, customFilters, itemType]);\n\n    const handleItemTypeChange = useCallback(\n        (type: LibraryItem) => {\n            albumFilters.clear();\n            songFilters.clear();\n            albumArtistFilters.clear();\n\n            // Clear all URL search params except 'type'\n            navigate(`?type=${type}`, { replace: true });\n        },\n        [albumFilters, albumArtistFilters, songFilters, navigate],\n    );\n\n    return (\n        <Stack gap={0}>\n            <PageHeader>\n                <Flex justify=\"space-between\" w=\"100%\">\n                    <LibraryHeaderBar ignoreMaxWidth>\n                        <PlayButton itemType={itemType} query={playQuery} />\n                        <LibraryHeaderBar.Title>\n                            <DropdownMenu position=\"right\">\n                                <DropdownMenu.Target>\n                                    <Stack gap={0} style={{ cursor: 'pointer' }}>\n                                        <Group>\n                                            <TextTitle isNoSelect order={3}>\n                                                {t('page.favorites.title', {\n                                                    postProcess: 'sentenceCase',\n                                                })}\n                                            </TextTitle>\n                                            <Icon icon=\"dropdown\" size=\"xl\" />\n                                        </Group>\n                                        <Text isMuted size=\"sm\">\n                                            {itemType === LibraryItem.ALBUM &&\n                                                t('entity.album', {\n                                                    count: 2,\n                                                    postProcess: 'sentenceCase',\n                                                })}\n                                            {itemType === LibraryItem.ALBUM_ARTIST &&\n                                                t('entity.artist', {\n                                                    count: 2,\n                                                    postProcess: 'sentenceCase',\n                                                })}\n                                            {itemType === LibraryItem.SONG &&\n                                                t('entity.track', {\n                                                    count: 2,\n                                                    postProcess: 'sentenceCase',\n                                                })}\n                                        </Text>\n                                    </Stack>\n                                </DropdownMenu.Target>\n                                <DropdownMenu.Dropdown>\n                                    <DropdownMenu.Item\n                                        isSelected={itemType === LibraryItem.SONG}\n                                        leftSection={<Icon icon=\"track\" size=\"xl\" />}\n                                        onClick={() => handleItemTypeChange(LibraryItem.SONG)}\n                                    >\n                                        {t('entity.track', {\n                                            count: 2,\n                                            postProcess: 'sentenceCase',\n                                        })}\n                                    </DropdownMenu.Item>\n                                    <DropdownMenu.Item\n                                        isSelected={itemType === LibraryItem.ALBUM}\n                                        leftSection={<Icon icon=\"album\" size=\"xl\" />}\n                                        onClick={() => handleItemTypeChange(LibraryItem.ALBUM)}\n                                    >\n                                        {t('entity.album', {\n                                            count: 2,\n                                            postProcess: 'sentenceCase',\n                                        })}\n                                    </DropdownMenu.Item>\n                                    <DropdownMenu.Item\n                                        isSelected={itemType === LibraryItem.ALBUM_ARTIST}\n                                        leftSection={<Icon icon=\"artist\" size=\"xl\" />}\n                                        onClick={() =>\n                                            handleItemTypeChange(LibraryItem.ALBUM_ARTIST)\n                                        }\n                                    >\n                                        {t('entity.artist', {\n                                            count: 2,\n                                            postProcess: 'sentenceCase',\n                                        })}\n                                    </DropdownMenu.Item>\n                                </DropdownMenu.Dropdown>\n                            </DropdownMenu>\n                        </LibraryHeaderBar.Title>\n                        <LibraryHeaderBar.Badge isLoading={!itemCount}>\n                            {itemCount}\n                        </LibraryHeaderBar.Badge>\n                    </LibraryHeaderBar>\n                    <Group>\n                        <ListSearchInput />\n                    </Group>\n                </Flex>\n            </PageHeader>\n            <FilterBar>\n                {itemType === LibraryItem.ALBUM && <AlbumListHeaderFilters />}\n                {itemType === LibraryItem.ALBUM_ARTIST && <AlbumArtistListHeaderFilters />}\n                {itemType === LibraryItem.SONG && <SongListHeaderFilters />}\n            </FilterBar>\n        </Stack>\n    );\n};\n\nconst PlayButton = ({ itemType, query }: { itemType: LibraryItem; query: Record<string, any> }) => {\n    return <LibraryHeaderBar.PlayButton itemType={itemType} listQuery={query} variant=\"filled\" />;\n};\n"
  },
  {
    "path": "src/renderer/features/favorites/routes/favorites-route.tsx",
    "content": "import { useMemo, useState } from 'react';\nimport { useSearchParams } from 'react-router';\n\nimport { ListContext } from '/@/renderer/context/list-context';\nimport { FavoritesContent } from '/@/renderer/features/favorites/components/favorites-content';\nimport { FavoritesHeader } from '/@/renderer/features/favorites/components/favorites-header';\nimport { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';\nimport { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';\nimport { LibraryItem } from '/@/shared/types/domain-types';\nimport { ItemListKey } from '/@/shared/types/types';\n\nconst FavoritesRoute = () => {\n    const [searchParams] = useSearchParams();\n    const itemType = (searchParams.get('type') as LibraryItem) || LibraryItem.SONG;\n\n    const [itemCount, setItemCount] = useState<number | undefined>(undefined);\n\n    const getPageKey = (type: LibraryItem): ItemListKey => {\n        switch (type) {\n            case LibraryItem.ALBUM:\n                return ItemListKey.ALBUM;\n            case LibraryItem.ALBUM_ARTIST:\n                return ItemListKey.ALBUM_ARTIST;\n            case LibraryItem.SONG:\n                return ItemListKey.SONG;\n            default:\n                return ItemListKey.SONG;\n        }\n    };\n\n    const pageKey = useMemo(() => getPageKey(itemType), [itemType]);\n\n    const customFilters = useMemo(() => {\n        switch (itemType) {\n            case LibraryItem.ALBUM:\n                return { favorite: true };\n            case LibraryItem.ALBUM_ARTIST:\n                return { favorite: true };\n            case LibraryItem.SONG:\n                return { favorite: true };\n            default:\n                return {};\n        }\n    }, [itemType]);\n\n    const providerValue = useMemo(() => {\n        return {\n            customFilters,\n            itemCount,\n            pageKey,\n            setItemCount,\n        };\n    }, [customFilters, itemCount, pageKey]);\n\n    return (\n        <AnimatedPage>\n            <ListContext.Provider value={providerValue}>\n                <FavoritesHeader itemType={itemType} />\n                <FavoritesContent itemType={itemType} />\n            </ListContext.Provider>\n        </AnimatedPage>\n    );\n};\n\nconst FavoritesRouteWithBoundary = () => {\n    return (\n        <PageErrorBoundary>\n            <FavoritesRoute />\n        </PageErrorBoundary>\n    );\n};\n\nexport default FavoritesRouteWithBoundary;\n"
  },
  {
    "path": "src/renderer/features/folders/api/folder-api.ts",
    "content": "import { queryOptions } from '@tanstack/react-query';\n\nimport { api } from '/@/renderer/api';\nimport { queryKeys } from '/@/renderer/api/query-keys';\nimport { QueryHookArgs } from '/@/renderer/lib/react-query';\nimport { FolderQuery } from '/@/shared/types/domain-types';\n\nexport const folderQueries = {\n    folder: (args: QueryHookArgs<FolderQuery>) => {\n        return queryOptions({\n            queryFn: ({ signal }) => {\n                return api.controller.getFolder({\n                    apiClientProps: { serverId: args.serverId, signal },\n                    query: args.query,\n                });\n            },\n            queryKey: queryKeys.folders.folder(args.serverId, args.query),\n            ...args.options,\n        });\n    },\n};\n"
  },
  {
    "path": "src/renderer/features/folders/components/folder-list-content.tsx",
    "content": "import { useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query';\nimport { Suspense, useCallback, useEffect, useMemo } from 'react';\n\nimport { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder';\nimport { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize';\nimport { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist';\nimport { ItemTableList } from '/@/renderer/components/item-list/item-table-list/item-table-list';\nimport { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';\nimport { DefaultItemControlProps } from '/@/renderer/components/item-list/types';\nimport { useListContext } from '/@/renderer/context/list-context';\nimport { folderQueries } from '/@/renderer/features/folders/api/folder-api';\nimport { FolderTreeBrowser } from '/@/renderer/features/folders/components/folder-tree-browser';\nimport { useFolderListFilters } from '/@/renderer/features/folders/hooks/use-folder-list-filters';\nimport { usePlayer } from '/@/renderer/features/player/context/player-context';\nimport { ListWithSidebarContainer } from '/@/renderer/features/shared/components/list-with-sidebar-container';\nimport { FILTER_KEYS } from '/@/renderer/features/shared/utils';\nimport { useCurrentServerId, useListSettings, usePlayerSong } from '/@/renderer/store';\nimport { Spinner } from '/@/shared/components/spinner/spinner';\nimport { Folder, LibraryItem, Song, SongListSort, SortOrder } from '/@/shared/types/domain-types';\nimport { ItemListKey, ListDisplayType, Play } from '/@/shared/types/types';\n\nexport const FolderListContent = () => {\n    return (\n        <Suspense fallback={<Spinner container />}>\n            <FolderListInnerContent />\n        </Suspense>\n    );\n};\n\nexport const FolderListInnerContent = () => {\n    const serverId = useCurrentServerId();\n    const queryClient = useQueryClient();\n    const { currentFolderId, query } = useFolderListFilters();\n\n    const getFolderQueryOptions = useCallback(\n        (folderId: string) => {\n            return folderQueries.folder({\n                query: {\n                    id: folderId,\n                    searchTerm: query[FILTER_KEYS.SHARED.SEARCH_TERM] as string | undefined,\n                    sortBy:\n                        (query[FILTER_KEYS.SHARED.SORT_BY] as SongListSort) || SongListSort.NAME,\n                    sortOrder: (query[FILTER_KEYS.SHARED.SORT_ORDER] as SortOrder) || SortOrder.ASC,\n                },\n                serverId,\n            });\n        },\n        [serverId, query],\n    );\n\n    const rootFolderQuery = useQuery({\n        ...getFolderQueryOptions('0'),\n        staleTime: 1000 * 60 * 5,\n    });\n\n    const currentFolderQuery = useSuspenseQuery({\n        ...getFolderQueryOptions(currentFolderId),\n        staleTime: 1000 * 60,\n    });\n\n    const fetchFolder = useCallback(\n        async (folderId: string) => {\n            const queryOptions = getFolderQueryOptions(folderId);\n            return queryClient.fetchQuery({\n                ...queryOptions,\n                staleTime: 1000 * 60 * 5,\n            });\n        },\n        [getFolderQueryOptions, queryClient],\n    );\n\n    return (\n        <>\n            <ListWithSidebarContainer.SidebarPortal>\n                <FolderTreeBrowser fetchFolder={fetchFolder} rootFolderQuery={rootFolderQuery} />\n            </ListWithSidebarContainer.SidebarPortal>\n            <FolderListView folderQuery={currentFolderQuery} />\n        </>\n    );\n};\n\ninterface FolderListViewProps {\n    folderQuery: ReturnType<typeof useSuspenseQuery<Folder>>;\n}\n\nexport const FolderListView = ({ folderQuery }: FolderListViewProps) => {\n    const { table } = useListSettings(ItemListKey.SONG);\n    const display = ListDisplayType.TABLE;\n    const { setItemCount } = useListContext();\n    const { currentFolderId, navigateToFolder } = useFolderListFilters();\n    const serverId = useCurrentServerId();\n\n    const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({\n        enabled: true,\n    });\n\n    const { handleColumnReordered } = useItemListColumnReorder({\n        itemListKey: ItemListKey.SONG,\n    });\n\n    const { handleColumnResized } = useItemListColumnResize({\n        itemListKey: ItemListKey.SONG,\n    });\n\n    const allItems = useMemo(() => {\n        if (!folderQuery.data?.children) {\n            return [];\n        }\n\n        const { folders = [], songs = [] } = folderQuery.data.children;\n        return [...folders, ...songs];\n    }, [folderQuery.data]);\n\n    useEffect(() => {\n        setItemCount?.(allItems.length);\n    }, [allItems.length, setItemCount]);\n\n    const player = usePlayer();\n\n    const overrideControls = useMemo(() => {\n        return {\n            onDoubleClick: ({ internalState, item, meta }: DefaultItemControlProps) => {\n                if (!item) {\n                    return;\n                }\n\n                if ((item as unknown as Folder)._itemType === LibraryItem.FOLDER) {\n                    const folder = item as unknown as Folder;\n                    return navigateToFolder(folder.id, folder.name);\n                }\n\n                const playType = (meta?.playType as Play) || Play.NOW;\n\n                const data = internalState?.getData();\n                if (!data) {\n                    return;\n                }\n\n                const validSongs = data.filter((d): d is Song => {\n                    return (\n                        (d as unknown as { _itemType: LibraryItem })._itemType === LibraryItem.SONG\n                    );\n                }) as Song[];\n\n                if (validSongs.length === 0) {\n                    return;\n                }\n\n                player.addToQueueByData(validSongs, playType, item.id);\n            },\n        };\n    }, [navigateToFolder, player]);\n\n    const currentSong = usePlayerSong();\n\n    switch (display) {\n        // case ListDisplayType.GRID: {\n        //     return (\n        //         <ItemGridList\n        //             data={allItems}\n        //             gap={grid.itemGap}\n        //             initialTop={{\n        //                 to: scrollOffset ?? 0,\n        //                 type: 'offset',\n        //             }}\n        //             itemsPerRow={grid.itemsPerRowEnabled ? grid.itemsPerRow : undefined}\n        //             itemType={LibraryItem.FOLDER}\n        //             onScrollEnd={handleOnScrollEnd}\n        //             overrideControls={overrideControls}\n        //         />\n        //     );\n        // }\n        case ListDisplayType.TABLE: {\n            return (\n                <ItemTableList\n                    activeRowId={currentSong?.id}\n                    autoFitColumns={table.autoFitColumns}\n                    CellComponent={ItemTableListColumn}\n                    columns={table.columns}\n                    data={allItems}\n                    enableAlternateRowColors={table.enableAlternateRowColors}\n                    enableDrag={true}\n                    enableExpansion={false}\n                    enableHeader={table.enableHeader}\n                    enableHorizontalBorders={table.enableHorizontalBorders}\n                    enableRowHoverHighlight={table.enableRowHoverHighlight}\n                    enableVerticalBorders={table.enableVerticalBorders}\n                    initialTop={{\n                        to: scrollOffset ?? 0,\n                        type: 'offset',\n                    }}\n                    itemType={LibraryItem.FOLDER}\n                    key={`folder-${serverId}-${currentFolderId}`}\n                    onColumnReordered={handleColumnReordered}\n                    onColumnResized={handleColumnResized}\n                    onScrollEnd={handleOnScrollEnd}\n                    overrideControls={overrideControls}\n                    size={table.size}\n                />\n            );\n        }\n        default:\n            return null;\n    }\n};\n"
  },
  {
    "path": "src/renderer/features/folders/components/folder-list-header-filters.tsx",
    "content": "import React, { useMemo } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport { SONG_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns';\nimport { useFolderListFilters } from '/@/renderer/features/folders/hooks/use-folder-list-filters';\nimport {\n    ListConfigMenu,\n    SONG_DISPLAY_TYPES,\n} from '/@/renderer/features/shared/components/list-config-menu';\nimport { ListRefreshButton } from '/@/renderer/features/shared/components/list-refresh-button';\nimport { ListSortByDropdown } from '/@/renderer/features/shared/components/list-sort-by-dropdown';\nimport { ListSortOrderToggleButton } from '/@/renderer/features/shared/components/list-sort-order-toggle-button';\nimport { useContainerQuery } from '/@/renderer/hooks';\nimport { truncateMiddle } from '/@/renderer/utils';\nimport { Breadcrumb } from '/@/shared/components/breadcrumb/breadcrumb';\nimport { Button } from '/@/shared/components/button/button';\nimport { Divider } from '/@/shared/components/divider/divider';\nimport { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu';\nimport { Flex } from '/@/shared/components/flex/flex';\nimport { Group } from '/@/shared/components/group/group';\nimport { Icon } from '/@/shared/components/icon/icon';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { LibraryItem, SongListSort, SortOrder } from '/@/shared/types/domain-types';\nimport { ItemListKey, ListDisplayType } from '/@/shared/types/types';\n\nconst MAX_BREADCRUMB_TEXT_LENGTH = 26;\n\nexport const FolderListHeaderFilters = () => {\n    const { t } = useTranslation();\n    const { folderPath, navigateToPathIndex, setFolderPath } = useFolderListFilters();\n    const {\n        is2xl,\n        isLg,\n        isMd,\n        isSm,\n        isXl,\n        isXs,\n        ref: breadcrumbContainerRef,\n    } = useContainerQuery();\n\n    const maxItems = useMemo(() => {\n        if (is2xl) return 8;\n        if (isXl) return 6;\n        if (isLg) return 4;\n        if (isMd) return 3;\n        if (isSm) return 2;\n        if (isXs) return 2;\n        return 1;\n    }, [is2xl, isLg, isMd, isSm, isXl, isXs]);\n\n    const allBreadcrumbItems = useMemo(() => {\n        const items: Array<{\n            fullLabel: string;\n            id: string;\n            label: string;\n            onClick: () => void;\n        }> = [];\n\n        const homeLabel = t('common.home', { postProcess: 'titleCase' });\n        items.push({\n            fullLabel: homeLabel,\n            id: 'folder-root',\n            label: homeLabel,\n            onClick: () => {\n                setFolderPath([]);\n            },\n        });\n\n        folderPath.forEach((folder, index) => {\n            items.push({\n                fullLabel: folder.name,\n                id: `folder-${folder.id}`,\n                label: truncateMiddle(folder.name, MAX_BREADCRUMB_TEXT_LENGTH),\n                onClick: () => navigateToPathIndex(index),\n            });\n        });\n\n        return items;\n    }, [folderPath, navigateToPathIndex, setFolderPath, t]);\n\n    const visibleItems = useMemo(() => {\n        const firstItem = allBreadcrumbItems[0];\n\n        if (maxItems === 1) {\n            return [firstItem];\n        }\n\n        if (allBreadcrumbItems.length <= maxItems) {\n            return allBreadcrumbItems;\n        }\n\n        const lastItem = allBreadcrumbItems[allBreadcrumbItems.length - 1];\n        const middleItems = allBreadcrumbItems.slice(1, -1);\n        const availableSlots = maxItems - 2;\n\n        if (availableSlots <= 0) {\n            return [firstItem, lastItem];\n        }\n\n        if (middleItems.length <= availableSlots) {\n            return [firstItem, ...middleItems, lastItem];\n        }\n\n        const startCount = Math.floor(availableSlots / 2);\n        const endCount = availableSlots - startCount;\n        const startMiddle = middleItems.slice(0, startCount);\n        const endMiddle = middleItems.slice(-endCount);\n\n        return [firstItem, ...startMiddle, ...endMiddle, lastItem];\n    }, [allBreadcrumbItems, maxItems]);\n\n    const collapsedItems = useMemo(() => {\n        if (maxItems === 1) {\n            return allBreadcrumbItems.slice(1);\n        }\n\n        if (allBreadcrumbItems.length <= maxItems) {\n            return [];\n        }\n\n        const middleItems = allBreadcrumbItems.slice(1, -1);\n        const availableSlots = maxItems - 2;\n\n        if (availableSlots <= 0) {\n            return middleItems;\n        }\n\n        if (middleItems.length <= availableSlots) {\n            return [];\n        }\n\n        const startCount = Math.floor(availableSlots / 2);\n        const endCount = availableSlots - startCount;\n        const visibleStart = middleItems.slice(0, startCount);\n        const visibleEnd = middleItems.slice(-endCount);\n\n        return middleItems.filter(\n            (item) => !visibleStart.includes(item) && !visibleEnd.includes(item),\n        );\n    }, [allBreadcrumbItems, maxItems]);\n\n    const breadcrumbItems = useMemo(() => {\n        const items: React.ReactNode[] = [];\n        const firstItem = allBreadcrumbItems[0];\n        const lastItem = allBreadcrumbItems[allBreadcrumbItems.length - 1];\n        const hasCollapsedItems = collapsedItems.length > 0;\n\n        const renderDropdown = () => (\n            <DropdownMenu key=\"breadcrumb-dropdown\" position=\"bottom-start\">\n                <DropdownMenu.Target>\n                    <Button size=\"compact-sm\" variant=\"subtle\">\n                        <Icon icon=\"ellipsisHorizontal\" />\n                    </Button>\n                </DropdownMenu.Target>\n                <DropdownMenu.Dropdown>\n                    {collapsedItems.map((collapsedItem) => (\n                        <DropdownMenu.Item key={collapsedItem.id} onClick={collapsedItem.onClick}>\n                            {collapsedItem.fullLabel}\n                        </DropdownMenu.Item>\n                    ))}\n                </DropdownMenu.Dropdown>\n            </DropdownMenu>\n        );\n\n        if (hasCollapsedItems && maxItems === 1) {\n            items.push(\n                <Button\n                    key={firstItem.id}\n                    onClick={firstItem.onClick}\n                    size=\"compact-sm\"\n                    variant=\"subtle\"\n                >\n                    {firstItem.label}\n                </Button>,\n            );\n            items.push(renderDropdown());\n            return items;\n        }\n\n        if (hasCollapsedItems) {\n            const middleItems = allBreadcrumbItems.slice(1, -1);\n            const availableSlots = maxItems - 2;\n            const startCount = Math.floor(availableSlots / 2);\n            const visibleStartMiddle = middleItems.slice(0, startCount);\n            const visibleEndMiddle = middleItems.slice(-(availableSlots - startCount));\n\n            visibleItems.forEach((item, index) => {\n                items.push(\n                    <Button key={item.id} onClick={item.onClick} size=\"compact-sm\" variant=\"subtle\">\n                        {item.label}\n                    </Button>,\n                );\n\n                if (index < visibleItems.length - 1) {\n                    const nextItem = visibleItems[index + 1];\n                    const isFirstItem = item.id === firstItem.id;\n                    const isLastStartMiddle =\n                        item.id !== firstItem.id &&\n                        item.id !== lastItem.id &&\n                        visibleStartMiddle.length > 0 &&\n                        item.id === visibleStartMiddle[visibleStartMiddle.length - 1].id;\n\n                    const shouldInsertDropdown =\n                        (isFirstItem && nextItem.id === lastItem.id) ||\n                        (isLastStartMiddle &&\n                            (nextItem.id === lastItem.id ||\n                                (visibleEndMiddle.length > 0 &&\n                                    nextItem.id === visibleEndMiddle[0].id)));\n\n                    if (shouldInsertDropdown) {\n                        items.push(renderDropdown());\n                    }\n                }\n            });\n        } else {\n            visibleItems.forEach((item) => {\n                items.push(\n                    <Button key={item.id} onClick={item.onClick} size=\"compact-sm\" variant=\"subtle\">\n                        {item.label}\n                    </Button>,\n                );\n            });\n        }\n\n        return items;\n    }, [visibleItems, collapsedItems, allBreadcrumbItems, maxItems]);\n\n    return (\n        <Stack>\n            <Flex justify=\"space-between\">\n                <Group gap=\"sm\" w=\"100%\">\n                    <ListSortByDropdown\n                        defaultSortByValue={SongListSort.ID}\n                        itemType={LibraryItem.FOLDER}\n                        listKey={ItemListKey.FOLDER}\n                    />\n                    <Divider orientation=\"vertical\" />\n                    <ListSortOrderToggleButton\n                        defaultSortOrder={SortOrder.ASC}\n                        listKey={ItemListKey.FOLDER}\n                    />\n                    <ListRefreshButton listKey={ItemListKey.SONG} />\n                </Group>\n                <Group gap=\"sm\" wrap=\"nowrap\">\n                    <ListConfigMenu\n                        displayTypes={[\n                            { hidden: true, value: ListDisplayType.GRID },\n                            ...SONG_DISPLAY_TYPES,\n                        ]}\n                        listKey={ItemListKey.SONG}\n                        optionsConfig={{\n                            grid: {\n                                itemsPerPage: { hidden: true },\n                                pagination: { hidden: true },\n                            },\n                            table: {\n                                itemsPerPage: { hidden: true },\n                                pagination: { hidden: true },\n                            },\n                        }}\n                        tableColumnsData={SONG_TABLE_COLUMNS}\n                    />\n                </Group>\n            </Flex>\n            <div ref={breadcrumbContainerRef}>\n                <Breadcrumb separator={<Icon icon=\"arrowRight\" />}>{breadcrumbItems}</Breadcrumb>\n            </div>\n        </Stack>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/folders/components/folder-list-header.tsx",
    "content": "import { useTranslation } from 'react-i18next';\n\nimport { useIsFetchingItemListCount } from '/@/renderer/components/item-list/helpers/use-is-fetching-item-list';\nimport { PageHeader } from '/@/renderer/components/page-header/page-header';\nimport { useListContext } from '/@/renderer/context/list-context';\nimport { FolderListHeaderFilters } from '/@/renderer/features/folders/components/folder-list-header-filters';\nimport { FilterBar } from '/@/renderer/features/shared/components/filter-bar';\nimport { LibraryHeaderBar } from '/@/renderer/features/shared/components/library-header-bar';\nimport { ListSearchInput } from '/@/renderer/features/shared/components/list-search-input';\nimport { Group } from '/@/shared/components/group/group';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { LibraryItem } from '/@/shared/types/domain-types';\n\ninterface FolderListHeaderProps {\n    title?: string;\n}\n\nexport const FolderListHeader = ({ title }: FolderListHeaderProps) => {\n    const { t } = useTranslation();\n\n    const pageTitle = title || t('page.folderList.title', { postProcess: 'titleCase' });\n\n    return (\n        <Stack gap={0}>\n            <PageHeader>\n                <LibraryHeaderBar ignoreMaxWidth>\n                    <Stack>\n                        <LibraryHeaderBar.Title>{pageTitle}</LibraryHeaderBar.Title>\n                    </Stack>\n                    <FolderListHeaderBadge />\n                </LibraryHeaderBar>\n                <Group>\n                    <ListSearchInput />\n                </Group>\n            </PageHeader>\n            <FilterBar>\n                <FolderListHeaderFilters />\n            </FilterBar>\n        </Stack>\n    );\n};\n\nconst FolderListHeaderBadge = () => {\n    const { itemCount } = useListContext();\n\n    const isFetching = useIsFetchingItemListCount({\n        itemType: LibraryItem.FOLDER,\n    });\n\n    return <LibraryHeaderBar.Badge isLoading={isFetching}>{itemCount}</LibraryHeaderBar.Badge>;\n};\n"
  },
  {
    "path": "src/renderer/features/folders/components/folder-tree-browser.module.css",
    "content": ".container {\n    width: 100%;\n    height: 100%;\n    padding: var(--theme-spacing-sm);\n}\n\n.row {\n    display: flex;\n    align-items: center;\n    width: 100%;\n    height: 100%;\n    padding: 0 var(--theme-spacing-sm);\n    cursor: pointer;\n    user-select: none;\n    border-radius: var(--theme-radius-md);\n    transition: background-color 0.15s ease-in-out;\n}\n\n.row:hover {\n    background-color: var(--theme-colors-surface);\n}\n\n.row.active {\n    color: var(--theme-colors-primary-filled);\n}\n\n.row.dragging {\n    opacity: 0.5;\n}\n\n.row-content {\n    display: flex;\n    gap: var(--theme-spacing-xs);\n    align-items: center;\n    width: 100%;\n}\n\n.expand-icon-container {\n    display: flex;\n    flex-shrink: 0;\n    align-items: center;\n    justify-content: center;\n}\n\n.expand-icon {\n    display: flex;\n    flex-shrink: 0;\n    align-items: center;\n    justify-content: center;\n    width: 1rem;\n    height: 1rem;\n    color: var(--theme-colors-foreground);\n    transition: transform 0.2s ease-in-out;\n}\n\n.expand-icon.expanded {\n    transform: rotate(90deg);\n}\n\n.expand-icon-placeholder {\n    display: flex;\n    flex-shrink: 0;\n    width: 1rem;\n    height: 1rem;\n}\n\n.folder-icon-container {\n    display: flex;\n    flex-shrink: 0;\n    align-items: center;\n    justify-content: center;\n}\n\n.folder-icon {\n    flex-shrink: 0;\n    color: var(--theme-colors-foreground);\n}\n\n.folder-name {\n    overflow: hidden;\n    text-overflow: ellipsis;\n    font-size: var(--theme-font-size-md);\n    color: var(--theme-colors-foreground);\n    white-space: nowrap;\n}\n\n.row.active .folder-name {\n    font-weight: 500;\n    color: var(--theme-colors-primary-filled);\n}\n\n.tooltip {\n    padding: var(--theme-spacing-sm) var(--theme-spacing-md) var(--theme-spacing-sm) 0;\n    font-size: var(--theme-font-size-lg);\n    font-weight: 500;\n}\n"
  },
  {
    "path": "src/renderer/features/folders/components/folder-tree-browser.tsx",
    "content": "import { type UseQueryResult } from '@tanstack/react-query';\nimport clsx from 'clsx';\nimport { useOverlayScrollbars } from 'overlayscrollbars-react';\nimport { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';\nimport { List, RowComponentProps } from 'react-window-v2';\n\nimport styles from './folder-tree-browser.module.css';\n\nimport { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';\nimport { useFolderListFilters } from '/@/renderer/features/folders/hooks/use-folder-list-filters';\nimport { useDragDrop } from '/@/renderer/hooks/use-drag-drop';\nimport { Icon } from '/@/shared/components/icon/icon';\nimport { Tooltip } from '/@/shared/components/tooltip/tooltip';\nimport { useMergedRef } from '/@/shared/hooks/use-merged-ref';\nimport { Folder, LibraryItem } from '/@/shared/types/domain-types';\nimport { DragOperation, DragTarget } from '/@/shared/types/drag-and-drop';\n\ninterface FlattenedNode {\n    depth: number;\n    folder: Folder;\n    hasChildren: boolean;\n    isExpanded: boolean;\n    path: Array<{ id: string; name: string }>;\n}\n\ninterface TreeNode {\n    childrenLoaded: boolean;\n    depth: number;\n    folder: Folder;\n    hasChildren: boolean;\n    isExpanded: boolean;\n}\n\nconst ITEM_HEIGHT = 32;\nconst INDENT_SIZE = 16;\n\ninterface FolderTreeBrowserProps {\n    fetchFolder: (folderId: string) => Promise<Folder>;\n    rootFolderQuery: UseQueryResult<Folder, Error>;\n}\n\nexport const FolderTreeBrowser = ({ fetchFolder, rootFolderQuery }: FolderTreeBrowserProps) => {\n    const { currentFolderId, folderPath, setFolderPath } = useFolderListFilters();\n    const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());\n    const [loadedNodes, setLoadedNodes] = useState<Map<string, Folder[]>>(new Map());\n    const containerRef = useRef<HTMLDivElement>(null);\n    const previousFolderPathRef = useRef<Array<{ id: string; name: string }>>([]);\n    const lastInternalFolderPathRef = useRef<Array<{ id: string; name: string }> | null>(null);\n\n    // Initialize root folder children when data is loaded\n    useEffect(() => {\n        if (rootFolderQuery.data?.children?.folders && !loadedNodes.has('0')) {\n            setLoadedNodes((prev) => {\n                const newMap = new Map(prev);\n                newMap.set('0', rootFolderQuery.data?.children?.folders || []);\n                return newMap;\n            });\n        }\n    }, [rootFolderQuery.data, loadedNodes]);\n\n    // Fetch folder when expanding a node\n    const fetchFolderChildren = useCallback(\n        async (folderId: string) => {\n            if (loadedNodes.has(folderId)) {\n                return;\n            }\n\n            try {\n                const result = await fetchFolder(folderId);\n\n                if (result?.children?.folders) {\n                    setLoadedNodes((prev) => {\n                        const newMap = new Map(prev);\n                        const folders = result?.children?.folders || [];\n                        newMap.set(folderId, folders);\n                        return newMap;\n                    });\n                } else {\n                    // Even if no children, mark as loaded to avoid refetching\n                    setLoadedNodes((prev) => {\n                        const newMap = new Map(prev);\n                        newMap.set(folderId, []);\n                        return newMap;\n                    });\n                }\n            } catch {\n                setLoadedNodes((prev) => {\n                    const newMap = new Map(prev);\n                    newMap.set(folderId, []);\n                    return newMap;\n                });\n            }\n        },\n        [fetchFolder, loadedNodes],\n    );\n\n    // Get children for a folder\n    const getFolderChildren = useCallback(\n        (folder: Folder): Folder[] => {\n            // First check if we have explicitly loaded children in loadedNodes\n            const loaded = loadedNodes.get(folder.id);\n            if (loaded !== undefined) {\n                return loaded;\n            }\n\n            // Otherwise, use children from the folder object itself (if available)\n            // This handles cases where children came with the parent folder's response\n            return folder.children?.folders || [];\n        },\n        [loadedNodes],\n    );\n\n    // Build tree structure from root\n    const buildTree = useCallback(\n        (folder: Folder, depth: number = 0): TreeNode => {\n            const folderId = folder.id;\n            const isExpanded = expandedNodes.has(folderId);\n            const children = getFolderChildren(folder);\n            const hasChildren = children.length > 0;\n            const childrenLoaded =\n                loadedNodes.has(folderId) || (folder.children?.folders?.length ?? 0) > 0;\n\n            return {\n                childrenLoaded,\n                depth,\n                folder,\n                hasChildren,\n                isExpanded,\n            };\n        },\n        [expandedNodes, loadedNodes, getFolderChildren],\n    );\n\n    // Flatten tree to list for virtualization\n    const flattenedNodes = useMemo((): FlattenedNode[] => {\n        if (!rootFolderQuery.data) {\n            return [];\n        }\n\n        const result: FlattenedNode[] = [];\n        const rootFolder = rootFolderQuery.data;\n\n        const traverse = (\n            folder: Folder,\n            depth: number,\n            path: Array<{ id: string; name: string }> = [],\n        ) => {\n            const node = buildTree(folder, depth);\n            const currentPath = [...path, { id: folder.id, name: folder.name }];\n            const isRoot = folder.id === '0';\n\n            // Skip the root folder (id: '0')\n            if (!isRoot) {\n                result.push({\n                    depth: node.depth - 1,\n                    folder: node.folder,\n                    hasChildren: node.hasChildren,\n                    isExpanded: node.isExpanded,\n                    path: currentPath,\n                });\n            }\n\n            // For root folder, always traverse children\n            const shouldTraverseChildren = isRoot\n                ? node.hasChildren\n                : node.isExpanded && node.hasChildren;\n\n            if (shouldTraverseChildren) {\n                const children = getFolderChildren(folder);\n                // Recursively traverse each child - this supports infinite nesting\n                children.forEach((child) => {\n                    traverse(child, depth + 1, currentPath);\n                });\n            }\n        };\n\n        traverse(rootFolder, 0);\n        return result;\n        // eslint-disable-next-line react-hooks/exhaustive-deps\n    }, [rootFolderQuery.data, expandedNodes, loadedNodes, buildTree, getFolderChildren]);\n\n    const toggleNode = useCallback(\n        (folderId: string, hasChildren: boolean, folder?: Folder) => {\n            setExpandedNodes((prev) => {\n                const newSet = new Set(prev);\n                if (newSet.has(folderId)) {\n                    newSet.delete(folderId);\n                } else {\n                    newSet.add(folderId);\n                    // Fetch children if not loaded and has children\n                    // Check both loadedNodes and folder.children to determine if we need to fetch\n                    const needsFetch =\n                        hasChildren &&\n                        !loadedNodes.has(folderId) &&\n                        !(folder?.children?.folders && folder.children.folders.length > 0);\n                    if (needsFetch) {\n                        fetchFolderChildren(folderId);\n                    }\n                }\n                return newSet;\n            });\n        },\n        [fetchFolderChildren, loadedNodes],\n    );\n\n    // Expand a node (doesn't collapse if already expanded)\n    const expandNode = useCallback(\n        (folderId: string, hasChildren: boolean, folder?: Folder) => {\n            setExpandedNodes((prev) => {\n                if (prev.has(folderId)) {\n                    return prev;\n                }\n\n                // Expand the node\n                const newSet = new Set(prev);\n                newSet.add(folderId);\n\n                // Fetch children if not loaded and has children\n                const needsFetch =\n                    hasChildren &&\n                    !loadedNodes.has(folderId) &&\n                    !(folder?.children?.folders && folder.children.folders.length > 0);\n                if (needsFetch) {\n                    fetchFolderChildren(folderId);\n                }\n\n                return newSet;\n            });\n        },\n        [fetchFolderChildren, loadedNodes],\n    );\n\n    // Handle node click - toggle expand/collapse and set current folder\n    const handleNodeClick = useCallback(\n        (\n            folder: Folder,\n            path: Array<{ id: string; name: string }>,\n            isExpanded: boolean,\n            isCurrentFolder: boolean,\n        ) => {\n            // Only toggle close if the node is expanded AND it's the current folder\n            if (isExpanded && isCurrentFolder) {\n                toggleNode(folder.id, true, folder);\n            } else if (!isExpanded) {\n                // Node is not expanded - check if we should expand it\n                const childrenLoaded = loadedNodes.has(folder.id);\n                const hasChildrenFromFolder = (folder.children?.folders?.length ?? 0) > 0;\n\n                // Determine if we should expand:\n                // - If children are loaded and empty, don't expand (we know it has no children)\n                // - Otherwise, try to expand/fetch (either has children or we don't know yet)\n                let shouldExpand = false;\n                let mightHaveChildren = false;\n\n                if (childrenLoaded) {\n                    // Children are loaded - check if there are any\n                    const loadedChildren = loadedNodes.get(folder.id) || [];\n                    shouldExpand = loadedChildren.length > 0;\n                    mightHaveChildren = loadedChildren.length > 0;\n                } else {\n                    // Children not loaded yet - assume it might have children and try to expand\n                    shouldExpand = true;\n                    mightHaveChildren = true;\n                }\n\n                // Override with folder's children if available (from parent response)\n                if (hasChildrenFromFolder) {\n                    shouldExpand = true;\n                    mightHaveChildren = true;\n                }\n\n                if (shouldExpand) {\n                    expandNode(folder.id, mightHaveChildren, folder);\n                }\n            }\n\n            // Set current folder path (full path from root to clicked folder)\n            // Skip the root folder (id: '0') from the path\n            const pathWithoutRoot = path.filter((item) => item.id !== '0');\n            // Mark this path as internal navigation to prevent auto-scroll\n            lastInternalFolderPathRef.current = pathWithoutRoot;\n            setFolderPath(pathWithoutRoot);\n        },\n        [expandNode, loadedNodes, setFolderPath, toggleNode],\n    );\n\n    const rowProps = useMemo(\n        () => ({\n            currentFolderId,\n            data: flattenedNodes,\n            handleNodeClick,\n            toggleNode,\n        }),\n        [currentFolderId, flattenedNodes, handleNodeClick, toggleNode],\n    );\n\n    const [initialize, osInstance] = useOverlayScrollbars({\n        defer: false,\n        events: {\n            initialized(osInstance) {\n                const { viewport } = osInstance.elements();\n                viewport.style.overflowX = `var(--os-viewport-overflow-x)`;\n            },\n        },\n        options: {\n            overflow: { x: 'hidden', y: 'scroll' },\n            paddingAbsolute: true,\n            scrollbars: {\n                autoHide: 'leave',\n                autoHideDelay: 500,\n                pointers: ['mouse', 'pen', 'touch'],\n                theme: 'feishin-os-scrollbar',\n                visibility: 'visible',\n            },\n        },\n    });\n\n    useEffect(() => {\n        const { current: container } = containerRef;\n\n        if (!container || !container.firstElementChild) {\n            return;\n        }\n\n        const viewport = container.firstElementChild as HTMLElement;\n\n        initialize({\n            elements: { viewport },\n            target: container,\n        });\n\n        return () => osInstance()?.destroy();\n    }, [initialize, osInstance]);\n\n    // Track when we need to scroll (set by external navigation detection)\n    const [shouldScrollToFolder, setShouldScrollToFolder] = useState<null | string>(null);\n\n    // Handle external navigation - expand parent folders\n    useEffect(() => {\n        // Skip if folderPath hasn't actually changed\n        const pathChanged =\n            previousFolderPathRef.current.length !== folderPath.length ||\n            previousFolderPathRef.current.some((item, index) => item.id !== folderPath[index]?.id);\n\n        if (!pathChanged || !currentFolderId || currentFolderId === '0') {\n            previousFolderPathRef.current = folderPath;\n            setShouldScrollToFolder(null);\n            return;\n        }\n\n        // Check if this is an internal navigation (from clicking in tree browser)\n        const isInternalNavigation =\n            lastInternalFolderPathRef.current &&\n            lastInternalFolderPathRef.current.length === folderPath.length &&\n            lastInternalFolderPathRef.current.every(\n                (item, index) => item.id === folderPath[index]?.id,\n            );\n\n        if (isInternalNavigation) {\n            // Clear the internal navigation marker\n            lastInternalFolderPathRef.current = null;\n            previousFolderPathRef.current = folderPath;\n            setShouldScrollToFolder(null);\n            return;\n        }\n\n        previousFolderPathRef.current = folderPath;\n\n        // Expand all parent folders in the path to make current folder visible\n        const expandPath = async () => {\n            const foldersToExpand = folderPath.slice(0, -1); // All except the current folder\n\n            for (const pathItem of foldersToExpand) {\n                if (!expandedNodes.has(pathItem.id)) {\n                    // Fetch children if not loaded\n                    if (!loadedNodes.has(pathItem.id)) {\n                        await fetchFolderChildren(pathItem.id);\n                    }\n\n                    // Expand the folder\n                    setExpandedNodes((prev) => {\n                        const newSet = new Set(prev);\n                        newSet.add(pathItem.id);\n                        return newSet;\n                    });\n                }\n            }\n\n            // Mark that we should scroll to this folder once it appears in the tree\n            setShouldScrollToFolder(currentFolderId);\n        };\n\n        expandPath();\n    }, [folderPath, currentFolderId, expandedNodes, fetchFolderChildren, loadedNodes]);\n\n    // Scroll to current folder when it becomes visible in the tree\n    useEffect(() => {\n        if (!shouldScrollToFolder || !containerRef.current) {\n            return;\n        }\n\n        const currentIndex = flattenedNodes.findIndex(\n            (node) => node.folder.id === shouldScrollToFolder,\n        );\n\n        if (currentIndex !== -1) {\n            const viewport = containerRef.current.firstElementChild as HTMLElement;\n            if (viewport) {\n                const viewportHeight = viewport.clientHeight;\n                const scrollOffset = currentIndex * ITEM_HEIGHT;\n                const centeredOffset = scrollOffset - viewportHeight / 2 + ITEM_HEIGHT / 2;\n\n                viewport.scrollTo({\n                    behavior: 'auto',\n                    top: Math.max(0, centeredOffset),\n                });\n\n                setShouldScrollToFolder(null);\n            }\n        }\n    }, [flattenedNodes, shouldScrollToFolder]);\n\n    return (\n        <div className={styles.container} ref={containerRef}>\n            <List\n                rowComponent={RowComponent}\n                rowCount={flattenedNodes.length}\n                rowHeight={ITEM_HEIGHT}\n                rowProps={rowProps}\n            />\n        </div>\n    );\n};\n\nconst RowComponent = ({\n    currentFolderId,\n    data,\n    handleNodeClick,\n    index,\n    style,\n    toggleNode,\n}: RowComponentProps<{\n    currentFolderId: null | string;\n    data: FlattenedNode[];\n    handleNodeClick: (\n        folder: Folder,\n        path: Array<{ id: string; name: string }>,\n        isExpanded: boolean,\n        isCurrentFolder: boolean,\n    ) => void;\n    toggleNode: (folderId: string, hasChildren: boolean, folder?: Folder) => void;\n}>) => {\n    const item = data[index];\n    const folderNameRef = useRef<HTMLSpanElement>(null);\n    const folderIconRef = useRef<HTMLDivElement>(null);\n    const expandIconRef = useRef<HTMLDivElement | null>(null);\n    const rowRef = useRef<HTMLDivElement>(null);\n    const [tooltipOffset, setTooltipOffset] = useState(0);\n\n    const { isDragging, ref: dragRef } = useDragDrop<HTMLDivElement>({\n        drag: {\n            getId: () => (item ? [item.folder.id] : []),\n            getItem: () => (item ? [item.folder] : []),\n            itemType: LibraryItem.FOLDER,\n            operation: [DragOperation.ADD],\n            target: DragTarget.FOLDER,\n        },\n        isEnabled: !!item,\n    });\n\n    // Merge dragRef with rowRef\n    const mergedRef = useMergedRef(rowRef, dragRef);\n\n    const calculateOffset = useCallback(() => {\n        const rowElement = rowRef.current;\n        if (rowElement && folderIconRef.current && expandIconRef.current) {\n            const width = rowElement.offsetWidth;\n            const paddingLeft = item.depth * INDENT_SIZE;\n            const folderIconWidth = folderIconRef.current.offsetWidth;\n            const expandIconWidth = expandIconRef.current.offsetWidth;\n            const itemPadding = 8;\n            setTooltipOffset(\n                -width + paddingLeft + folderIconWidth + expandIconWidth + itemPadding,\n            );\n        }\n    }, [item.depth]);\n\n    useLayoutEffect(() => {\n        if (!item) {\n            return;\n        }\n\n        // Use requestAnimationFrame to ensure DOM is fully laid out\n        const rafId = requestAnimationFrame(() => {\n            calculateOffset();\n        });\n\n        const handleResize = () => {\n            calculateOffset();\n        };\n        window.addEventListener('resize', handleResize);\n        return () => {\n            cancelAnimationFrame(rafId);\n            window.removeEventListener('resize', handleResize);\n        };\n    }, [item, calculateOffset]);\n\n    // Recalculate offset when refs become available\n    useEffect(() => {\n        if (rowRef.current && folderIconRef.current && expandIconRef.current) {\n            calculateOffset();\n        }\n    }, [calculateOffset]);\n\n    if (!item) {\n        return <div style={style} />;\n    }\n\n    const isActive = currentFolderId === item.folder.id;\n    const paddingLeft = item.depth * INDENT_SIZE;\n\n    const handleExpandClick = (e: React.MouseEvent) => {\n        e.stopPropagation();\n        toggleNode(item.folder.id, item.hasChildren, item.folder);\n    };\n\n    const handleRowClick = () => {\n        handleNodeClick(item.folder, item.path, item.isExpanded, isActive);\n    };\n\n    const handleContextMenu = (e: React.MouseEvent<HTMLDivElement>) => {\n        e.preventDefault();\n        e.stopPropagation();\n        ContextMenuController.call({\n            cmd: {\n                items: [item.folder],\n                type: LibraryItem.FOLDER,\n            },\n            event: e,\n        });\n    };\n\n    return (\n        <Tooltip\n            classNames={{\n                tooltip: styles.tooltip,\n            }}\n            label={item.folder.name}\n            offset={tooltipOffset}\n            openDelay={0}\n            position=\"right\"\n            withArrow={false}\n        >\n            <div\n                className={clsx(styles.row, {\n                    [styles.active]: isActive,\n                    [styles.dragging]: isDragging,\n                })}\n                onClick={handleRowClick}\n                onContextMenu={handleContextMenu}\n                ref={mergedRef}\n                style={{\n                    ...style,\n                    paddingLeft: `${paddingLeft}px`,\n                }}\n            >\n                <div className={styles.rowContent}>\n                    {item.hasChildren ? (\n                        <div className={styles.expandIconContainer} ref={expandIconRef}>\n                            <Icon\n                                className={clsx(styles.expandIcon, {\n                                    [styles.expanded]: item.isExpanded,\n                                })}\n                                icon=\"arrowRightS\"\n                                onClick={handleExpandClick}\n                                size=\"sm\"\n                            />\n                        </div>\n                    ) : (\n                        <div className={styles.expandIconPlaceholder} ref={expandIconRef} />\n                    )}\n                    <div className={styles.folderIconContainer} ref={folderIconRef}>\n                        <Icon className={styles.folderIcon} icon=\"folder\" size=\"md\" />\n                    </div>\n                    <span className={styles.folderName} ref={folderNameRef}>\n                        {item.folder.name}\n                    </span>\n                </div>\n            </div>\n        </Tooltip>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/folders/hooks/use-folder-list-filters.ts",
    "content": "import { useMemo } from 'react';\nimport { useSearchParams } from 'react-router';\n\nimport { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-search-term-filter';\nimport { useSortByFilter } from '/@/renderer/features/shared/hooks/use-sort-by-filter';\nimport { useSortOrderFilter } from '/@/renderer/features/shared/hooks/use-sort-order-filter';\nimport { FILTER_KEYS } from '/@/renderer/features/shared/utils';\nimport { parseJsonParam, setJsonSearchParam } from '/@/renderer/utils/query-params';\nimport { SongListSort, SortOrder } from '/@/shared/types/domain-types';\nimport { ItemListKey } from '/@/shared/types/types';\n\nexport type FolderPathItem = {\n    id: string;\n    name: string;\n};\n\nexport const useFolderListFilters = () => {\n    const { sortBy } = useSortByFilter<SongListSort>(SongListSort.ID, ItemListKey.FOLDER);\n\n    const { sortOrder } = useSortOrderFilter(SortOrder.ASC, ItemListKey.FOLDER);\n\n    const { searchTerm, setSearchTerm } = useSearchTermFilter('');\n\n    const [searchParams, setSearchParams] = useSearchParams();\n\n    const folderPath = useMemo(() => {\n        const path = parseJsonParam<FolderPathItem[]>(searchParams, FILTER_KEYS.FOLDER.FOLDER_PATH);\n        return path || [];\n    }, [searchParams]);\n\n    const setFolderPath = (path: FolderPathItem[]) => {\n        setSearchParams(\n            (prev) => {\n                const newParams = setJsonSearchParam(prev, FILTER_KEYS.FOLDER.FOLDER_PATH, path);\n                return newParams;\n            },\n            { replace: false },\n        );\n    };\n\n    // Navigate to a folder (adds to path)\n    const navigateToFolder = (folderId: string, folderName: string) => {\n        setFolderPath([...folderPath, { id: folderId, name: folderName }]);\n    };\n\n    // Navigate back to a specific folder in the path (truncates path)\n    const navigateToPathIndex = (index: number) => {\n        setFolderPath(folderPath.slice(0, index + 1));\n    };\n\n    // Get current folder ID (last item in path, or '0' for root)\n    const currentFolderId = useMemo(() => {\n        return folderPath.length > 0 ? folderPath[folderPath.length - 1].id : '0';\n    }, [folderPath]);\n\n    const query = {\n        [FILTER_KEYS.SHARED.SEARCH_TERM]: searchTerm ?? undefined,\n        [FILTER_KEYS.SHARED.SORT_BY]: sortBy ?? undefined,\n        [FILTER_KEYS.SHARED.SORT_ORDER]: sortOrder ?? undefined,\n    };\n\n    return {\n        currentFolderId,\n        folderPath,\n        navigateToFolder,\n        navigateToPathIndex,\n        query,\n        setFolderPath,\n        setSearchTerm,\n    };\n};\n"
  },
  {
    "path": "src/renderer/features/folders/routes/folder-list-route.tsx",
    "content": "import { useMemo, useState } from 'react';\n\nimport { ListContext } from '/@/renderer/context/list-context';\nimport { FolderListContent } from '/@/renderer/features/folders/components/folder-list-content';\nimport { FolderListHeader } from '/@/renderer/features/folders/components/folder-list-header';\nimport { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';\nimport { ListWithSidebarContainer } from '/@/renderer/features/shared/components/list-with-sidebar-container';\nimport { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';\nimport { ItemListKey } from '/@/shared/types/types';\n\nconst FolderListRoute = () => {\n    const pageKey = ItemListKey.SONG;\n\n    const [itemCount, setItemCount] = useState<number | undefined>(undefined);\n\n    const providerValue = useMemo(() => {\n        return {\n            id: undefined,\n            itemCount,\n            pageKey,\n            setItemCount,\n        };\n    }, [itemCount, pageKey]);\n\n    return (\n        <AnimatedPage>\n            <ListContext.Provider value={providerValue}>\n                <FolderListHeader />\n                <ListWithSidebarContainer useBreakpoint>\n                    <FolderListContent />\n                </ListWithSidebarContainer>\n            </ListContext.Provider>\n        </AnimatedPage>\n    );\n};\n\nconst FolderListRouteWithBoundary = () => {\n    return (\n        <PageErrorBoundary>\n            <FolderListRoute />\n        </PageErrorBoundary>\n    );\n};\n\nexport default FolderListRouteWithBoundary;\n"
  },
  {
    "path": "src/renderer/features/genres/api/genres-api.ts",
    "content": "import { queryOptions, useSuspenseQuery } from '@tanstack/react-query';\n\nimport { api } from '/@/renderer/api';\nimport { queryKeys } from '/@/renderer/api/query-keys';\nimport { QueryHookArgs } from '/@/renderer/lib/react-query';\nimport { useCurrentServerId } from '/@/renderer/store';\nimport {\n    GenreListQuery,\n    GenreListSort,\n    ListCountQuery,\n    SortOrder,\n} from '/@/shared/types/domain-types';\n\nexport const genresQueries = {\n    list: (args: QueryHookArgs<GenreListQuery>) => {\n        return queryOptions({\n            gcTime: 1000 * 60 * 60,\n            queryFn: ({ signal }) => {\n                return api.controller.getGenreList({\n                    apiClientProps: { serverId: args.serverId, signal },\n                    query: args.query,\n                });\n            },\n            queryKey: queryKeys.genres.list(args.serverId, args.query),\n            staleTime: 1000 * 60 * 60,\n            ...args.options,\n        });\n    },\n    listCount: (args: QueryHookArgs<ListCountQuery<GenreListQuery>>) => {\n        return queryOptions({\n            gcTime: 1000 * 60 * 60,\n            queryFn: ({ signal }) => {\n                return api.controller\n                    .getGenreList({\n                        apiClientProps: { serverId: args.serverId, signal },\n                        query: { ...args.query, limit: 1, startIndex: 0 },\n                    })\n                    .then((result) => result?.totalRecordCount ?? 0);\n            },\n            queryKey: queryKeys.genres.count(\n                args.serverId,\n                Object.keys(args.query).length === 0 ? undefined : args.query,\n            ),\n            staleTime: 1000 * 60 * 60,\n            ...args.options,\n        });\n    },\n};\n\nexport const useGenreList = () => {\n    const serverId = useCurrentServerId();\n\n    return useSuspenseQuery({\n        ...genresQueries.list({\n            query: {\n                limit: -1,\n                sortBy: GenreListSort.NAME,\n                sortOrder: SortOrder.ASC,\n                startIndex: 0,\n            },\n            serverId,\n        }),\n        gcTime: Infinity,\n        staleTime: Infinity,\n    });\n};\n"
  },
  {
    "path": "src/renderer/features/genres/components/genre-detail-content.tsx",
    "content": "import { Suspense, useMemo } from 'react';\nimport { useParams } from 'react-router';\n\nimport { AlbumListView } from '/@/renderer/features/albums/components/album-list-content';\nimport { ListFilters, ListFiltersTitle } from '/@/renderer/features/shared/components/list-filters';\nimport { ListWithSidebarContainer } from '/@/renderer/features/shared/components/list-with-sidebar-container';\nimport { SaveAsCollectionButton } from '/@/renderer/features/shared/components/save-as-collection-button';\nimport { SongListView } from '/@/renderer/features/songs/components/song-list-content';\nimport { GenreTarget, useGenreTarget, useListSettings } from '/@/renderer/store';\nimport { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';\nimport { Spinner } from '/@/shared/components/spinner/spinner';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { LibraryItem } from '/@/shared/types/domain-types';\nimport { ItemListKey } from '/@/shared/types/types';\n\nconst GenreDetailFilters = () => {\n    const genreTarget = useGenreTarget();\n\n    if (genreTarget === GenreTarget.ALBUM) {\n        return (\n            <ListWithSidebarContainer.SidebarPortal>\n                <Stack h=\"100%\" style={{ minHeight: 0 }}>\n                    <ListFiltersTitle itemType={LibraryItem.ALBUM} />\n                    <ScrollArea style={{ flex: 1, minHeight: 0 }}>\n                        <ListFilters itemType={LibraryItem.ALBUM} />\n                    </ScrollArea>\n                    <Stack p=\"sm\">\n                        <SaveAsCollectionButton fullWidth itemType={LibraryItem.ALBUM} />\n                    </Stack>\n                </Stack>\n            </ListWithSidebarContainer.SidebarPortal>\n        );\n    }\n\n    if (genreTarget === GenreTarget.TRACK) {\n        return (\n            <ListWithSidebarContainer.SidebarPortal>\n                <Stack h=\"100%\" style={{ minHeight: 0 }}>\n                    <ListFiltersTitle itemType={LibraryItem.SONG} />\n                    <ScrollArea style={{ flex: 1, minHeight: 0 }}>\n                        <ListFilters itemType={LibraryItem.SONG} />\n                    </ScrollArea>\n                    <Stack p=\"sm\">\n                        <SaveAsCollectionButton fullWidth itemType={LibraryItem.SONG} />\n                    </Stack>\n                </Stack>\n            </ListWithSidebarContainer.SidebarPortal>\n        );\n    }\n\n    return null;\n};\n\nexport const GenreDetailContent = () => {\n    const genreTarget = useGenreTarget();\n\n    return (\n        <>\n            <GenreDetailFilters />\n            {genreTarget === GenreTarget.ALBUM && <GenreDetailContentAlbums />}\n            {genreTarget === GenreTarget.TRACK && <GenreDetailContentSongs />}\n        </>\n    );\n};\n\nfunction GenreDetailContentAlbums() {\n    const { genreId } = useParams() as { genreId: string };\n    const { display, grid, itemsPerPage, pagination, table } = useListSettings(ItemListKey.ALBUM);\n\n    const overrideQuery = useMemo(() => {\n        return {\n            genreIds: [genreId],\n        };\n    }, [genreId]);\n\n    return (\n        <Suspense fallback={<Spinner container />}>\n            <AlbumListView\n                display={display}\n                grid={grid}\n                itemsPerPage={itemsPerPage}\n                overrideQuery={overrideQuery}\n                pagination={pagination}\n                table={table}\n            />\n        </Suspense>\n    );\n}\n\nfunction GenreDetailContentSongs() {\n    const { genreId } = useParams() as { genreId: string };\n    const { display, grid, itemsPerPage, pagination, table } = useListSettings(ItemListKey.SONG);\n\n    const overrideQuery = useMemo(() => {\n        return {\n            genreIds: [genreId],\n        };\n    }, [genreId]);\n\n    return (\n        <Suspense fallback={<Spinner container />}>\n            <SongListView\n                display={display}\n                grid={grid}\n                itemsPerPage={itemsPerPage}\n                overrideQuery={overrideQuery}\n                pagination={pagination}\n                table={table}\n            />\n        </Suspense>\n    );\n}\n"
  },
  {
    "path": "src/renderer/features/genres/components/genre-detail-header.tsx",
    "content": "import { useMemo } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport { PageHeader } from '/@/renderer/components/page-header/page-header';\nimport { useListContext } from '/@/renderer/context/list-context';\nimport { AlbumListHeaderFilters } from '/@/renderer/features/albums/components/album-list-header-filters';\nimport { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album-list-filters';\nimport { FilterBar } from '/@/renderer/features/shared/components/filter-bar';\nimport { LibraryHeaderBar } from '/@/renderer/features/shared/components/library-header-bar';\nimport { ListSearchInput } from '/@/renderer/features/shared/components/list-search-input';\nimport { SongListHeaderFilters } from '/@/renderer/features/songs/components/song-list-header-filters';\nimport { useSongListFilters } from '/@/renderer/features/songs/hooks/use-song-list-filters';\nimport { GenreTarget, useGenreTarget } from '/@/renderer/store';\nimport { Group } from '/@/shared/components/group/group';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { LibraryItem } from '/@/shared/types/domain-types';\n\ninterface GenreDetailHeaderProps {\n    title?: string;\n}\n\nexport const GenreDetailHeader = ({ title }: GenreDetailHeaderProps) => {\n    const { t } = useTranslation();\n\n    const { itemCount } = useListContext();\n    const pageTitle = title || t('page.genreList.title', { postProcess: 'titleCase' });\n\n    const genreTarget = useGenreTarget();\n\n    return (\n        <Stack gap={0}>\n            <PageHeader>\n                <LibraryHeaderBar ignoreMaxWidth>\n                    <PlayButton />\n                    <LibraryHeaderBar.Title>{pageTitle}</LibraryHeaderBar.Title>\n                    <LibraryHeaderBar.Badge isLoading={!itemCount}>\n                        {itemCount}\n                    </LibraryHeaderBar.Badge>\n                </LibraryHeaderBar>\n                <Group>\n                    <ListSearchInput />\n                </Group>\n            </PageHeader>\n            <FilterBar>\n                {genreTarget === GenreTarget.ALBUM ? (\n                    <AlbumListHeaderFilters toggleGenreTarget />\n                ) : (\n                    <SongListHeaderFilters toggleGenreTarget />\n                )}\n            </FilterBar>\n        </Stack>\n    );\n};\n\nconst PlayButton = () => {\n    const genreTarget = useGenreTarget();\n\n    switch (genreTarget) {\n        case GenreTarget.ALBUM:\n            return <AlbumPlayButton />;\n        case GenreTarget.TRACK:\n            return <SongPlayButton />;\n        default:\n            return null;\n    }\n};\n\nconst AlbumPlayButton = () => {\n    const { query } = useAlbumListFilters();\n    const { id } = useListContext();\n\n    const mergedQuery = useMemo(() => {\n        return {\n            ...query,\n            genreIds: [id],\n        };\n    }, [query, id]);\n\n    return (\n        <LibraryHeaderBar.PlayButton\n            itemType={LibraryItem.ALBUM}\n            listQuery={mergedQuery}\n            variant=\"filled\"\n        />\n    );\n};\n\nconst SongPlayButton = () => {\n    const { query } = useSongListFilters();\n    const { id } = useListContext();\n\n    const mergedQuery = useMemo(() => {\n        return {\n            ...query,\n            genreIds: [id],\n        };\n    }, [query, id]);\n\n    return (\n        <LibraryHeaderBar.PlayButton\n            itemType={LibraryItem.SONG}\n            listQuery={mergedQuery}\n            variant=\"filled\"\n        />\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/genres/components/genre-list-content.tsx",
    "content": "import { lazy, Suspense, useMemo } from 'react';\n\nimport { useGenreListFilters } from '/@/renderer/features/genres/hooks/use-genre-list-filters';\nimport { ItemListSettings, useCurrentServer, useListSettings } from '/@/renderer/store';\nimport { Spinner } from '/@/shared/components/spinner/spinner';\nimport { GenreListQuery } from '/@/shared/types/domain-types';\nimport { ItemListKey, ListDisplayType, ListPaginationType } from '/@/shared/types/types';\n\nconst GenreListInfiniteGrid = lazy(() =>\n    import('/@/renderer/features/genres/components/genre-list-infinite-grid').then((module) => ({\n        default: module.GenreListInfiniteGrid,\n    })),\n);\n\nconst GenreListPaginatedGrid = lazy(() =>\n    import('/@/renderer/features/genres/components/genre-list-paginated-grid').then((module) => ({\n        default: module.GenreListPaginatedGrid,\n    })),\n);\n\nconst GenreListInfiniteTable = lazy(() =>\n    import('/@/renderer/features/genres/components/genre-list-infinite-table').then((module) => ({\n        default: module.GenreListInfiniteTable,\n    })),\n);\n\nconst GenreListPaginatedTable = lazy(() =>\n    import('/@/renderer/features/genres/components/genre-list-paginated-table').then((module) => ({\n        default: module.GenreListPaginatedTable,\n    })),\n);\n\nexport const GenreListContent = () => {\n    const { display, grid, itemsPerPage, pagination, table } = useListSettings(ItemListKey.GENRE);\n\n    return (\n        <Suspense fallback={<Spinner container />}>\n            <GenreListView\n                display={display}\n                grid={grid}\n                itemsPerPage={itemsPerPage}\n                pagination={pagination}\n                table={table}\n            />\n        </Suspense>\n    );\n};\n\nexport const GenreListView = ({\n    display,\n    grid,\n    itemsPerPage,\n    overrideQuery,\n    pagination,\n    table,\n}: ItemListSettings & { overrideQuery?: Omit<GenreListQuery, 'limit' | 'startIndex'> }) => {\n    const server = useCurrentServer();\n\n    const { query } = useGenreListFilters();\n\n    const mergedQuery = useMemo(() => {\n        if (!overrideQuery) {\n            return query;\n        }\n\n        return {\n            ...query,\n            ...overrideQuery,\n            sortBy: overrideQuery.sortBy || query.sortBy,\n            sortOrder: overrideQuery.sortOrder || query.sortOrder,\n        };\n    }, [query, overrideQuery]);\n\n    switch (display) {\n        case ListDisplayType.GRID: {\n            switch (pagination) {\n                case ListPaginationType.INFINITE: {\n                    return (\n                        <GenreListInfiniteGrid\n                            gap={grid.itemGap}\n                            itemsPerPage={itemsPerPage}\n                            itemsPerRow={grid.itemsPerRowEnabled ? grid.itemsPerRow : undefined}\n                            query={mergedQuery}\n                            serverId={server.id}\n                            size={grid.size}\n                        />\n                    );\n                }\n                case ListPaginationType.PAGINATED: {\n                    return (\n                        <GenreListPaginatedGrid\n                            gap={grid.itemGap}\n                            itemsPerPage={itemsPerPage}\n                            itemsPerRow={grid.itemsPerRowEnabled ? grid.itemsPerRow : undefined}\n                            query={mergedQuery}\n                            serverId={server.id}\n                            size={grid.size}\n                        />\n                    );\n                }\n                default:\n                    return null;\n            }\n        }\n        case ListDisplayType.TABLE: {\n            switch (pagination) {\n                case ListPaginationType.INFINITE: {\n                    return (\n                        <GenreListInfiniteTable\n                            autoFitColumns={table.autoFitColumns}\n                            columns={table.columns}\n                            enableAlternateRowColors={table.enableAlternateRowColors}\n                            enableHeader={table.enableHeader}\n                            enableHorizontalBorders={table.enableHorizontalBorders}\n                            enableRowHoverHighlight={table.enableRowHoverHighlight}\n                            enableVerticalBorders={table.enableVerticalBorders}\n                            itemsPerPage={itemsPerPage}\n                            query={mergedQuery}\n                            serverId={server.id}\n                            size={table.size}\n                        />\n                    );\n                }\n                case ListPaginationType.PAGINATED: {\n                    return (\n                        <GenreListPaginatedTable\n                            autoFitColumns={table.autoFitColumns}\n                            columns={table.columns}\n                            enableAlternateRowColors={table.enableAlternateRowColors}\n                            enableHeader={table.enableHeader}\n                            enableHorizontalBorders={table.enableHorizontalBorders}\n                            enableRowHoverHighlight={table.enableRowHoverHighlight}\n                            enableVerticalBorders={table.enableVerticalBorders}\n                            itemsPerPage={itemsPerPage}\n                            query={mergedQuery}\n                            serverId={server.id}\n                            size={table.size}\n                        />\n                    );\n                }\n                default:\n                    return null;\n            }\n        }\n    }\n\n    return null;\n};\n"
  },
  {
    "path": "src/renderer/features/genres/components/genre-list-header-filters.tsx",
    "content": "import { GENRE_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns';\nimport { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';\nimport { ListDisplayTypeToggleButton } from '/@/renderer/features/shared/components/list-display-type-toggle-button';\nimport { ListRefreshButton } from '/@/renderer/features/shared/components/list-refresh-button';\nimport { ListSortByDropdown } from '/@/renderer/features/shared/components/list-sort-by-dropdown';\nimport { ListSortOrderToggleButton } from '/@/renderer/features/shared/components/list-sort-order-toggle-button';\nimport { Divider } from '/@/shared/components/divider/divider';\nimport { Flex } from '/@/shared/components/flex/flex';\nimport { Group } from '/@/shared/components/group/group';\nimport { GenreListSort, LibraryItem, SortOrder } from '/@/shared/types/domain-types';\nimport { ItemListKey } from '/@/shared/types/types';\n\nexport const GenreListHeaderFilters = () => {\n    return (\n        <Flex justify=\"space-between\">\n            <Group gap=\"sm\" w=\"100%\">\n                <ListSortByDropdown\n                    defaultSortByValue={GenreListSort.NAME}\n                    itemType={LibraryItem.GENRE}\n                    listKey={ItemListKey.GENRE}\n                />\n                <Divider orientation=\"vertical\" />\n                <ListSortOrderToggleButton\n                    defaultSortOrder={SortOrder.ASC}\n                    listKey={ItemListKey.GENRE}\n                />\n                <ListRefreshButton listKey={ItemListKey.GENRE} />\n            </Group>\n            <Group gap=\"sm\" wrap=\"nowrap\">\n                <ListDisplayTypeToggleButton listKey={ItemListKey.GENRE} />\n                <ListConfigMenu\n                    listKey={ItemListKey.GENRE}\n                    tableColumnsData={GENRE_TABLE_COLUMNS}\n                />\n            </Group>\n        </Flex>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/genres/components/genre-list-header.tsx",
    "content": "import { useTranslation } from 'react-i18next';\n\nimport { useIsFetchingItemListCount } from '/@/renderer/components/item-list/helpers/use-is-fetching-item-list';\nimport { PageHeader } from '/@/renderer/components/page-header/page-header';\nimport { useListContext } from '/@/renderer/context/list-context';\nimport { GenreListHeaderFilters } from '/@/renderer/features/genres/components/genre-list-header-filters';\nimport { useGenreListFilters } from '/@/renderer/features/genres/hooks/use-genre-list-filters';\nimport { FilterBar } from '/@/renderer/features/shared/components/filter-bar';\nimport { LibraryHeaderBar } from '/@/renderer/features/shared/components/library-header-bar';\nimport { ListSearchInput } from '/@/renderer/features/shared/components/list-search-input';\nimport { Group } from '/@/shared/components/group/group';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { LibraryItem } from '/@/shared/types/domain-types';\n\ninterface GenreListHeaderProps {\n    title?: string;\n}\n\nexport const GenreListHeader = ({ title }: GenreListHeaderProps) => {\n    const { t } = useTranslation();\n\n    const pageTitle = title || t('page.genreList.title', { postProcess: 'titleCase' });\n\n    return (\n        <Stack gap={0}>\n            <PageHeader>\n                <LibraryHeaderBar ignoreMaxWidth>\n                    <PlayButton />\n                    <LibraryHeaderBar.Title>{pageTitle}</LibraryHeaderBar.Title>\n                    <GenreListHeaderBadge />\n                </LibraryHeaderBar>\n                <Group>\n                    <ListSearchInput />\n                </Group>\n            </PageHeader>\n            <FilterBar>\n                <GenreListHeaderFilters />\n            </FilterBar>\n        </Stack>\n    );\n};\n\nconst GenreListHeaderBadge = () => {\n    const { itemCount } = useListContext();\n\n    const isFetching = useIsFetchingItemListCount({\n        itemType: LibraryItem.GENRE,\n    });\n\n    return <LibraryHeaderBar.Badge isLoading={isFetching}>{itemCount}</LibraryHeaderBar.Badge>;\n};\n\nconst PlayButton = () => {\n    const { query } = useGenreListFilters();\n\n    return <LibraryHeaderBar.PlayButton itemType={LibraryItem.GENRE} listQuery={query} />;\n};\n"
  },
  {
    "path": "src/renderer/features/genres/components/genre-list-infinite-grid.tsx",
    "content": "import { UseSuspenseQueryOptions } from '@tanstack/react-query';\n\nimport { api } from '/@/renderer/api';\nimport { useItemListInfiniteLoader } from '/@/renderer/components/item-list/helpers/item-list-infinite-loader';\nimport { useGridRows } from '/@/renderer/components/item-list/helpers/use-grid-rows';\nimport { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist';\nimport { ItemGridList } from '/@/renderer/components/item-list/item-grid-list/item-grid-list';\nimport { ItemListGridComponentProps } from '/@/renderer/components/item-list/types';\nimport { genresQueries } from '/@/renderer/features/genres/api/genres-api';\nimport { useGeneralSettings } from '/@/renderer/store';\nimport {\n    GenreListQuery,\n    GenreListSort,\n    LibraryItem,\n    SortOrder,\n} from '/@/shared/types/domain-types';\nimport { ItemListKey } from '/@/shared/types/types';\n\ninterface GenreListInfiniteGridProps extends ItemListGridComponentProps<GenreListQuery> {}\n\nexport const GenreListInfiniteGrid = ({\n    gap = 'md',\n    itemsPerPage = 100,\n    itemsPerRow,\n    query = {\n        sortBy: GenreListSort.NAME,\n        sortOrder: SortOrder.ASC,\n    },\n    saveScrollOffset = true,\n    serverId,\n    size,\n}: GenreListInfiniteGridProps) => {\n    const listCountQuery = genresQueries.listCount({\n        query: { ...query },\n        serverId: serverId,\n    }) as UseSuspenseQueryOptions<number, Error, number, readonly unknown[]>;\n\n    const listQueryFn = api.controller.getGenreList;\n\n    const { dataVersion, getItem, getItemIndex, itemCount, loadedItems, onRangeChanged } =\n        useItemListInfiniteLoader({\n            eventKey: ItemListKey.GENRE,\n            itemsPerPage,\n            itemType: LibraryItem.GENRE,\n            listCountQuery,\n            listQueryFn,\n            query,\n            serverId,\n        });\n\n    const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({\n        enabled: saveScrollOffset,\n    });\n\n    const rows = useGridRows(LibraryItem.GENRE, ItemListKey.GENRE, size);\n    const { enableGridMultiSelect } = useGeneralSettings();\n\n    return (\n        <ItemGridList\n            data={loadedItems}\n            dataVersion={dataVersion}\n            enableMultiSelect={enableGridMultiSelect}\n            gap={gap}\n            getItem={getItem}\n            getItemIndex={getItemIndex}\n            initialTop={{\n                to: scrollOffset ?? 0,\n                type: 'offset',\n            }}\n            itemCount={itemCount}\n            itemsPerRow={itemsPerRow}\n            itemType={LibraryItem.GENRE}\n            onRangeChanged={onRangeChanged}\n            onScrollEnd={handleOnScrollEnd}\n            rows={rows}\n            size={size}\n        />\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/genres/components/genre-list-infinite-table.tsx",
    "content": "import { UseSuspenseQueryOptions } from '@tanstack/react-query';\n\nimport { api } from '/@/renderer/api';\nimport { useItemListInfiniteLoader } from '/@/renderer/components/item-list/helpers/item-list-infinite-loader';\nimport { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder';\nimport { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize';\nimport { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist';\nimport { ItemTableList } from '/@/renderer/components/item-list/item-table-list/item-table-list';\nimport { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';\nimport { ItemListTableComponentProps } from '/@/renderer/components/item-list/types';\nimport { genresQueries } from '/@/renderer/features/genres/api/genres-api';\nimport {\n    GenreListQuery,\n    GenreListSort,\n    LibraryItem,\n    SortOrder,\n} from '/@/shared/types/domain-types';\nimport { ItemListKey } from '/@/shared/types/types';\n\ninterface GenreListInfiniteTableProps extends ItemListTableComponentProps<GenreListQuery> {}\n\nexport const GenreListInfiniteTable = ({\n    autoFitColumns = false,\n    columns,\n    enableAlternateRowColors = false,\n    enableHeader = true,\n    enableHorizontalBorders = false,\n    enableRowHoverHighlight = true,\n    enableSelection = true,\n    enableVerticalBorders = false,\n    itemsPerPage = 100,\n    query = {\n        sortBy: GenreListSort.NAME,\n        sortOrder: SortOrder.ASC,\n    },\n    saveScrollOffset = true,\n    serverId,\n    size = 'default',\n}: GenreListInfiniteTableProps) => {\n    const listCountQuery = genresQueries.listCount({\n        query: { ...query },\n        serverId: serverId,\n    }) as UseSuspenseQueryOptions<number, Error, number, readonly unknown[]>;\n\n    const listQueryFn = api.controller.getGenreList;\n\n    const { getItem, getItemIndex, itemCount, loadedItems, onRangeChanged } =\n        useItemListInfiniteLoader({\n            eventKey: ItemListKey.GENRE,\n            itemsPerPage,\n            itemType: LibraryItem.GENRE,\n            listCountQuery,\n            listQueryFn,\n            query,\n            serverId,\n        });\n\n    const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({\n        enabled: saveScrollOffset,\n    });\n\n    const { handleColumnReordered } = useItemListColumnReorder({\n        itemListKey: ItemListKey.GENRE,\n    });\n\n    const { handleColumnResized } = useItemListColumnResize({\n        itemListKey: ItemListKey.GENRE,\n    });\n\n    return (\n        <ItemTableList\n            autoFitColumns={autoFitColumns}\n            CellComponent={ItemTableListColumn}\n            columns={columns}\n            data={loadedItems}\n            enableAlternateRowColors={enableAlternateRowColors}\n            enableExpansion={false}\n            enableHeader={enableHeader}\n            enableHorizontalBorders={enableHorizontalBorders}\n            enableRowHoverHighlight={enableRowHoverHighlight}\n            enableSelection={enableSelection}\n            enableVerticalBorders={enableVerticalBorders}\n            getItem={getItem}\n            getItemIndex={getItemIndex}\n            initialTop={{\n                to: scrollOffset ?? 0,\n                type: 'offset',\n            }}\n            itemCount={itemCount}\n            itemType={LibraryItem.GENRE}\n            onColumnReordered={handleColumnReordered}\n            onColumnResized={handleColumnResized}\n            onRangeChanged={onRangeChanged}\n            onScrollEnd={handleOnScrollEnd}\n            size={size}\n        />\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/genres/components/genre-list-paginated-grid.tsx",
    "content": "import { UseSuspenseQueryOptions } from '@tanstack/react-query';\n\nimport { api } from '/@/renderer/api';\nimport { useItemListPaginatedLoader } from '/@/renderer/components/item-list/helpers/item-list-paginated-loader';\nimport { useGridRows } from '/@/renderer/components/item-list/helpers/use-grid-rows';\nimport { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist';\nimport { ItemGridList } from '/@/renderer/components/item-list/item-grid-list/item-grid-list';\nimport { ItemListWithPagination } from '/@/renderer/components/item-list/item-list-pagination/item-list-pagination';\nimport { useItemListPagination } from '/@/renderer/components/item-list/item-list-pagination/use-item-list-pagination';\nimport { ItemListGridComponentProps } from '/@/renderer/components/item-list/types';\nimport { genresQueries } from '/@/renderer/features/genres/api/genres-api';\nimport { useGeneralSettings } from '/@/renderer/store';\nimport {\n    GenreListQuery,\n    GenreListSort,\n    LibraryItem,\n    SortOrder,\n} from '/@/shared/types/domain-types';\nimport { ItemListKey } from '/@/shared/types/types';\n\ninterface GenreListPaginatedGridProps extends ItemListGridComponentProps<GenreListQuery> {}\n\nexport const GenreListPaginatedGrid = ({\n    gap = 'md',\n    itemsPerPage = 100,\n    itemsPerRow,\n    query = {\n        sortBy: GenreListSort.NAME,\n        sortOrder: SortOrder.ASC,\n    },\n    saveScrollOffset = true,\n    serverId,\n    size,\n}: GenreListPaginatedGridProps) => {\n    const listCountQuery = genresQueries.listCount({\n        query: { ...query },\n        serverId: serverId,\n    }) as UseSuspenseQueryOptions<number, Error, number, readonly unknown[]>;\n\n    const listQueryFn = api.controller.getGenreList;\n\n    const { currentPage, onChange } = useItemListPagination();\n\n    const { data, pageCount, totalItemCount } = useItemListPaginatedLoader({\n        currentPage,\n        eventKey: ItemListKey.GENRE,\n        itemsPerPage,\n        itemType: LibraryItem.GENRE,\n        listCountQuery,\n        listQueryFn,\n        query,\n        serverId,\n    });\n\n    const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({\n        enabled: saveScrollOffset,\n    });\n\n    const rows = useGridRows(LibraryItem.GENRE, ItemListKey.GENRE, size);\n    const { enableGridMultiSelect } = useGeneralSettings();\n\n    return (\n        <ItemListWithPagination\n            currentPage={currentPage}\n            itemsPerPage={itemsPerPage}\n            onChange={onChange}\n            pageCount={pageCount}\n            totalItemCount={totalItemCount}\n        >\n            <ItemGridList\n                currentPage={currentPage}\n                data={data || []}\n                enableMultiSelect={enableGridMultiSelect}\n                gap={gap}\n                initialTop={{\n                    to: scrollOffset ?? 0,\n                    type: 'offset',\n                }}\n                itemsPerRow={itemsPerRow}\n                itemType={LibraryItem.GENRE}\n                onScrollEnd={handleOnScrollEnd}\n                rows={rows}\n                size={size}\n            />\n        </ItemListWithPagination>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/genres/components/genre-list-paginated-table.tsx",
    "content": "import { UseSuspenseQueryOptions } from '@tanstack/react-query';\n\nimport { api } from '/@/renderer/api';\nimport { useItemListPaginatedLoader } from '/@/renderer/components/item-list/helpers/item-list-paginated-loader';\nimport { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder';\nimport { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize';\nimport { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist';\nimport { ItemListWithPagination } from '/@/renderer/components/item-list/item-list-pagination/item-list-pagination';\nimport { useItemListPagination } from '/@/renderer/components/item-list/item-list-pagination/use-item-list-pagination';\nimport { ItemTableList } from '/@/renderer/components/item-list/item-table-list/item-table-list';\nimport { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';\nimport { ItemListTableComponentProps } from '/@/renderer/components/item-list/types';\nimport { genresQueries } from '/@/renderer/features/genres/api/genres-api';\nimport {\n    GenreListQuery,\n    GenreListSort,\n    LibraryItem,\n    SortOrder,\n} from '/@/shared/types/domain-types';\nimport { ItemListKey } from '/@/shared/types/types';\n\ninterface GenreListPaginatedTableProps extends ItemListTableComponentProps<GenreListQuery> {}\n\nexport const GenreListPaginatedTable = ({\n    autoFitColumns = false,\n    columns,\n    enableAlternateRowColors = false,\n    enableHeader = true,\n    enableHorizontalBorders = false,\n    enableRowHoverHighlight = true,\n    enableSelection = true,\n    enableVerticalBorders = false,\n    itemsPerPage = 100,\n    query = {\n        sortBy: GenreListSort.NAME,\n        sortOrder: SortOrder.ASC,\n    },\n    saveScrollOffset = true,\n    serverId,\n    size = 'default',\n}: GenreListPaginatedTableProps) => {\n    const listCountQuery = genresQueries.listCount({\n        query: { ...query },\n        serverId: serverId,\n    }) as UseSuspenseQueryOptions<number, Error, number, readonly unknown[]>;\n\n    const listQueryFn = api.controller.getGenreList;\n\n    const { currentPage, onChange } = useItemListPagination();\n\n    const { data, pageCount, totalItemCount } = useItemListPaginatedLoader({\n        currentPage,\n        eventKey: ItemListKey.GENRE,\n        itemsPerPage,\n        itemType: LibraryItem.GENRE,\n        listCountQuery,\n        listQueryFn,\n        query,\n        serverId,\n    });\n\n    const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({\n        enabled: saveScrollOffset,\n    });\n\n    const { handleColumnReordered } = useItemListColumnReorder({\n        itemListKey: ItemListKey.GENRE,\n    });\n\n    const { handleColumnResized } = useItemListColumnResize({\n        itemListKey: ItemListKey.GENRE,\n    });\n\n    const startRowIndex = currentPage * itemsPerPage;\n\n    return (\n        <ItemListWithPagination\n            currentPage={currentPage}\n            itemsPerPage={itemsPerPage}\n            onChange={onChange}\n            pageCount={pageCount}\n            totalItemCount={totalItemCount}\n        >\n            <ItemTableList\n                autoFitColumns={autoFitColumns}\n                CellComponent={ItemTableListColumn}\n                columns={columns}\n                data={data || []}\n                enableAlternateRowColors={enableAlternateRowColors}\n                enableExpansion={false}\n                enableHeader={enableHeader}\n                enableHorizontalBorders={enableHorizontalBorders}\n                enableRowHoverHighlight={enableRowHoverHighlight}\n                enableSelection={enableSelection}\n                enableVerticalBorders={enableVerticalBorders}\n                initialTop={{\n                    to: scrollOffset ?? 0,\n                    type: 'offset',\n                }}\n                itemType={LibraryItem.GENRE}\n                onColumnReordered={handleColumnReordered}\n                onColumnResized={handleColumnResized}\n                onScrollEnd={handleOnScrollEnd}\n                size={size}\n                startRowIndex={startRowIndex}\n            />\n        </ItemListWithPagination>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/genres/hooks/use-genre-list-filters.ts",
    "content": "import { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-search-term-filter';\nimport { useSortByFilter } from '/@/renderer/features/shared/hooks/use-sort-by-filter';\nimport { useSortOrderFilter } from '/@/renderer/features/shared/hooks/use-sort-order-filter';\nimport { FILTER_KEYS } from '/@/renderer/features/shared/utils';\nimport { GenreListSort } from '/@/shared/types/domain-types';\nimport { ItemListKey } from '/@/shared/types/types';\n\nexport const useGenreListFilters = () => {\n    const { sortBy } = useSortByFilter<GenreListSort>(null, ItemListKey.GENRE);\n\n    const { sortOrder } = useSortOrderFilter(null, ItemListKey.GENRE);\n\n    const { searchTerm, setSearchTerm } = useSearchTermFilter('');\n\n    const query = {\n        [FILTER_KEYS.SHARED.SEARCH_TERM]: searchTerm ?? undefined,\n        [FILTER_KEYS.SHARED.SORT_BY]: sortBy ?? undefined,\n        [FILTER_KEYS.SHARED.SORT_ORDER]: sortOrder ?? undefined,\n    };\n\n    return {\n        query,\n        setSearchTerm,\n    };\n};\n"
  },
  {
    "path": "src/renderer/features/genres/routes/genre-detail-route.tsx",
    "content": "import { useMemo, useState } from 'react';\nimport { useParams } from 'react-router';\n\nimport { ListContext } from '/@/renderer/context/list-context';\nimport { useGenreList } from '/@/renderer/features/genres/api/genres-api';\nimport { GenreDetailContent } from '/@/renderer/features/genres/components/genre-detail-content';\nimport { GenreDetailHeader } from '/@/renderer/features/genres/components/genre-detail-header';\nimport { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';\nimport { ListWithSidebarContainer } from '/@/renderer/features/shared/components/list-with-sidebar-container';\nimport { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';\nimport { GenreTarget, useGenreTarget } from '/@/renderer/store';\nimport { usePageSidebar } from '/@/renderer/store/app.store';\nimport { ItemListKey } from '/@/shared/types/types';\n\nconst GenreDetailRoute = () => {\n    const { genreId } = useParams() as { genreId: string };\n    const genreTarget = useGenreTarget();\n    const pageKey =\n        genreTarget === GenreTarget.ALBUM ? ItemListKey.GENRE_ALBUM : ItemListKey.GENRE_SONG;\n\n    const [itemCount, setItemCount] = useState<number | undefined>(undefined);\n    const [isSidebarOpen, setIsSidebarOpen] = usePageSidebar(pageKey);\n\n    const providerValue = useMemo(() => {\n        return {\n            id: genreId,\n            isSidebarOpen,\n            itemCount,\n            pageKey,\n            setIsSidebarOpen,\n            setItemCount,\n        };\n    }, [genreId, isSidebarOpen, itemCount, pageKey, setIsSidebarOpen, setItemCount]);\n\n    const { data: genres } = useGenreList();\n\n    const name = useMemo(() => {\n        return genres?.items.find((g) => g.id === genreId)?.name || '—';\n    }, [genreId, genres]);\n\n    return (\n        <AnimatedPage>\n            <ListContext.Provider value={providerValue}>\n                <GenreDetailHeader title={name} />\n                <ListWithSidebarContainer>\n                    <GenreDetailContent />\n                </ListWithSidebarContainer>\n            </ListContext.Provider>\n        </AnimatedPage>\n    );\n};\n\nconst GenreDetailRouteWithBoundary = () => {\n    return (\n        <PageErrorBoundary>\n            <GenreDetailRoute />\n        </PageErrorBoundary>\n    );\n};\n\nexport default GenreDetailRouteWithBoundary;\n"
  },
  {
    "path": "src/renderer/features/genres/routes/genre-list-route.tsx",
    "content": "import { useMemo, useState } from 'react';\n\nimport { ListContext } from '/@/renderer/context/list-context';\nimport { GenreListContent } from '/@/renderer/features/genres/components/genre-list-content';\nimport { GenreListHeader } from '/@/renderer/features/genres/components/genre-list-header';\nimport { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';\nimport { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';\nimport { ItemListKey } from '/@/shared/types/types';\n\nconst GenreListRoute = () => {\n    const pageKey = ItemListKey.GENRE;\n\n    const [itemCount, setItemCount] = useState<number | undefined>(undefined);\n\n    const providerValue = useMemo(() => {\n        return {\n            id: undefined,\n            itemCount,\n            pageKey,\n            setItemCount,\n        };\n    }, [itemCount, pageKey, setItemCount]);\n\n    return (\n        <AnimatedPage>\n            <ListContext.Provider value={providerValue}>\n                <GenreListHeader />\n                <GenreListContent />\n            </ListContext.Provider>\n        </AnimatedPage>\n    );\n};\n\nconst GenreListRouteWithBoundary = () => {\n    return (\n        <PageErrorBoundary>\n            <GenreListRoute />\n        </PageErrorBoundary>\n    );\n};\n\nexport default GenreListRouteWithBoundary;\n"
  },
  {
    "path": "src/renderer/features/home/api/home-api.ts",
    "content": "import { queryOptions } from '@tanstack/react-query';\n\nimport { api } from '/@/renderer/api';\nimport { queryKeys } from '/@/renderer/api/query-keys';\nimport { QueryHookArgs } from '/@/renderer/lib/react-query';\nimport { AlbumListQuery, AlbumListSort, SortOrder } from '/@/shared/types/domain-types';\n\nexport const homeQueries = {\n    recentlyPlayed: (args: QueryHookArgs<Partial<AlbumListQuery>>) => {\n        const requestQuery: AlbumListQuery = {\n            limit: 5,\n            sortBy: AlbumListSort.RECENTLY_PLAYED,\n            sortOrder: SortOrder.ASC,\n            startIndex: 0,\n            ...args.query,\n        };\n\n        return queryOptions({\n            queryFn: ({ signal }) => {\n                return api.controller.getAlbumList({\n                    apiClientProps: { serverId: args.serverId, signal },\n                    query: requestQuery,\n                });\n            },\n            queryKey: queryKeys.albums.list(args.serverId, requestQuery),\n            ...args.options,\n        });\n    },\n};\n"
  },
  {
    "path": "src/renderer/features/home/components/album-infinite-feature-carousel.tsx",
    "content": "import { QueryFunctionContext, useSuspenseInfiniteQuery } from '@tanstack/react-query';\nimport { useEffect, useMemo, useRef } from 'react';\n\nimport { api } from '/@/renderer/api';\nimport { queryKeys } from '/@/renderer/api/query-keys';\nimport { FeatureCarousel } from '/@/renderer/components/feature-carousel/feature-carousel';\nimport { useCurrentServerId } from '/@/renderer/store';\nimport { Album, AlbumListResponse, AlbumListSort, SortOrder } from '/@/shared/types/domain-types';\n\ninterface InfiniteAlbumFeatureCarouselProps {\n    itemLimit?: number;\n    queryKey?: QueryFunctionContext['queryKey'];\n}\n\nexport const AlbumInfiniteFeatureCarousel = ({\n    itemLimit = 20,\n    queryKey,\n}: InfiniteAlbumFeatureCarouselProps) => {\n    const serverId = useCurrentServerId();\n    const loadMoreTriggeredRef = useRef(false);\n\n    const defaultQueryKey = queryKeys.albums.infiniteList(serverId, {\n        sortBy: AlbumListSort.RANDOM,\n        sortOrder: SortOrder.DESC,\n    });\n\n    const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =\n        useSuspenseInfiniteQuery<AlbumListResponse>({\n            getNextPageParam: (lastPage, _allPages, lastPageParam) => {\n                if (lastPage.items.length < itemLimit) {\n                    return undefined;\n                }\n\n                const nextPageParam = Number(lastPageParam) + itemLimit;\n\n                return String(nextPageParam);\n            },\n            initialPageParam: '0',\n            queryFn: ({ pageParam, signal }) => {\n                return api.controller.getAlbumList({\n                    apiClientProps: { serverId, signal },\n                    query: {\n                        limit: itemLimit,\n                        sortBy: AlbumListSort.RANDOM,\n                        sortOrder: SortOrder.DESC,\n                        startIndex: Number(pageParam),\n                    },\n                });\n            },\n            queryKey: queryKey || defaultQueryKey,\n        });\n\n    // Flatten all pages and filter for albums with images\n    const albumsWithImages = useMemo(() => {\n        const allAlbums = data.pages.flatMap((page: AlbumListResponse) => page.items);\n        // Filter for albums with images and remove duplicates by ID\n        const uniqueAlbums = new Map<string, Album>();\n        for (const album of allAlbums) {\n            if (album.imageId && !uniqueAlbums.has(album.id)) {\n                uniqueAlbums.set(album.id, album);\n            }\n        }\n        return Array.from(uniqueAlbums.values());\n    }, [data.pages]);\n\n    const handleNearEnd = () => {\n        if (hasNextPage && !isFetchingNextPage && !loadMoreTriggeredRef.current) {\n            loadMoreTriggeredRef.current = true;\n            fetchNextPage().finally(() => {\n                loadMoreTriggeredRef.current = false;\n            });\n        }\n    };\n\n    useEffect(() => {\n        if (\n            albumsWithImages.length < itemLimit * 2 &&\n            hasNextPage &&\n            !isFetchingNextPage &&\n            !loadMoreTriggeredRef.current\n        ) {\n            loadMoreTriggeredRef.current = true;\n            fetchNextPage().finally(() => {\n                loadMoreTriggeredRef.current = false;\n            });\n        }\n    }, [albumsWithImages.length, hasNextPage, isFetchingNextPage, fetchNextPage, itemLimit]);\n\n    if (albumsWithImages.length === 0) {\n        return null;\n    }\n\n    return <FeatureCarousel data={albumsWithImages} onNearEnd={handleNearEnd} />;\n};\n"
  },
  {
    "path": "src/renderer/features/home/components/album-infinite-single-feature-carousel.tsx",
    "content": "import { QueryFunctionContext, useSuspenseInfiniteQuery } from '@tanstack/react-query';\nimport { useEffect, useMemo, useRef } from 'react';\n\nimport { api } from '/@/renderer/api';\nimport { queryKeys } from '/@/renderer/api/query-keys';\nimport { SingleFeatureCarousel } from '/@/renderer/components/feature-carousel/single-feature-carousel';\nimport { useCurrentServerId } from '/@/renderer/store';\nimport { Album, AlbumListResponse, AlbumListSort, SortOrder } from '/@/shared/types/domain-types';\n\ninterface InfiniteAlbumSingleFeatureCarouselProps {\n    itemLimit?: number;\n    queryKey?: QueryFunctionContext['queryKey'];\n}\n\nexport const AlbumInfiniteSingleFeatureCarousel = ({\n    itemLimit = 20,\n    queryKey,\n}: InfiniteAlbumSingleFeatureCarouselProps) => {\n    const serverId = useCurrentServerId();\n    const loadMoreTriggeredRef = useRef(false);\n\n    const defaultQueryKey = queryKeys.albums.infiniteList(serverId, {\n        sortBy: AlbumListSort.RANDOM,\n        sortOrder: SortOrder.DESC,\n    });\n\n    const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =\n        useSuspenseInfiniteQuery<AlbumListResponse>({\n            getNextPageParam: (lastPage, _allPages, lastPageParam) => {\n                if (lastPage.items.length < itemLimit) {\n                    return undefined;\n                }\n\n                const nextPageParam = Number(lastPageParam) + itemLimit;\n\n                return String(nextPageParam);\n            },\n            initialPageParam: '0',\n            queryFn: ({ pageParam, signal }) => {\n                return api.controller.getAlbumList({\n                    apiClientProps: { serverId, signal },\n                    query: {\n                        limit: itemLimit,\n                        sortBy: AlbumListSort.RANDOM,\n                        sortOrder: SortOrder.DESC,\n                        startIndex: Number(pageParam),\n                    },\n                });\n            },\n            queryKey: queryKey || defaultQueryKey,\n        });\n\n    // Flatten all pages and filter for albums with images\n    const albumsWithImages = useMemo(() => {\n        const allAlbums = data.pages.flatMap((page: AlbumListResponse) => page.items);\n        // Filter for albums with images and remove duplicates by ID\n        const uniqueAlbums = new Map<string, Album>();\n        for (const album of allAlbums) {\n            if (album.imageId && !uniqueAlbums.has(album.id)) {\n                uniqueAlbums.set(album.id, album);\n            }\n        }\n        return Array.from(uniqueAlbums.values());\n    }, [data.pages]);\n\n    const handleNearEnd = () => {\n        if (hasNextPage && !isFetchingNextPage && !loadMoreTriggeredRef.current) {\n            loadMoreTriggeredRef.current = true;\n            fetchNextPage().finally(() => {\n                loadMoreTriggeredRef.current = false;\n            });\n        }\n    };\n\n    useEffect(() => {\n        if (\n            albumsWithImages.length < itemLimit * 2 &&\n            hasNextPage &&\n            !isFetchingNextPage &&\n            !loadMoreTriggeredRef.current\n        ) {\n            loadMoreTriggeredRef.current = true;\n            fetchNextPage().finally(() => {\n                loadMoreTriggeredRef.current = false;\n            });\n        }\n    }, [albumsWithImages.length, hasNextPage, isFetchingNextPage, fetchNextPage, itemLimit]);\n\n    if (albumsWithImages.length === 0) {\n        return null;\n    }\n\n    return <SingleFeatureCarousel data={albumsWithImages} onNearEnd={handleNearEnd} />;\n};\n"
  },
  {
    "path": "src/renderer/features/home/components/featured-genres.module.css",
    "content": ".container {\n    display: flex;\n    flex-direction: column;\n    gap: var(--theme-spacing-md);\n    width: 100%;\n}\n\n.grid {\n    display: grid;\n    grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));\n    gap: var(--theme-spacing-md);\n    padding: var(--theme-spacing-xs) 0;\n}\n\n.genre-container {\n    position: relative;\n    min-height: 3rem;\n    overflow: hidden;\n    background-color: var(--theme-colors-surface);\n    border-radius: var(--theme-radius-md);\n}\n\n.genre-container::before {\n    position: absolute;\n    top: 0;\n    bottom: 0;\n    left: 0;\n    width: 0.5rem;\n    content: '';\n    background-color: var(--genre-color, transparent);\n}\n\n.genre-link {\n    position: relative;\n    z-index: 1;\n    display: flex;\n    align-items: center;\n    width: 100%;\n    min-width: 0;\n    min-height: 3rem;\n    padding: 0 var(--theme-spacing-xl);\n    font-size: var(--theme-font-size-md);\n    font-weight: 600;\n    color: inherit;\n    text-align: left;\n    text-decoration: none;\n    text-shadow: none;\n    cursor: pointer;\n    user-select: none;\n    transition:\n        transform 0.2s ease,\n        box-shadow 0.2s ease;\n}\n\n.genre-link:hover {\n    box-shadow: 0 4px 8px rgb(0 0 0 / 20%);\n}\n\n.genre-link:active {\n    transform: translateY(0);\n}\n\n.genre-name {\n    flex: 1 1 auto;\n    margin-right: var(--theme-spacing-xl);\n    overflow: hidden;\n    text-overflow: ellipsis;\n    font-weight: 500;\n    white-space: nowrap;\n}\n\n.play-button-wrapper {\n    position: absolute;\n    top: 50%;\n    right: var(--theme-spacing-md);\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    opacity: 0;\n    transform: translateY(-50%) translateX(8px);\n    transition:\n        opacity 0.15s ease-out,\n        transform 0.15s ease-out;\n}\n\n.genre-link:hover .play-button-wrapper {\n    opacity: 1;\n    transform: translateY(-50%) translateX(0);\n}\n"
  },
  {
    "path": "src/renderer/features/home/components/featured-genres.tsx",
    "content": "import { useQueryClient, useSuspenseQuery } from '@tanstack/react-query';\nimport { shuffle } from 'lodash';\nimport { memo, useCallback, useMemo } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { generatePath, Link } from 'react-router';\n\nimport styles from './featured-genres.module.css';\n\nimport { api } from '/@/renderer/api';\nimport { queryKeys } from '/@/renderer/api/query-keys';\nimport { genresQueries } from '/@/renderer/features/genres/api/genres-api';\nimport { useIsPlayerFetching, usePlayer } from '/@/renderer/features/player/context/player-context';\nimport { PlayButton } from '/@/renderer/features/shared/components/play-button';\nimport { useContainerQuery } from '/@/renderer/hooks';\nimport { AppRoute } from '/@/renderer/router/routes';\nimport { useCurrentServer, useCurrentServerId } from '/@/renderer/store';\nimport { Button } from '/@/shared/components/button/button';\nimport { Group } from '/@/shared/components/group/group';\nimport { TextTitle } from '/@/shared/components/text-title/text-title';\nimport { Genre, GenreListSort, Played, SortOrder } from '/@/shared/types/domain-types';\nimport { Play } from '/@/shared/types/types';\nimport { stringToColor } from '/@/shared/utils/string-to-color';\n\nfunction getGenresToShow(breakpoints: {\n    isLargerThanLg: boolean;\n    isLargerThanMd: boolean;\n    isLargerThanSm: boolean;\n    isLargerThanXl: boolean;\n    isLargerThanXxl: boolean;\n    isLargerThanXxxl: boolean;\n}) {\n    if (breakpoints.isLargerThanXxxl) {\n        return 18;\n    }\n\n    if (breakpoints.isLargerThanXxl) {\n        return 15;\n    }\n\n    if (breakpoints.isLargerThanXl) {\n        return 12;\n    }\n\n    if (breakpoints.isLargerThanLg) {\n        return 12;\n    }\n\n    if (breakpoints.isLargerThanMd) {\n        return 12;\n    }\n\n    if (breakpoints.isLargerThanSm) {\n        return 8;\n    }\n\n    return 6;\n}\n\nexport const FeaturedGenres = () => {\n    const { t } = useTranslation();\n    const server = useCurrentServer();\n    const { ref, ...cq } = useContainerQuery({\n        lg: 900,\n        md: 600,\n        sm: 360,\n    });\n\n    const genresQuery = useSuspenseQuery({\n        ...genresQueries.list({\n            query: {\n                limit: -1,\n                sortBy: GenreListSort.NAME,\n                sortOrder: SortOrder.ASC,\n                startIndex: 0,\n            },\n            serverId: server?.id,\n        }),\n        queryKey: [server.id, 'home', 'featured-genres'],\n    });\n\n    const randomGenres = useMemo(() => {\n        if (!genresQuery.data?.items) return [];\n        return shuffle(genresQuery.data.items);\n    }, [genresQuery.data]);\n\n    const genresToShow = useMemo(() => {\n        return getGenresToShow({\n            isLargerThanLg: cq.isLg,\n            isLargerThanMd: cq.isMd,\n            isLargerThanSm: cq.isSm,\n            isLargerThanXl: cq.isXl,\n            isLargerThanXxl: cq.is2xl,\n            isLargerThanXxxl: cq.is3xl,\n        });\n    }, [cq.isLg, cq.isMd, cq.isSm, cq.isXl, cq.is2xl, cq.is3xl]);\n\n    const visibleGenres = useMemo(() => {\n        return randomGenres.slice(0, genresToShow);\n    }, [randomGenres, genresToShow]);\n\n    const genresWithColors = useMemo(() => {\n        if (!visibleGenres) return [];\n\n        return visibleGenres.map((genre: Genre) => {\n            const { color, isLight } = stringToColor(genre.name);\n            const path = generatePath(AppRoute.LIBRARY_GENRES_DETAIL, { genreId: genre.id });\n\n            return {\n                ...genre,\n                color,\n                isLight,\n                path,\n            };\n        });\n    }, [visibleGenres]);\n\n    return (\n        <div className={styles.container} ref={ref}>\n            {cq.isCalculated && (\n                <>\n                    <Group align=\"flex-end\" justify=\"space-between\">\n                        <TextTitle fw={700} isNoSelect order={3}>\n                            {t('entity.genre', { count: 2, postProcess: 'titleCase' })}\n                        </TextTitle>\n                        <Button\n                            component={Link}\n                            size=\"compact-sm\"\n                            to={AppRoute.LIBRARY_GENRES}\n                            variant=\"subtle\"\n                        >\n                            {t('action.viewMore', { postProcess: 'sentenceCase' })}\n                        </Button>\n                    </Group>\n                    <div className={styles.grid}>\n                        {genresWithColors.map((genre) => (\n                            <GenreItem genre={genre} key={genre.id} />\n                        ))}\n                    </div>\n                </>\n            )}\n        </div>\n    );\n};\n\nconst GenrePlayButton = ({ genre }: { genre: Genre }) => {\n    const queryClient = useQueryClient();\n    const isPlayerFetching = useIsPlayerFetching();\n    const player = usePlayer();\n    const serverId = useCurrentServerId();\n\n    const handlePlay = useCallback(\n        async (genre: Genre) => {\n            if (!serverId) return;\n\n            const data = await queryClient.fetchQuery({\n                gcTime: 0,\n                queryFn: () => {\n                    return api.controller.getRandomSongList({\n                        apiClientProps: { serverId },\n                        query: {\n                            genre: genre.id,\n                            limit: 100,\n                            played: Played.All,\n                        },\n                    });\n                },\n                queryKey: queryKeys.player.fetch(),\n                staleTime: 0,\n            });\n\n            player.addToQueueByData(data?.items || [], Play.NOW);\n        },\n        [player, queryClient, serverId],\n    );\n\n    return (\n        <span className={styles.playButtonWrapper}>\n            <PlayButton\n                fill={true}\n                isSecondary\n                loading={isPlayerFetching}\n                onClick={() => handlePlay(genre)}\n            />\n        </span>\n    );\n};\n\nconst GenreItem = memo(({ genre }: { genre: Genre & { color: string; path: string } }) => {\n    return (\n        <div\n            className={styles.genreContainer}\n            key={genre.id}\n            style={\n                {\n                    '--genre-color': genre.color,\n                } as React.CSSProperties\n            }\n        >\n            <Link className={styles.genreLink} state={{ item: genre }} to={genre.path}>\n                <span className={styles.genreName}>{genre.name}</span>\n                <GenrePlayButton genre={genre} />\n            </Link>\n        </div>\n    );\n});\n\nGenreItem.displayName = 'GenreItem';\n"
  },
  {
    "path": "src/renderer/features/home/routes/home-route.tsx",
    "content": "import { Suspense, useRef } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport { useGridCarouselContainerQuery } from '/@/renderer/components/grid-carousel/grid-carousel-v2';\nimport { NativeScrollArea } from '/@/renderer/components/native-scroll-area/native-scroll-area';\nimport { AlbumInfiniteCarousel } from '/@/renderer/features/albums/components/album-infinite-carousel';\nimport { AlbumInfiniteFeatureCarousel } from '/@/renderer/features/home/components/album-infinite-feature-carousel';\nimport { AlbumInfiniteSingleFeatureCarousel } from '/@/renderer/features/home/components/album-infinite-single-feature-carousel';\nimport { FeaturedGenres } from '/@/renderer/features/home/components/featured-genres';\nimport { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';\nimport { LibraryContainer } from '/@/renderer/features/shared/components/library-container';\nimport { LibraryHeaderBar } from '/@/renderer/features/shared/components/library-header-bar';\nimport { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';\nimport { SongInfiniteCarousel } from '/@/renderer/features/songs/components/song-infinite-carousel';\nimport {\n    HomeFeatureStyle,\n    HomeItem,\n    useCurrentServer,\n    useHomeFeature,\n    useHomeFeatureStyle,\n    useHomeItems,\n    useWindowSettings,\n} from '/@/renderer/store';\nimport { Spinner } from '/@/shared/components/spinner/spinner';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport {\n    AlbumListSort,\n    LibraryItem,\n    ServerType,\n    SongListSort,\n    SortOrder,\n} from '/@/shared/types/domain-types';\nimport { Platform } from '/@/shared/types/types';\n\nconst HomeRoute = () => {\n    const { t } = useTranslation();\n    const scrollAreaRef = useRef<HTMLDivElement>(null);\n    const server = useCurrentServer();\n    const { windowBarStyle } = useWindowSettings();\n    const homeFeature = useHomeFeature();\n    const homeFeatureStyle = useHomeFeatureStyle();\n    const homeItems = useHomeItems();\n    const containerQuery = useGridCarouselContainerQuery();\n\n    const isJellyfin = server?.type === ServerType.JELLYFIN;\n\n    const carousels = {\n        [HomeItem.MOST_PLAYED]: {\n            enableRefresh: true,\n            itemType: isJellyfin ? LibraryItem.SONG : LibraryItem.ALBUM,\n            sortBy: isJellyfin ? SongListSort.PLAY_COUNT : AlbumListSort.PLAY_COUNT,\n            sortOrder: SortOrder.DESC,\n            title: t('page.home.mostPlayed', { postProcess: 'sentenceCase' }),\n        },\n        [HomeItem.RANDOM]: {\n            enableRefresh: true,\n            itemType: LibraryItem.ALBUM,\n            sortBy: AlbumListSort.RANDOM,\n            sortOrder: SortOrder.ASC,\n            title: t('page.home.explore', { postProcess: 'sentenceCase' }),\n        },\n        [HomeItem.RECENTLY_ADDED]: {\n            enableRefresh: true,\n            itemType: LibraryItem.ALBUM,\n            sortBy: AlbumListSort.RECENTLY_ADDED,\n            sortOrder: SortOrder.DESC,\n            title: t('page.home.newlyAdded', { postProcess: 'sentenceCase' }),\n        },\n        [HomeItem.RECENTLY_PLAYED]: {\n            enableRefresh: true,\n            itemType: isJellyfin ? LibraryItem.SONG : LibraryItem.ALBUM,\n            sortBy: isJellyfin ? SongListSort.RECENTLY_PLAYED : AlbumListSort.RECENTLY_PLAYED,\n            sortOrder: SortOrder.DESC,\n            title: t('page.home.recentlyPlayed', { postProcess: 'sentenceCase' }),\n        },\n        [HomeItem.RECENTLY_RELEASED]: {\n            enableRefresh: true,\n            itemType: LibraryItem.ALBUM,\n            sortBy: AlbumListSort.RELEASE_DATE,\n            sortOrder: SortOrder.DESC,\n            title: t('page.home.recentlyReleased', { postProcess: 'sentenceCase' }),\n        },\n    };\n\n    const sortedItems = homeItems.filter((item) => !item.disabled);\n\n    const sortedCarousel = sortedItems\n        .filter((item) => item.id !== HomeItem.GENRES)\n        .map((item) => ({\n            ...carousels[item.id],\n            uniqueId: item.id,\n        }));\n\n    return (\n        <AnimatedPage>\n            <NativeScrollArea\n                pageHeaderProps={{\n                    backgroundColor: 'var(--theme-colors-background)',\n                    children: (\n                        <LibraryHeaderBar>\n                            <LibraryHeaderBar.Title>\n                                {t('page.home.title', { postProcess: 'titleCase' })}\n                            </LibraryHeaderBar.Title>\n                        </LibraryHeaderBar>\n                    ),\n                    offset: 200,\n                }}\n                ref={scrollAreaRef}\n            >\n                <LibraryContainer>\n                    <Stack\n                        gap=\"2xl\"\n                        mb=\"5rem\"\n                        pt={windowBarStyle === Platform.WEB ? '5rem' : '3rem'}\n                        px=\"2rem\"\n                        ref={containerQuery.ref}\n                    >\n                        {homeFeature && homeFeatureStyle === HomeFeatureStyle.SINGLE && (\n                            <AlbumInfiniteSingleFeatureCarousel />\n                        )}\n                        {homeFeature && homeFeatureStyle === HomeFeatureStyle.MULTIPLE && (\n                            <AlbumInfiniteFeatureCarousel />\n                        )}\n                        {sortedItems.map((item) => {\n                            if (item.id === HomeItem.GENRES) {\n                                return <FeaturedGenres key=\"featured-genres\" />;\n                            }\n\n                            const carousel = sortedCarousel.find((c) => c.uniqueId === item.id);\n                            if (!carousel) {\n                                return null;\n                            }\n\n                            if (carousel.itemType === LibraryItem.ALBUM) {\n                                return (\n                                    <AlbumInfiniteCarousel\n                                        containerQuery={containerQuery}\n                                        enableRefresh={carousel.enableRefresh}\n                                        key={`carousel-${carousel.uniqueId}`}\n                                        queryKey={['home', 'album', carousel.uniqueId] as const}\n                                        rowCount={1}\n                                        sortBy={carousel.sortBy as AlbumListSort}\n                                        sortOrder={carousel.sortOrder}\n                                        title={carousel.title}\n                                    />\n                                );\n                            }\n\n                            if (carousel.itemType === LibraryItem.SONG) {\n                                return (\n                                    <SongInfiniteCarousel\n                                        containerQuery={containerQuery}\n                                        enableRefresh={carousel.enableRefresh}\n                                        key={`carousel-${carousel.uniqueId}`}\n                                        queryKey={['home', 'song', carousel.uniqueId] as const}\n                                        rowCount={1}\n                                        sortBy={carousel.sortBy as SongListSort}\n                                        sortOrder={carousel.sortOrder}\n                                        title={carousel.title}\n                                    />\n                                );\n                            }\n\n                            return null;\n                        })}\n                    </Stack>\n                </LibraryContainer>\n            </NativeScrollArea>\n        </AnimatedPage>\n    );\n};\n\nconst HomeRouteWithBoundary = () => {\n    return (\n        <PageErrorBoundary>\n            <Suspense fallback={<Spinner container />}>\n                <HomeRoute />\n            </Suspense>\n        </PageErrorBoundary>\n    );\n};\n\nexport default HomeRouteWithBoundary;\n"
  },
  {
    "path": "src/renderer/features/item-details/components/item-details-modal.tsx",
    "content": "import { TFunction } from 'i18next';\nimport { ReactNode, useMemo, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { generatePath, Link } from 'react-router';\n\nimport { SongPath } from '/@/renderer/features/item-details/components/song-path';\nimport { AppRoute } from '/@/renderer/router/routes';\nimport { formatDurationString, formatSizeString } from '/@/renderer/utils';\nimport { formatDateRelative, formatRating } from '/@/renderer/utils/format';\nimport { replaceURLWithHTMLLinks } from '/@/renderer/utils/linkify';\nimport { normalizeReleaseTypes } from '/@/renderer/utils/normalize-release-types';\nimport { sanitize } from '/@/renderer/utils/sanitize';\nimport { SEPARATOR_STRING } from '/@/shared/api/utils';\nimport { Icon } from '/@/shared/components/icon/icon';\nimport { Select } from '/@/shared/components/select/select';\nimport { Separator } from '/@/shared/components/separator/separator';\nimport { Spoiler } from '/@/shared/components/spoiler/spoiler';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { Table } from '/@/shared/components/table/table';\nimport { Text } from '/@/shared/components/text/text';\nimport {\n    Album,\n    AlbumArtist,\n    AnyLibraryItem,\n    Artist,\n    ExplicitStatus,\n    LibraryItem,\n    Playlist,\n    RelatedArtist,\n    Song,\n} from '/@/shared/types/domain-types';\n\nexport type ItemDetailsModalProps = {\n    item?: Album | AlbumArtist | Artist | Playlist | Song;\n    items?: (Album | AlbumArtist | Artist | Playlist | Song)[];\n};\n\ntype ItemDetailRow<T> = {\n    count?: number;\n    key?: keyof T;\n    label: string;\n    postprocess?: string[];\n    render?: (item: T, t: TFunction<'translation'>) => ReactNode;\n};\n\nconst handleRow = <T extends AnyLibraryItem>(\n    t: TFunction<'translation'>,\n    item: T,\n    rule: ItemDetailRow<T>,\n) => {\n    let value: ReactNode;\n\n    if (rule.render) {\n        value = rule.render(item, t);\n    } else {\n        const prop = item[rule.key!];\n        value = prop !== undefined && prop !== null ? String(prop) : null;\n    }\n\n    if (!value) return null;\n\n    return (\n        <Table.Tr key={rule.label}>\n            <Table.Th>\n                {t(rule.label, {\n                    ...(rule.count !== undefined && { count: rule.count }),\n                    postProcess: rule.postprocess || 'sentenceCase',\n                })}\n            </Table.Th>\n            <Table.Td>{value}</Table.Td>\n        </Table.Tr>\n    );\n};\n\nconst formatArtists = (artists: null | RelatedArtist[] | undefined) =>\n    artists?.map((artist, index) => (\n        <span key={artist.id || artist.name}>\n            {index > 0 && <Separator />}\n            {artist.id ? (\n                <Text\n                    component={Link}\n                    fw={600}\n                    isLink\n                    overflow=\"visible\"\n                    size=\"md\"\n                    to={\n                        artist.id\n                            ? generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {\n                                  albumArtistId: artist.id,\n                              })\n                            : ''\n                    }\n                >\n                    {artist.name || '—'}\n                </Text>\n            ) : (\n                <Text component=\"span\" overflow=\"visible\" size=\"md\">\n                    {artist.name || '-'}\n                </Text>\n            )}\n        </span>\n    ));\n\nconst formatComment = (item: Album | Song) =>\n    item.comment ? (\n        <Spoiler maxHeight={50}>\n            <Text>{replaceURLWithHTMLLinks(item.comment)}</Text>\n        </Spoiler>\n    ) : null;\n\nconst FormatGenre = (item: Album | AlbumArtist | Playlist | Song) => {\n    if (!item.genres?.length) {\n        return null;\n    }\n\n    return item.genres?.map((genre, index) => (\n        <span key={genre.id}>\n            {index > 0 && <Separator />}\n            <Text\n                component={Link}\n                fw={600}\n                isLink\n                overflow=\"visible\"\n                size=\"md\"\n                to={\n                    genre.id\n                        ? generatePath(AppRoute.LIBRARY_GENRES_DETAIL, { genreId: genre.id })\n                        : ''\n                }\n            >\n                {genre.name || '—'}\n            </Text>\n        </span>\n    ));\n};\n\nconst BoolField = (key: boolean) =>\n    key ? <Icon color=\"success\" icon=\"check\" /> : <Icon color=\"error\" icon=\"x\" />;\n\nconst AlbumPropertyMapping: ItemDetailRow<Album>[] = [\n    { key: 'name', label: 'common.title' },\n    { count: 1, label: 'entity.albumArtist', render: (item) => formatArtists(item.albumArtists) },\n    {\n        label: 'common.releaseType',\n        render: (item, t) => normalizeReleaseTypes(item.releaseTypes, t).join(SEPARATOR_STRING),\n    },\n    { count: 2, label: 'entity.genre', render: FormatGenre },\n    {\n        label: 'common.duration',\n        render: (album) => album.duration && formatDurationString(album.duration),\n    },\n    { key: 'releaseYear', label: 'filter.releaseYear' },\n    { key: 'songCount', label: 'filter.songCount' },\n    {\n        label: 'filter.explicitStatus',\n        render: (album, t) =>\n            album.explicitStatus === ExplicitStatus.EXPLICIT\n                ? t('common.explicit', { postProcess: 'sentenceCase' })\n                : album.explicitStatus === ExplicitStatus.CLEAN\n                  ? t('common.clean', { postProcess: 'sentenceCase' })\n                  : null,\n    },\n    { label: 'filter.isCompilation', render: (album) => BoolField(album.isCompilation || false) },\n    {\n        key: 'size',\n        label: 'common.size',\n        render: (album) => album.size && formatSizeString(album.size),\n    },\n    {\n        label: 'common.favorite',\n        render: (album) => BoolField(album.userFavorite),\n    },\n    { label: 'common.rating', render: formatRating },\n    { key: 'playCount', label: 'filter.playCount' },\n    {\n        label: 'filter.lastPlayed',\n        render: (song) => formatDateRelative(song.lastPlayedAt),\n    },\n    {\n        label: 'common.modified',\n        render: (song) => formatDateRelative(song.updatedAt),\n    },\n    { label: 'filter.comment', render: formatComment },\n    {\n        label: 'common.mbid',\n        postprocess: [],\n        render: (album) =>\n            album.mbzId ? (\n                <Link\n                    rel=\"noopener noreferrer\"\n                    target=\"_blank\"\n                    to={`https://musicbrainz.org/release/${album.mbzId}`}\n                >\n                    {album.mbzId}\n                </Link>\n            ) : null,\n    },\n    { key: 'id', label: 'filter.id' },\n    { key: 'version', label: 'common.version' },\n    { label: 'common.recordLabel', render: (item) => item.recordLabels.join(SEPARATOR_STRING) },\n];\n\nconst AlbumArtistPropertyMapping: ItemDetailRow<AlbumArtist>[] = [\n    { key: 'name', label: 'common.name' },\n    { count: 2, label: 'entity.genre', render: FormatGenre },\n    {\n        label: 'common.duration',\n        render: (artist) => artist.duration && formatDurationString(artist.duration),\n    },\n    { key: 'songCount', label: 'filter.songCount' },\n    {\n        label: 'common.favorite',\n        render: (artist) => BoolField(artist.userFavorite),\n    },\n    { label: 'common.rating', render: formatRating },\n    { key: 'playCount', label: 'filter.playCount' },\n    {\n        label: 'filter.lastPlayed',\n        render: (song) => formatDateRelative(song.lastPlayedAt),\n    },\n    {\n        label: 'common.mbid',\n        postprocess: [],\n        render: (artist) =>\n            artist.mbz ? (\n                <Link\n                    rel=\"noopener noreferrer\"\n                    target=\"_blank\"\n                    to={`https://musicbrainz.org/artist/${artist.mbz}`}\n                >\n                    {artist.mbz}\n                </Link>\n            ) : null,\n    },\n    {\n        label: 'common.biography',\n        render: (artist) =>\n            artist.biography ? (\n                <Spoiler>\n                    <Text dangerouslySetInnerHTML={{ __html: sanitize(artist.biography) }} />\n                </Spoiler>\n            ) : null,\n    },\n    { key: 'id', label: 'filter.id' },\n];\n\nconst PlaylistPropertyMapping: ItemDetailRow<Playlist>[] = [\n    { key: 'name', label: 'common.title' },\n    { key: 'description', label: 'common.description' },\n    { count: 2, label: 'entity.genre', render: FormatGenre },\n    {\n        label: 'common.duration',\n        render: (playlist) => playlist.duration && formatDurationString(playlist.duration),\n    },\n    { key: 'songCount', label: 'filter.songCount' },\n    {\n        key: 'size',\n        label: 'common.size',\n        render: (playlist) => playlist.size && formatSizeString(playlist.size),\n    },\n    { key: 'owner', label: 'common.owner' },\n    { key: 'public', label: 'form.createPlaylist.input_public' },\n    {\n        label: 'entity.smartPlaylist',\n        render: (playlist) => (playlist.rules ? BoolField(true) : null),\n    },\n    { key: 'id', label: 'filter.id' },\n];\n\nconst SongPropertyMapping: ItemDetailRow<Song>[] = [\n    { key: 'name', label: 'common.title' },\n    { key: 'path', label: 'common.path', render: SongPath },\n    { count: 1, label: 'entity.albumArtist', render: (item) => formatArtists(item.albumArtists) },\n    {\n        count: 2,\n        key: 'artists',\n        label: 'entity.artist',\n        render: (item) => formatArtists(item.artists),\n    },\n    {\n        count: 1,\n        key: 'album',\n        label: 'entity.album',\n        render: (song) =>\n            song.albumId &&\n            song.album && (\n                <Text\n                    component={Link}\n                    fw={600}\n                    isLink\n                    overflow=\"visible\"\n                    size=\"md\"\n                    to={\n                        song.albumId\n                            ? generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {\n                                  albumId: song.albumId,\n                              })\n                            : ''\n                    }\n                >\n                    {song.album}\n                </Text>\n            ),\n    },\n    { key: 'discNumber', label: 'common.disc' },\n    { key: 'trackNumber', label: 'common.trackNumber' },\n    { key: 'releaseYear', label: 'filter.releaseYear' },\n    {\n        label: 'filter.explicitStatus',\n        render: (song, t) =>\n            song.explicitStatus === ExplicitStatus.EXPLICIT\n                ? t('common.explicit', { postProcess: 'sentenceCase' })\n                : song.explicitStatus === ExplicitStatus.CLEAN\n                  ? t('common.clean', { postProcess: 'sentenceCase' })\n                  : null,\n    },\n    { count: 2, label: 'entity.genre', render: FormatGenre },\n    {\n        label: 'common.duration',\n        render: (song) => formatDurationString(song.duration),\n    },\n    { label: 'filter.isCompilation', render: (song) => BoolField(song.compilation || false) },\n    { key: 'container', label: 'common.codec' },\n    { key: 'bitRate', label: 'common.bitrate', render: (song) => `${song.bitRate} kbps` },\n    { key: 'sampleRate', label: 'common.sampleRate' },\n    { key: 'bitDepth', label: 'common.bitDepth' },\n    { count: 2, key: 'channels', label: 'common.channel' },\n    { key: 'size', label: 'common.size', render: (song) => formatSizeString(song.size) },\n    {\n        label: 'common.favorite',\n        render: (song) => BoolField(song.userFavorite),\n    },\n    { label: 'common.rating', render: formatRating },\n    { key: 'playCount', label: 'filter.playCount' },\n    {\n        label: 'filter.lastPlayed',\n        render: (song) => formatDateRelative(song.lastPlayedAt),\n    },\n    {\n        label: 'common.modified',\n        render: (song) => formatDateRelative(song.updatedAt),\n    },\n    {\n        label: 'common.albumGain',\n        render: (song) => (song.gain?.album !== undefined ? `${song.gain.album} dB` : null),\n    },\n    {\n        label: 'common.trackGain',\n        render: (song) => (song.gain?.track !== undefined ? `${song.gain.track} dB` : null),\n    },\n    {\n        label: 'common.albumPeak',\n        render: (song) => (song.peak?.album !== undefined ? `${song.peak.album}` : null),\n    },\n    {\n        label: 'common.trackPeak',\n        render: (song) => (song.peak?.track !== undefined ? `${song.peak.track}` : null),\n    },\n    { label: 'filter.comment', render: formatComment },\n    { key: 'id', label: 'filter.id' },\n];\n\nconst handleTags = (item: Album | Song, t: TFunction) => {\n    if (item.tags) {\n        const tags = Object.entries(item.tags).map(([tag, fields]) => {\n            return (\n                <Table.Tr key={tag}>\n                    <Table.Th>\n                        {tag.slice(0, 1).toLocaleUpperCase()}\n                        {tag.slice(1)}\n                    </Table.Th>\n                    <Table.Td>\n                        {fields.length === 0 ? BoolField(true) : fields.join(SEPARATOR_STRING)}\n                    </Table.Td>\n                </Table.Tr>\n            );\n        });\n\n        if (tags.length) {\n            return [\n                <Table.Tr key=\"tags\">\n                    <Table.Th>{t('common.tags', { postProcess: 'sentenceCase' })}</Table.Th>\n                    <Table.Td>{tags.length}</Table.Td>\n                </Table.Tr>,\n            ].concat(tags);\n        }\n    }\n\n    return [];\n};\n\nconst handleParticipants = (item: Album | Song, t: TFunction) => {\n    if (item.participants) {\n        const participants = Object.entries(item.participants).map(([role, participants]) => {\n            return (\n                <Table.Tr key={role}>\n                    <Table.Th>\n                        {role.slice(0, 1).toLocaleUpperCase()}\n                        {role.slice(1)}\n                    </Table.Th>\n                    <Table.Td>{formatArtists(participants)}</Table.Td>\n                </Table.Tr>\n            );\n        });\n\n        if (participants.length) {\n            return [\n                <Table.Tr key=\"participants\">\n                    <Table.Th>\n                        {t('common.additionalParticipants', {\n                            postProcess: 'sentenceCase',\n                        })}\n                    </Table.Th>\n                    <Table.Td>{participants.length}</Table.Td>\n                </Table.Tr>,\n            ].concat(participants);\n        }\n    }\n\n    return [];\n};\n\nexport const ItemDetailsModal = ({ item, items }: ItemDetailsModalProps) => {\n    const { t } = useTranslation();\n    const allItems = useMemo(() => items || (item ? [item] : []), [item, items]);\n    const [selectedIndex, setSelectedIndex] = useState(0);\n\n    const selectedItem = useMemo(() => {\n        return allItems[selectedIndex] || null;\n    }, [allItems, selectedIndex]);\n\n    const selectData = useMemo(() => {\n        return allItems.map((it, index) => ({\n            label:\n                it.name ||\n                `${t('common.item', { defaultValue: 'Item', postProcess: 'sentenceCase' })} ${index + 1}`,\n            value: String(index),\n        }));\n    }, [allItems, t]);\n\n    if (!selectedItem) {\n        return null;\n    }\n\n    let body: ReactNode[] = [];\n\n    switch (selectedItem._itemType) {\n        case LibraryItem.ALBUM:\n            body = AlbumPropertyMapping.map((rule) => handleRow(t, selectedItem, rule));\n            body.push(...handleParticipants(selectedItem, t));\n            body.push(...handleTags(selectedItem, t));\n            break;\n        case LibraryItem.ALBUM_ARTIST:\n            body = AlbumArtistPropertyMapping.map((rule) => handleRow(t, selectedItem, rule));\n            break;\n        case LibraryItem.PLAYLIST:\n            body = PlaylistPropertyMapping.map((rule) => handleRow(t, selectedItem, rule));\n            break;\n        case LibraryItem.SONG:\n            body = SongPropertyMapping.map((rule) => handleRow(t, selectedItem, rule));\n            body.push(...handleParticipants(selectedItem, t));\n            body.push(...handleTags(selectedItem, t));\n            break;\n        default:\n            body = [];\n    }\n\n    return (\n        <Stack gap=\"md\">\n            {allItems.length > 1 && (\n                <Select\n                    data={selectData}\n                    onChange={(value) => {\n                        if (value) {\n                            setSelectedIndex(Number(value));\n                        }\n                    }}\n                    value={String(selectedIndex)}\n                />\n            )}\n            <Table\n                highlightOnHover={false}\n                styles={{\n                    th: {\n                        color: 'var(--theme-colors-foreground-muted)',\n                        fontWeight: 500,\n                        padding: 'var(--theme-spacing-sm)',\n                    },\n                    tr: {\n                        color: 'var(--theme-colors-foreground-muted)',\n                        padding: 'var(--theme-spacing-xl)',\n                    },\n                }}\n                withRowBorders={true}\n            >\n                <Table.Tbody>{body}</Table.Tbody>\n            </Table>\n        </Stack>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/item-details/components/song-path.tsx",
    "content": "import isElectron from 'is-electron';\nimport { useTranslation } from 'react-i18next';\n\nimport { ActionIcon } from '/@/shared/components/action-icon/action-icon';\nimport { CopyButton } from '/@/shared/components/copy-button/copy-button';\nimport { Group } from '/@/shared/components/group/group';\nimport { Icon } from '/@/shared/components/icon/icon';\nimport { Text } from '/@/shared/components/text/text';\nimport { toast } from '/@/shared/components/toast/toast';\nimport { Tooltip } from '/@/shared/components/tooltip/tooltip';\n\nconst util = isElectron() ? window.api.utils : null;\n\nexport type SongPathProps = {\n    path: null | string;\n};\n\nexport const SongPath = ({ path }: SongPathProps) => {\n    const { t } = useTranslation();\n\n    if (!path) return null;\n\n    return (\n        <Group>\n            <CopyButton timeout={2000} value={path}>\n                {({ copied, copy }) => (\n                    <Tooltip\n                        label={t(\n                            copied ? 'page.itemDetail.copiedPath' : 'page.itemDetail.copyPath',\n                            {\n                                postProcess: 'sentenceCase',\n                            },\n                        )}\n                        withinPortal\n                    >\n                        <ActionIcon onClick={copy} variant=\"transparent\">\n                            {copied ? <Icon icon=\"check\" /> : <Icon icon=\"clipboardCopy\" />}\n                        </ActionIcon>\n                    </Tooltip>\n                )}\n            </CopyButton>\n            {util && (\n                <Tooltip\n                    label={t('page.itemDetail.openFile', { postProcess: 'sentenceCase' })}\n                    withinPortal\n                >\n                    <ActionIcon\n                        icon=\"externalLink\"\n                        onClick={() => {\n                            util.openItem(path).catch((error) => {\n                                toast.error({\n                                    message: (error as Error).message,\n                                    title: t('error.openError', {\n                                        postProcess: 'sentenceCase',\n                                    }),\n                                });\n                            });\n                        }}\n                        variant=\"transparent\"\n                    />\n                </Tooltip>\n            )}\n            <Text style={{ userSelect: 'all' }}>{path}</Text>\n        </Group>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/login/routes/login-route.tsx",
    "content": "import isElectron from 'is-electron';\nimport { nanoid } from 'nanoid/non-secure';\nimport { useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { Navigate } from 'react-router';\n\nimport { api } from '/@/renderer/api';\nimport { PageHeader } from '/@/renderer/components/page-header/page-header';\nimport {\n    isLegacyAuth,\n    isServerLock,\n} from '/@/renderer/features/action-required/utils/window-properties';\nimport JellyfinIcon from '/@/renderer/features/servers/assets/jellyfin.png';\nimport NavidromeIcon from '/@/renderer/features/servers/assets/navidrome.png';\nimport SubsonicIcon from '/@/renderer/features/servers/assets/opensubsonic.png';\nimport { IgnoreCorsSslSwitches } from '/@/renderer/features/servers/components/ignore-cors-ssl-switches';\nimport { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';\nimport { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';\nimport { AppRoute } from '/@/renderer/router/routes';\nimport {\n    getServerById,\n    useAuthStoreActions,\n    useCurrentServer,\n    useServerList,\n} from '/@/renderer/store';\nimport { Button } from '/@/shared/components/button/button';\nimport { Center } from '/@/shared/components/center/center';\nimport { Code } from '/@/shared/components/code/code';\nimport { Paper } from '/@/shared/components/paper/paper';\nimport { PasswordInput } from '/@/shared/components/password-input/password-input';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { TextInput } from '/@/shared/components/text-input/text-input';\nimport { TextTitle } from '/@/shared/components/text-title/text-title';\nimport { Text } from '/@/shared/components/text/text';\nimport { toast } from '/@/shared/components/toast/toast';\nimport { useForm } from '/@/shared/hooks/use-form';\nimport { AuthenticationResponse, ServerListItemWithCredential } from '/@/shared/types/domain-types';\nimport { ServerType, toServerType } from '/@/shared/types/types';\n\nconst localSettings = isElectron() ? window.api.localSettings : null;\n\nconst SERVER_ICONS: Record<ServerType, string> = {\n    [ServerType.JELLYFIN]: JellyfinIcon,\n    [ServerType.NAVIDROME]: NavidromeIcon,\n    [ServerType.SUBSONIC]: SubsonicIcon,\n};\n\nconst SERVER_NAMES: Record<ServerType, string> = {\n    [ServerType.JELLYFIN]: 'Jellyfin',\n    [ServerType.NAVIDROME]: 'Navidrome',\n    [ServerType.SUBSONIC]: 'OpenSubsonic',\n};\n\nconst normalizeUrl = (url: string) => url.replace(/\\/$/, '');\n\nconst LoginRoute = () => {\n    const { t } = useTranslation();\n    const [isLoading, setIsLoading] = useState(false);\n    const { addServer, setCurrentServer, updateServer } = useAuthStoreActions();\n    const currentServer = useCurrentServer();\n    const serverList = useServerList();\n\n    // Check if server lock is configured\n    const serverLock = isServerLock();\n    const serverType = window.SERVER_TYPE ? toServerType(window.SERVER_TYPE) : null;\n    const serverName = window.SERVER_NAME || '';\n    const serverUrl = window.SERVER_URL || '';\n    const remoteUrl = window.REMOTE_URL || '';\n    const legacyAuth = serverLock && isLegacyAuth();\n\n    const config = [\n        {\n            isValid: true,\n            key: 'SERVER_LOCK',\n            value: serverLock,\n        },\n        {\n            isValid: serverType !== null,\n            key: 'SERVER_TYPE',\n            value: serverType,\n        },\n        {\n            isValid: true,\n            key: 'SERVER_NAME',\n            value: serverName,\n        },\n        {\n            isValid: serverUrl !== '',\n            key: 'SERVER_URL',\n            value: serverUrl,\n        },\n        {\n            isValid: true,\n            key: 'REMOTE_URL',\n            value: remoteUrl,\n        },\n    ];\n\n    const form = useForm({\n        initialValues: {\n            password: '',\n            username: '',\n        },\n    });\n\n    // If server lock is not enabled, or we already have a server, redirect to home\n    if (currentServer) {\n        return <Navigate replace to={AppRoute.HOME} />;\n    }\n\n    // If any of the config values are invalid, show error\n    if (config.some((c) => !c.isValid)) {\n        return (\n            <AnimatedPage>\n                <PageHeader />\n                <Center style={{ height: '100%', width: '100vw' }}>\n                    <Stack>\n                        <TextTitle fw={600}>\n                            {t('error.genericError', { postProcess: 'sentenceCase' })}\n                        </TextTitle>\n                        <Text fw={500}>\n                            {t('error.serverNotSelectedError', { postProcess: 'sentenceCase' })}\n                        </Text>\n                        <Code block>{JSON.stringify(config, null, 2)}</Code>\n                    </Stack>\n                </Center>\n            </AnimatedPage>\n        );\n    }\n\n    const handleSubmit = form.onSubmit(async (values) => {\n        const authFunction = api.controller.authenticate;\n\n        if (!authFunction) {\n            return toast.error({\n                message: t('error.invalidServer', { postProcess: 'sentenceCase' }),\n            });\n        }\n\n        try {\n            setIsLoading(true);\n            const data: AuthenticationResponse | undefined = await authFunction(\n                serverUrl,\n                {\n                    legacy: legacyAuth,\n                    password: values.password,\n                    username: values.username,\n                },\n                serverType as ServerType,\n            );\n\n            if (!data) {\n                return toast.error({\n                    message: t('error.authenticationFailed', { postProcess: 'sentenceCase' }),\n                });\n            }\n\n            const normalizedUrl = normalizeUrl(serverUrl);\n            const normalizedRemoteURL = normalizeUrl(remoteUrl);\n            const existingServer =\n                serverLock &&\n                Object.values(serverList).find((s) => normalizeUrl(s.url) === normalizedUrl);\n\n            const serverItem: ServerListItemWithCredential = {\n                credential: data.credential,\n                id: nanoid(),\n                isAdmin: data.isAdmin,\n                name: serverName,\n                remoteUrl: normalizedRemoteURL,\n                type: serverType as ServerType,\n                url: normalizedUrl,\n                userId: data.userId,\n                username: data.username,\n            };\n\n            if (existingServer) {\n                const updates: Partial<ServerListItemWithCredential> = {\n                    credential: data.credential,\n                    isAdmin: data.isAdmin,\n                    userId: data.userId,\n                    username: data.username,\n                };\n                if (data.ndCredential !== undefined) {\n                    updates.ndCredential = data.ndCredential;\n                }\n                updateServer(existingServer.id, updates);\n                const updated = getServerById(existingServer.id);\n                if (updated) setCurrentServer(updated);\n            } else {\n                if (data.ndCredential !== undefined) {\n                    serverItem.ndCredential = data.ndCredential;\n                }\n                addServer(serverItem);\n                setCurrentServer(serverItem);\n            }\n\n            toast.success({\n                message: t('form.addServer.success', { postProcess: 'sentenceCase' }),\n            });\n\n            if (localSettings && values.password) {\n                const saved = await localSettings.passwordSet(values.password, serverItem.id);\n                if (!saved) {\n                    toast.error({\n                        message: t('form.addServer.error', {\n                            context: 'savePassword',\n                            postProcess: 'sentenceCase',\n                        }),\n                    });\n                }\n            }\n        } catch (err: any) {\n            setIsLoading(false);\n            return toast.error({ message: err?.message });\n        }\n\n        return setIsLoading(false);\n    });\n\n    const isSubmitDisabled = !form.values.username || !form.values.password;\n    const serverIcon = SERVER_ICONS[serverType as ServerType];\n    const serverDisplayName = SERVER_NAMES[serverType as ServerType];\n\n    return (\n        <AnimatedPage>\n            <PageHeader />\n            <Center style={{ height: '100%', width: '100vw' }}>\n                <Paper p=\"xl\" style={{ maxWidth: '400px', width: '100%' }}>\n                    <form onSubmit={handleSubmit}>\n                        <Stack gap=\"xl\">\n                            <Stack align=\"center\" gap=\"md\">\n                                <img\n                                    alt={serverDisplayName}\n                                    height=\"80\"\n                                    src={serverIcon}\n                                    width=\"80\"\n                                />\n                                <Text fw={600} size=\"xl\">\n                                    {serverName}\n                                </Text>\n                                {serverName && (\n                                    <Text c=\"dimmed\" size=\"sm\">\n                                        {serverDisplayName}\n                                    </Text>\n                                )}\n                            </Stack>\n\n                            <Stack gap=\"md\">\n                                <TextInput\n                                    data-autofocus\n                                    label={t('form.addServer.input', {\n                                        context: 'username',\n                                        postProcess: 'titleCase',\n                                    })}\n                                    required\n                                    variant=\"filled\"\n                                    {...form.getInputProps('username')}\n                                />\n                                <PasswordInput\n                                    label={t('form.addServer.input', {\n                                        context: 'password',\n                                        postProcess: 'titleCase',\n                                    })}\n                                    required\n                                    variant=\"filled\"\n                                    {...form.getInputProps('password')}\n                                />\n                                <IgnoreCorsSslSwitches />\n                            </Stack>\n\n                            <Button\n                                disabled={isSubmitDisabled}\n                                fullWidth\n                                loading={isLoading}\n                                type=\"submit\"\n                                variant=\"filled\"\n                            >\n                                {t('common.login', {\n                                    defaultValue: 'Login',\n                                    postProcess: 'titleCase',\n                                })}\n                            </Button>\n                        </Stack>\n                    </form>\n                </Paper>\n            </Center>\n        </AnimatedPage>\n    );\n};\n\nconst LoginRouteWithBoundary = () => {\n    return (\n        <PageErrorBoundary>\n            <LoginRoute />\n        </PageErrorBoundary>\n    );\n};\n\nexport default LoginRouteWithBoundary;\n"
  },
  {
    "path": "src/renderer/features/lyrics/api/lyric-translate.ts",
    "content": "import axios from 'axios';\n\nexport const translateLyrics = async (\n    originalLyrics: string,\n    translationApiKey: string,\n    translationApiProvider: null | string,\n    translationTargetLanguage: null | string,\n) => {\n    let TranslatedText = '';\n    if (translationApiProvider === 'Microsoft Azure') {\n        try {\n            const response = await axios({\n                data: [\n                    {\n                        Text: originalLyrics,\n                    },\n                ],\n                headers: {\n                    'Content-Type': 'application/json',\n                    'Ocp-Apim-Subscription-Key': translationApiKey,\n                },\n                method: 'post',\n                url: `https://api.cognitive.microsofttranslator.com/translate?api-version=3.0&to=${translationTargetLanguage as string}`,\n            });\n            TranslatedText = response.data[0].translations[0].text;\n        } catch (e) {\n            console.error('Microsoft Azure translate request got an error!', e);\n            return null;\n        }\n    } else if (translationApiProvider === 'Google Cloud') {\n        try {\n            const response = await axios({\n                data: {\n                    format: 'text',\n                    q: originalLyrics,\n                },\n                headers: {\n                    'Content-Type': 'application/json',\n                },\n                method: 'post',\n                url: `https://translation.googleapis.com/language/translate/v2?target=${translationTargetLanguage as string}&key=${translationApiKey}`,\n            });\n            TranslatedText = response.data.data.translations[0].translatedText;\n        } catch (e) {\n            console.error('Google Cloud translate request got an error!', e);\n            return null;\n        }\n    }\n    return TranslatedText;\n};\n"
  },
  {
    "path": "src/renderer/features/lyrics/api/lyrics-api.ts",
    "content": "import { queryOptions } from '@tanstack/react-query';\nimport isElectron from 'is-electron';\n\nimport { api } from '/@/renderer/api';\nimport { queryKeys } from '/@/renderer/api/query-keys';\nimport { queryClient, QueryHookArgs } from '/@/renderer/lib/react-query';\nimport { getServerById, useSettingsStore } from '/@/renderer/store';\nimport { hasFeature } from '/@/shared/api/utils';\nimport {\n    FullLyricsMetadata,\n    InternetProviderLyricResponse,\n    InternetProviderLyricSearchResponse,\n    LyricGetQuery,\n    LyricSearchQuery,\n    LyricsOverride,\n    LyricsQuery,\n    QueueSong,\n    Song,\n    StructuredLyric,\n    SynchronizedLyricsArray,\n} from '/@/shared/types/domain-types';\nimport { LyricSource } from '/@/shared/types/domain-types';\nimport { LyricsResponse } from '/@/shared/types/domain-types';\nimport { ServerFeature } from '/@/shared/types/features-types';\n\nconst lyricsIpc = isElectron() ? window.api.lyrics : null;\n\nexport type LyricsQueryResult = {\n    local: FullLyricsMetadata | null | StructuredLyric[];\n    overrideData: LyricsResponse | null;\n    overrideSelection: LyricsOverride | null;\n    remoteAuto: FullLyricsMetadata | null;\n    selected: FullLyricsMetadata | null | StructuredLyric;\n    selectedOffsetMs: number;\n    selectedStructuredIndex: number;\n    selectedSynced: boolean;\n    suppressRemoteAuto: boolean;\n};\n\n// Match LRC lyrics format by https://github.com/ustbhuangyi/lyric-parser\n// [mm:ss.SSS] text\nconst timeExp = /\\[(\\d{2,}):(\\d{2})(?:\\.(\\d{2,3}))?]([^\\n]+)(\\n|$)/g;\n\n// Match karaoke lyrics format returned by NetEase\n// [SSS,???] text\nconst alternateTimeExp = /\\[(\\d*),(\\d*)]([^\\n]+)(\\n|$)/g;\n\nconst formatLyrics = (lyrics: string) => {\n    const synchronizedLines = lyrics.matchAll(timeExp);\n    const formattedLyrics: SynchronizedLyricsArray = [];\n\n    for (const line of synchronizedLines) {\n        const [, minute, sec, ms, text] = line;\n        const minutes = parseInt(minute, 10);\n        const seconds = parseInt(sec, 10);\n        const milis = ms?.length === 3 ? parseInt(ms, 10) : parseInt(ms, 10) * 10;\n\n        const timeInMilis = (minutes * 60 + seconds) * 1000 + milis;\n\n        formattedLyrics.push([timeInMilis, text]);\n    }\n\n    if (formattedLyrics.length > 0) return formattedLyrics;\n\n    const alternateSynchronizedLines = lyrics.matchAll(alternateTimeExp);\n    for (const line of alternateSynchronizedLines) {\n        const [, timeInMilis, , text] = line;\n        const cleanText = text\n            .replaceAll(/\\(\\d+,\\d+\\)/g, '')\n            .replaceAll(/\\s,/g, ',')\n            .replaceAll(/\\s\\./g, '.');\n        formattedLyrics.push([Number(timeInMilis), cleanText]);\n    }\n\n    if (formattedLyrics.length > 0) return formattedLyrics;\n\n    // If no synchronized lyrics were found, return the original lyrics\n    return lyrics;\n};\n\nexport const formatLyricsForDisplay = formatLyrics;\n\nexport function computeSelectedFromResult(\n    result: Pick<\n        LyricsQueryResult,\n        'local' | 'overrideData' | 'overrideSelection' | 'remoteAuto' | 'selectedOffsetMs'\n    >,\n    preferLocalLyrics: boolean,\n    selectedStructuredIndex: number,\n): {\n    selected: FullLyricsMetadata | null | StructuredLyric;\n    selectedSynced: boolean;\n} {\n    const { local, overrideData, overrideSelection, remoteAuto, selectedOffsetMs } = result;\n\n    // Override takes precedence over local and remote lyrics in all scenarios if available\n    if (overrideSelection && overrideData) {\n        const overrideLyrics: FullLyricsMetadata = {\n            artist: overrideSelection.artist,\n            lyrics: overrideData,\n            name: overrideSelection.name,\n            offsetMs: selectedOffsetMs,\n            remote: overrideSelection.remote ?? true,\n            source: overrideSelection.source,\n        };\n        return {\n            selected: overrideLyrics,\n            selectedSynced: Array.isArray(overrideData),\n        };\n    }\n\n    const hasLocalLocal =\n        (Array.isArray(local) && local.length > 0) ||\n        (local != null && !Array.isArray(local) && 'lyrics' in local && Boolean(local.lyrics));\n\n    // If setting is set to prefer local lyrics, return the local lyrics if available\n    if (preferLocalLyrics && hasLocalLocal) {\n        if (Array.isArray(local) && local.length > 0) {\n            const item = local[Math.min(selectedStructuredIndex, local.length - 1)];\n            return { selected: item, selectedSynced: item.synced };\n        }\n\n        if (local != null && !Array.isArray(local) && 'lyrics' in local && local.lyrics) {\n            return { selected: local, selectedSynced: Array.isArray(local.lyrics) };\n        }\n    }\n\n    // If remote lyrics are automatically fetched and available, return the remote auto lyrics\n    if (remoteAuto) {\n        return {\n            selected: remoteAuto,\n            selectedSynced: Array.isArray(remoteAuto.lyrics),\n        };\n    }\n\n    // Otherwise, we just return the local lyrics if available, using structured lyrics if available\n    if (Array.isArray(local) && local.length > 0) {\n        const item = local[Math.min(selectedStructuredIndex, local.length - 1)];\n        return { selected: item, selectedSynced: item.synced };\n    }\n\n    if (local != null && !Array.isArray(local) && 'lyrics' in local && local.lyrics) {\n        return { selected: local, selectedSynced: Array.isArray(local.lyrics) };\n    }\n\n    // If no lyrics are available, return null\n    return { selected: null, selectedSynced: false };\n}\n\nexport async function fetchLocalLyrics(params: {\n    serverId: string;\n    signal?: AbortSignal;\n    song: QueueSong;\n}): Promise<FullLyricsMetadata | null | StructuredLyric[]> {\n    const { serverId, signal, song } = params;\n    const server = getServerById(serverId);\n    if (!server) throw new Error('Server not found');\n\n    if (hasFeature(server, ServerFeature.LYRICS_MULTIPLE_STRUCTURED)) {\n        const subsonicLyrics = await api.controller\n            .getStructuredLyrics({\n                apiClientProps: { serverId, signal },\n                query: { songId: song.id },\n            })\n            .catch(console.error);\n        if (subsonicLyrics?.length) return subsonicLyrics;\n    } else if (hasFeature(server, ServerFeature.LYRICS_SINGLE_STRUCTURED)) {\n        const jfLyrics = await api.controller\n            .getLyrics({\n                apiClientProps: { serverId, signal },\n                query: { songId: song.id },\n            })\n            .catch((err) => console.error(err));\n        if (jfLyrics) {\n            return {\n                artist: song.artists?.[0]?.name,\n                lyrics: jfLyrics,\n                name: song.name,\n                remote: false,\n                source: server?.name ?? 'music server',\n            };\n        }\n    } else if (song.lyrics) {\n        return {\n            artist: song.artists?.[0]?.name,\n            lyrics: formatLyrics(song.lyrics),\n            name: song.name,\n            remote: false,\n            source: server?.name ?? 'music server',\n        };\n    }\n    return null;\n}\n\nexport async function fetchRemoteLyricsAuto(song: QueueSong): Promise<FullLyricsMetadata | null> {\n    const { fetch } = useSettingsStore.getState().lyrics;\n    if (!fetch) return null;\n    const remoteLyricsResult: InternetProviderLyricResponse | null =\n        await lyricsIpc?.getRemoteLyricsBySong(song);\n\n    if (remoteLyricsResult) {\n        return {\n            ...remoteLyricsResult,\n            lyrics: formatLyrics(remoteLyricsResult.lyrics),\n            remote: true,\n        };\n    }\n    return null;\n}\n\nexport async function fetchRemoteLyricsById(params: {\n    remoteSongId: string;\n    remoteSource: LyricSource;\n    song?: QueueSong | Song;\n}): Promise<LyricsResponse | null> {\n    const result = await lyricsIpc?.getRemoteLyricsByRemoteId(params as LyricGetQuery);\n    if (result) return formatLyrics(result);\n    return null;\n}\n\nexport function getDisplayOffset(\n    selected: FullLyricsMetadata | null | StructuredLyric,\n    storedOffsetMs: number,\n    selectedStructuredIndex: number,\n    local: FullLyricsMetadata | null | StructuredLyric[],\n): number {\n    if (selected && 'offsetMs' in selected && selected.offsetMs !== undefined) {\n        return selected.offsetMs;\n    }\n\n    if (Array.isArray(local) && local.length > 0) {\n        const item = local[Math.min(selectedStructuredIndex, local.length - 1)];\n        return item.offsetMs ?? storedOffsetMs;\n    }\n\n    return storedOffsetMs;\n}\n\nconst emptyResult = (): LyricsQueryResult => ({\n    local: null,\n    overrideData: null,\n    overrideSelection: null,\n    remoteAuto: null,\n    selected: null,\n    selectedOffsetMs: 0,\n    selectedStructuredIndex: 0,\n    selectedSynced: false,\n    suppressRemoteAuto: false,\n});\n\nexport const lyricsQueries = {\n    search: (args: Omit<QueryHookArgs<LyricSearchQuery>, 'serverId'>) => {\n        return queryOptions({\n            gcTime: 1000 * 60 * 1,\n            queryFn: () => {\n                if (lyricsIpc) {\n                    return lyricsIpc.searchRemoteLyrics(args.query);\n                }\n                return {} as Record<LyricSource, InternetProviderLyricSearchResponse[]>;\n            },\n            queryKey: queryKeys.songs.lyricsSearch(args.query),\n            staleTime: 1000 * 60 * 1,\n            ...args.options,\n        });\n    },\n    songLyrics: (args: QueryHookArgs<LyricsQuery>, song: QueueSong | undefined) => {\n        const lyricsKey = queryKeys.songs.lyrics(args.serverId, args.query);\n        return queryOptions({\n            gcTime: Infinity,\n            queryFn: async ({ signal }): Promise<LyricsQueryResult> => {\n                if (!song) return emptyResult();\n\n                const prev = queryClient.getQueryData<LyricsQueryResult>(lyricsKey);\n                const overrideSelection = prev?.overrideSelection ?? null;\n                const suppressRemoteAuto = prev?.suppressRemoteAuto ?? false;\n                const selectedStructuredIndex = prev?.selectedStructuredIndex ?? 0;\n                const selectedOffsetMs = prev?.selectedOffsetMs ?? 0;\n                const preferLocalLyrics = useSettingsStore.getState().lyrics.preferLocalLyrics;\n\n                // Fetch local lyrics\n                const localPromise = fetchLocalLyrics({ serverId: args.serverId, signal, song });\n\n                // Fetch remote auto lyrics\n                const remoteAutoPromise =\n                    suppressRemoteAuto || !useSettingsStore.getState().lyrics.fetch\n                        ? null\n                        : fetchRemoteLyricsAuto(song);\n\n                // Fetch override data\n                const overrideDataPromise = overrideSelection\n                    ? fetchRemoteLyricsById({\n                          remoteSongId: overrideSelection.id,\n                          remoteSource: overrideSelection.source as LyricSource,\n                          song,\n                      })\n                    : null;\n\n                const [local, remoteAuto, overrideData] = await Promise.all([\n                    localPromise,\n                    remoteAutoPromise,\n                    overrideDataPromise,\n                ]);\n\n                const partial: Pick<\n                    LyricsQueryResult,\n                    | 'local'\n                    | 'overrideData'\n                    | 'overrideSelection'\n                    | 'remoteAuto'\n                    | 'selectedOffsetMs'\n                > = {\n                    local,\n                    overrideData,\n                    overrideSelection,\n                    remoteAuto,\n                    selectedOffsetMs,\n                };\n                const { selected, selectedSynced } = computeSelectedFromResult(\n                    partial,\n                    preferLocalLyrics,\n                    selectedStructuredIndex,\n                );\n                const displayOffset = getDisplayOffset(\n                    selected,\n                    selectedOffsetMs,\n                    selectedStructuredIndex,\n                    local,\n                );\n                const resultSelectedOffsetMs = displayOffset;\n\n                return {\n                    ...emptyResult(),\n                    ...partial,\n                    selected,\n                    selectedOffsetMs: resultSelectedOffsetMs,\n                    selectedStructuredIndex,\n                    selectedSynced,\n                    suppressRemoteAuto,\n                };\n            },\n            queryKey: lyricsKey,\n            staleTime: Infinity,\n            ...args.options,\n        });\n    },\n    songLyricsByRemoteId: (args: QueryHookArgs<Partial<LyricGetQuery>>) => {\n        return queryOptions({\n            gcTime: Infinity,\n            queryFn: async () => {\n                const q = args.query;\n                if (!q?.remoteSongId || !q?.remoteSource) return null;\n                return fetchRemoteLyricsById({\n                    remoteSongId: q.remoteSongId,\n                    remoteSource: q.remoteSource as LyricSource,\n                    song: q.song as QueueSong | Song | undefined,\n                });\n            },\n            queryKey: queryKeys.songs.lyricsByRemoteId(args.query),\n            staleTime: Infinity,\n            ...args.options,\n        });\n    },\n};\n"
  },
  {
    "path": "src/renderer/features/lyrics/components/lyrics-export-form.tsx",
    "content": "import { closeAllModals, openModal } from '@mantine/modals';\nimport formatDuration from 'format-duration';\nimport { useCallback, useMemo } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport i18n from '/@/i18n/i18n';\nimport { Button } from '/@/shared/components/button/button';\nimport { Checkbox } from '/@/shared/components/checkbox/checkbox';\nimport { Code } from '/@/shared/components/code/code';\nimport { Divider } from '/@/shared/components/divider/divider';\nimport { Group } from '/@/shared/components/group/group';\nimport { NumberInput } from '/@/shared/components/number-input/number-input';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { useForm } from '/@/shared/hooks/use-form';\nimport { FullLyricsMetadata } from '/@/shared/types/domain-types';\n\ninterface LyricsExportFormProps {\n    lyrics: FullLyricsMetadata;\n    offsetMs: number;\n    synced: boolean;\n}\n\nexport const LyricsExportForm = ({ lyrics, offsetMs, synced }: LyricsExportFormProps) => {\n    const { t } = useTranslation();\n\n    const form = useForm({\n        initialValues: {\n            offsetMs,\n            synced,\n        },\n    });\n\n    const displayedLyrics = useMemo(() => {\n        if (form.values.synced && Array.isArray(lyrics.lyrics)) {\n            const contents = lyrics.lyrics\n                .map(\n                    (lyric) =>\n                        `[${formatDuration(lyric[0], { leading: true, ms: true })}]${lyric[1]}`,\n                )\n                .join('\\n');\n\n            return `[ar:${lyrics.artist}]\n[ti:${lyrics.name}]\n[offset:${form.values.offsetMs + (lyrics.offsetMs ?? 0)}]\n${contents}\n`;\n        } else {\n            if (Array.isArray(lyrics.lyrics)) {\n                return lyrics.lyrics.map((lyric) => lyric[1]).join('\\n') + '\\n';\n            }\n            return lyrics.lyrics;\n        }\n    }, [\n        form.values.offsetMs,\n        form.values.synced,\n        lyrics.artist,\n        lyrics.lyrics,\n        lyrics.name,\n        lyrics.offsetMs,\n    ]);\n\n    const exportLyrics = useCallback(() => {\n        const extension = form.values.synced ? '.lrc' : '.txt';\n        const lyricFile = new File([displayedLyrics], lyrics.name + extension, {\n            type: 'text/plain',\n        });\n\n        const lyricsFileLink = document.createElement('a');\n        const lyricsFileUrl = URL.createObjectURL(lyricFile);\n        lyricsFileLink.href = lyricsFileUrl;\n        lyricsFileLink.download = lyricFile.name;\n        lyricsFileLink.click();\n\n        URL.revokeObjectURL(lyricsFileUrl);\n\n        closeAllModals();\n    }, [displayedLyrics, form.values.synced, lyrics.name]);\n\n    return (\n        <Stack h=\"100%\" w=\"100%\">\n            {synced && (\n                <form>\n                    <Group grow>\n                        <Checkbox\n                            data-autofocus\n                            label={t('form.lyricsExport.input', {\n                                context: 'synced',\n                                postProcess: 'titleCase',\n                            })}\n                            {...form.getInputProps('synced', { type: 'checkbox' })}\n                        />\n                        <NumberInput\n                            data-autofocus\n                            label={t('form.lyricsExport.input', {\n                                context: 'offset',\n                                postProcess: 'titleCase',\n                            })}\n                            {...form.getInputProps('offsetMs')}\n                        />\n                    </Group>\n                </form>\n            )}\n            <Code block>{displayedLyrics}</Code>\n            <Divider />\n            <Group justify=\"flex-end\">\n                <Button onClick={() => closeAllModals()} variant=\"default\">\n                    {t('common.close', { postProcess: 'titleCase' })}\n                </Button>\n                <Button onClick={exportLyrics} variant=\"filled\">\n                    {t('form.lyricsExport.export', { postProcess: 'titleCase' })}\n                </Button>\n            </Group>\n        </Stack>\n    );\n};\n\nexport const openLyricsExportModal = ({ lyrics, offsetMs, synced }: LyricsExportFormProps) => {\n    openModal({\n        children: <LyricsExportForm lyrics={lyrics} offsetMs={offsetMs} synced={synced} />,\n        size: 'xl',\n        styles: {\n            body: {\n                height: '600px',\n            },\n        },\n        title: i18n.t('form.lyricSearch.title', { postProcess: 'titleCase' }) as string,\n    });\n};\n"
  },
  {
    "path": "src/renderer/features/lyrics/components/lyrics-search-form.module.css",
    "content": ".search-item {\n    all: unset;\n    box-sizing: border-box !important;\n    padding: 0.5rem;\n    cursor: pointer;\n    border-radius: 5px;\n\n    &:hover,\n    &:active,\n    &:focus-visible {\n        @mixin dark {\n            background-color: lighten(var(--theme-colors-background), 10%);\n        }\n\n        @mixin light {\n            background-color: darken(var(--theme-colors-background), 5%);\n        }\n    }\n}\n\n.selected {\n    background-color: alpha(var(--theme-colors-primary), 0.3);\n\n    &:hover,\n    &:active,\n    &:focus-visible {\n        @mixin dark {\n            background-color: alpha(var(--theme-colors-primary), 0.4);\n        }\n\n        @mixin light {\n            background-color: alpha(var(--theme-colors-primary), 0.4);\n        }\n    }\n}\n\n.lyrics-preview {\n    :global(.synchronized-lyrics) {\n        height: auto !important;\n        padding: 1rem 0 !important;\n        overflow: visible !important;\n        transform: none !important;\n    }\n}\n\n.lyrics-content-wrapper {\n    :global(> div) {\n        height: auto !important;\n        max-height: none !important;\n        padding: 1rem 0 !important;\n        overflow: visible !important;\n        transform: none !important;\n    }\n}\n"
  },
  {
    "path": "src/renderer/features/lyrics/components/lyrics-search-form.tsx",
    "content": "import { closeAllModals, openModal } from '@mantine/modals';\nimport { useQuery } from '@tanstack/react-query';\nimport clsx from 'clsx';\nimport orderBy from 'lodash/orderBy';\nimport { useMemo, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport styles from './lyrics-search-form.module.css';\n\nimport i18n from '/@/i18n/i18n';\nimport { lyricsQueries } from '/@/renderer/features/lyrics/api/lyrics-api';\nimport { openLyricsExportModal } from '/@/renderer/features/lyrics/components/lyrics-export-form';\nimport {\n    SynchronizedLyrics,\n    SynchronizedLyricsProps,\n} from '/@/renderer/features/lyrics/synchronized-lyrics';\nimport {\n    UnsynchronizedLyrics,\n    UnsynchronizedLyricsProps,\n} from '/@/renderer/features/lyrics/unsynchronized-lyrics';\nimport { usePlayerSong } from '/@/renderer/store';\nimport { ActionIcon } from '/@/shared/components/action-icon/action-icon';\nimport { Button } from '/@/shared/components/button/button';\nimport { Center } from '/@/shared/components/center/center';\nimport { Divider } from '/@/shared/components/divider/divider';\nimport { Group } from '/@/shared/components/group/group';\nimport { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';\nimport { Spinner } from '/@/shared/components/spinner/spinner';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { TextInput } from '/@/shared/components/text-input/text-input';\nimport { Text } from '/@/shared/components/text/text';\nimport { useDebouncedValue } from '/@/shared/hooks/use-debounced-value';\nimport { useForm } from '/@/shared/hooks/use-form';\nimport {\n    FullLyricsMetadata,\n    InternetProviderLyricSearchResponse,\n    LyricSource,\n    LyricsOverride,\n} from '/@/shared/types/domain-types';\n\ninterface SearchResultProps {\n    data: InternetProviderLyricSearchResponse;\n    isSelected?: boolean;\n    onClick?: () => void;\n}\nconst SearchResult = ({ data, isSelected, onClick }: SearchResultProps) => {\n    const { t } = useTranslation();\n    const { artist, id, isSync, name, score, source } = data;\n\n    const percentageScore = useMemo(() => {\n        if (!score) return 0;\n        return ((1 - score) * 100).toFixed(2);\n    }, [score]);\n\n    const cleanId =\n        source === LyricSource.GENIUS ? id.replace(/^((http[s]?|ftp):\\/)?\\/?([^:/\\s]+)/g, '') : id;\n\n    const syncStatus = useMemo(() => {\n        if (isSync === true) {\n            return t('page.fullscreenPlayer.config.synchronized', {\n                postProcess: 'sentenceCase',\n            });\n        }\n        if (isSync === false) {\n            return t('page.fullscreenPlayer.config.unsynchronized', {\n                postProcess: 'sentenceCase',\n            });\n        }\n\n        return t('common.unknown', { postProcess: 'titleCase' });\n    }, [isSync, t]);\n\n    return (\n        <button\n            className={clsx(styles.searchItem, {\n                [styles.selected]: isSelected,\n            })}\n            onClick={onClick}\n        >\n            <Group justify=\"space-between\" wrap=\"nowrap\">\n                <Stack gap={0} maw=\"65%\">\n                    <Text fw={600} size=\"md\">\n                        {name}\n                    </Text>\n                    <Text isMuted>{artist}</Text>\n                    <Group gap=\"sm\" wrap=\"nowrap\">\n                        <Text isMuted size=\"sm\">\n                            {[source, cleanId, syncStatus].join(' — ')}\n                        </Text>\n                    </Group>\n                </Stack>\n                <Text>{percentageScore}%</Text>\n            </Group>\n        </button>\n    );\n};\n\ninterface LyricSearchFormProps {\n    artist?: string;\n    name?: string;\n    onSearchOverride?: (params: LyricsOverride) => void;\n}\n\nexport const LyricsSearchForm = ({ artist, name, onSearchOverride }: LyricSearchFormProps) => {\n    const { t } = useTranslation();\n    const currentSong = usePlayerSong();\n    const [selectedResult, setSelectedResult] =\n        useState<InternetProviderLyricSearchResponse | null>(null);\n\n    const form = useForm({\n        initialValues: {\n            artist: artist || '',\n            name: name || '',\n        },\n    });\n\n    const [debouncedArtist] = useDebouncedValue(form.values.artist, 500);\n    const [debouncedName] = useDebouncedValue(form.values.name, 500);\n\n    const { data, isLoading } = useQuery(\n        lyricsQueries.search({\n            query: { artist: debouncedArtist, name: debouncedName },\n        }),\n    );\n\n    const { data: previewData, isLoading: isPreviewLoading } = useQuery(\n        lyricsQueries.songLyricsByRemoteId({\n            options: {\n                enabled: !!selectedResult,\n            },\n            query: {\n                remoteSongId: selectedResult?.id,\n                remoteSource: selectedResult?.source as LyricSource | undefined,\n                song: currentSong,\n            },\n            serverId: currentSong?._serverId || '',\n        }),\n    );\n\n    const searchResults = useMemo(() => {\n        if (!data) return [];\n\n        const results: InternetProviderLyricSearchResponse[] = [];\n        Object.keys(data).forEach((key) => {\n            (data[key as keyof typeof data] || []).forEach((result) => results.push(result));\n        });\n\n        const scoredResults = orderBy(results, ['score'], ['asc']);\n\n        return scoredResults;\n    }, [data]);\n\n    const handleApply = () => {\n        if (selectedResult && onSearchOverride) {\n            onSearchOverride({\n                artist: selectedResult.artist,\n                id: selectedResult.id,\n                name: selectedResult.name,\n                remote: true,\n                source: selectedResult.source as LyricSource,\n            });\n            closeAllModals();\n        }\n    };\n\n    const handleExport = () => {\n        if (selectedResult && previewData) {\n            const lyricsMetadata: FullLyricsMetadata = {\n                artist: selectedResult.artist,\n                lyrics: previewData,\n                name: selectedResult.name,\n                offsetMs: 0,\n                remote: true,\n                source: selectedResult.source,\n            };\n            const synced = Array.isArray(previewData);\n            openLyricsExportModal({ lyrics: lyricsMetadata, offsetMs: 0, synced });\n        }\n    };\n\n    return (\n        <Stack h=\"100%\" w=\"100%\">\n            <form>\n                <Group grow>\n                    <TextInput\n                        data-autofocus\n                        label={t('form.lyricSearch.input', {\n                            context: 'name',\n                            postProcess: 'titleCase',\n                        })}\n                        rightSection={\n                            form.values.name ? (\n                                <ActionIcon\n                                    icon=\"x\"\n                                    onClick={() => form.setFieldValue('name', '')}\n                                    size=\"sm\"\n                                    variant=\"transparent\"\n                                />\n                            ) : null\n                        }\n                        {...form.getInputProps('name')}\n                    />\n                    <TextInput\n                        label={t('form.lyricSearch.input', {\n                            context: 'artist',\n                            postProcess: 'titleCase',\n                        })}\n                        rightSection={\n                            form.values.artist ? (\n                                <ActionIcon\n                                    icon=\"x\"\n                                    onClick={() => form.setFieldValue('artist', '')}\n                                    size=\"sm\"\n                                    variant=\"transparent\"\n                                />\n                            ) : null\n                        }\n                        {...form.getInputProps('artist')}\n                    />\n                </Group>\n            </form>\n            <Divider />\n            <Group align=\"flex-start\" grow style={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>\n                <Stack style={{ flex: 1, height: '100%', minHeight: 0, overflow: 'hidden' }}>\n                    <ScrollArea\n                        style={{\n                            height: '100%',\n                            paddingRight: '1rem',\n                        }}\n                    >\n                        {isLoading ? (\n                            <Spinner container />\n                        ) : (\n                            <Stack gap=\"md\">\n                                {searchResults.map((result) => (\n                                    <SearchResult\n                                        data={result}\n                                        isSelected={\n                                            selectedResult?.id === result.id &&\n                                            selectedResult?.source === result.source\n                                        }\n                                        key={`${result.source}-${result.id}`}\n                                        onClick={() => setSelectedResult(result)}\n                                    />\n                                ))}\n                            </Stack>\n                        )}\n                    </ScrollArea>\n                </Stack>\n                {selectedResult && (\n                    <Stack style={{ flex: 1, height: '100%', minHeight: 0, overflow: 'hidden' }}>\n                        <ScrollArea\n                            className={styles['lyrics-preview']}\n                            style={{\n                                height: '100%',\n                                paddingRight: '1rem',\n                            }}\n                        >\n                            {isPreviewLoading ? (\n                                <Spinner container />\n                            ) : previewData ? (\n                                <div\n                                    className={styles['lyrics-content-wrapper']}\n                                    style={{ width: '100%' }}\n                                >\n                                    {Array.isArray(previewData) ? (\n                                        <SynchronizedLyrics\n                                            style={{ padding: 0 }}\n                                            {...({\n                                                artist: selectedResult.artist,\n                                                lyrics: previewData,\n                                                name: selectedResult.name,\n                                                remote: true,\n                                                source: selectedResult.source,\n                                            } as SynchronizedLyricsProps)}\n                                        />\n                                    ) : (\n                                        <UnsynchronizedLyrics\n                                            {...({\n                                                artist: selectedResult.artist,\n                                                lyrics: previewData,\n                                                name: selectedResult.name,\n                                                remote: true,\n                                                source: selectedResult.source,\n                                            } as UnsynchronizedLyricsProps)}\n                                        />\n                                    )}\n                                </div>\n                            ) : (\n                                <Center>\n                                    <Text isMuted>\n                                        {t('page.fullscreenPlayer.noLyrics', {\n                                            postProcess: 'sentenceCase',\n                                        })}\n                                    </Text>\n                                </Center>\n                            )}\n                        </ScrollArea>\n                    </Stack>\n                )}\n            </Group>\n            <Divider />\n            <Group justify=\"flex-end\">\n                <Button onClick={() => closeAllModals()} variant=\"default\">\n                    {t('common.cancel', { postProcess: 'titleCase' })}\n                </Button>\n                <Button\n                    disabled={!selectedResult || !previewData}\n                    onClick={handleExport}\n                    variant=\"default\"\n                >\n                    {t('form.lyricsExport.export', { postProcess: 'titleCase' })}\n                </Button>\n                <Button disabled={!selectedResult} onClick={handleApply} variant=\"filled\">\n                    {t('common.confirm', { postProcess: 'titleCase' })}\n                </Button>\n            </Group>\n        </Stack>\n    );\n};\n\nexport const openLyricSearchModal = ({ artist, name, onSearchOverride }: LyricSearchFormProps) => {\n    openModal({\n        children: (\n            <LyricsSearchForm artist={artist} name={name} onSearchOverride={onSearchOverride} />\n        ),\n        size: 'xl',\n        styles: {\n            body: {\n                height: '600px',\n            },\n        },\n        title: i18n.t('form.lyricSearch.title', { postProcess: 'titleCase' }) as string,\n    });\n};\n"
  },
  {
    "path": "src/renderer/features/lyrics/components/lyrics-settings-form.tsx",
    "content": "import isElectron from 'is-electron';\nimport { useTranslation } from 'react-i18next';\n\nimport { languages } from '/@/i18n/i18n';\nimport {\n    SettingOption,\n    SettingsSection,\n} from '/@/renderer/features/settings/components/settings-section';\nimport {\n    useLyricsDisplaySettings,\n    useLyricsSettings,\n    useSettingsStore,\n    useSettingsStoreActions,\n} from '/@/renderer/store';\nimport { Fieldset } from '/@/shared/components/fieldset/fieldset';\nimport { MultiSelect } from '/@/shared/components/multi-select/multi-select';\nimport { NumberInput } from '/@/shared/components/number-input/number-input';\nimport { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control';\nimport { Select } from '/@/shared/components/select/select';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { Switch } from '/@/shared/components/switch/switch';\nimport { TextInput } from '/@/shared/components/text-input/text-input';\nimport { Text } from '/@/shared/components/text/text';\nimport { LyricSource } from '/@/shared/types/domain-types';\n\nconst localSettings = isElectron() ? window.api.localSettings : null;\n\ninterface LyricsSettingsFormProps {\n    settingsKey: string;\n}\n\nexport const LyricsSettingsForm = ({ settingsKey }: LyricsSettingsFormProps) => {\n    const { t } = useTranslation();\n    const lyricsSettings = useLyricsSettings();\n    const displaySettings = useLyricsDisplaySettings(settingsKey);\n    const allLyricsDisplay = useSettingsStore((state) => state.lyricsDisplay);\n    const { setSettings } = useSettingsStoreActions();\n\n    const updateLyricsSetting = (updates: Partial<typeof lyricsSettings>) => {\n        setSettings({\n            lyrics: {\n                ...lyricsSettings,\n                ...updates,\n            },\n        });\n    };\n\n    const updateDisplaySetting = (updates: Partial<typeof displaySettings>) => {\n        setSettings({\n            lyricsDisplay: {\n                ...allLyricsDisplay,\n                [settingsKey]: {\n                    ...displaySettings,\n                    ...updates,\n                },\n            },\n        });\n    };\n\n    const displayOptions: SettingOption[] = [\n        {\n            control: (\n                <NumberInput\n                    onBlur={(e) => {\n                        const value = Number(e.currentTarget.value);\n                        updateDisplaySetting({ fontSize: value });\n                    }}\n                    rightSection={\n                        <Text pr=\"md\" size=\"sm\">\n                            px\n                        </Text>\n                    }\n                    step={1}\n                    value={displaySettings.fontSize}\n                    width={100}\n                />\n            ),\n            description: '',\n            title: t(\n                `${t('page.fullscreenPlayer.config.lyricSize')} (${t('page.fullscreenPlayer.config.synchronized')})`,\n                { postProcess: 'sentenceCase' },\n            ),\n        },\n        {\n            control: (\n                <NumberInput\n                    onBlur={(e) => {\n                        const value = Number(e.currentTarget.value);\n                        updateDisplaySetting({ fontSizeUnsync: value });\n                    }}\n                    rightSection={\n                        <Text pr=\"md\" size=\"sm\">\n                            px\n                        </Text>\n                    }\n                    step={1}\n                    value={displaySettings.fontSizeUnsync}\n                    width={100}\n                />\n            ),\n            description: '',\n            title: t(\n                `${t('page.fullscreenPlayer.config.lyricSize')} (${t('page.fullscreenPlayer.config.unsynchronized')})`,\n                { postProcess: 'sentenceCase' },\n            ),\n        },\n        {\n            control: (\n                <NumberInput\n                    onBlur={(e) => {\n                        const value = Number(e.currentTarget.value);\n                        updateDisplaySetting({ gap: value });\n                    }}\n                    rightSection={\n                        <Text pr=\"md\" size=\"sm\">\n                            px\n                        </Text>\n                    }\n                    step={1}\n                    value={displaySettings.gap}\n                    width={100}\n                />\n            ),\n            description: '',\n            title: t(\n                `${t('page.fullscreenPlayer.config.lyricGap')} (${t('page.fullscreenPlayer.config.synchronized')})`,\n                { postProcess: 'sentenceCase' },\n            ),\n        },\n        {\n            control: (\n                <NumberInput\n                    onBlur={(e) => {\n                        const value = Number(e.currentTarget.value);\n                        updateDisplaySetting({ gapUnsync: value });\n                    }}\n                    rightSection={\n                        <Text pr=\"md\" size=\"sm\">\n                            px\n                        </Text>\n                    }\n                    step={1}\n                    value={displaySettings.gapUnsync}\n                    width={100}\n                />\n            ),\n            description: '',\n            title: t(\n                `${t('page.fullscreenPlayer.config.lyricGap')} (${t('page.fullscreenPlayer.config.unsynchronized')})`,\n                { postProcess: 'sentenceCase' },\n            ),\n        },\n        {\n            control: (\n                <SegmentedControl\n                    data={[\n                        { label: t('common.left', { postProcess: 'titleCase' }), value: 'left' },\n                        {\n                            label: t('common.center', { postProcess: 'titleCase' }),\n                            value: 'center',\n                        },\n                        { label: t('common.right', { postProcess: 'titleCase' }), value: 'right' },\n                    ]}\n                    onChange={(value) =>\n                        updateLyricsSetting({ alignment: value as 'center' | 'left' | 'right' })\n                    }\n                    value={lyricsSettings.alignment}\n                />\n            ),\n            description: '',\n            title: t('page.fullscreenPlayer.config.lyricAlignment', {\n                postProcess: 'sentenceCase',\n            }),\n        },\n        {\n            control: (\n                <Switch\n                    aria-label=\"Follow lyrics\"\n                    defaultChecked={lyricsSettings.follow}\n                    onChange={(e) => updateLyricsSetting({ follow: e.currentTarget.checked })}\n                />\n            ),\n            description: '',\n            title: t('page.fullscreenPlayer.config.followCurrentLyric', {\n                postProcess: 'sentenceCase',\n            }),\n        },\n        {\n            control: (\n                <Switch\n                    aria-label=\"Show match\"\n                    defaultChecked={lyricsSettings.showMatch}\n                    onChange={(e) => updateLyricsSetting({ showMatch: e.currentTarget.checked })}\n                />\n            ),\n            description: '',\n            title: t('page.fullscreenPlayer.config.showLyricMatch', {\n                postProcess: 'sentenceCase',\n            }),\n        },\n        {\n            control: (\n                <Switch\n                    aria-label=\"Show provider\"\n                    defaultChecked={lyricsSettings.showProvider}\n                    onChange={(e) => updateLyricsSetting({ showProvider: e.currentTarget.checked })}\n                />\n            ),\n            description: '',\n            title: t('page.fullscreenPlayer.config.showLyricProvider', {\n                postProcess: 'sentenceCase',\n            }),\n        },\n    ];\n\n    const lyricOptions: SettingOption[] = [\n        {\n            control: (\n                <Switch\n                    aria-label=\"Prefer local lyrics\"\n                    defaultChecked={lyricsSettings.preferLocalLyrics}\n                    onChange={(e) =>\n                        updateLyricsSetting({ preferLocalLyrics: e.currentTarget.checked })\n                    }\n                />\n            ),\n            description: t('setting.preferLocalLyrics', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: !isElectron(),\n            title: t('setting.preferLocalLyrics', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <Switch\n                    aria-label=\"Enable fetching lyrics\"\n                    defaultChecked={lyricsSettings.fetch}\n                    onChange={(e) => updateLyricsSetting({ fetch: e.currentTarget.checked })}\n                />\n            ),\n            description: t('setting.lyricFetch', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: !isElectron(),\n            title: t('setting.lyricFetch', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <MultiSelect\n                    aria-label=\"Lyric providers\"\n                    clearable\n                    data={Object.values(LyricSource)}\n                    defaultValue={lyricsSettings.sources}\n                    onChange={(e: string[]) => {\n                        localSettings?.set('lyrics', e);\n                        updateLyricsSetting({ sources: e.map((source) => source as LyricSource) });\n                    }}\n                    width={300}\n                />\n            ),\n            description: t('setting.lyricFetchProvider', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: !isElectron(),\n            title: t('setting.lyricFetchProvider', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <Switch\n                    aria-label=\"Enable NetEase translations\"\n                    defaultChecked={lyricsSettings.enableNeteaseTranslation}\n                    onChange={(e) => {\n                        const isChecked = e.currentTarget.checked;\n                        updateLyricsSetting({ enableNeteaseTranslation: isChecked });\n                        localSettings?.set('enableNeteaseTranslation', isChecked);\n                    }}\n                />\n            ),\n            description: t('setting.neteaseTranslation', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: !isElectron(),\n            title: t('setting.neteaseTranslation', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <NumberInput\n                    defaultValue={lyricsSettings.delayMs}\n                    onBlur={(e) => {\n                        const value = Number(e.currentTarget.value);\n                        updateLyricsSetting({ delayMs: value });\n                    }}\n                    step={10}\n                    width={100}\n                />\n            ),\n            description: t('setting.lyricOffset', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: !isElectron(),\n            title: t('setting.lyricOffset', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <Select\n                    data={languages}\n                    onChange={(value) => {\n                        updateLyricsSetting({ translationTargetLanguage: value });\n                    }}\n                    value={lyricsSettings.translationTargetLanguage}\n                />\n            ),\n            description: t('setting.translationTargetLanguage', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: !isElectron(),\n            title: t('setting.translationTargetLanguage', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <Select\n                    clearable\n                    data={['Microsoft Azure', 'Google Cloud']}\n                    onChange={(value) => {\n                        updateLyricsSetting({ translationApiProvider: value });\n                    }}\n                    value={lyricsSettings.translationApiProvider}\n                />\n            ),\n            description: t('setting.translationApiProvider', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: !isElectron(),\n            title: t('setting.translationApiProvider', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <TextInput\n                    onChange={(e) => {\n                        updateLyricsSetting({ translationApiKey: e.currentTarget.value });\n                    }}\n                    value={lyricsSettings.translationApiKey}\n                />\n            ),\n            description: t('setting.translationApiKey', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: !isElectron(),\n            title: t('setting.translationApiKey', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <Switch\n                    aria-label=\"Enable auto translation\"\n                    defaultChecked={lyricsSettings.enableAutoTranslation}\n                    onChange={(e) =>\n                        updateLyricsSetting({ enableAutoTranslation: e.currentTarget.checked })\n                    }\n                />\n            ),\n            description: t('setting.enableAutoTranslation', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: !isElectron(),\n            title: t('setting.enableAutoTranslation', { postProcess: 'sentenceCase' }),\n        },\n    ];\n\n    return (\n        <Stack gap=\"md\" p=\"md\">\n            <Fieldset legend={t('page.setting.lyricsDisplay', { postProcess: 'sentenceCase' })}>\n                <SettingsSection options={displayOptions} />\n            </Fieldset>\n            <Fieldset legend={t('page.setting.lyrics', { postProcess: 'sentenceCase' })}>\n                <SettingsSection options={lyricOptions} />\n            </Fieldset>\n        </Stack>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/lyrics/components/lyrics-settings-modal.tsx",
    "content": "import { ContextModalProps } from '@mantine/modals';\n\nimport { LyricsSettingsForm } from './lyrics-settings-form';\n\nexport const LyricsSettingsContextModal = ({\n    innerProps,\n}: ContextModalProps<{ settingsKey: string }>) => {\n    return <LyricsSettingsForm settingsKey={innerProps.settingsKey} />;\n};\n"
  },
  {
    "path": "src/renderer/features/lyrics/lyric-line.module.css",
    "content": ".lyric-line {\n    padding: 0 1rem;\n    font-weight: 600;\n    line-height: 1.2;\n    color: var(--theme-colors-foreground);\n    word-break: normal;\n    opacity: 0.35;\n    transition:\n        opacity 0.3s ease-in-out,\n        transform 0.3s ease-in-out;\n}\n\n.lyric-line:global(.active) {\n    opacity: 1 !important;\n}\n\n.lyric-line:global(.unsynchronized) {\n    opacity: 1;\n}\n\n.lyric-line:global(.synchronized) {\n    cursor: pointer;\n}\n"
  },
  {
    "path": "src/renderer/features/lyrics/lyric-line.tsx",
    "content": "import clsx from 'clsx';\nimport { ComponentPropsWithoutRef, memo, useMemo } from 'react';\n\nimport styles from './lyric-line.module.css';\n\nimport { Box } from '/@/shared/components/box/box';\nimport { Stack } from '/@/shared/components/stack/stack';\n\ninterface LyricLineProps extends ComponentPropsWithoutRef<'div'> {\n    alignment: 'center' | 'left' | 'right';\n    fontSize: number;\n    text: string;\n}\n\nexport const LyricLine = memo(\n    ({ alignment, className, fontSize, text, ...props }: LyricLineProps) => {\n        const lines = useMemo(() => text.split('_BREAK_'), [text]);\n\n        const style = useMemo(\n            () => ({\n                fontSize,\n                textAlign: alignment,\n            }),\n            [fontSize, alignment],\n        );\n\n        return (\n            <Box className={clsx(styles.lyricLine, className)} style={style} {...props}>\n                <Stack gap={0}>\n                    {lines.map((line, index) => (\n                        <span key={index}>{line}</span>\n                    ))}\n                </Stack>\n            </Box>\n        );\n    },\n);\n\nLyricLine.displayName = 'LyricLine';\n"
  },
  {
    "path": "src/renderer/features/lyrics/lyrics-actions.tsx",
    "content": "import isElectron from 'is-electron';\nimport { useTranslation } from 'react-i18next';\n\nimport { openLyricSearchModal } from '/@/renderer/features/lyrics/components/lyrics-search-form';\nimport { useLyricsSettings, usePlayerSong } from '/@/renderer/store';\nimport { ActionIcon } from '/@/shared/components/action-icon/action-icon';\nimport { Button } from '/@/shared/components/button/button';\nimport { Center } from '/@/shared/components/center/center';\nimport { Group } from '/@/shared/components/group/group';\nimport { NumberInput } from '/@/shared/components/number-input/number-input';\nimport { Select } from '/@/shared/components/select/select';\nimport { Tooltip } from '/@/shared/components/tooltip/tooltip';\nimport { LyricsOverride } from '/@/shared/types/domain-types';\n\ninterface LyricsActionsProps {\n    hasLyrics: boolean;\n    index: number;\n    languages: { label: string; value: string }[];\n    offsetMs: number;\n    onExportLyrics: () => void;\n    onRemoveLyric: () => void;\n    onSearchOverride: (params: LyricsOverride) => void;\n    onTranslateLyric?: () => void;\n    onUpdateOffset: (offsetMs: number) => void;\n    setIndex: (idx: number) => void;\n    settingsKey?: string;\n    synced?: boolean;\n}\n\nexport const LyricsActions = ({\n    hasLyrics,\n    index,\n    languages,\n    offsetMs,\n    onExportLyrics,\n    onRemoveLyric,\n    onSearchOverride,\n    onTranslateLyric,\n    onUpdateOffset,\n    setIndex,\n}: LyricsActionsProps) => {\n    const { t } = useTranslation();\n    const currentSong = usePlayerSong();\n    const { sources } = useLyricsSettings();\n\n    const handleLyricOffset = (e: number | string) => {\n        onUpdateOffset(Number(e));\n    };\n\n    const isActionsDisabled = !currentSong;\n    const isDesktop = isElectron();\n\n    return (\n        <>\n            <div style={{ position: 'relative', width: '100%' }}>\n                {hasLyrics && (\n                    <Center pb=\"md\">\n                        {languages.length > 1 && (\n                            <Select\n                                clearable={false}\n                                data={languages}\n                                onChange={(value) => setIndex(parseInt(value!, 10))}\n                                style={{ bottom: 30, position: 'absolute' }}\n                                value={index.toString()}\n                            />\n                        )}\n                        <Button\n                            onClick={onExportLyrics}\n                            size=\"compact-sm\"\n                            uppercase\n                            variant=\"subtle\"\n                        >\n                            {t('form.lyricsExport.export', { postProcess: 'sentenceCase ' })}\n                        </Button>\n                    </Center>\n                )}\n\n                <Group justify=\"center\">\n                    {isDesktop && sources.length ? (\n                        <Button\n                            disabled={isActionsDisabled}\n                            onClick={() =>\n                                openLyricSearchModal({\n                                    artist: currentSong?.artistName,\n                                    name: currentSong?.name,\n                                    onSearchOverride,\n                                })\n                            }\n                            uppercase\n                            variant=\"subtle\"\n                        >\n                            {t('common.search', { postProcess: 'titleCase' })}\n                        </Button>\n                    ) : null}\n                    <ActionIcon\n                        aria-label=\"Decrease lyric offset\"\n                        icon=\"minus\"\n                        onClick={() => handleLyricOffset(offsetMs - 50)}\n                        tooltip={{\n                            label: t('common.slower', { postProcess: 'sentenceCase' }),\n                            openDelay: 0,\n                        }}\n                        variant=\"subtle\"\n                    />\n                    <Tooltip\n                        label={t('setting.lyricOffset', { postProcess: 'sentenceCase' })}\n                        openDelay={0}\n                    >\n                        <NumberInput\n                            aria-label=\"Lyric offset\"\n                            onChange={handleLyricOffset}\n                            styles={{ input: { textAlign: 'center' } }}\n                            value={offsetMs || 0}\n                            width={70}\n                        />\n                    </Tooltip>\n                    <ActionIcon\n                        aria-label=\"Increase lyric offset\"\n                        icon=\"plus\"\n                        onClick={() => handleLyricOffset(offsetMs + 50)}\n                        tooltip={{\n                            label: t('common.faster', { postProcess: 'sentenceCase' }),\n                            openDelay: 0,\n                        }}\n                        variant=\"subtle\"\n                    />\n                    {isDesktop && sources.length ? (\n                        <Button\n                            disabled={isActionsDisabled}\n                            onClick={onRemoveLyric}\n                            uppercase\n                            variant=\"subtle\"\n                        >\n                            {t('common.clear', { postProcess: 'sentenceCase' })}\n                        </Button>\n                    ) : null}\n                </Group>\n\n                <div style={{ position: 'absolute', right: 0, top: -50 }}>\n                    {isDesktop && sources.length && onTranslateLyric ? (\n                        <Button\n                            disabled={isActionsDisabled}\n                            onClick={onTranslateLyric}\n                            uppercase\n                            variant=\"subtle\"\n                        >\n                            {t('common.translation', { postProcess: 'sentenceCase' })}\n                        </Button>\n                    ) : null}\n                </div>\n            </div>\n        </>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/lyrics/lyrics.module.css",
    "content": ".actions-container {\n    position: absolute;\n    bottom: 0;\n    left: 0;\n    z-index: 50;\n    display: flex;\n    gap: 0.5rem;\n    align-items: center;\n    justify-content: center;\n    width: 100%;\n    pointer-events: none;\n    opacity: 0;\n    transition: opacity 0.2s ease-in-out;\n\n    &:hover {\n        opacity: 1 !important;\n    }\n\n    &:focus-within {\n        opacity: 1 !important;\n    }\n\n    :global(> *),\n    :global(> * > *),\n    :global(div) {\n        pointer-events: none;\n    }\n\n    :global(button),\n    :global(input),\n    :global([role='button']),\n    :global([role='combobox']),\n    :global([role='textbox']) {\n        pointer-events: auto;\n    }\n}\n\n.lyrics-container {\n    position: relative;\n    display: flex;\n    width: 100%;\n    min-width: 0;\n    height: 100%;\n    overflow: hidden;\n\n    &:hover {\n        .actions-container {\n            opacity: 0.6;\n        }\n    }\n}\n\n.scroll-container {\n    position: relative;\n    z-index: 1;\n    width: 100%;\n    height: 100%;\n    text-align: center;\n    mask-image: linear-gradient(\n        180deg,\n        transparent 0%,\n        rgb(0 0 0 / 100%) 15%,\n        rgb(0 0 0 / 100%) 85%,\n        transparent 95%\n    );\n}\n\n.settings-icon {\n    z-index: 100;\n    opacity: 0;\n    transition: opacity 0.2s ease;\n}\n\n.lyrics-container:hover .settings-icon {\n    opacity: 1;\n}\n"
  },
  {
    "path": "src/renderer/features/lyrics/lyrics.tsx",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { AnimatePresence, motion } from 'motion/react';\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport styles from './lyrics.module.css';\n\nimport { queryKeys } from '/@/renderer/api/query-keys';\nimport { translateLyrics } from '/@/renderer/features/lyrics/api/lyric-translate';\nimport {\n    computeSelectedFromResult,\n    getDisplayOffset,\n    lyricsQueries,\n    type LyricsQueryResult,\n} from '/@/renderer/features/lyrics/api/lyrics-api';\nimport { openLyricsExportModal } from '/@/renderer/features/lyrics/components/lyrics-export-form';\nimport { LyricsActions } from '/@/renderer/features/lyrics/lyrics-actions';\nimport {\n    SynchronizedLyrics,\n    SynchronizedLyricsProps,\n} from '/@/renderer/features/lyrics/synchronized-lyrics';\nimport {\n    UnsynchronizedLyrics,\n    UnsynchronizedLyricsProps,\n} from '/@/renderer/features/lyrics/unsynchronized-lyrics';\nimport { openLyricsSettingsModal } from '/@/renderer/features/lyrics/utils/open-lyrics-settings-modal';\nimport { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';\nimport { ComponentErrorBoundary } from '/@/renderer/features/shared/components/component-error-boundary';\nimport { queryClient } from '/@/renderer/lib/react-query';\nimport { useLyricsSettings, usePlayerSong } from '/@/renderer/store';\nimport { ActionIcon } from '/@/shared/components/action-icon/action-icon';\nimport { Center } from '/@/shared/components/center/center';\nimport { Group } from '/@/shared/components/group/group';\nimport { Spinner } from '/@/shared/components/spinner/spinner';\nimport { Text } from '/@/shared/components/text/text';\nimport { LyricsOverride } from '/@/shared/types/domain-types';\n\ntype LyricsProps = {\n    fadeOutNoLyricsMessage?: boolean;\n    settingsKey?: string;\n};\n\nexport const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default' }: LyricsProps) => {\n    const currentSong = usePlayerSong();\n    const {\n        enableAutoTranslation,\n        preferLocalLyrics,\n        translationApiKey,\n        translationApiProvider,\n        translationTargetLanguage,\n    } = useLyricsSettings();\n    const { t } = useTranslation();\n    const [index, setIndexState] = useState(0);\n    const [translatedLyrics, setTranslatedLyrics] = useState<null | string>(null);\n    const [showTranslation, setShowTranslation] = useState(false);\n    const [pendingSongId, setPendingSongId] = useState<string | undefined>(currentSong?.id);\n    const lyricsFetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n    const previousSongIdRef = useRef<string | undefined>(currentSong?.id);\n\n    useEffect(() => {\n        const currentSongId = currentSong?.id;\n        const previousSongId = previousSongIdRef.current;\n\n        if (currentSongId === previousSongId) {\n            return;\n        }\n\n        previousSongIdRef.current = currentSongId;\n        setPendingSongId(undefined);\n\n        if (!currentSongId) {\n            return;\n        }\n\n        clearTimeout(lyricsFetchTimeoutRef.current);\n        lyricsFetchTimeoutRef.current = setTimeout(() => {\n            setPendingSongId(currentSongId);\n        }, 500);\n\n        return () => {\n            clearTimeout(lyricsFetchTimeoutRef.current);\n        };\n    }, [currentSong?.id]);\n\n    const lyricsKey = useMemo(() => {\n        if (!currentSong?._serverId || !currentSong?.id) return null;\n        return queryKeys.songs.lyrics(currentSong._serverId, { songId: currentSong.id });\n    }, [currentSong]);\n\n    const { data, isLoading } = useQuery(\n        lyricsQueries.songLyrics(\n            {\n                options: {\n                    enabled: !!pendingSongId && pendingSongId === currentSong?.id,\n                },\n                query: { songId: currentSong?.id || '' },\n                serverId: currentSong?._serverId || '',\n            },\n            currentSong,\n        ),\n    );\n\n    const indexToUse = data?.selectedStructuredIndex ?? index;\n    useEffect(() => {\n        if (data != null) setIndexState(data.selectedStructuredIndex);\n    }, [data]);\n\n    const { selected: lyrics, selectedSynced: synced } = useMemo(() => {\n        if (!data) return { selected: null, selectedSynced: false };\n        return computeSelectedFromResult(data, preferLocalLyrics, indexToUse);\n    }, [data, indexToUse, preferLocalLyrics]);\n\n    const currentOffsetMs = useMemo(() => {\n        if (!data) return 0;\n        return getDisplayOffset(lyrics, data.selectedOffsetMs, indexToUse, data.local);\n    }, [data, indexToUse, lyrics]);\n\n    const handleOnSearchOverride = useCallback(\n        (params: LyricsOverride) => {\n            if (!lyricsKey) return;\n            queryClient.setQueryData<LyricsQueryResult>(lyricsKey, (prev) =>\n                prev ? { ...prev, overrideSelection: params } : prev,\n            );\n            queryClient.invalidateQueries({ queryKey: lyricsKey });\n        },\n        [lyricsKey],\n    );\n\n    const handleUpdateOffset = useCallback(\n        (offsetMs: number) => {\n            if (!currentSong || !lyricsKey) return;\n\n            queryClient.setQueryData<LyricsQueryResult>(lyricsKey, (prev) => {\n                if (!prev) return prev;\n                const updated = { ...prev, selectedOffsetMs: offsetMs };\n                if (Array.isArray(prev.local) && prev.local.length > 0) {\n                    const idx = Math.min(indexToUse, prev.local.length - 1);\n                    updated.local = [...prev.local];\n                    updated.local[idx] = {\n                        ...updated.local[idx],\n                        offsetMs,\n                    };\n                }\n                return updated;\n            });\n        },\n        [currentSong, indexToUse, lyricsKey],\n    );\n\n    const setIndex = useCallback(\n        (newIndex: number) => {\n            setIndexState(newIndex);\n            if (!lyricsKey || !data) return;\n            const { selected: nextSelected, selectedSynced: nextSynced } =\n                computeSelectedFromResult(data, preferLocalLyrics, newIndex);\n            const nextOffset = getDisplayOffset(\n                nextSelected,\n                data.selectedOffsetMs,\n                newIndex,\n                data.local,\n            );\n            queryClient.setQueryData<LyricsQueryResult>(lyricsKey, (prev) =>\n                prev\n                    ? {\n                          ...prev,\n                          selected: nextSelected,\n                          selectedOffsetMs: nextOffset,\n                          selectedStructuredIndex: newIndex,\n                          selectedSynced: nextSynced,\n                      }\n                    : prev,\n            );\n        },\n        [data, lyricsKey, preferLocalLyrics],\n    );\n\n    const handleOnRemoveLyric = useCallback(async () => {\n        if (!currentSong || !lyricsKey) return;\n\n        queryClient.setQueryData<LyricsQueryResult>(lyricsKey, (prev) =>\n            prev\n                ? {\n                      ...prev,\n                      overrideData: null,\n                      overrideSelection: null,\n                      remoteAuto: null,\n                      suppressRemoteAuto: true,\n                  }\n                : prev,\n        );\n        await queryClient.invalidateQueries({ queryKey: lyricsKey });\n    }, [currentSong, lyricsKey]);\n\n    const fetchTranslation = useCallback(async () => {\n        if (!lyrics) return;\n        const originalLyrics = Array.isArray(lyrics.lyrics)\n            ? lyrics.lyrics.map(([, line]) => line).join('\\n')\n            : lyrics.lyrics;\n        const TranslatedText: null | string = await translateLyrics(\n            originalLyrics,\n            translationApiKey,\n            translationApiProvider,\n            translationTargetLanguage,\n        );\n        setTranslatedLyrics(TranslatedText);\n        setShowTranslation(true);\n    }, [lyrics, translationApiKey, translationApiProvider, translationTargetLanguage]);\n\n    const handleOnTranslateLyric = useCallback(async () => {\n        if (translatedLyrics) {\n            setShowTranslation(!showTranslation);\n            return;\n        }\n        await fetchTranslation();\n    }, [translatedLyrics, showTranslation, fetchTranslation]);\n\n    usePlayerEvents(\n        {\n            onCurrentSongChange: () => {\n                setIndexState(0);\n                setShowTranslation(false);\n                setTranslatedLyrics(null);\n            },\n        },\n        [],\n    );\n\n    useEffect(() => {\n        if (lyrics && !translatedLyrics && enableAutoTranslation) {\n            fetchTranslation();\n        }\n    }, [lyrics, translatedLyrics, enableAutoTranslation, fetchTranslation]);\n\n    const languages = useMemo(() => {\n        const local = data?.local;\n        if (Array.isArray(local)) {\n            return local.map((lyric, idx) => ({ label: lyric.lang, value: idx.toString() }));\n        }\n        if (local && !Array.isArray(local) && 'lyrics' in local) {\n            return [{ label: 'xxx', value: '0' }];\n        }\n        return [];\n    }, [data?.local]);\n\n    const isLoadingLyrics = isLoading;\n    const hasNoLyrics = !lyrics;\n    const [shouldFadeOut, setShouldFadeOut] = useState(false);\n\n    useEffect(() => {\n        if (!fadeOutNoLyricsMessage) {\n            setShouldFadeOut(false);\n            return undefined;\n        }\n\n        if (!isLoadingLyrics && hasNoLyrics) {\n            const timer = setTimeout(() => {\n                setShouldFadeOut(true);\n            }, 3000);\n            return () => clearTimeout(timer);\n        }\n\n        if (!hasNoLyrics) {\n            setShouldFadeOut(false);\n        }\n\n        return undefined;\n    }, [isLoadingLyrics, hasNoLyrics, fadeOutNoLyricsMessage]);\n\n    const handleExportLyrics = useCallback(() => {\n        if (lyrics) {\n            openLyricsExportModal({ lyrics, offsetMs: currentOffsetMs, synced });\n        }\n    }, [currentOffsetMs, lyrics, synced]);\n\n    const handleOpenSettings = () => {\n        openLyricsSettingsModal(settingsKey);\n    };\n\n    return (\n        <ComponentErrorBoundary>\n            <div className={styles.lyricsContainer}>\n                <ActionIcon\n                    className={styles.settingsIcon}\n                    icon=\"settings2\"\n                    iconProps={{ size: 'lg' }}\n                    onClick={handleOpenSettings}\n                    pos=\"absolute\"\n                    right={0}\n                    top={0}\n                    variant=\"subtle\"\n                />\n                {isLoadingLyrics ? (\n                    <Spinner container />\n                ) : (\n                    <AnimatePresence mode=\"sync\">\n                        {hasNoLyrics ? (\n                            <Center w=\"100%\">\n                                <motion.div\n                                    animate={{ opacity: shouldFadeOut ? 0 : 1 }}\n                                    initial={{ opacity: 1 }}\n                                    transition={{ duration: 0.5 }}\n                                >\n                                    <Group>\n                                        <Text fw={500} isMuted isNoSelect>\n                                            {t('page.fullscreenPlayer.noLyrics', {\n                                                postProcess: 'sentenceCase',\n                                            })}\n                                        </Text>\n                                    </Group>\n                                </motion.div>\n                            </Center>\n                        ) : (\n                            <motion.div\n                                animate={{ opacity: 1 }}\n                                className={styles.scrollContainer}\n                                initial={{ opacity: 0 }}\n                                transition={{ duration: 0.5 }}\n                            >\n                                {synced ? (\n                                    <SynchronizedLyrics\n                                        {...(lyrics as SynchronizedLyricsProps)}\n                                        offsetMs={currentOffsetMs}\n                                        settingsKey={settingsKey}\n                                        translatedLyrics={showTranslation ? translatedLyrics : null}\n                                    />\n                                ) : (\n                                    <UnsynchronizedLyrics\n                                        {...(lyrics as UnsynchronizedLyricsProps)}\n                                        settingsKey={settingsKey}\n                                        translatedLyrics={showTranslation ? translatedLyrics : null}\n                                    />\n                                )}\n                            </motion.div>\n                        )}\n                    </AnimatePresence>\n                )}\n                <div className={styles.actionsContainer}>\n                    <LyricsActions\n                        hasLyrics={!!lyrics}\n                        index={indexToUse}\n                        languages={languages}\n                        offsetMs={currentOffsetMs}\n                        onExportLyrics={handleExportLyrics}\n                        onRemoveLyric={handleOnRemoveLyric}\n                        onSearchOverride={handleOnSearchOverride}\n                        onTranslateLyric={\n                            translationApiProvider && translationApiKey\n                                ? handleOnTranslateLyric\n                                : undefined\n                        }\n                        onUpdateOffset={handleUpdateOffset}\n                        setIndex={setIndex}\n                        settingsKey={settingsKey}\n                    />\n                </div>\n            </div>\n        </ComponentErrorBoundary>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/lyrics/synchronized-lyrics.module.css",
    "content": ".container {\n    display: flex;\n    flex-direction: column;\n    width: 100%;\n    height: 100%;\n    padding: 10vh 0 50vh;\n    overflow: scroll;\n    word-break: break-all;\n    transform: translateY(-2rem);\n\n    @media screen and (orientation: portrait) {\n        padding: 5vh 0;\n    }\n}\n"
  },
  {
    "path": "src/renderer/features/lyrics/synchronized-lyrics.tsx",
    "content": "import clsx from 'clsx';\nimport isElectron from 'is-electron';\nimport { useCallback, useEffect, useRef } from 'react';\n\nimport styles from './synchronized-lyrics.module.css';\n\nimport { LyricLine } from '/@/renderer/features/lyrics/lyric-line';\nimport {\n    useLyricsDisplaySettings,\n    useLyricsSettings,\n    usePlaybackType,\n    usePlayerActions,\n    usePlayerStatus,\n} from '/@/renderer/store';\nimport { usePlayerTimestamp } from '/@/renderer/store/timestamp.store';\nimport { FullLyricsMetadata, SynchronizedLyricsArray } from '/@/shared/types/domain-types';\nimport { PlayerStatus, PlayerType } from '/@/shared/types/types';\n\nconst mpvPlayer = isElectron() ? window.api.mpvPlayer : null;\nconst utils = isElectron() ? window.api.utils : null;\nconst mpris = isElectron() && utils?.isLinux() ? window.api.mpris : null;\n\nexport interface SynchronizedLyricsProps extends Omit<FullLyricsMetadata, 'lyrics'> {\n    lyrics: SynchronizedLyricsArray;\n    offsetMs?: number;\n    settingsKey?: string;\n    style?: React.CSSProperties;\n    translatedLyrics?: null | string;\n}\n\nexport const SynchronizedLyrics = ({\n    artist,\n    lyrics,\n    name,\n    offsetMs,\n    remote,\n    settingsKey = 'default',\n    source,\n    style,\n    translatedLyrics,\n}: SynchronizedLyricsProps) => {\n    const playbackType = usePlaybackType();\n    const lyricsSettings = useLyricsSettings();\n    const displaySettings = useLyricsDisplaySettings(settingsKey);\n    const settings = {\n        ...lyricsSettings,\n        fontSize:\n            displaySettings.fontSize && displaySettings.fontSize !== 0\n                ? displaySettings.fontSize\n                : 24,\n        gap: displaySettings.gap && displaySettings.gap !== 0 ? displaySettings.gap : 24,\n    };\n    const { mediaSeekToTimestamp } = usePlayerActions();\n    const status = usePlayerStatus();\n    const timestamp = usePlayerTimestamp();\n\n    const effectiveOffsetMs = offsetMs ?? 0;\n\n    const handleSeek = useCallback(\n        (time: number) => {\n            if (playbackType === PlayerType.LOCAL && mpvPlayer) {\n                mpvPlayer.seekTo(time);\n            } else {\n                mpris?.updateSeek(time);\n                mediaSeekToTimestamp(time);\n            }\n        },\n        [mediaSeekToTimestamp, playbackType],\n    );\n\n    // const seeked = useSeeked();\n\n    // A reference to the timeout handler\n    const lyricTimer = useRef<null | ReturnType<typeof setTimeout>>(null);\n\n    // A reference to the lyrics. This is necessary for the\n    // timers, which are not part of react necessarily, to always\n    // have the most updated values\n    const lyricRef = useRef<null | SynchronizedLyricsArray>(null);\n\n    // A constantly increasing value, used to tell timers that may be out of date\n    // whether to proceed or stop\n    const timerEpoch = useRef(0);\n\n    const delayMsRef = useRef(effectiveOffsetMs);\n    const followRef = useRef(settings.follow);\n    const userScrollingRef = useRef(false);\n    const scrollTimeoutRef = useRef<null | ReturnType<typeof setTimeout>>(null);\n    const containerRef = useRef<HTMLDivElement | null>(null);\n    const programmaticScrollRef = useRef(false);\n\n    const getCurrentLyric = (timeInMs: number) => {\n        if (lyricRef.current) {\n            const activeLyrics = lyricRef.current;\n            for (let idx = 0; idx < activeLyrics.length; idx += 1) {\n                if (timeInMs <= activeLyrics[idx][0]) {\n                    return idx === 0 ? idx : idx - 1;\n                }\n            }\n\n            return activeLyrics.length - 1;\n        }\n\n        return -1;\n    };\n\n    const setCurrentLyricRef = useRef<\n        (timeInMs: number, epoch?: number, targetIndex?: number) => void\n    >(() => {});\n\n    const setCurrentLyric = useCallback(\n        (timeInMs: number, epoch?: number, targetIndex?: number) => {\n            const start = performance.now();\n            let nextEpoch: number;\n\n            if (epoch === undefined) {\n                timerEpoch.current = (timerEpoch.current + 1) % 10000;\n                nextEpoch = timerEpoch.current;\n            } else if (epoch !== timerEpoch.current) {\n                return;\n            } else {\n                nextEpoch = epoch;\n            }\n\n            let index: number;\n\n            if (targetIndex === undefined) {\n                index = getCurrentLyric(timeInMs);\n            } else {\n                index = targetIndex;\n            }\n\n            // Directly modify the dom instead of using react to prevent rerender\n            document\n                .querySelectorAll('.synchronized-lyrics .active')\n                .forEach((node) => node.classList.remove('active'));\n\n            if (index === -1) {\n                lyricRef.current = null;\n                return;\n            }\n\n            const doc = document.getElementById(\n                'sychronized-lyrics-scroll-container',\n            ) as HTMLElement;\n            const currentLyric = document.querySelector(`#lyric-${index}`) as HTMLElement;\n\n            const offsetTop = currentLyric?.offsetTop - doc?.clientHeight / 2 || 0;\n\n            if (currentLyric === null) {\n                lyricRef.current = null;\n                return;\n            }\n\n            currentLyric.classList.add('active');\n\n            if (followRef.current && !userScrollingRef.current) {\n                programmaticScrollRef.current = true;\n                doc?.scroll({ behavior: 'smooth', top: offsetTop });\n                setTimeout(() => {\n                    programmaticScrollRef.current = false;\n                }, 600);\n            }\n\n            if (index !== lyricRef.current!.length - 1) {\n                const nextTime = lyricRef.current![index + 1][0];\n\n                const elapsed = performance.now() - start;\n\n                lyricTimer.current = setTimeout(\n                    () => {\n                        setCurrentLyricRef.current(nextTime, nextEpoch, index + 1);\n                    },\n                    nextTime - timeInMs - elapsed,\n                );\n            }\n        },\n        [],\n    );\n\n    // Store the callback in a ref so it can be called recursively\n    useEffect(() => {\n        setCurrentLyricRef.current = setCurrentLyric;\n    }, [setCurrentLyric]);\n\n    useEffect(() => {\n        // Copy the follow settings into a ref that can be accessed in the timeout\n        followRef.current = settings.follow;\n    }, [settings.follow]);\n\n    useEffect(() => {\n        // This handler is used to handle when lyrics change. It is in some sense the\n        // 'primary' handler for parsing lyrics, as unlike the other callbacks, it will\n        // ALSO remove listeners on close.\n        lyricRef.current = lyrics;\n\n        if (status === PlayerStatus.PLAYING) {\n            // Use the current timestamp from player events\n            setCurrentLyric(timestamp * 1000 + delayMsRef.current);\n\n            return () => {\n                // Cleanup: clear the timer when lyrics change or component unmounts\n                if (lyricTimer.current) clearTimeout(lyricTimer.current);\n            };\n        }\n\n        return () => {};\n    }, [lyrics, setCurrentLyric, status, timestamp]);\n\n    useEffect(() => {\n        // This handler is used to deal with changes to the current delay. If the offset\n        // changes, we should immediately stop the current listening set and calculate\n        // the correct one using the new offset. Afterwards, timing can be calculated like normal\n        const newOffset = offsetMs ?? 0;\n        const changed = delayMsRef.current !== newOffset;\n\n        if (!changed) {\n            return;\n        }\n\n        if (lyricTimer.current) {\n            clearTimeout(lyricTimer.current);\n        }\n\n        delayMsRef.current = newOffset;\n\n        // Use the current timestamp from player events\n        setCurrentLyric(timestamp * 1000 + delayMsRef.current);\n    }, [setCurrentLyric, offsetMs, timestamp]);\n\n    useEffect(() => {\n        // This handler is used specifically for dealing with seeking and progress updates.\n        // When the timestamp changes, update the current lyric position.\n        if (status !== PlayerStatus.PLAYING) {\n            if (lyricTimer.current) {\n                clearTimeout(lyricTimer.current);\n            }\n\n            return;\n        }\n\n        if (lyricTimer.current) {\n            clearTimeout(lyricTimer.current);\n        }\n\n        setCurrentLyric(timestamp * 1000 + delayMsRef.current);\n    }, [timestamp, setCurrentLyric, status]);\n\n    useEffect(() => {\n        // Guaranteed cleanup; stop the timer, and just in case also increment\n        // the epoch to instruct any dangling timers to stop\n        if (lyricTimer.current) {\n            clearTimeout(lyricTimer.current);\n        }\n\n        timerEpoch.current += 1;\n    }, []);\n\n    // Handle manual scrolling - pause auto-scroll when user scrolls\n    useEffect(() => {\n        const container =\n            containerRef.current ||\n            (document.getElementById('sychronized-lyrics-scroll-container') as HTMLElement);\n        if (!container) return;\n\n        const handleScroll = () => {\n            // Ignore programmatic scrolls (auto-scroll)\n            if (programmaticScrollRef.current) {\n                return;\n            }\n\n            userScrollingRef.current = true;\n\n            if (scrollTimeoutRef.current) {\n                clearTimeout(scrollTimeoutRef.current);\n            }\n\n            // Re-enable auto-scroll after 3 seconds of no scrolling\n            scrollTimeoutRef.current = setTimeout(() => {\n                userScrollingRef.current = false;\n            }, 3000);\n        };\n\n        container.addEventListener('scroll', handleScroll, { passive: true });\n\n        return () => {\n            container.removeEventListener('scroll', handleScroll);\n            if (scrollTimeoutRef.current) {\n                clearTimeout(scrollTimeoutRef.current);\n            }\n        };\n    }, []);\n\n    const hideScrollbar = () => {\n        const doc = document.getElementById('sychronized-lyrics-scroll-container') as HTMLElement;\n        doc.classList.add('hide-scrollbar');\n    };\n\n    const showScrollbar = () => {\n        const doc = document.getElementById('sychronized-lyrics-scroll-container') as HTMLElement;\n        doc.classList.remove('hide-scrollbar');\n    };\n\n    return (\n        <div\n            className={clsx(styles.container, 'synchronized-lyrics overlay-scrollbar')}\n            id=\"sychronized-lyrics-scroll-container\"\n            onMouseEnter={showScrollbar}\n            onMouseLeave={hideScrollbar}\n            ref={containerRef}\n            style={{ gap: `${settings.gap}px`, ...style }}\n        >\n            {settings.showProvider && source && (\n                <LyricLine\n                    alignment={settings.alignment}\n                    className=\"lyric-credit\"\n                    fontSize={settings.fontSize}\n                    text={`Provided by ${source}`}\n                />\n            )}\n            {settings.showMatch && remote && (\n                <LyricLine\n                    alignment={settings.alignment}\n                    className=\"lyric-credit\"\n                    fontSize={settings.fontSize}\n                    text={`\"${name} by ${artist}\"`}\n                />\n            )}\n            {lyrics.map(([time, text], idx) => (\n                <LyricLine\n                    alignment={settings.alignment}\n                    className=\"lyric-line synchronized\"\n                    fontSize={settings.fontSize}\n                    id={`lyric-${idx}`}\n                    key={idx}\n                    onClick={() => {\n                        if (time > 0 && Number.isFinite(time)) {\n                            handleSeek(time / 1000);\n                        }\n                    }}\n                    text={\n                        text +\n                        (translatedLyrics ? `_BREAK_${translatedLyrics.split('\\n')[idx]}` : '')\n                    }\n                />\n            ))}\n        </div>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/lyrics/unsynchronized-lyrics.module.css",
    "content": ".container {\n    display: flex;\n    flex-direction: column;\n    width: 100%;\n    height: 100%;\n    padding: 10vh 0 6vh;\n    overflow: scroll;\n    transform: translateY(-2rem);\n\n    @media screen and (orientation: portrait) {\n        padding: 5vh 0;\n    }\n}\n"
  },
  {
    "path": "src/renderer/features/lyrics/unsynchronized-lyrics.tsx",
    "content": "import { useMemo } from 'react';\n\nimport styles from './unsynchronized-lyrics.module.css';\n\nimport { LyricLine } from '/@/renderer/features/lyrics/lyric-line';\nimport { useLyricsDisplaySettings, useLyricsSettings } from '/@/renderer/store';\nimport { FullLyricsMetadata } from '/@/shared/types/domain-types';\n\nexport interface UnsynchronizedLyricsProps extends Omit<FullLyricsMetadata, 'lyrics'> {\n    lyrics: string;\n    settingsKey?: string;\n    translatedLyrics?: null | string;\n}\n\nexport const UnsynchronizedLyrics = ({\n    artist,\n    lyrics,\n    name,\n    remote,\n    settingsKey = 'default',\n    source,\n    translatedLyrics,\n}: UnsynchronizedLyricsProps) => {\n    const lyricsSettings = useLyricsSettings();\n    const displaySettings = useLyricsDisplaySettings(settingsKey);\n    const settings = {\n        ...lyricsSettings,\n        fontSizeUnsync:\n            displaySettings.fontSizeUnsync && displaySettings.fontSizeUnsync !== 0\n                ? displaySettings.fontSizeUnsync\n                : 24,\n        gapUnsync:\n            displaySettings.gapUnsync && displaySettings.gapUnsync !== 0\n                ? displaySettings.gapUnsync\n                : 24,\n    };\n    const lines = useMemo(() => {\n        return lyrics.split('\\n');\n    }, [lyrics]);\n\n    const translatedLines = useMemo(() => {\n        return translatedLyrics ? translatedLyrics.split('\\n') : [];\n    }, [translatedLyrics]);\n\n    return (\n        <div className={styles.container} style={{ gap: `${settings.gapUnsync}px` }}>\n            {settings.showProvider && source && (\n                <LyricLine\n                    alignment={settings.alignment}\n                    className=\"lyric-credit\"\n                    fontSize={settings.fontSizeUnsync}\n                    text={`Provided by ${source}`}\n                />\n            )}\n            {settings.showMatch && remote && (\n                <LyricLine\n                    alignment={settings.alignment}\n                    className=\"lyric-credit\"\n                    fontSize={settings.fontSizeUnsync}\n                    text={`\"${name} by ${artist}\"`}\n                />\n            )}\n            {lines.map((text, idx) => (\n                <LyricLine\n                    alignment={settings.alignment}\n                    className=\"lyric-line unsynchronized\"\n                    fontSize={settings.fontSizeUnsync}\n                    id={`lyric-${idx}`}\n                    key={idx}\n                    text={text + (translatedLines[idx] ? `_BREAK_${translatedLines[idx]}` : '')}\n                />\n            ))}\n        </div>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/lyrics/utils/open-lyrics-settings-modal.ts",
    "content": "import { openContextModal } from '@mantine/modals';\n\nimport i18n from '/@/i18n/i18n';\n\nexport const openLyricsSettingsModal = (settingsKey: string = 'default') => {\n    openContextModal({\n        innerProps: { settingsKey },\n        modalKey: 'lyricsSettings',\n        overlayProps: {\n            blur: 0,\n            opacity: 0,\n        },\n        size: 'xl',\n        styles: {\n            content: {\n                height: '90%',\n                maxWidth: '1400px',\n                minHeight: '600px',\n                width: '100%',\n            },\n        },\n        title: i18n.t('common.setting', { count: 2, postProcess: 'titleCase' }),\n        transitionProps: {\n            transition: 'pop',\n        },\n    });\n};\n"
  },
  {
    "path": "src/renderer/features/now-playing/components/drawer-play-queue.tsx",
    "content": "import { useRef, useState } from 'react';\n\nimport { ItemListHandle } from '/@/renderer/components/item-list/types';\nimport { PlayQueue } from '/@/renderer/features/now-playing/components/play-queue';\nimport { PlayQueueListControls } from '/@/renderer/features/now-playing/components/play-queue-list-controls';\nimport { Flex } from '/@/shared/components/flex/flex';\nimport { ItemListKey } from '/@/shared/types/types';\n\nexport const DrawerPlayQueue = () => {\n    const queueRef = useRef<ItemListHandle | null>(null);\n    const [search, setSearch] = useState<string | undefined>(undefined);\n\n    return (\n        <Flex direction=\"column\" h=\"100%\">\n            <div\n                style={{\n                    backgroundColor: 'var(--theme-colors-background)',\n                    borderRadius: '10px',\n                }}\n            >\n                <PlayQueueListControls\n                    handleSearch={setSearch}\n                    searchTerm={search}\n                    tableRef={queueRef}\n                    type={ItemListKey.SIDE_QUEUE}\n                />\n            </div>\n            <Flex bg=\"var(--theme-colors-background)\" h=\"100%\" mb=\"0.6rem\">\n                <PlayQueue listKey={ItemListKey.SIDE_QUEUE} ref={queueRef} searchTerm={search} />\n            </Flex>\n        </Flex>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/now-playing/components/now-playing-header.tsx",
    "content": "import { useTranslation } from 'react-i18next';\n\nimport { PageHeader } from '/@/renderer/components/page-header/page-header';\nimport { LibraryHeaderBar } from '/@/renderer/features/shared/components/library-header-bar';\n\nexport const NowPlayingHeader = () => {\n    const { t } = useTranslation();\n\n    return (\n        <PageHeader>\n            <LibraryHeaderBar ignoreMaxWidth>\n                <LibraryHeaderBar.Title>\n                    {t('page.sidebar.nowPlaying', { postProcess: 'titleCase' })}\n                </LibraryHeaderBar.Title>\n            </LibraryHeaderBar>\n        </PageHeader>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/now-playing/components/play-queue-list-controls.tsx",
    "content": "import { useIsFetching } from '@tanstack/react-query';\nimport { t } from 'i18next';\nimport { RefObject } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport { queryKeys } from '/@/renderer/api/query-keys';\nimport { SONG_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns';\nimport { ItemListHandle } from '/@/renderer/components/item-list/types';\nimport { usePlayer } from '/@/renderer/features/player/context/player-context';\nimport { useRestoreQueue, useSaveQueue } from '/@/renderer/features/player/hooks/use-queue-restore';\nimport {\n    ListConfigMenu,\n    SONG_DISPLAY_TYPES,\n} from '/@/renderer/features/shared/components/list-config-menu';\nimport { SearchInput } from '/@/renderer/features/shared/components/search-input';\nimport { useCurrentServer, usePlayerStoreBase } from '/@/renderer/store';\nimport { hasFeature } from '/@/shared/api/utils';\nimport { ActionIcon } from '/@/shared/components/action-icon/action-icon';\nimport { Group } from '/@/shared/components/group/group';\nimport { ServerFeature } from '/@/shared/types/features-types';\nimport { ItemListKey, ListDisplayType } from '/@/shared/types/types';\n\ninterface PlayQueueListOptionsProps {\n    handleSearch: (value: string) => void;\n    searchTerm?: string;\n    tableRef: RefObject<ItemListHandle | null>;\n    type: ItemListKey;\n}\n\nexport const PlayQueueListControls = ({\n    handleSearch,\n    searchTerm,\n    tableRef,\n    type,\n}: PlayQueueListOptionsProps) => {\n    const { t } = useTranslation();\n    const player = usePlayer();\n\n    const handleClearQueue = () => {\n        player.clearQueue();\n    };\n\n    const handleJumpToCurrent = () => {\n        const index = usePlayerStoreBase.getState().player.index;\n        if (index !== -1) {\n            tableRef.current?.scrollToIndex(index);\n        }\n    };\n\n    const handleShuffleQueue = () => {\n        player.shuffleAll();\n    };\n\n    return (\n        <Group h=\"65px\" justify=\"space-between\" px=\"1rem\" py=\"1rem\" w=\"100%\">\n            <Group gap=\"xs\">\n                <QueueRestoreActions />\n                <ActionIcon\n                    icon=\"mediaShuffle\"\n                    iconProps={{ size: 'lg' }}\n                    onClick={handleShuffleQueue}\n                    tooltip={{ label: t('player.shuffle', { postProcess: 'sentenceCase' }) }}\n                    variant=\"subtle\"\n                />\n                <ActionIcon\n                    icon=\"x\"\n                    iconProps={{ size: 'lg' }}\n                    onClick={handleClearQueue}\n                    tooltip={{ label: t('action.clearQueue', { postProcess: 'sentenceCase' }) }}\n                    variant=\"subtle\"\n                />\n                <ActionIcon\n                    icon=\"goToItem\"\n                    iconProps={{ size: 'lg' }}\n                    onClick={handleJumpToCurrent}\n                    tooltip={{ label: t('action.goToCurrent', { postProcess: 'sentenceCase' }) }}\n                    variant=\"subtle\"\n                />\n            </Group>\n            <Group gap=\"xs\">\n                <SearchInput\n                    enableHotkey={false}\n                    onChange={(e) => handleSearch(e.target.value)}\n                    value={searchTerm}\n                />\n                <ListConfigMenu\n                    displayTypes={[\n                        { hidden: true, value: ListDisplayType.GRID },\n                        ...SONG_DISPLAY_TYPES,\n                    ]}\n                    listKey={type}\n                    optionsConfig={{\n                        table: {\n                            itemsPerPage: { hidden: true },\n                            pagination: { hidden: true },\n                        },\n                    }}\n                    tableColumnsData={SONG_TABLE_COLUMNS}\n                />\n            </Group>\n        </Group>\n    );\n};\n\nconst QueueRestoreActions = () => {\n    const server = useCurrentServer();\n    const supportsQueue = hasFeature(server, ServerFeature.SERVER_PLAY_QUEUE);\n\n    const isFetching = useIsFetching({ queryKey: queryKeys.player.fetch({ type: 'queue' }) });\n\n    const { isPending: isSavingQueue, mutate: handleSaveQueue } = useSaveQueue();\n\n    const handleRestoreQueue = useRestoreQueue();\n\n    if (!supportsQueue) {\n        return null;\n    }\n\n    return (\n        <>\n            <ActionIcon\n                disabled={Boolean(isFetching)}\n                icon=\"upload\"\n                iconProps={{ size: 'lg' }}\n                loading={isSavingQueue}\n                onClick={() => handleSaveQueue()}\n                tooltip={{\n                    label: t('player.saveQueueToServer', {\n                        postProcess: 'sentenceCase',\n                    }),\n                }}\n                variant=\"subtle\"\n            />\n            <ActionIcon\n                disabled={isSavingQueue || Boolean(isFetching)}\n                icon=\"download\"\n                iconProps={{ size: 'lg' }}\n                loading={Boolean(isFetching)}\n                onClick={handleRestoreQueue}\n                tooltip={{\n                    label: t('player.restoreQueueFromServer', {\n                        postProcess: 'sentenceCase',\n                    }),\n                }}\n                variant=\"subtle\"\n            />\n        </>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/now-playing/components/play-queue.module.css",
    "content": ".container {\n    position: relative;\n    flex: 1;\n    width: 100%;\n    min-height: 0;\n}\n\n.group-row {\n    display: flex;\n    align-items: center;\n    width: 100%;\n    height: 100%;\n    padding: 0 var(--theme-spacing-md);\n    text-transform: uppercase;\n    user-select: none;\n}\n\n.drop-zone {\n    position: absolute;\n    top: 40px;\n    right: 0;\n    bottom: 0;\n    left: 0;\n    z-index: 10;\n    outline: none;\n    background-color: var(--theme-colors-background);\n    border-radius: var(--theme-radius-md);\n    opacity: 0.6;\n}\n\n.drop-zone.dragged-over {\n    outline: 2px solid var(--theme-colors-primary);\n    outline-offset: -4px;\n}\n"
  },
  {
    "path": "src/renderer/features/now-playing/components/play-queue.tsx",
    "content": "import clsx from 'clsx';\nimport { forwardRef, useEffect, useMemo, useRef, useState } from 'react';\n\nimport styles from './play-queue.module.css';\n\nimport { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder';\nimport { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize';\nimport {\n    ItemTableList,\n    TableGroupHeader,\n} from '/@/renderer/components/item-list/item-table-list/item-table-list';\nimport { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';\nimport { ItemListHandle } from '/@/renderer/components/item-list/types';\nimport { eventEmitter } from '/@/renderer/events/event-emitter';\nimport { useIsPlayerFetching, usePlayer } from '/@/renderer/features/player/context/player-context';\nimport { searchLibraryItems } from '/@/renderer/features/shared/utils';\nimport { useDragDrop } from '/@/renderer/hooks/use-drag-drop';\nimport {\n    isShuffleEnabled,\n    mapShuffledToQueueIndex,\n    subscribeCurrentTrack,\n    subscribePlayerQueue,\n    useFollowCurrentSong,\n    useListSettings,\n    usePlayerActions,\n    usePlayerSong,\n    usePlayerStore,\n} from '/@/renderer/store';\nimport { Flex } from '/@/shared/components/flex/flex';\nimport { LoadingOverlay } from '/@/shared/components/loading-overlay/loading-overlay';\nimport { useDebouncedValue } from '/@/shared/hooks/use-debounced-value';\nimport { useFocusWithin } from '/@/shared/hooks/use-focus-within';\nimport { useHotkeys } from '/@/shared/hooks/use-hotkeys';\nimport { useMergedRef } from '/@/shared/hooks/use-merged-ref';\nimport { Folder, LibraryItem, QueueSong, Song } from '/@/shared/types/domain-types';\nimport { DragTarget } from '/@/shared/types/drag-and-drop';\nimport { ItemListKey, Play } from '/@/shared/types/types';\n\ntype QueueProps = {\n    enableScrollShadow?: boolean;\n    listKey: ItemListKey;\n    searchTerm: string | undefined;\n};\n\nexport const PlayQueue = forwardRef<ItemListHandle, QueueProps>(\n    ({ enableScrollShadow = true, listKey, searchTerm }, ref) => {\n        const { table } = useListSettings(listKey) || {};\n\n        const isFetching = useIsPlayerFetching();\n        const tableRef = useRef<ItemListHandle>(null);\n        const mergedRef = useMergedRef(ref, tableRef);\n        const { getQueue } = usePlayerActions();\n        const followCurrentSong = useFollowCurrentSong();\n\n        const [debouncedSearchTerm] = useDebouncedValue(searchTerm, 200);\n\n        const [data, setData] = useState<QueueSong[]>([]);\n        const [groups, setGroups] = useState<TableGroupHeader[]>([]);\n\n        useEffect(() => {\n            const setQueue = () => {\n                const queue = getQueue() || { groups: [], items: [] };\n\n                setData(queue.items);\n\n                setGroups([]);\n            };\n\n            const unsub = subscribePlayerQueue(() => {\n                setQueue();\n            });\n\n            const unsubCurrentTrack = subscribeCurrentTrack((e) => {\n                if (followCurrentSong && e.index !== -1) {\n                    tableRef.current?.scrollToIndex(e.index, {\n                        align: 'center',\n                        behavior: 'auto',\n                    });\n                }\n            });\n\n            const handleAutoDJQueueAdded = () => {\n                if (followCurrentSong) {\n                    const state = usePlayerStore.getState();\n                    let index = state.player.index;\n\n                    if (isShuffleEnabled(state)) {\n                        index = mapShuffledToQueueIndex(index, state.queue.shuffled);\n                    }\n\n                    if (index !== -1) {\n                        // Use setTimeout to ensure the DOM has updated with the new queue items\n                        setTimeout(() => {\n                            tableRef.current?.scrollToIndex(index, {\n                                align: 'center',\n                                behavior: 'auto',\n                            });\n                        }, 0);\n                    }\n                }\n            };\n\n            eventEmitter.on('AUTODJ_QUEUE_ADDED', handleAutoDJQueueAdded);\n\n            setQueue();\n\n            if (followCurrentSong) {\n                const state = usePlayerStore.getState();\n                let index = state.player.index;\n\n                if (isShuffleEnabled(state)) {\n                    index = mapShuffledToQueueIndex(index, state.queue.shuffled);\n                }\n\n                if (index !== -1) {\n                    setTimeout(() => {\n                        tableRef.current?.scrollToIndex(index, {\n                            align: 'center',\n                            behavior: 'auto',\n                        });\n                    }, 0);\n                }\n            }\n\n            return () => {\n                unsub();\n                unsubCurrentTrack();\n                eventEmitter.off('AUTODJ_QUEUE_ADDED', handleAutoDJQueueAdded);\n            };\n        }, [getQueue, tableRef, followCurrentSong]);\n\n        const filteredData: QueueSong[] = useMemo(() => {\n            if (debouncedSearchTerm) {\n                const searched = searchLibraryItems(data, debouncedSearchTerm, LibraryItem.SONG);\n                return searched;\n            }\n\n            return data;\n        }, [data, debouncedSearchTerm]);\n\n        const isEmpty = filteredData.length === 0;\n\n        const { handleColumnReordered } = useItemListColumnReorder({\n            itemListKey: listKey,\n        });\n\n        const { handleColumnResized } = useItemListColumnResize({\n            itemListKey: listKey,\n        });\n\n        const currentSong = usePlayerSong();\n\n        const currentSongUniqueId = currentSong?._uniqueId;\n\n        const { focused, ref: containerFocusRef } = useFocusWithin();\n        const player = usePlayer();\n\n        useHotkeys([\n            [\n                'delete',\n                () => {\n                    if (focused) {\n                        const selectedItems =\n                            tableRef.current?.internalState.getSelected() as QueueSong[];\n\n                        if (!selectedItems || selectedItems.length === 0) {\n                            return;\n                        }\n\n                        player.clearSelected(selectedItems);\n                    }\n                },\n            ],\n        ]);\n\n        return (\n            <div className={styles.container} ref={containerFocusRef}>\n                <LoadingOverlay pos=\"absolute\" visible={isFetching} />\n                <ItemTableList\n                    activeRowId={currentSongUniqueId}\n                    autoFitColumns={table.autoFitColumns}\n                    CellComponent={ItemTableListColumn}\n                    columns={table.columns}\n                    data={filteredData}\n                    enableAlternateRowColors={table.enableAlternateRowColors}\n                    enableDrag\n                    enableExpansion={false}\n                    enableHeader={table.enableHeader}\n                    enableHorizontalBorders={table.enableHorizontalBorders}\n                    enableRowHoverHighlight={table.enableRowHoverHighlight}\n                    enableScrollShadow={enableScrollShadow}\n                    enableSelection\n                    enableSelectionDialog={false}\n                    enableVerticalBorders={table.enableVerticalBorders}\n                    getRowId=\"_uniqueId\"\n                    groups={groups.length > 0 ? groups : undefined}\n                    initialTop={{\n                        to: 0,\n                        type: 'offset',\n                    }}\n                    itemType={LibraryItem.QUEUE_SONG}\n                    onColumnReordered={handleColumnReordered}\n                    onColumnResized={handleColumnResized}\n                    ref={mergedRef}\n                    size={table.size}\n                />\n                {isEmpty && <EmptyQueueDropZone />}\n            </div>\n        );\n    },\n);\n\nconst EmptyQueueDropZone = () => {\n    const playerContext = usePlayer();\n\n    const { isDraggedOver, ref } = useDragDrop<HTMLDivElement>({\n        drop: {\n            canDrop: () => {\n                return true;\n            },\n            getData: () => {\n                return {\n                    id: [],\n                    item: [],\n                    itemType: LibraryItem.QUEUE_SONG,\n                    type: DragTarget.QUEUE_SONG,\n                };\n            },\n            onDrag: () => {\n                return;\n            },\n            onDragLeave: () => {\n                return;\n            },\n            onDrop: (args) => {\n                if (args.self.type === DragTarget.QUEUE_SONG) {\n                    const sourceServerId = (\n                        args.source.item?.[0] as unknown as { _serverId: string }\n                    )?._serverId;\n\n                    const sourceItemType = args.source.itemType as LibraryItem;\n\n                    switch (args.source.type) {\n                        case DragTarget.ALBUM: {\n                            if (sourceServerId) {\n                                playerContext.addToQueueByFetch(\n                                    sourceServerId,\n                                    args.source.id,\n                                    sourceItemType,\n                                    Play.NOW,\n                                );\n                            }\n                            break;\n                        }\n                        case DragTarget.ALBUM_ARTIST: {\n                            if (sourceServerId) {\n                                playerContext.addToQueueByFetch(\n                                    sourceServerId,\n                                    args.source.id,\n                                    sourceItemType,\n                                    Play.NOW,\n                                );\n                            }\n                            break;\n                        }\n                        case DragTarget.ARTIST: {\n                            if (sourceServerId) {\n                                playerContext.addToQueueByFetch(\n                                    sourceServerId,\n                                    args.source.id,\n                                    sourceItemType,\n                                    Play.NOW,\n                                );\n                            }\n                            break;\n                        }\n                        case DragTarget.FOLDER: {\n                            const items = args.source.item;\n\n                            const { folders, songs } = (items || []).reduce<{\n                                folders: Folder[];\n                                songs: Song[];\n                            }>(\n                                (acc, item) => {\n                                    if ((item as unknown as Song)._itemType === LibraryItem.SONG) {\n                                        acc.songs.push(item as unknown as Song);\n                                    } else if (\n                                        (item as unknown as Folder)._itemType === LibraryItem.FOLDER\n                                    ) {\n                                        acc.folders.push(item as unknown as Folder);\n                                    }\n                                    return acc;\n                                },\n                                { folders: [], songs: [] },\n                            );\n\n                            const folderIds = folders.map((folder) => folder.id);\n\n                            // Handle folders: fetch and add to queue\n                            if (folderIds.length > 0) {\n                                playerContext.addToQueueByFetch(\n                                    sourceServerId,\n                                    folderIds,\n                                    LibraryItem.FOLDER,\n                                    Play.NOW,\n                                );\n                            }\n\n                            // Handle songs: add directly to queue\n                            if (songs.length > 0) {\n                                playerContext.addToQueueByData(songs, Play.NOW);\n                            }\n\n                            break;\n                        }\n                        case DragTarget.GENRE: {\n                            if (sourceServerId) {\n                                playerContext.addToQueueByFetch(\n                                    sourceServerId,\n                                    args.source.id,\n                                    sourceItemType,\n                                    Play.NOW,\n                                );\n                            }\n                            break;\n                        }\n                        case DragTarget.PLAYLIST: {\n                            if (sourceServerId) {\n                                playerContext.addToQueueByFetch(\n                                    sourceServerId,\n                                    args.source.id,\n                                    sourceItemType,\n                                    Play.NOW,\n                                );\n                            }\n                            break;\n                        }\n                        case DragTarget.QUEUE_SONG: {\n                            const sourceItems = (args.source.item || []) as QueueSong[];\n                            if (sourceItems.length > 0) {\n                                playerContext.addToQueueByData(sourceItems, Play.NOW);\n                            }\n                            break;\n                        }\n                        case DragTarget.SONG: {\n                            const sourceItems = (args.source.item || []) as Song[];\n                            if (sourceItems.length > 0) {\n                                playerContext.addToQueueByData(sourceItems, Play.NOW);\n                            }\n                            break;\n                        }\n                        default: {\n                            break;\n                        }\n                    }\n                }\n\n                return;\n            },\n        },\n        isEnabled: true,\n    });\n\n    return (\n        <Flex\n            align=\"center\"\n            className={clsx(styles.dropZone, {\n                [styles.draggedOver]: isDraggedOver,\n            })}\n            direction=\"column\"\n            gap=\"md\"\n            justify=\"center\"\n            ref={ref}\n            w=\"100%\"\n        />\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/now-playing/components/popover-play-queue.tsx",
    "content": "import { t } from 'i18next';\nimport { useRef, useState } from 'react';\n\nimport { ItemListHandle } from '/@/renderer/components/item-list/types';\nimport { PlayQueue } from '/@/renderer/features/now-playing/components/play-queue';\nimport { PlayQueueListControls } from '/@/renderer/features/now-playing/components/play-queue-list-controls';\nimport { ActionIcon } from '/@/shared/components/action-icon/action-icon';\nimport { Popover } from '/@/shared/components/popover/popover';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { useDisclosure } from '/@/shared/hooks/use-disclosure';\nimport { ItemListKey } from '/@/shared/types/types';\n\ninterface PopoverPlayQueueProps {\n    onClose?: () => void;\n    onToggle?: (e: React.MouseEvent<HTMLButtonElement>) => void;\n    opened?: boolean;\n}\n\nexport const PopoverPlayQueue = ({\n    onClose,\n    onToggle,\n    opened: controlledOpened,\n}: PopoverPlayQueueProps = {}) => {\n    const queueRef = useRef<ItemListHandle | null>(null);\n    const [search, setSearch] = useState<string | undefined>(undefined);\n\n    const [internalOpened, internalHandlers] = useDisclosure(false);\n\n    const opened = controlledOpened !== undefined ? controlledOpened : internalOpened;\n    const handleClose = onClose ? onClose : internalHandlers.close;\n    const handleToggle = onToggle ? onToggle : internalHandlers.toggle;\n\n    return (\n        <Popover\n            arrowSize={24}\n            offset={12}\n            onClose={handleClose}\n            opened={opened}\n            position=\"top\"\n            transitionProps={{\n                transition: 'fade',\n            }}\n            withArrow\n        >\n            <Popover.Target>\n                <ActionIcon\n                    icon=\"arrowUpToLine\"\n                    iconProps={{\n                        size: 'lg',\n                    }}\n                    onClick={handleToggle}\n                    size=\"sm\"\n                    tooltip={{\n                        label: t('player.viewQueue', { postProcess: 'titleCase' }),\n                        openDelay: 0,\n                    }}\n                    variant=\"subtle\"\n                />\n            </Popover.Target>\n            <Popover.Dropdown h=\"600px\" mah=\"80dvh\" opacity={0.95} p=\"xs\" w=\"560px\">\n                <Stack gap={0} h=\"100%\" w=\"100%\">\n                    <PlayQueueListControls\n                        handleSearch={setSearch}\n                        searchTerm={search}\n                        tableRef={queueRef}\n                        type={ItemListKey.SIDE_QUEUE}\n                    />\n                    <PlayQueue\n                        listKey={ItemListKey.SIDE_QUEUE}\n                        ref={queueRef}\n                        searchTerm={search}\n                    />\n                </Stack>\n            </Popover.Dropdown>\n        </Popover>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/now-playing/components/sidebar-play-queue.module.css",
    "content": ".play-queue-section {\n    position: relative;\n    display: flex;\n    flex: 1;\n    flex-direction: column;\n    height: 100%;\n    min-height: 0;\n    overflow: hidden;\n}\n\n.lyrics-section {\n    position: relative;\n    display: flex;\n    flex-direction: column;\n    height: 100%;\n    min-height: 0;\n    padding: var(--theme-spacing-md);\n    overflow: hidden;\n    background: var(--theme-colors-background);\n    background-color: var(--theme-colors-background-alternate);\n}\n\n.lyrics-section :global(.synchronized-lyrics) {\n    padding: 2rem 0 4rem !important;\n    transform: translateY(0) !important;\n}\n\n.lyrics-section :global(.synchronized-lyrics .lyric-line) {\n    padding: 0.25rem 0;\n}\n\n.visualizer-overlay {\n    position: absolute;\n    top: 0;\n    right: 0;\n    bottom: 0;\n    left: 0;\n    z-index: 0;\n    pointer-events: none;\n}\n\n.visualizer-section {\n    position: relative;\n    display: flex;\n    flex-direction: column;\n    height: 100%;\n    min-height: 0;\n    max-height: 100%;\n    overflow: hidden;\n    background: var(--theme-colors-background);\n    background-color: var(--theme-colors-background-alternate);\n}\n\n.resize-handle {\n    position: relative;\n    z-index: 10;\n    flex-shrink: 0;\n    width: 100%;\n    height: 0;\n    padding: 4px 0;\n    margin: -4px 0;\n    cursor: row-resize;\n\n    &::before {\n        position: absolute;\n        top: 50%;\n        left: 0;\n        width: 100%;\n        height: 1px;\n        content: '';\n        background: var(--theme-colors-border);\n        transform: translateY(-50%);\n        transition: opacity 0.2s ease;\n    }\n}\n\n.panel-reorder-controls {\n    position: absolute;\n    top: var(--theme-spacing-md);\n    left: var(--theme-spacing-md);\n    z-index: 100;\n    pointer-events: auto;\n    opacity: 0;\n    transition: opacity 0.2s ease;\n}\n\n.lyrics-section:hover .panel-reorder-controls,\n.visualizer-section:hover .panel-reorder-controls {\n    opacity: 1;\n}\n\n.draggable-region {\n    position: relative;\n    flex-shrink: 0;\n    width: 100%;\n    height: 65px;\n    -webkit-app-region: drag;\n}\n\n.draggable-region::after {\n    position: absolute;\n    top: 0;\n    right: 0;\n    width: 130px;\n    height: 100%;\n    content: '';\n    -webkit-app-region: no-drag;\n}\n"
  },
  {
    "path": "src/renderer/features/now-playing/components/sidebar-play-queue.tsx",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport isElectron from 'is-electron';\nimport { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\n// import { Group, Panel, Separator, useDefaultLayout } from 'react-resizable-panels';\nimport { Pane, SplitPane, usePersistence } from 'react-split-pane';\n\nimport styles from './sidebar-play-queue.module.css';\n\nimport { ItemListHandle } from '/@/renderer/components/item-list/types';\nimport { lyricsQueries } from '/@/renderer/features/lyrics/api/lyrics-api';\nimport { Lyrics } from '/@/renderer/features/lyrics/lyrics';\nimport { PlayQueue } from '/@/renderer/features/now-playing/components/play-queue';\nimport { PlayQueueListControls } from '/@/renderer/features/now-playing/components/play-queue-list-controls';\nimport {\n    useCombinedLyricsAndVisualizer,\n    useFullScreenPlayerStore,\n    usePlaybackSettings,\n    usePlayerSong,\n    useSettingsStore,\n    useSettingsStoreActions,\n    useShowLyricsInSidebar,\n    useShowVisualizerInSidebar,\n    useSidebarPanelOrder,\n    useWindowSettings,\n} from '/@/renderer/store';\nimport { ActionIcon, ActionIconGroup } from '/@/shared/components/action-icon/action-icon';\nimport { Flex } from '/@/shared/components/flex/flex';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { ItemListKey, Platform, PlayerType } from '/@/shared/types/types';\n\ntype SidebarPanelType = 'lyrics' | 'queue' | 'visualizer';\n\nconst AudioMotionAnalyzerVisualizer = lazy(() =>\n    import('../../visualizer/components/audiomotionanalyzer/visualizer').then((module) => ({\n        default: module.Visualizer,\n    })),\n);\n\nconst ButterchurnVisualizer = lazy(() =>\n    import('../../visualizer/components/butternchurn/visualizer').then((module) => ({\n        default: module.Visualizer,\n    })),\n);\n\nexport const SidebarPlayQueue = () => {\n    const tableRef = useRef<ItemListHandle | null>(null);\n    const [search, setSearch] = useState<string | undefined>(undefined);\n    const {\n        expanded: isFullScreenPlayerExpanded,\n        visualizerExpanded: isFullScreenVisualizerExpanded,\n    } = useFullScreenPlayerStore();\n    const [shouldRender, setShouldRender] = useState(!isFullScreenPlayerExpanded);\n    const combinedLyricsAndVisualizer = useCombinedLyricsAndVisualizer();\n    const showLyricsInSidebar = useShowLyricsInSidebar();\n    const showVisualizerInSidebar = useShowVisualizerInSidebar();\n    const sidebarPanelOrder = useSidebarPanelOrder();\n    const { type, webAudio } = usePlaybackSettings();\n    const { windowBarStyle } = useWindowSettings();\n    const showVisualizer = showVisualizerInSidebar && type === PlayerType.WEB && webAudio;\n    const showPanel = showLyricsInSidebar || showVisualizer;\n\n    const shouldAddTopMargin = isElectron() && windowBarStyle === Platform.WEB;\n\n    useEffect(() => {\n        if (isFullScreenPlayerExpanded || isFullScreenVisualizerExpanded) {\n            // Immediately hide when fullscreen player opens\n            setShouldRender(false);\n            return undefined;\n        } else {\n            // Wait 500ms before re-rendering when fullscreen player closes to avoid performance issues\n            const timeoutId = setTimeout(() => {\n                setShouldRender(true);\n            }, 500);\n\n            return () => {\n                clearTimeout(timeoutId);\n            };\n        }\n    }, [isFullScreenPlayerExpanded, isFullScreenVisualizerExpanded]);\n\n    const [defaultLayout, onLayoutChange] = usePersistence({\n        debounce: 300,\n        key: 'sidebar-play-queue-container',\n        storage: localStorage,\n    });\n\n    // Filter and order panels based on what's enabled\n    const orderedPanels = useMemo(() => {\n        if (combinedLyricsAndVisualizer) {\n            // When combined, use the order from settings but filter to only show queue and lyrics (combined)\n            const visiblePanels = sidebarPanelOrder.filter((panel) => {\n                if (panel === 'queue') return true;\n                if (panel === 'lyrics') return showLyricsInSidebar || showVisualizer;\n                return false;\n            });\n            return visiblePanels;\n        }\n\n        const visiblePanels = sidebarPanelOrder.filter((panel) => {\n            if (panel === 'queue') return true;\n            if (panel === 'lyrics') return showLyricsInSidebar;\n            if (panel === 'visualizer') return showVisualizer;\n            return false;\n        });\n\n        return visiblePanels;\n    }, [combinedLyricsAndVisualizer, showLyricsInSidebar, showVisualizer, sidebarPanelOrder]);\n\n    const renderPanel = (panelType: SidebarPanelType) => {\n        if (panelType === 'queue') {\n            return (\n                <Stack gap={0} h=\"100%\" w=\"100%\">\n                    <PlayQueueListControls\n                        handleSearch={setSearch}\n                        searchTerm={search}\n                        tableRef={tableRef}\n                        type={ItemListKey.SIDE_QUEUE}\n                    />\n                    <div className={styles.playQueueSection}>\n                        <PlayQueue\n                            listKey={ItemListKey.SIDE_QUEUE}\n                            ref={tableRef}\n                            searchTerm={search}\n                        />\n                    </div>\n                </Stack>\n            );\n        }\n\n        if (combinedLyricsAndVisualizer && (panelType === 'lyrics' || panelType === 'visualizer')) {\n            return <CombinedLyricsAndVisualizerPanel />;\n        }\n\n        if (panelType === 'lyrics') {\n            return <LyricsPanel />;\n        }\n\n        if (panelType === 'visualizer') {\n            return <VisualizerPanel />;\n        }\n\n        return null;\n    };\n\n    const getPanelSize = useCallback(\n        (panelType: SidebarPanelType, index: number) => {\n            // Queue panel should always autofit\n            if (panelType === 'queue') {\n                return undefined;\n            }\n\n            // If defaultLayout exists and has saved sizes, use them\n            if (\n                defaultLayout &&\n                Array.isArray(defaultLayout) &&\n                defaultLayout[index] !== undefined\n            ) {\n                return defaultLayout[index];\n            }\n\n            // Calculate default sizes for non-queue panels based on order\n            const nonQueuePanels = orderedPanels.filter((p) => p !== 'queue');\n            const nonQueueCount = nonQueuePanels.length;\n\n            if (nonQueueCount === 0) {\n                return undefined;\n            }\n\n            // If only one non-queue panel, give it a default size\n            if (nonQueueCount === 1) {\n                return 100;\n            }\n\n            // If multiple non-queue panels, distribute sizes evenly\n            // First non-queue panel gets a size, others get undefined to share remaining\n            const nonQueueIndex = orderedPanels.slice(0, index).filter((p) => p !== 'queue').length;\n            if (nonQueueIndex === 0) {\n                // First non-queue panel gets a default size\n                return 100;\n            }\n\n            // Other non-queue panels autofit\n            return undefined;\n        },\n        [defaultLayout, orderedPanels],\n    );\n\n    // Unmount when fullscreen player is open\n    if (!shouldRender) {\n        return null;\n    }\n\n    return (\n        <Stack gap={0} h=\"100%\" id=\"sidebar-play-queue-container\" pos=\"relative\" w=\"100%\">\n            {shouldAddTopMargin && <div className={styles.draggableRegion} />}\n            {showPanel ? (\n                <SplitPane\n                    direction=\"vertical\"\n                    dividerClassName={styles.resizeHandle}\n                    onResize={onLayoutChange}\n                    style={{\n                        display: 'flex',\n                        flex: 1,\n                        flexDirection: 'column',\n                        minHeight: 0,\n                        overflow: 'hidden',\n                    }}\n                >\n                    {orderedPanels.map((panel, index) => (\n                        <Pane key={panel} size={getPanelSize(panel, index)}>\n                            {renderPanel(panel)}\n                        </Pane>\n                    ))}\n                </SplitPane>\n            ) : (\n                <Stack gap={0} h=\"100%\" w=\"100%\">\n                    <PlayQueueListControls\n                        handleSearch={setSearch}\n                        searchTerm={search}\n                        tableRef={tableRef}\n                        type={ItemListKey.SIDE_QUEUE}\n                    />\n                    <Flex direction=\"column\" style={{ flex: 1, minHeight: 0 }}>\n                        <div className={styles.playQueueSection}>\n                            <PlayQueue\n                                listKey={ItemListKey.SIDE_QUEUE}\n                                ref={tableRef}\n                                searchTerm={search}\n                            />\n                        </div>\n                    </Flex>\n                </Stack>\n            )}\n        </Stack>\n    );\n};\n\nconst PanelReorderControls = ({ panelType }: { panelType: 'lyrics' | 'visualizer' }) => {\n    const { t } = useTranslation();\n    const { setSettings } = useSettingsStoreActions();\n    const sidebarPanelOrder = useSidebarPanelOrder();\n    const combinedLyricsAndVisualizer = useCombinedLyricsAndVisualizer();\n\n    const currentIndex = sidebarPanelOrder.indexOf(panelType);\n    const canMoveUp = currentIndex > 0;\n    const canMoveDown = currentIndex < sidebarPanelOrder.length - 1;\n\n    const handleMoveUp = useCallback(() => {\n        if (!canMoveUp) return;\n\n        const newOrder = [...sidebarPanelOrder];\n        const targetIndex = currentIndex - 1;\n\n        [newOrder[currentIndex], newOrder[targetIndex]] = [\n            newOrder[targetIndex],\n            newOrder[currentIndex],\n        ];\n\n        setSettings({\n            general: {\n                sidebarPanelOrder: newOrder,\n            },\n        });\n    }, [canMoveUp, currentIndex, sidebarPanelOrder, setSettings]);\n\n    const handleMoveDown = useCallback(() => {\n        if (!canMoveDown) return;\n\n        const newOrder = [...sidebarPanelOrder];\n        [newOrder[currentIndex], newOrder[currentIndex + 1]] = [\n            newOrder[currentIndex + 1],\n            newOrder[currentIndex],\n        ];\n\n        setSettings({\n            general: {\n                sidebarPanelOrder: newOrder,\n            },\n        });\n    }, [canMoveDown, currentIndex, sidebarPanelOrder, setSettings]);\n\n    const handleClose = useCallback(() => {\n        if (combinedLyricsAndVisualizer && panelType === 'lyrics') {\n            setSettings({\n                general: {\n                    showLyricsInSidebar: false,\n                    showVisualizerInSidebar: false,\n                },\n            });\n        } else if (panelType === 'lyrics') {\n            setSettings({\n                general: {\n                    showLyricsInSidebar: false,\n                },\n            });\n        } else if (panelType === 'visualizer') {\n            setSettings({\n                general: {\n                    showVisualizerInSidebar: false,\n                },\n            });\n        }\n    }, [combinedLyricsAndVisualizer, panelType, setSettings]);\n\n    return (\n        <div className={styles.panelReorderControls}>\n            <ActionIconGroup>\n                <ActionIcon\n                    disabled={!canMoveUp}\n                    icon=\"arrowUp\"\n                    iconProps={{ size: 'sm' }}\n                    onClick={handleMoveUp}\n                    size=\"xs\"\n                    tooltip={{\n                        label: t('action.moveUp', { postProcess: 'sentenceCase' }),\n                    }}\n                    variant=\"subtle\"\n                />\n                <ActionIcon\n                    disabled={!canMoveDown}\n                    icon=\"arrowDown\"\n                    iconProps={{ size: 'sm' }}\n                    onClick={handleMoveDown}\n                    size=\"xs\"\n                    tooltip={{\n                        label: t('action.moveDown', { postProcess: 'sentenceCase' }),\n                    }}\n                    variant=\"subtle\"\n                />\n                <ActionIcon\n                    icon=\"x\"\n                    iconProps={{ size: 'sm' }}\n                    onClick={handleClose}\n                    size=\"xs\"\n                    tooltip={{\n                        label: t('common.close', { postProcess: 'sentenceCase' }),\n                    }}\n                    variant=\"subtle\"\n                />\n            </ActionIconGroup>\n        </div>\n    );\n};\n\nconst LyricsPanel = () => {\n    return (\n        <div className={styles.lyricsSection}>\n            <PanelReorderControls panelType=\"lyrics\" />\n            <Lyrics fadeOutNoLyricsMessage={false} settingsKey=\"sidebar\" />\n        </div>\n    );\n};\n\nconst VisualizerPanel = () => {\n    const visualizerType = useSettingsStore((store) => store.visualizer.type);\n\n    return (\n        <div className={styles.visualizerSection}>\n            <PanelReorderControls panelType=\"visualizer\" />\n            <Suspense fallback={<></>}>\n                {visualizerType === 'butterchurn' ? (\n                    <ButterchurnVisualizer />\n                ) : (\n                    <AudioMotionAnalyzerVisualizer />\n                )}\n            </Suspense>\n        </div>\n    );\n};\n\nconst CombinedLyricsAndVisualizerPanel = () => {\n    const currentSong = usePlayerSong();\n    const visualizerType = useSettingsStore((store) => store.visualizer.type);\n    const showLyricsInSidebar = useShowLyricsInSidebar();\n    const showVisualizerInSidebar = useShowVisualizerInSidebar();\n    const { type, webAudio } = usePlaybackSettings();\n    const showVisualizer = showVisualizerInSidebar && type === PlayerType.WEB && webAudio;\n\n    const { data: lyricsData } = useQuery(\n        lyricsQueries.songLyrics(\n            {\n                options: {\n                    enabled: !!currentSong?.id && showLyricsInSidebar,\n                },\n                query: { songId: currentSong?.id || '' },\n                serverId: currentSong?._serverId || '',\n            },\n            currentSong,\n        ),\n    );\n\n    const hasLyrics = useMemo(() => {\n        if (!lyricsData) return false;\n\n        if (Array.isArray(lyricsData)) {\n            return lyricsData.length > 0 && !!lyricsData[0]?.lyrics;\n        }\n\n        const lyrics = lyricsData.selected?.lyrics;\n\n        if (Array.isArray(lyrics)) {\n            return lyrics.length > 0;\n        }\n\n        if (typeof lyrics === 'string') {\n            return lyrics.trim().length > 0;\n        }\n\n        return false;\n    }, [lyricsData]);\n\n    return (\n        <div className={styles.lyricsSection}>\n            <PanelReorderControls panelType=\"lyrics\" />\n            {showLyricsInSidebar && <Lyrics fadeOutNoLyricsMessage={true} settingsKey=\"sidebar\" />}\n            {showVisualizer && (\n                <div\n                    className={styles.visualizerOverlay}\n                    style={{\n                        opacity: hasLyrics && showLyricsInSidebar ? 0.2 : 1,\n                    }}\n                >\n                    <Suspense fallback={<></>}>\n                        {visualizerType === 'butterchurn' ? (\n                            <ButterchurnVisualizer />\n                        ) : (\n                            <AudioMotionAnalyzerVisualizer />\n                        )}\n                    </Suspense>\n                </div>\n            )}\n        </div>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/now-playing/routes/now-playing-route.tsx",
    "content": "import { useEffect, useRef, useState } from 'react';\n\nimport { ItemListHandle } from '/@/renderer/components/item-list/types';\nimport { NowPlayingHeader } from '/@/renderer/features/now-playing/components/now-playing-header';\nimport { PlayQueue } from '/@/renderer/features/now-playing/components/play-queue';\nimport { PlayQueueListControls } from '/@/renderer/features/now-playing/components/play-queue-list-controls';\nimport { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';\nimport { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';\nimport { useAppStoreActions } from '/@/renderer/store';\nimport { ItemListKey } from '/@/shared/types/types';\n\nconst NowPlayingRoute = () => {\n    const [search, setSearch] = useState<string | undefined>(undefined);\n    const { setSideBar } = useAppStoreActions();\n    const tableRef = useRef<ItemListHandle | null>(null);\n\n    useEffect(() => {\n        // On page enter, set rightExpanded to false\n        setSideBar({ rightExpanded: false });\n\n        return () => {\n            // On page exit, set rightExpanded to true\n            setSideBar({ rightExpanded: true });\n        };\n    }, [setSideBar]);\n\n    return (\n        <AnimatedPage>\n            <NowPlayingHeader />\n            <PlayQueueListControls\n                handleSearch={setSearch}\n                searchTerm={search}\n                tableRef={tableRef}\n                type={ItemListKey.QUEUE_SONG}\n            />\n            <PlayQueue listKey={ItemListKey.QUEUE_SONG} ref={tableRef} searchTerm={search} />\n        </AnimatedPage>\n    );\n};\n\nconst NowPlayingRouteWithBoundary = () => {\n    return (\n        <PageErrorBoundary>\n            <NowPlayingRoute />\n        </PageErrorBoundary>\n    );\n};\n\nexport default NowPlayingRouteWithBoundary;\n"
  },
  {
    "path": "src/renderer/features/player/audio-player/engine/mpv-player-engine.tsx",
    "content": "import type { RefObject } from 'react';\n\nimport isElectron from 'is-electron';\nimport { useEffect, useImperativeHandle, useRef, useState } from 'react';\n\nimport { eventEmitter } from '/@/renderer/events/event-emitter';\nimport { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';\nimport { getSongUrl } from '/@/renderer/features/player/audio-player/hooks/use-stream-url';\nimport { AudioPlayer, PlayerOnProgressProps } from '/@/renderer/features/player/audio-player/types';\nimport { useRadioStore } from '/@/renderer/features/radio/hooks/use-radio-player';\nimport { getMpvProperties } from '/@/renderer/features/settings/components/playback/mpv-properties';\nimport {\n    usePlaybackSettings,\n    usePlayerActions,\n    usePlayerSong,\n    usePlayerStore,\n    useSettingsStore,\n} from '/@/renderer/store';\nimport { PlayerStatus } from '/@/shared/types/types';\n\nexport interface MpvPlayerEngineHandle extends AudioPlayer {}\n\ninterface MpvPlayerEngineProps {\n    isMuted: boolean;\n    isTransitioning: boolean;\n    onEnded: () => void;\n    onProgress: (e: PlayerOnProgressProps) => void;\n    playerRef: RefObject<MpvPlayerEngineHandle | null>;\n    playerStatus: PlayerStatus;\n    speed?: number;\n    volume: number;\n}\n\nconst mpvPlayer = isElectron() ? window.api.mpvPlayer : null;\nconst mpvPlayerListener = isElectron() ? window.api.mpvPlayerListener : null;\nconst ipc = isElectron() ? window.api.ipc : null;\n\nconst PROGRESS_UPDATE_INTERVAL = 250;\n\nexport const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {\n    const {\n        isMuted,\n        isTransitioning,\n        onEnded,\n        onProgress,\n        playerRef,\n        playerStatus,\n        speed,\n        volume,\n    } = props;\n\n    const [internalVolume, setInternalVolume] = useState(volume / 100 || 0);\n    const currentSong = usePlayerSong();\n\n    const progressIntervalRef = useRef<NodeJS.Timeout | null>(null);\n    const isInitializedRef = useRef<boolean>(false);\n    const hasPopulatedQueueRef = useRef<boolean>(false);\n    const isMountedRef = useRef<boolean>(true);\n\n    const { mpvAudioDeviceId, transcode } = usePlaybackSettings();\n    const mpvExtraParameters = useSettingsStore((store) => store.playback.mpvExtraParameters);\n    const mpvProperties = useSettingsStore((store) => store.playback.mpvProperties);\n    const [reloadTrigger, setReloadTrigger] = useState(0);\n\n    useEffect(() => {\n        const handleMpvReload = () => {\n            setReloadTrigger((prev) => prev + 1);\n        };\n\n        eventEmitter.on('MPV_RELOAD', handleMpvReload);\n\n        return () => {\n            eventEmitter.off('MPV_RELOAD', handleMpvReload);\n        };\n    }, []);\n\n    // Start the mpv instance on startup\n    useEffect(() => {\n        isMountedRef.current = true;\n\n        const initializeMpv = async () => {\n            // Always quit mpv first to ensure clean state, especially during HMR remounts\n            const isRunning: boolean | undefined = await mpvPlayer?.isRunning();\n            if (isRunning) {\n                mpvPlayer?.quit();\n\n                let attempts = 0;\n                const maxAttempts = 20;\n                while (attempts < maxAttempts) {\n                    await new Promise((resolve) => setTimeout(resolve, 100));\n                    const stillRunning = await mpvPlayer?.isRunning();\n                    if (!stillRunning) {\n                        break;\n                    }\n                    attempts++;\n                }\n            }\n\n            // Reset initialization state\n            isInitializedRef.current = false;\n            hasPopulatedQueueRef.current = false;\n\n            // Initialize mpv with fresh state\n            const properties: Record<string, any> = {\n                ...getMpvProperties(mpvProperties),\n                speed: speed,\n                volume: volume,\n            };\n\n            const extraParameters: string[] = [...mpvExtraParameters];\n\n            const audioDevice = mpvAudioDeviceId?.trim() || 'auto';\n            extraParameters.push(`--audio-device=${audioDevice}`);\n\n            await mpvPlayer?.initialize({\n                extraParameters,\n                properties,\n            });\n\n            // After initialization, populate the queue if currentSrc is available\n            // Don't override queue if radio is active\n            const radioState = useRadioStore.getState();\n\n            if (!radioState.currentStreamUrl) {\n                const playerData = usePlayerStore.getState().getPlayerData();\n                const currentSongUrl = playerData.currentSong\n                    ? getSongUrl(playerData.currentSong, transcode)\n                    : undefined;\n                const nextSongUrl = playerData.nextSong\n                    ? getSongUrl(playerData.nextSong, transcode)\n                    : undefined;\n\n                if (currentSongUrl && nextSongUrl && !hasPopulatedQueueRef.current && mpvPlayer) {\n                    mpvPlayer.setQueue(currentSongUrl, nextSongUrl, true);\n                    hasPopulatedQueueRef.current = true;\n                }\n            }\n\n            isInitializedRef.current = true;\n        };\n\n        initializeMpv();\n\n        return () => {\n            isMountedRef.current = false;\n            // Quit mpv on unmount\n            mpvPlayer?.quit();\n            isInitializedRef.current = false;\n            hasPopulatedQueueRef.current = false;\n        };\n        // Note: volume, speed, and transcode are intentionally not in dependencies.\n        // Volume and speed changes are handled by separate useEffects below to avoid\n        // reinitializing the entire player. Transcode changes are handled by queue\n        // update callbacks in usePlayerEvents.\n        // reloadTrigger is included to allow manual reload via MPV_RELOAD event.\n        // eslint-disable-next-line react-hooks/exhaustive-deps\n    }, [mpvExtraParameters, mpvProperties, mpvAudioDeviceId, reloadTrigger]);\n\n    // Update volume\n    useEffect(() => {\n        if (!mpvPlayer) {\n            return;\n        }\n\n        const vol = volume / 100 || 0;\n        queueMicrotask(() => {\n            setInternalVolume(vol);\n        });\n        mpvPlayer.volume(volume);\n    }, [volume]);\n\n    // Update mute status\n    useEffect(() => {\n        if (!mpvPlayer) {\n            return;\n        }\n\n        mpvPlayer.mute(isMuted);\n    }, [isMuted]);\n\n    // Update speed/playback rate\n    useEffect(() => {\n        if (!mpvPlayer) {\n            return;\n        }\n\n        if (!speed) {\n            return;\n        }\n\n        mpvPlayer.setProperties({ speed });\n    }, [speed]);\n\n    // Handle play/pause status\n    useEffect(() => {\n        if (!mpvPlayer) {\n            return;\n        }\n\n        if (playerStatus === PlayerStatus.PLAYING) {\n            mpvPlayer.play();\n        } else if (playerStatus === PlayerStatus.PAUSED) {\n            mpvPlayer.pause();\n        }\n    }, [playerStatus]);\n\n    const hasCurrentSong = !!currentSong?.id;\n\n    // Set up progress tracking\n    useEffect(() => {\n        if (progressIntervalRef.current) {\n            clearInterval(progressIntervalRef.current);\n        }\n\n        if (!hasCurrentSong) {\n            return;\n        }\n\n        const updateProgress = async () => {\n            if (!mpvPlayer || !isMountedRef.current) {\n                return;\n            }\n\n            try {\n                const time = await mpvPlayer.getCurrentTime();\n                if (time !== undefined && isMountedRef.current) {\n                    onProgress({\n                        played: time / (time + 10),\n                        playedSeconds: time,\n                    });\n                }\n            } catch {\n                // Handle error silently\n            }\n        };\n\n        const interval = PROGRESS_UPDATE_INTERVAL;\n        progressIntervalRef.current = setInterval(updateProgress, interval);\n        updateProgress();\n\n        return () => {\n            isMountedRef.current = false;\n            if (progressIntervalRef.current) {\n                clearInterval(progressIntervalRef.current);\n                progressIntervalRef.current = null;\n            }\n        };\n    }, [hasCurrentSong, isTransitioning, onProgress]);\n\n    const { mediaAutoNext } = usePlayerActions();\n\n    useEffect(() => {\n        if (!mpvPlayerListener) {\n            return;\n        }\n\n        const handleOnAutoNext = () => {\n            mediaAutoNext();\n            handleMpvAutoNext(transcode);\n        };\n\n        mpvPlayerListener.rendererAutoNext(handleOnAutoNext);\n\n        return () => {\n            ipc?.removeAllListeners('renderer-player-auto-next');\n        };\n    }, [mediaAutoNext, onEnded, transcode]);\n\n    usePlayerEvents(\n        {\n            onMediaNext: () => {\n                replaceMpvQueue(transcode);\n            },\n            onMediaPrev: () => {\n                replaceMpvQueue(transcode);\n            },\n            onNextSongInsertion: (song) => {\n                const radioState = useRadioStore.getState();\n\n                if (radioState.currentStreamUrl) {\n                    return;\n                }\n\n                const nextSongUrl = song ? getSongUrl(song, transcode) : undefined;\n                mpvPlayer?.setQueueNext(nextSongUrl);\n            },\n            onPlayerPlay: () => {\n                replaceMpvQueue(transcode);\n            },\n            onQueueCleared: () => {},\n            onQueueRestored: () => {\n                replaceMpvQueue(transcode);\n            },\n        },\n        [transcode],\n    );\n\n    useImperativeHandle<MpvPlayerEngineHandle, MpvPlayerEngineHandle>(playerRef, () => ({\n        decreaseVolume(by: number) {\n            const newVol = Math.max(0, internalVolume - by / 100);\n            setInternalVolume(newVol);\n            if (mpvPlayer) {\n                mpvPlayer.volume(newVol * 100);\n            }\n        },\n        increaseVolume(by: number) {\n            const newVol = Math.min(1, internalVolume + by / 100);\n            setInternalVolume(newVol);\n            if (mpvPlayer) {\n                mpvPlayer.volume(newVol * 100);\n            }\n        },\n        pause() {\n            if (mpvPlayer) {\n                mpvPlayer.pause();\n            }\n        },\n        play() {\n            if (mpvPlayer) {\n                mpvPlayer.play();\n            }\n        },\n        seekTo(seekTo: number) {\n            if (mpvPlayer) {\n                mpvPlayer.seekTo(seekTo);\n            }\n        },\n        setVolume(vol: number) {\n            const volDecimal = vol / 100 || 0;\n            setInternalVolume(volDecimal);\n            if (mpvPlayer) {\n                mpvPlayer.volume(vol);\n            }\n        },\n    }));\n\n    return <div id=\"mpv-player-engine\" style={{ display: 'none' }} />;\n};\n\nMpvPlayerEngine.displayName = 'MpvPlayerEngine';\n\nfunction handleMpvAutoNext(transcode: {\n    bitrate?: number | undefined;\n    enabled: boolean;\n    format?: string | undefined;\n}) {\n    const playerData = usePlayerStore.getState().getPlayerData();\n    const nextSongUrl = playerData.nextSong\n        ? getSongUrl(playerData.nextSong, transcode)\n        : undefined;\n    mpvPlayer?.autoNext(nextSongUrl);\n}\n\nfunction replaceMpvQueue(transcode: {\n    bitrate?: number | undefined;\n    enabled: boolean;\n    format?: string | undefined;\n}) {\n    // Don't override queue if radio is active\n    const radioState = useRadioStore.getState();\n\n    if (radioState.currentStreamUrl) {\n        return;\n    }\n\n    const playerData = usePlayerStore.getState().getPlayerData();\n    const currentSongUrl = playerData.currentSong\n        ? getSongUrl(playerData.currentSong, transcode)\n        : undefined;\n    const nextSongUrl = playerData.nextSong\n        ? getSongUrl(playerData.nextSong, transcode)\n        : undefined;\n    mpvPlayer?.setQueue(currentSongUrl, nextSongUrl, false);\n}\n"
  },
  {
    "path": "src/renderer/features/player/audio-player/engine/wavesurfer-player-engine.tsx",
    "content": "import type { RefObject } from 'react';\nimport type WaveSurfer from 'wavesurfer.js';\n\nimport { useWavesurfer } from '@wavesurfer/react';\nimport { useEffect, useImperativeHandle, useRef, useState } from 'react';\n\nimport { AudioPlayer, PlayerOnProgressProps } from '/@/renderer/features/player/audio-player/types';\nimport { convertToLogVolume } from '/@/renderer/features/player/audio-player/utils/player-utils';\nimport { PlayerStatus } from '/@/shared/types/types';\n\nexport interface WaveSurferPlayerEngineHandle extends AudioPlayer {\n    player1(): {\n        ref: null | WaveSurfer;\n        setVolume: (volume: number) => void;\n    };\n    player2(): {\n        ref: null | WaveSurfer;\n        setVolume: (volume: number) => void;\n    };\n}\n\ninterface WaveSurferPlayerEngineProps {\n    isMuted: boolean;\n    isTransitioning: boolean;\n    onEndedPlayer1: () => void;\n    onEndedPlayer2: () => void;\n    onProgressPlayer1: (e: PlayerOnProgressProps) => void;\n    onProgressPlayer2: (e: PlayerOnProgressProps) => void;\n    playerNum: number;\n    playerRef: RefObject<null | WaveSurferPlayerEngineHandle>;\n    playerStatus: PlayerStatus;\n    speed?: number;\n    src1: string | undefined;\n    src2: string | undefined;\n    volume: number;\n}\n\n// Credits: https://gist.github.com/novwhisky/8a1a0168b94f3b6abfaa?permalink_comment_id=1551393#gistcomment-1551393\n// This is used so that the player will always have an <audio> element. This means that\n// player1Source and player2Source are connected BEFORE the user presses play for\n// the first time. This workaround is important for Safari, which seems to require the\n// source to be connected PRIOR to resuming audio context\nconst EMPTY_SOURCE =\n    'data:audio/mp3;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU2LjM2LjEwMAAAAAAAAAAAAAAA//OEAAAAAAAAAAAAAAAAAAAAAAAASW5mbwAAAA8AAAAEAAABIADAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV6urq6urq6urq6urq6urq6urq6urq6urq6v////////////////////////////////8AAAAATGF2YzU2LjQxAAAAAAAAAAAAAAAAJAAAAAAAAAAAASDs90hvAAAAAAAAAAAAAAAAAAAA//MUZAAAAAGkAAAAAAAAA0gAAAAATEFN//MUZAMAAAGkAAAAAAAAA0gAAAAARTMu//MUZAYAAAGkAAAAAAAAA0gAAAAAOTku//MUZAkAAAGkAAAAAAAAA0gAAAAANVVV';\n\nexport const WaveSurferPlayerEngine = (props: WaveSurferPlayerEngineProps) => {\n    const {\n        isMuted,\n        isTransitioning,\n        onEndedPlayer1,\n        onEndedPlayer2,\n        onProgressPlayer1,\n        onProgressPlayer2,\n        playerNum,\n        playerRef,\n        playerStatus,\n        speed,\n        src1,\n        src2,\n        volume,\n    } = props;\n\n    const container1Ref = useRef<HTMLDivElement>(null);\n    const container2Ref = useRef<HTMLDivElement>(null);\n\n    const [internalVolume1, setInternalVolume1] = useState(volume / 100 || 0);\n    const [internalVolume2, setInternalVolume2] = useState(volume / 100 || 0);\n\n    const { wavesurfer: wavesurfer1 } = useWavesurfer({\n        barWidth: 0,\n        container: container1Ref,\n        cursorColor: 'transparent',\n        height: 0,\n        interact: false,\n        normalize: false,\n        progressColor: 'transparent',\n        url: src1 || EMPTY_SOURCE,\n        waveColor: 'transparent',\n    });\n\n    const { wavesurfer: wavesurfer2 } = useWavesurfer({\n        barWidth: 0,\n        container: container2Ref,\n        cursorColor: 'transparent',\n        height: 0,\n        interact: false,\n        normalize: false,\n        progressColor: 'transparent',\n        url: src2 || EMPTY_SOURCE,\n        waveColor: 'transparent',\n    });\n\n    // Handle volume changes\n    useEffect(() => {\n        if (wavesurfer1) {\n            const logVolume1 = convertToLogVolume(internalVolume1);\n            wavesurfer1.setVolume(isMuted ? 0 : logVolume1);\n        }\n    }, [wavesurfer1, internalVolume1, isMuted]);\n\n    useEffect(() => {\n        if (wavesurfer2) {\n            const logVolume2 = convertToLogVolume(internalVolume2);\n            wavesurfer2.setVolume(isMuted ? 0 : logVolume2);\n        }\n    }, [wavesurfer2, internalVolume2, isMuted]);\n\n    // Handle playback rate (speed)\n    useEffect(() => {\n        if (wavesurfer1 && speed) {\n            wavesurfer1.setPlaybackRate(speed);\n        }\n    }, [wavesurfer1, speed]);\n\n    useEffect(() => {\n        if (wavesurfer2 && speed) {\n            wavesurfer2.setPlaybackRate(speed);\n        }\n    }, [wavesurfer2, speed]);\n\n    // Handle play/pause based on playerNum and status\n    useEffect(() => {\n        if (!wavesurfer1 || !wavesurfer2) return;\n\n        if (playerNum === 1 && playerStatus === PlayerStatus.PLAYING) {\n            wavesurfer1.play();\n        } else {\n            wavesurfer1.pause();\n        }\n\n        if (playerNum === 2 && playerStatus === PlayerStatus.PLAYING) {\n            wavesurfer2.play();\n        } else {\n            wavesurfer2.pause();\n        }\n    }, [wavesurfer1, wavesurfer2, playerNum, playerStatus]);\n\n    // Handle progress updates for player1\n    useEffect(() => {\n        if (!wavesurfer1 || !src1) return;\n\n        const updateProgress = () => {\n            const currentTime = wavesurfer1.getCurrentTime();\n            const duration = wavesurfer1.getDuration();\n\n            if (duration > 0) {\n                onProgressPlayer1({\n                    played: currentTime / duration,\n                    playedSeconds: currentTime,\n                });\n            }\n        };\n\n        const interval = setInterval(updateProgress, isTransitioning ? 10 : 250);\n\n        return () => clearInterval(interval);\n    }, [wavesurfer1, src1, isTransitioning, onProgressPlayer1]);\n\n    // Handle progress updates for player2\n    useEffect(() => {\n        if (!wavesurfer2 || !src2) return;\n\n        const updateProgress = () => {\n            const currentTime = wavesurfer2.getCurrentTime();\n            const duration = wavesurfer2.getDuration();\n\n            if (duration > 0) {\n                onProgressPlayer2({\n                    played: currentTime / duration,\n                    playedSeconds: currentTime,\n                });\n            }\n        };\n\n        const interval = setInterval(updateProgress, isTransitioning ? 10 : 250);\n\n        return () => clearInterval(interval);\n    }, [wavesurfer2, src2, isTransitioning, onProgressPlayer2]);\n\n    // Handle ended events\n    useEffect(() => {\n        if (!wavesurfer1 || !src1) return;\n\n        const handleEnded = () => {\n            onEndedPlayer1();\n        };\n\n        wavesurfer1.on('finish', handleEnded);\n\n        return () => {\n            wavesurfer1.un('finish', handleEnded);\n        };\n    }, [wavesurfer1, src1, onEndedPlayer1]);\n\n    useEffect(() => {\n        if (!wavesurfer2 || !src2) return;\n\n        const handleEnded = () => {\n            onEndedPlayer2();\n        };\n\n        wavesurfer2.on('finish', handleEnded);\n\n        return () => {\n            wavesurfer2.un('finish', handleEnded);\n        };\n    }, [wavesurfer2, src2, onEndedPlayer2]);\n\n    useImperativeHandle<WaveSurferPlayerEngineHandle, WaveSurferPlayerEngineHandle>(\n        playerRef,\n        () => ({\n            decreaseVolume(by: number) {\n                setInternalVolume1(Math.max(0, internalVolume1 - by / 100));\n                setInternalVolume2(Math.max(0, internalVolume2 - by / 100));\n            },\n            increaseVolume(by: number) {\n                setInternalVolume1(Math.min(1, internalVolume1 + by / 100));\n                setInternalVolume2(Math.min(1, internalVolume2 + by / 100));\n            },\n            pause() {\n                wavesurfer1?.pause();\n                wavesurfer2?.pause();\n            },\n            play() {\n                if (playerNum === 1) {\n                    wavesurfer1?.play();\n                } else {\n                    wavesurfer2?.play();\n                }\n            },\n            player1() {\n                return {\n                    ref: wavesurfer1 || null,\n                    setVolume: (volume: number) => setInternalVolume1(volume / 100 || 0),\n                };\n            },\n            player2() {\n                return {\n                    ref: wavesurfer2 || null,\n                    setVolume: (volume: number) => setInternalVolume2(volume / 100 || 0),\n                };\n            },\n            seekTo(seekTo: number) {\n                if (playerNum === 1) {\n                    wavesurfer1?.seekTo(seekTo);\n                } else {\n                    wavesurfer2?.seekTo(seekTo);\n                }\n            },\n            setVolume(volume: number) {\n                setInternalVolume1(volume / 100 || 0);\n                setInternalVolume2(volume / 100 || 0);\n            },\n            setVolume1(volume: number) {\n                setInternalVolume1(volume / 100 || 0);\n            },\n            setVolume2(volume: number) {\n                setInternalVolume2(volume / 100 || 0);\n            },\n        }),\n        [wavesurfer1, wavesurfer2, playerNum, internalVolume1, internalVolume2],\n    );\n\n    return (\n        <div id=\"wavesurfer-player-engine\" style={{ display: 'none' }}>\n            {Boolean(src1) && <div id=\"wavesurfer-player-1\" ref={container1Ref} />}\n            {Boolean(src2) && <div id=\"wavesurfer-player-2\" ref={container2Ref} />}\n        </div>\n    );\n};\n\nWaveSurferPlayerEngine.displayName = 'WaveSurferPlayerEngine';\n"
  },
  {
    "path": "src/renderer/features/player/audio-player/engine/web-player-engine.tsx",
    "content": "import type { RefObject } from 'react';\nimport type ReactPlayer from 'react-player';\n\nimport { useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';\n\nimport { AudioPlayer, PlayerOnProgressProps } from '/@/renderer/features/player/audio-player/types';\nimport { convertToLogVolume } from '/@/renderer/features/player/audio-player/utils/player-utils';\nimport { LogCategory, logFn } from '/@/renderer/utils/logger';\nimport { logMsg } from '/@/renderer/utils/logger-message';\nimport { PlayerStatus } from '/@/shared/types/types';\n\nexport interface WebPlayerEngineHandle extends AudioPlayer {\n    player1(): {\n        ref: null | ReactPlayer;\n        setVolume: (volume: number) => void;\n    };\n    player2(): {\n        ref: null | ReactPlayer;\n        setVolume: (volume: number) => void;\n    };\n}\n\ninterface WebPlayerEngineProps {\n    isMuted: boolean;\n    isTransitioning: boolean;\n    onEndedPlayer1: () => void;\n    onEndedPlayer2: () => void;\n    onErrorPause: () => void;\n    onProgressPlayer1: (e: PlayerOnProgressProps) => void;\n    onProgressPlayer2: (e: PlayerOnProgressProps) => void;\n    onStartedPlayer1: (player: ReactPlayer) => void;\n    onStartedPlayer2: (player: ReactPlayer) => void;\n    playerNum: number;\n    playerRef: RefObject<null | WebPlayerEngineHandle>;\n    playerStatus: PlayerStatus;\n    preservesPitch: boolean;\n    speed?: number;\n    src1: string | undefined;\n    src2: string | undefined;\n    volume: number;\n}\n\nconst MAX_NETWORK_RETRIES = 5;\nconst NETWORK_RETRY_DELAY_MS = 2000;\n\n// Credits: https://gist.github.com/novwhisky/8a1a0168b94f3b6abfaa?permalink_comment_id=1551393#gistcomment-1551393\n// This is used so that the player will always have an <audio> element. This means that\n// player1Source and player2Source are connected BEFORE the user presses play for\n// the first time. This workaround is important for Safari, which seems to require the\n// source to be connected PRIOR to resuming audio context\nconst EMPTY_SOURCE =\n    'data:audio/mp3;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU2LjM2LjEwMAAAAAAAAAAAAAAA//OEAAAAAAAAAAAAAAAAAAAAAAAASW5mbwAAAA8AAAAEAAABIADAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV6urq6urq6urq6urq6urq6urq6urq6urq6v////////////////////////////////8AAAAATGF2YzU2LjQxAAAAAAAAAAAAAAAAJAAAAAAAAAAAASDs90hvAAAAAAAAAAAAAAAAAAAA//MUZAAAAAGkAAAAAAAAA0gAAAAATEFN//MUZAMAAAGkAAAAAAAAA0gAAAAARTMu//MUZAYAAAGkAAAAAAAAA0gAAAAAOTku//MUZAkAAAGkAAAAAAAAA0gAAAAANVVV';\n\nexport const WebPlayerEngine = (props: WebPlayerEngineProps) => {\n    const {\n        isMuted,\n        isTransitioning,\n        onEndedPlayer1,\n        onEndedPlayer2,\n        onErrorPause,\n        onProgressPlayer1,\n        onProgressPlayer2,\n        onStartedPlayer1,\n        onStartedPlayer2,\n        playerNum,\n        playerRef,\n        playerStatus,\n        preservesPitch,\n        speed,\n        src1,\n        src2,\n        volume,\n    } = props;\n\n    const player1Ref = useRef<null | ReactPlayer>(null);\n    const player2Ref = useRef<null | ReactPlayer>(null);\n    const networkRetryCount1 = useRef(0);\n    const networkRetryCount2 = useRef(0);\n    const [ReactPlayerComponent, setReactPlayerComponent] = useState<any>(null);\n    const [isLoading, setIsLoading] = useState(true);\n\n    useEffect(() => {\n        let isMounted = true;\n\n        const loadReactPlayer = async () => {\n            try {\n                const module = await import('react-player');\n                if (isMounted) {\n                    setReactPlayerComponent(() => module.default);\n                    setIsLoading(false);\n                }\n            } catch (error) {\n                console.error('Failed to load react-player:', error);\n                setIsLoading(false);\n            }\n        };\n\n        loadReactPlayer();\n\n        return () => {\n            isMounted = false;\n        };\n    }, []);\n\n    const [internalVolume1, setInternalVolume1] = useState(volume / 100 || 0);\n    const [internalVolume2, setInternalVolume2] = useState(volume / 100 || 0);\n\n    useImperativeHandle<WebPlayerEngineHandle, WebPlayerEngineHandle>(playerRef, () => ({\n        decreaseVolume(by: number) {\n            setInternalVolume1(Math.max(0, internalVolume1 - by / 100));\n            setInternalVolume2(Math.max(0, internalVolume2 - by / 100));\n        },\n        increaseVolume(by: number) {\n            setInternalVolume1(Math.min(1, internalVolume1 + by / 100));\n            setInternalVolume2(Math.min(1, internalVolume2 + by / 100));\n        },\n        pause() {\n            player1Ref.current?.getInternalPlayer()?.pause();\n            player2Ref.current?.getInternalPlayer()?.pause();\n        },\n        play() {\n            player1Ref.current?.getInternalPlayer()?.pause();\n            player2Ref.current?.getInternalPlayer()?.pause();\n            if (playerNum === 1) {\n                player1Ref.current?.getInternalPlayer()?.play();\n            } else {\n                player2Ref.current?.getInternalPlayer()?.play();\n            }\n        },\n        player1() {\n            return {\n                ref: player1Ref?.current,\n                setVolume: (volume: number) => setInternalVolume1(volume / 100 || 0),\n            };\n        },\n        player2() {\n            return {\n                ref: player2Ref?.current,\n                setVolume: (volume: number) => setInternalVolume2(volume / 100 || 0),\n            };\n        },\n        seekTo(seekTo: number) {\n            playerNum === 1\n                ? player1Ref.current?.seekTo(seekTo)\n                : player2Ref.current?.seekTo(seekTo);\n        },\n        setVolume(volume: number) {\n            setInternalVolume1(volume / 100 || 0);\n            setInternalVolume2(volume / 100 || 0);\n        },\n        setVolume1(volume: number) {\n            setInternalVolume1(volume / 100 || 0);\n        },\n        setVolume2(volume: number) {\n            setInternalVolume2(volume / 100 || 0);\n        },\n    }));\n\n    const volume1 = convertToLogVolume(internalVolume1);\n    const volume2 = convertToLogVolume(internalVolume2);\n\n    const pauseBothPlayers = useCallback(() => {\n        player1Ref.current?.getInternalPlayer()?.pause();\n        player2Ref.current?.getInternalPlayer()?.pause();\n    }, []);\n\n    const handleOnError = (\n        playerRef: React.RefObject<null | ReactPlayer>,\n        onEnded: () => void,\n        onErrorPause: () => void,\n        networkRetryCountRef: React.RefObject<number>,\n    ) => {\n        return ({ target }: ErrorEvent) => {\n            const { current: player } = playerRef;\n\n            if (!player || !(target instanceof Audio)) {\n                return;\n            }\n\n            const { error } = target;\n\n            logFn.error(logMsg[LogCategory.PLAYER].playbackError, {\n                category: LogCategory.PLAYER,\n                meta: { error },\n            });\n\n            const isNetworkError =\n                error?.code === MediaError.MEDIA_ERR_NETWORK ||\n                error?.code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED;\n\n            if (isNetworkError) {\n                if (networkRetryCountRef.current < MAX_NETWORK_RETRIES) {\n                    networkRetryCountRef.current += 1;\n                    const audio = target;\n                    setTimeout(() => {\n                        pauseBothPlayers();\n                        audio.load();\n                        audio.play().catch(() => {\n                            logFn.error(logMsg[LogCategory.PLAYER].playbackError, {\n                                category: LogCategory.PLAYER,\n                                meta: { error: 'Failed to play audio after network error' },\n                            });\n                        });\n                    }, NETWORK_RETRY_DELAY_MS);\n                    return;\n                }\n            }\n\n            if (error?.code !== MediaError.MEDIA_ERR_DECODE && !isNetworkError) {\n                return;\n            }\n\n            pauseBothPlayers();\n            if (error?.code === MediaError.MEDIA_ERR_DECODE) {\n                onEnded();\n            } else {\n                if (onErrorPause) {\n                    onErrorPause();\n                }\n            }\n        };\n    };\n\n    useEffect(() => {\n        networkRetryCount1.current = 0;\n        networkRetryCount2.current = 0;\n    }, [src1, src2]);\n\n    // When not transitioning, ensure only the active player can play (e.g. after seek/prev during transition)\n    useEffect(() => {\n        if (isTransitioning) return;\n        if (playerStatus !== PlayerStatus.PLAYING) {\n            pauseBothPlayers();\n            return;\n        }\n        if (playerNum === 1) {\n            player2Ref.current?.getInternalPlayer()?.pause();\n        } else {\n            player1Ref.current?.getInternalPlayer()?.pause();\n        }\n    }, [isTransitioning, playerNum, playerStatus, pauseBothPlayers]);\n\n    useEffect(() => {\n        const player1 = player1Ref.current?.getInternalPlayer();\n        if (player1 && player1 instanceof HTMLAudioElement) {\n            player1.preservesPitch = preservesPitch;\n        }\n        const player2 = player2Ref.current?.getInternalPlayer();\n        if (player2 && player2 instanceof HTMLAudioElement) {\n            player2.preservesPitch = preservesPitch;\n        }\n    }, [preservesPitch]);\n\n    const handleOnReadyPlayer1 = useCallback(\n        (player: ReactPlayer) => {\n            const internal = player.getInternalPlayer();\n            if (internal && internal instanceof HTMLAudioElement) {\n                internal.preservesPitch = preservesPitch;\n            }\n            onStartedPlayer1(player);\n        },\n        [onStartedPlayer1, preservesPitch],\n    );\n\n    const handleOnReadyPlayer2 = useCallback(\n        (player: ReactPlayer) => {\n            const internal = player.getInternalPlayer();\n            if (internal && internal instanceof HTMLAudioElement) {\n                internal.preservesPitch = preservesPitch;\n            }\n            onStartedPlayer2(player);\n        },\n        [onStartedPlayer2, preservesPitch],\n    );\n\n    if (isLoading || !ReactPlayerComponent) {\n        return <div id=\"web-player-engine\" style={{ display: 'none' }} />;\n    }\n\n    return (\n        <div id=\"web-player-engine\" style={{ display: 'none' }}>\n            <ReactPlayerComponent\n                config={{\n                    file: { attributes: { crossOrigin: 'anonymous' }, forceAudio: true },\n                }}\n                controls={false}\n                height={0}\n                id=\"web-player-1\"\n                muted={isMuted}\n                onEnded={src1 ? () => onEndedPlayer1() : undefined}\n                onError={handleOnError(\n                    player1Ref,\n                    () => onEndedPlayer1(),\n                    onErrorPause,\n                    networkRetryCount1,\n                )}\n                onProgress={onProgressPlayer1}\n                onReady={handleOnReadyPlayer1}\n                playbackRate={speed || 1}\n                playing={playerNum === 1 && playerStatus === PlayerStatus.PLAYING}\n                progressInterval={isTransitioning ? 10 : 250}\n                ref={player1Ref}\n                url={src1 || EMPTY_SOURCE}\n                volume={volume1}\n                width={0}\n            />\n            <ReactPlayerComponent\n                config={{\n                    file: { attributes: { crossOrigin: 'anonymous' }, forceAudio: true },\n                }}\n                controls={false}\n                height={0}\n                id=\"web-player-2\"\n                muted={isMuted}\n                onEnded={src2 ? () => onEndedPlayer2() : undefined}\n                onError={handleOnError(\n                    player2Ref,\n                    () => onEndedPlayer2(),\n                    onErrorPause,\n                    networkRetryCount2,\n                )}\n                onProgress={onProgressPlayer2}\n                onReady={handleOnReadyPlayer2}\n                playbackRate={speed || 1}\n                playing={playerNum === 2 && playerStatus === PlayerStatus.PLAYING}\n                progressInterval={isTransitioning ? 10 : 250}\n                ref={player2Ref}\n                url={src2 || EMPTY_SOURCE}\n                volume={volume2}\n                width={0}\n            />\n        </div>\n    );\n};\n\nWebPlayerEngine.displayName = 'WebPlayerEngine';\n"
  },
  {
    "path": "src/renderer/features/player/audio-player/hooks/use-main-player-listener.tsx",
    "content": "import { t } from 'i18next';\nimport isElectron from 'is-electron';\nimport { useCallback, useEffect } from 'react';\n\nimport { useIsRadioActive } from '/@/renderer/features/radio/hooks/use-radio-player';\nimport { usePlayerActions, useVolumeWheelStep } from '/@/renderer/store';\nimport { toast } from '/@/shared/components/toast/toast';\n\nconst mpvPlayer = isElectron() ? window.api.mpvPlayer : null;\nconst mpvPlayerListener = isElectron() ? window.api.mpvPlayerListener : null;\nconst ipc = isElectron() ? window.api.ipc : null;\n\nexport const useMainPlayerListener = () => {\n    const isRadioActive = useIsRadioActive();\n    const volumeWheelStep = useVolumeWheelStep();\n    const {\n        decreaseVolume,\n        increaseVolume,\n        mediaAutoNext,\n        mediaNext,\n        mediaPause,\n        mediaPlay,\n        mediaPrevious,\n        mediaSkipBackward,\n        mediaSkipForward,\n        mediaStop,\n        mediaToggleMute,\n        mediaTogglePlayPause,\n        toggleRepeat,\n        toggleShuffle,\n    } = usePlayerActions();\n\n    const handleMpvError = useCallback(\n        (message: string) => {\n            toast.error({\n                id: 'mpv-error',\n                message,\n                title: t('error.playbackError', { postProcess: 'sentenceCase' }) as string,\n            });\n            mediaPause();\n            mpvPlayer!.pause();\n        },\n        [mediaPause],\n    );\n\n    useEffect(() => {\n        if (!mpvPlayerListener) {\n            return;\n        }\n\n        mpvPlayerListener.rendererPlayPause(() => {\n            if (!isRadioActive) {\n                mediaTogglePlayPause();\n            }\n        });\n\n        mpvPlayerListener.rendererNext(() => {\n            if (!isRadioActive) {\n                mediaNext();\n            }\n        });\n\n        mpvPlayerListener.rendererPrevious(() => {\n            if (!isRadioActive) {\n                mediaPrevious();\n            }\n        });\n\n        mpvPlayerListener.rendererPlay(() => {\n            if (!isRadioActive) {\n                mediaPlay();\n            }\n        });\n\n        mpvPlayerListener.rendererPause(() => {\n            if (!isRadioActive) {\n                mediaPause();\n            }\n        });\n\n        mpvPlayerListener.rendererStop(() => {\n            if (!isRadioActive) {\n                mediaStop();\n            }\n        });\n\n        mpvPlayerListener.rendererSkipForward(() => {\n            mediaSkipForward();\n        });\n\n        mpvPlayerListener.rendererSkipBackward(() => {\n            mediaSkipBackward();\n        });\n\n        mpvPlayerListener.rendererToggleShuffle(() => {\n            toggleShuffle();\n        });\n\n        mpvPlayerListener.rendererToggleRepeat(() => {\n            toggleRepeat();\n        });\n\n        mpvPlayerListener.rendererVolumeMute(() => {\n            mediaToggleMute();\n        });\n\n        mpvPlayerListener.rendererVolumeUp(() => {\n            increaseVolume(volumeWheelStep);\n        });\n\n        mpvPlayerListener.rendererVolumeDown(() => {\n            decreaseVolume(volumeWheelStep);\n        });\n\n        mpvPlayerListener.rendererError((_event: any, message: string) => {\n            handleMpvError(message);\n        });\n\n        return () => {\n            ipc?.removeAllListeners('renderer-player-play-pause');\n            ipc?.removeAllListeners('renderer-player-next');\n            ipc?.removeAllListeners('renderer-player-previous');\n            ipc?.removeAllListeners('renderer-player-play');\n            ipc?.removeAllListeners('renderer-player-pause');\n            ipc?.removeAllListeners('renderer-player-stop');\n            ipc?.removeAllListeners('renderer-player-skip-forward');\n            ipc?.removeAllListeners('renderer-player-skip-backward');\n            ipc?.removeAllListeners('renderer-player-toggle-shuffle');\n            ipc?.removeAllListeners('renderer-player-toggle-repeat');\n            ipc?.removeAllListeners('renderer-player-volume-mute');\n            ipc?.removeAllListeners('renderer-player-volume-up');\n            ipc?.removeAllListeners('renderer-player-volume-down');\n            ipc?.removeAllListeners('renderer-player-error');\n        };\n    }, [\n        decreaseVolume,\n        handleMpvError,\n        increaseVolume,\n        isRadioActive,\n        mediaAutoNext,\n        mediaNext,\n        mediaPause,\n        mediaPlay,\n        mediaPrevious,\n        mediaSkipForward,\n        mediaSkipBackward,\n        mediaStop,\n        mediaToggleMute,\n        mediaTogglePlayPause,\n        toggleRepeat,\n        toggleShuffle,\n        volumeWheelStep,\n    ]);\n};\n\nconst MainPlayerListenerHookInner = () => {\n    useMainPlayerListener();\n    return null;\n};\n\nexport const MainPlayerListenerHook = () => {\n    const isElectronEnv = isElectron();\n    const mpvPlayerListener = isElectronEnv ? window.api.mpvPlayerListener : null;\n\n    if (mpvPlayerListener === null) {\n        return null;\n    }\n\n    return <MainPlayerListenerHookInner />;\n};\n"
  },
  {
    "path": "src/renderer/features/player/audio-player/hooks/use-player-events.ts",
    "content": "import { useEffect } from 'react';\n\nimport { eventEmitter } from '/@/renderer/events/event-emitter';\nimport {\n    subscribeCurrentTrack,\n    subscribeNextSongInsertion,\n    subscribePlayerMute,\n    subscribePlayerProgress,\n    subscribePlayerQueue,\n    subscribePlayerRepeat,\n    subscribePlayerSeekToTimestamp,\n    subscribePlayerShuffle,\n    subscribePlayerSpeed,\n    subscribePlayerStatus,\n    subscribePlayerVolume,\n    subscribeQueueCleared,\n} from '/@/renderer/store';\nimport { LibraryItem, QueueData, QueueSong, Song } from '/@/shared/types/domain-types';\nimport { PlayerRepeat, PlayerShuffle, PlayerStatus } from '/@/shared/types/types';\n\ninterface PlayerEvents {\n    cleanup: () => void;\n}\n\ninterface PlayerEventsCallbacks {\n    onCurrentSongChange?: (\n        properties: { index: number; song: QueueSong | undefined },\n        prev: { index: number; song: QueueSong | undefined },\n    ) => void;\n    onMediaNext?: (properties: { currentIndex: number; nextIndex: number }) => void;\n    onMediaPrev?: (properties: { currentIndex: number; prevIndex: number }) => void;\n    onNextSongInsertion?: (song: QueueSong | undefined) => void;\n    onPlayerMute?: (properties: { muted: boolean }, prev: { muted: boolean }) => void;\n    onPlayerPlay?: (properties: { id: string; index: number }) => void;\n    onPlayerProgress?: (properties: { timestamp: number }, prev: { timestamp: number }) => void;\n    onPlayerQueueChange?: (queue: QueueData, prev: QueueData) => void;\n    onPlayerRepeat?: (properties: { repeat: PlayerRepeat }, prev: { repeat: PlayerRepeat }) => void;\n    onPlayerRepeated?: (properties: { index: number }) => void;\n    onPlayerSeek?: (properties: { seconds: number }, prev: { seconds: number }) => void;\n    onPlayerSeekToTimestamp?: (\n        properties: { timestamp: number },\n        prev: { timestamp: number },\n    ) => void;\n    onPlayerShuffle?: (\n        properties: { shuffle: PlayerShuffle },\n        prev: { shuffle: PlayerShuffle },\n    ) => void;\n    onPlayerSpeed?: (properties: { speed: number }, prev: { speed: number }) => void;\n    onPlayerStatus?: (properties: { status: PlayerStatus }, prev: { status: PlayerStatus }) => void;\n    onPlayerVolume?: (properties: { volume: number }, prev: { volume: number }) => void;\n    onQueueCleared?: () => void;\n    onQueueRestored?: (properties: { data: Song[]; index: number; position: number }) => void;\n    onUserFavorite?: (properties: {\n        favorite: boolean;\n        id: string[];\n        itemType: LibraryItem;\n        serverId: string;\n    }) => void;\n    onUserRating?: (properties: {\n        id: string[];\n        itemType: LibraryItem;\n        rating: null | number;\n        serverId: string;\n    }) => void;\n}\n\nexport function usePlayerEvents(callbacks: PlayerEventsCallbacks, deps: React.DependencyList) {\n    useEffect(() => {\n        const engine = createPlayerEvents(callbacks);\n\n        return () => {\n            engine.cleanup();\n        };\n        // eslint-disable-next-line react-hooks/exhaustive-deps\n    }, [...deps]);\n}\n\nfunction createPlayerEvents(callbacks: PlayerEventsCallbacks): PlayerEvents {\n    const unsubscribers: (() => void)[] = [];\n\n    // Subscribe to current track changes\n    if (callbacks.onCurrentSongChange) {\n        const unsubscribe = subscribeCurrentTrack(callbacks.onCurrentSongChange);\n        unsubscribers.push(unsubscribe);\n    }\n\n    // Subscribe to next song insertions (when a song is added at next position)\n    if (callbacks.onNextSongInsertion) {\n        const unsubscribe = subscribeNextSongInsertion(callbacks.onNextSongInsertion);\n        unsubscribers.push(unsubscribe);\n    }\n\n    // Subscribe to player progress\n    if (callbacks.onPlayerProgress) {\n        const unsubscribe = subscribePlayerProgress(callbacks.onPlayerProgress);\n        unsubscribers.push(unsubscribe);\n    }\n\n    // Subscribe to queue changes\n    if (callbacks.onPlayerQueueChange) {\n        const unsubscribe = subscribePlayerQueue(callbacks.onPlayerQueueChange);\n        unsubscribers.push(unsubscribe);\n    }\n\n    // Subscribe to queue cleared events\n    if (callbacks.onQueueCleared) {\n        const unsubscribe = subscribeQueueCleared(callbacks.onQueueCleared);\n        unsubscribers.push(unsubscribe);\n    }\n\n    // Subscribe to seek events\n    if (callbacks.onPlayerSeekToTimestamp) {\n        const unsubscribe = subscribePlayerSeekToTimestamp(callbacks.onPlayerSeekToTimestamp);\n        unsubscribers.push(unsubscribe);\n    }\n\n    // Subscribe to player status changes\n    if (callbacks.onPlayerStatus) {\n        const unsubscribe = subscribePlayerStatus(callbacks.onPlayerStatus);\n        unsubscribers.push(unsubscribe);\n    }\n\n    // Subscribe to volume changes\n    if (callbacks.onPlayerVolume) {\n        const unsubscribe = subscribePlayerVolume(callbacks.onPlayerVolume);\n        unsubscribers.push(unsubscribe);\n    }\n\n    // Subscribe to mute changes\n    if (callbacks.onPlayerMute) {\n        const unsubscribe = subscribePlayerMute(callbacks.onPlayerMute);\n        unsubscribers.push(unsubscribe);\n    }\n\n    // Subscribe to speed changes\n    if (callbacks.onPlayerSpeed) {\n        const unsubscribe = subscribePlayerSpeed(callbacks.onPlayerSpeed);\n        unsubscribers.push(unsubscribe);\n    }\n\n    // Subscribe to repeat changes\n    if (callbacks.onPlayerRepeat) {\n        const unsubscribe = subscribePlayerRepeat(callbacks.onPlayerRepeat);\n        unsubscribers.push(unsubscribe);\n    }\n\n    // Subscribe to shuffle changes\n    if (callbacks.onPlayerShuffle) {\n        const unsubscribe = subscribePlayerShuffle(callbacks.onPlayerShuffle);\n        unsubscribers.push(unsubscribe);\n    }\n\n    if (callbacks.onMediaNext) {\n        eventEmitter.on('MEDIA_NEXT', callbacks.onMediaNext);\n    }\n\n    if (callbacks.onMediaPrev) {\n        eventEmitter.on('MEDIA_PREV', callbacks.onMediaPrev);\n    }\n\n    if (callbacks.onPlayerPlay) {\n        eventEmitter.on('PLAYER_PLAY', callbacks.onPlayerPlay);\n    }\n\n    if (callbacks.onPlayerRepeated) {\n        eventEmitter.on('PLAYER_REPEATED', callbacks.onPlayerRepeated);\n    }\n\n    if (callbacks.onQueueRestored) {\n        eventEmitter.on('QUEUE_RESTORED', callbacks.onQueueRestored);\n    }\n\n    if (callbacks.onUserFavorite) {\n        eventEmitter.on('USER_FAVORITE', callbacks.onUserFavorite);\n    }\n\n    if (callbacks.onUserRating) {\n        eventEmitter.on('USER_RATING', callbacks.onUserRating);\n    }\n\n    return {\n        cleanup: () => {\n            unsubscribers.forEach((unsubscribe) => unsubscribe());\n            if (callbacks.onMediaNext) {\n                eventEmitter.off('MEDIA_NEXT', callbacks.onMediaNext);\n            }\n            if (callbacks.onMediaPrev) {\n                eventEmitter.off('MEDIA_PREV', callbacks.onMediaPrev);\n            }\n            if (callbacks.onPlayerPlay) {\n                eventEmitter.off('PLAYER_PLAY', callbacks.onPlayerPlay);\n            }\n            if (callbacks.onPlayerRepeated) {\n                eventEmitter.off('PLAYER_REPEATED', callbacks.onPlayerRepeated);\n            }\n            if (callbacks.onQueueRestored) {\n                eventEmitter.off('QUEUE_RESTORED', callbacks.onQueueRestored);\n            }\n            if (callbacks.onUserFavorite) {\n                eventEmitter.off('USER_FAVORITE', callbacks.onUserFavorite);\n            }\n            if (callbacks.onUserRating) {\n                eventEmitter.off('USER_RATING', callbacks.onUserRating);\n            }\n        },\n    };\n}\n"
  },
  {
    "path": "src/renderer/features/player/audio-player/hooks/use-stream-url.tsx",
    "content": "import { useMemo, useRef } from 'react';\n\nimport { api } from '/@/renderer/api';\nimport { TranscodingConfig } from '/@/renderer/store';\nimport { QueueSong } from '/@/shared/types/domain-types';\n\nexport function useSongUrl(\n    song: QueueSong | undefined,\n    current: boolean,\n    transcode: TranscodingConfig,\n): string | undefined {\n    const prior = useRef(['', '']);\n\n    return useMemo(() => {\n        if (song?._serverId) {\n            // If we are the current track, we do not want a transcoding\n            // reconfiguration to force a restart.\n            if (current && prior.current[0] === song._uniqueId) {\n                return prior.current[1];\n            }\n\n            const url = api.controller.getStreamUrl({\n                apiClientProps: { serverId: song._serverId },\n                query: {\n                    bitrate: transcode.bitrate,\n                    format: transcode.format,\n                    id: song.id,\n                    transcode: transcode.enabled,\n                },\n            });\n\n            // transcoding enabled; save the updated result\n            prior.current = [song._uniqueId, url];\n            return url;\n        }\n\n        // no track; clear result\n        prior.current = ['', ''];\n        return undefined;\n    }, [\n        song?._serverId,\n        song?._uniqueId,\n        song?.id,\n        current,\n        transcode.bitrate,\n        transcode.format,\n        transcode.enabled,\n    ]);\n}\n\nexport const getSongUrl = (song: QueueSong, transcode: TranscodingConfig) => {\n    return api.controller.getStreamUrl({\n        apiClientProps: { serverId: song._serverId },\n        query: {\n            bitrate: transcode.bitrate,\n            format: transcode.format,\n            id: song.id,\n            transcode: transcode.enabled,\n        },\n    });\n};\n"
  },
  {
    "path": "src/renderer/features/player/audio-player/mpv-player.tsx",
    "content": "import isElectron from 'is-electron';\nimport { useCallback, useEffect, useRef, useState } from 'react';\n\nimport { MpvPlayerEngine, MpvPlayerEngineHandle } from './engine/mpv-player-engine';\n\nimport { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';\nimport { usePlayer } from '/@/renderer/features/player/context/player-context';\nimport {\n    usePlaybackSettings,\n    usePlayerActions,\n    usePlayerData,\n    usePlayerMuted,\n    usePlayerProperties,\n    usePlayerStore,\n    usePlayerVolume,\n} from '/@/renderer/store';\nimport { PlayerStatus } from '/@/shared/types/types';\n\nconst PLAY_PAUSE_FADE_DURATION = 300;\nconst PLAY_PAUSE_FADE_INTERVAL = 10;\n\nconst mpvPlayer = isElectron() ? window.api.mpvPlayer : null;\n\nexport function MpvPlayer() {\n    const playerRef = useRef<MpvPlayerEngineHandle>(null);\n    const { currentSong, status } = usePlayerData();\n    const { mediaAutoNext, setTimestamp } = usePlayerActions();\n    const { speed } = usePlayerProperties();\n    const isMuted = usePlayerMuted();\n    const volume = usePlayerVolume();\n    const { audioFadeOnStatusChange } = usePlaybackSettings();\n\n    const [localPlayerStatus, setLocalPlayerStatus] = useState<PlayerStatus>(status);\n    const [isTransitioning, setIsTransitioning] = useState(false);\n    const fadeIntervalRef = useRef<NodeJS.Timeout | null>(null);\n\n    const fadeAndSetStatus = useCallback(\n        async (startVolume: number, endVolume: number, duration: number, status: PlayerStatus) => {\n            // Cancel any in-progress fade\n            if (fadeIntervalRef.current) {\n                clearInterval(fadeIntervalRef.current);\n                fadeIntervalRef.current = null;\n            }\n\n            // Set initial volume immediately to ensure we start from the correct position\n            // This is especially important when cancelling a previous fade\n            playerRef.current?.setVolume(startVolume);\n\n            const steps = duration / PLAY_PAUSE_FADE_INTERVAL;\n            const volumeStep = (endVolume - startVolume) / steps;\n            let currentStep = 0;\n\n            const promise = new Promise<void>((resolve) => {\n                fadeIntervalRef.current = setInterval(() => {\n                    currentStep++;\n                    const newVolume = startVolume + volumeStep * currentStep;\n\n                    playerRef.current?.setVolume(newVolume);\n\n                    if (currentStep >= steps) {\n                        if (fadeIntervalRef.current) {\n                            clearInterval(fadeIntervalRef.current);\n                            fadeIntervalRef.current = null;\n                        }\n                        // Ensure final volume is exactly the target\n                        playerRef.current?.setVolume(endVolume);\n                        resolve();\n                    }\n                }, PLAY_PAUSE_FADE_INTERVAL);\n            });\n\n            if (status === PlayerStatus.PAUSED) {\n                await promise;\n                setLocalPlayerStatus(status);\n            } else if (status === PlayerStatus.PLAYING) {\n                setLocalPlayerStatus(status);\n                await promise;\n            }\n        },\n        [],\n    );\n\n    const onProgress = useCallback(() => {\n        // Progress callback is now only used for transition logic\n        // Timestamp updates are handled separately in useEffect\n    }, []);\n\n    const handleOnEnded = useCallback(() => {\n        // When mpv auto-advances to the next song (position 1 becomes position 0),\n        // we need to update the player store first, then update the mpv queue with the new next song\n        // This follows the same pattern as the old useCenterControls implementation\n        const playerData = mediaAutoNext();\n\n        // Update the mpv queue with the new next song\n        // The engine will handle the queue update through the useEffect when nextSong changes\n        playerRef.current?.setVolume(volume);\n        setIsTransitioning(false);\n\n        return playerData;\n    }, [mediaAutoNext, volume, setIsTransitioning]);\n\n    const player = usePlayer();\n\n    usePlayerEvents(\n        {\n            onPlayerSeekToTimestamp: (properties) => {\n                const timestamp = properties.timestamp;\n                playerRef.current?.seekTo(timestamp);\n            },\n            onPlayerStatus: async (properties) => {\n                const status = properties.status;\n                const volume = usePlayerStore.getState().player.volume;\n                if (audioFadeOnStatusChange) {\n                    if (status === PlayerStatus.PAUSED) {\n                        fadeAndSetStatus(volume, 0, PLAY_PAUSE_FADE_DURATION, PlayerStatus.PAUSED);\n                    } else if (status === PlayerStatus.PLAYING) {\n                        fadeAndSetStatus(0, volume, PLAY_PAUSE_FADE_DURATION, PlayerStatus.PLAYING);\n                    }\n                } else {\n                    if (status === PlayerStatus.PAUSED) {\n                        playerRef.current?.setVolume(0);\n                        setLocalPlayerStatus(PlayerStatus.PAUSED);\n                    } else if (status === PlayerStatus.PLAYING) {\n                        playerRef.current?.setVolume(volume);\n                        setLocalPlayerStatus(PlayerStatus.PLAYING);\n                    }\n                }\n            },\n            onPlayerVolume: (properties) => {\n                const volume = properties.volume;\n                playerRef.current?.setVolume(volume);\n            },\n            onQueueCleared: () => {\n                player.mediaStop();\n            },\n        },\n        [volume, fadeAndSetStatus, audioFadeOnStatusChange],\n    );\n\n    // Cleanup fade interval on unmount\n    useEffect(() => {\n        return () => {\n            if (fadeIntervalRef.current) {\n                clearInterval(fadeIntervalRef.current);\n                fadeIntervalRef.current = null;\n            }\n        };\n    }, []);\n\n    const hasCurrentSong = !!currentSong?.id;\n\n    useEffect(() => {\n        if (localPlayerStatus !== PlayerStatus.PLAYING || !hasCurrentSong) {\n            return;\n        }\n\n        const interval = setInterval(async () => {\n            if (!mpvPlayer) {\n                return;\n            }\n\n            try {\n                const time = await mpvPlayer.getCurrentTime();\n                if (time !== undefined) {\n                    setTimestamp(Number(time.toFixed(0)));\n                }\n            } catch {\n                // Do nothing\n            }\n        }, 500);\n\n        return () => clearInterval(interval);\n    }, [hasCurrentSong, localPlayerStatus, setTimestamp]);\n\n    return (\n        <MpvPlayerEngine\n            isMuted={isMuted}\n            isTransitioning={isTransitioning}\n            onEnded={handleOnEnded}\n            onProgress={onProgress}\n            playerRef={playerRef}\n            playerStatus={localPlayerStatus}\n            speed={speed}\n            volume={volume}\n        />\n    );\n}\n"
  },
  {
    "path": "src/renderer/features/player/audio-player/types.ts",
    "content": "export interface AudioPlayer {\n    decreaseVolume(by: number): void;\n    increaseVolume(by: number): void;\n    pause(): void;\n    play(): void;\n    seekTo(seekTo: number): void;\n    setVolume(volume: number): void;\n}\n\nexport interface PlayerOnProgressProps {\n    played: number;\n    playedSeconds: number;\n}\n"
  },
  {
    "path": "src/renderer/features/player/audio-player/utils/list-handlers.ts",
    "content": "import type { Dispatch } from 'react';\n\nimport { CrossfadeStyle } from '/@/shared/types/types';\n\nexport const gaplessHandler = (args: {\n    currentTime: number;\n    duration: number;\n    isFlac: boolean;\n    isTransitioning: boolean;\n    nextPlayerRef: any;\n    setIsTransitioning: Dispatch<boolean>;\n}) => {\n    const { currentTime, duration, isFlac, isTransitioning, nextPlayerRef, setIsTransitioning } =\n        args;\n\n    if (!isTransitioning) {\n        if (currentTime > duration - 2) {\n            return setIsTransitioning(true);\n        }\n\n        return null;\n    }\n\n    const durationPadding = isFlac ? 0.065 : 0.116;\n    if (currentTime + durationPadding >= duration) {\n        return nextPlayerRef.current\n            .getInternalPlayer()\n            ?.play()\n            .catch(() => {});\n    }\n\n    return null;\n};\n\nexport const crossfadeHandler = (args: {\n    currentPlayer: 1 | 2;\n    currentPlayerRef: any;\n    currentTime: number;\n    duration: number;\n    fadeDuration: number;\n    fadeType: CrossfadeStyle;\n    isTransitioning: boolean;\n    nextPlayerRef: any;\n    player: 1 | 2;\n    setIsTransitioning: Dispatch<boolean>;\n    volume: number;\n}) => {\n    const {\n        currentPlayer,\n        currentPlayerRef,\n        currentTime,\n        duration,\n        fadeDuration,\n        fadeType,\n        isTransitioning,\n        nextPlayerRef,\n        player,\n        setIsTransitioning,\n        volume,\n    } = args;\n\n    if (!isTransitioning || currentPlayer !== player) {\n        // check for a large-enough duration, as the default audio element has some dummy audio\n        const shouldBeginTransition = duration > 0.5 && currentTime >= duration - fadeDuration;\n\n        if (shouldBeginTransition) {\n            setIsTransitioning(true);\n            return nextPlayerRef.current\n                .getInternalPlayer()\n                ?.play()\n                .catch(() => {});\n        }\n        return null;\n    }\n\n    const timeLeft = duration - currentTime;\n    let currentPlayerVolumeCalculation;\n    let nextPlayerVolumeCalculation;\n    let percentageOfFadeLeft;\n    let n;\n    switch (fadeType) {\n        case 'dipped':\n            // https://math.stackexchange.com/a/4622\n            percentageOfFadeLeft = timeLeft / fadeDuration;\n            currentPlayerVolumeCalculation = percentageOfFadeLeft ** 2 * volume;\n            nextPlayerVolumeCalculation = (percentageOfFadeLeft - 1) ** 2 * volume;\n            break;\n        case 'equalPower':\n            // https://dsp.stackexchange.com/a/14755\n            percentageOfFadeLeft = (timeLeft / fadeDuration) * 2;\n            currentPlayerVolumeCalculation = Math.sqrt(0.5 * percentageOfFadeLeft) * volume;\n            nextPlayerVolumeCalculation = Math.sqrt(0.5 * (2 - percentageOfFadeLeft)) * volume;\n            break;\n        case fadeType.match(/constantPower.*/)?.input:\n            // https://math.stackexchange.com/a/26159\n            n =\n                fadeType === 'constantPower'\n                    ? 0\n                    : fadeType === 'constantPowerSlowFade'\n                      ? 1\n                      : fadeType === 'constantPowerSlowCut'\n                        ? 3\n                        : 10;\n\n            percentageOfFadeLeft = timeLeft / fadeDuration;\n            currentPlayerVolumeCalculation =\n                Math.cos((Math.PI / 4) * ((2 * percentageOfFadeLeft - 1) ** (2 * n + 1) - 1)) *\n                volume;\n            nextPlayerVolumeCalculation =\n                Math.cos((Math.PI / 4) * ((2 * percentageOfFadeLeft - 1) ** (2 * n + 1) + 1)) *\n                volume;\n            break;\n        case 'linear':\n            currentPlayerVolumeCalculation = (timeLeft / fadeDuration) * volume;\n            nextPlayerVolumeCalculation = ((fadeDuration - timeLeft) / fadeDuration) * volume;\n            break;\n\n        default:\n            currentPlayerVolumeCalculation = (timeLeft / fadeDuration) * volume;\n            nextPlayerVolumeCalculation = ((fadeDuration - timeLeft) / fadeDuration) * volume;\n            break;\n    }\n\n    const currentPlayerVolume =\n        currentPlayerVolumeCalculation >= 0 ? currentPlayerVolumeCalculation : 0;\n\n    const nextPlayerVolume =\n        nextPlayerVolumeCalculation <= volume ? nextPlayerVolumeCalculation : volume;\n\n    if (currentPlayer === 1) {\n        currentPlayerRef.current.getInternalPlayer().volume = currentPlayerVolume;\n        nextPlayerRef.current.getInternalPlayer().volume = nextPlayerVolume;\n    } else {\n        currentPlayerRef.current.getInternalPlayer().volume = currentPlayerVolume;\n        nextPlayerRef.current.getInternalPlayer().volume = nextPlayerVolume;\n    }\n    // }\n\n    return null;\n};\n"
  },
  {
    "path": "src/renderer/features/player/audio-player/utils/player-utils.ts",
    "content": "export const convertToLogVolume = (linearVolume: number) => {\n    return Math.pow(linearVolume, 2.0);\n};\n"
  },
  {
    "path": "src/renderer/features/player/audio-player/wavesurfer-player.tsx",
    "content": "import type { Dispatch } from 'react';\nimport type WaveSurfer from 'wavesurfer.js';\n\nimport { useCallback, useEffect, useRef, useState } from 'react';\n\nimport {\n    WaveSurferPlayerEngine,\n    WaveSurferPlayerEngineHandle,\n} from '/@/renderer/features/player/audio-player/engine/wavesurfer-player-engine';\nimport { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';\nimport { useSongUrl } from '/@/renderer/features/player/audio-player/hooks/use-stream-url';\nimport { PlayerOnProgressProps } from '/@/renderer/features/player/audio-player/types';\nimport {\n    usePlaybackSettings,\n    usePlayerActions,\n    usePlayerData,\n    usePlayerMuted,\n    usePlayerProperties,\n    usePlayerVolume,\n} from '/@/renderer/store';\nimport { PlayerStatus, PlayerStyle } from '/@/shared/types/types';\n\nconst PLAY_PAUSE_FADE_DURATION = 300;\nconst PLAY_PAUSE_FADE_INTERVAL = 10;\n\nexport function WaveSurferPlayer() {\n    const playerRef = useRef<null | WaveSurferPlayerEngineHandle>(null);\n    const { num, player1, player2, status } = usePlayerData();\n    const { mediaAutoNext, setTimestamp } = usePlayerActions();\n    const { crossfadeDuration, speed, transitionType } = usePlayerProperties();\n    const isMuted = usePlayerMuted();\n    const volume = usePlayerVolume();\n    const { transcode } = usePlaybackSettings();\n\n    const [localPlayerStatus, setLocalPlayerStatus] = useState<PlayerStatus>(status);\n    const [isTransitioning, setIsTransitioning] = useState<boolean | string>(false);\n\n    const fadeAndSetStatus = useCallback(\n        async (startVolume: number, endVolume: number, duration: number, status: PlayerStatus) => {\n            if (isTransitioning) {\n                return setLocalPlayerStatus(status);\n            }\n\n            const steps = duration / PLAY_PAUSE_FADE_INTERVAL;\n            const volumeStep = (endVolume - startVolume) / steps;\n            let currentStep = 0;\n\n            const promise = new Promise((resolve) => {\n                const interval = setInterval(() => {\n                    currentStep++;\n                    const newVolume = startVolume + volumeStep * currentStep;\n\n                    playerRef.current?.setVolume(newVolume);\n\n                    if (currentStep >= steps) {\n                        clearInterval(interval);\n                        setIsTransitioning(false);\n                        resolve(true);\n                    }\n                }, PLAY_PAUSE_FADE_INTERVAL);\n            });\n\n            if (status === PlayerStatus.PAUSED) {\n                await promise;\n                setLocalPlayerStatus(status);\n            } else if (status === PlayerStatus.PLAYING) {\n                setLocalPlayerStatus(status);\n                await promise;\n            }\n        },\n        [isTransitioning],\n    );\n\n    const onProgressPlayer1 = useCallback(\n        (e: PlayerOnProgressProps) => {\n            if (!playerRef.current?.player1()) {\n                return;\n            }\n\n            switch (transitionType) {\n                case PlayerStyle.CROSSFADE:\n                    crossfadeHandler({\n                        crossfadeDuration: crossfadeDuration,\n                        currentPlayer: playerRef.current.player1(),\n                        currentPlayerNum: num,\n                        currentTime: e.playedSeconds,\n                        duration: getDuration(playerRef.current.player1().ref),\n                        hasNextSong: Boolean(player2),\n                        isTransitioning,\n                        nextPlayer: playerRef.current.player2(),\n                        playerNum: 1,\n                        setIsTransitioning,\n                        volume,\n                    });\n                    break;\n                case PlayerStyle.GAPLESS:\n                    gaplessHandler({\n                        currentTime: e.playedSeconds,\n                        duration: getDuration(playerRef.current.player1().ref),\n                        isFlac: false,\n                        isTransitioning,\n                        nextPlayer: playerRef.current.player2(),\n                        setIsTransitioning,\n                    });\n                    break;\n            }\n        },\n        [crossfadeDuration, isTransitioning, num, player2, transitionType, volume],\n    );\n\n    const onProgressPlayer2 = useCallback(\n        (e: PlayerOnProgressProps) => {\n            if (!playerRef.current?.player2()) {\n                return;\n            }\n\n            switch (transitionType) {\n                case PlayerStyle.CROSSFADE:\n                    crossfadeHandler({\n                        crossfadeDuration: crossfadeDuration,\n                        currentPlayer: playerRef.current.player2(),\n                        currentPlayerNum: num,\n                        currentTime: e.playedSeconds,\n                        duration: getDuration(playerRef.current.player2().ref),\n                        hasNextSong: Boolean(player1),\n                        isTransitioning,\n                        nextPlayer: playerRef.current.player1(),\n                        playerNum: 2,\n                        setIsTransitioning,\n                        volume,\n                    });\n                    break;\n                case PlayerStyle.GAPLESS:\n                    gaplessHandler({\n                        currentTime: e.playedSeconds,\n                        duration: getDuration(playerRef.current.player2().ref),\n                        isFlac: false,\n                        isTransitioning,\n                        nextPlayer: playerRef.current.player1(),\n                        setIsTransitioning,\n                    });\n                    break;\n            }\n        },\n        [crossfadeDuration, isTransitioning, num, player1, transitionType, volume],\n    );\n\n    const handleOnEndedPlayer1 = useCallback(() => {\n        const promise = new Promise((resolve) => {\n            mediaAutoNext();\n            resolve(true);\n        });\n\n        promise.then(() => {\n            playerRef.current?.player1()?.ref?.pause();\n            playerRef.current?.setVolume(volume);\n            setIsTransitioning(false);\n        });\n    }, [mediaAutoNext, volume]);\n\n    const handleOnEndedPlayer2 = useCallback(() => {\n        const promise = new Promise((resolve) => {\n            mediaAutoNext();\n            resolve(true);\n        });\n\n        promise.then(() => {\n            playerRef.current?.player2()?.ref?.pause();\n            playerRef.current?.setVolume(volume);\n            setIsTransitioning(false);\n        });\n    }, [mediaAutoNext, volume]);\n\n    usePlayerEvents(\n        {\n            onPlayerSeekToTimestamp: (properties) => {\n                const timestamp = properties.timestamp;\n                const activePlayer =\n                    num === 1 ? playerRef.current?.player1() : playerRef.current?.player2();\n                const wavesurfer = activePlayer?.ref;\n\n                if (wavesurfer) {\n                    const duration = wavesurfer.getDuration();\n                    if (duration > 0) {\n                        // Convert timestamp to ratio (0-1) for wavesurfer\n                        const ratio = timestamp / duration;\n                        wavesurfer.seekTo(ratio);\n                    }\n                }\n            },\n            onPlayerStatus: async (properties) => {\n                const status = properties.status;\n                if (status === PlayerStatus.PAUSED) {\n                    fadeAndSetStatus(volume, 0, PLAY_PAUSE_FADE_DURATION, PlayerStatus.PAUSED);\n                } else if (status === PlayerStatus.PLAYING) {\n                    fadeAndSetStatus(0, volume, PLAY_PAUSE_FADE_DURATION, PlayerStatus.PLAYING);\n                }\n            },\n            onPlayerVolume: (properties) => {\n                const volume = properties.volume;\n                playerRef.current?.setVolume(volume);\n            },\n        },\n        [volume, num, isTransitioning],\n    );\n\n    useEffect(() => {\n        if (localPlayerStatus !== PlayerStatus.PLAYING) {\n            return;\n        }\n\n        const interval = setInterval(() => {\n            const activePlayer =\n                num === 1 ? playerRef.current?.player1() : playerRef.current?.player2();\n            const wavesurfer = activePlayer?.ref;\n\n            if (!wavesurfer) {\n                return;\n            }\n\n            const currentTime = wavesurfer.getCurrentTime();\n\n            if (\n                transitionType === PlayerStyle.CROSSFADE ||\n                transitionType === PlayerStyle.GAPLESS\n            ) {\n                setTimestamp(Number(currentTime.toFixed(0)));\n            }\n        }, 500);\n\n        return () => clearInterval(interval);\n    }, [localPlayerStatus, num, setTimestamp, transitionType]);\n\n    const player1Url = useSongUrl(player1, num === 1, transcode);\n    const player2Url = useSongUrl(player2, num === 2, transcode);\n\n    return (\n        <WaveSurferPlayerEngine\n            isMuted={isMuted}\n            isTransitioning={Boolean(isTransitioning)}\n            onEndedPlayer1={handleOnEndedPlayer1}\n            onEndedPlayer2={handleOnEndedPlayer2}\n            onProgressPlayer1={onProgressPlayer1}\n            onProgressPlayer2={onProgressPlayer2}\n            playerNum={num}\n            playerRef={playerRef}\n            playerStatus={localPlayerStatus}\n            speed={speed}\n            src1={player1Url}\n            src2={player2Url}\n            volume={volume}\n        />\n    );\n}\n\nfunction crossfadeHandler(args: {\n    crossfadeDuration: number;\n    currentPlayer: {\n        ref: null | WaveSurfer;\n        setVolume: (volume: number) => void;\n    };\n    currentPlayerNum: number;\n    currentTime: number;\n    duration: number;\n    hasNextSong: boolean;\n    isTransitioning: boolean | string;\n    nextPlayer: {\n        ref: null | WaveSurfer;\n        setVolume: (volume: number) => void;\n    };\n    playerNum: number;\n    setIsTransitioning: Dispatch<boolean | string>;\n    volume: number;\n}) {\n    const {\n        crossfadeDuration,\n        currentPlayer,\n        currentPlayerNum,\n        currentTime,\n        duration,\n        hasNextSong,\n        isTransitioning,\n        nextPlayer,\n        playerNum,\n        setIsTransitioning,\n        volume,\n    } = args;\n    const player = `player${playerNum}`;\n\n    // If there is no next song queued, don't begin or continue a transition\n    if (!hasNextSong) {\n        currentPlayer.setVolume(volume);\n        nextPlayer.setVolume(0);\n        nextPlayer.ref?.pause();\n\n        if (isTransitioning) {\n            setIsTransitioning(false);\n        }\n        return;\n    }\n\n    if (!isTransitioning) {\n        if (duration > 0 && currentTime > duration - crossfadeDuration) {\n            nextPlayer.setVolume(0);\n            nextPlayer.ref?.play();\n            return setIsTransitioning(player);\n        }\n\n        return;\n    }\n\n    if (isTransitioning !== player && currentPlayerNum !== playerNum) {\n        return;\n    }\n\n    const timeLeft = duration - currentTime;\n\n    // Calculate the volume levels based on time remaining\n    const currentPlayerVolume = (timeLeft / crossfadeDuration) * volume;\n    const nextPlayerVolume = ((crossfadeDuration - timeLeft) / crossfadeDuration) * volume;\n\n    // Set volumes for both players\n    currentPlayer.setVolume(currentPlayerVolume);\n    nextPlayer.setVolume(nextPlayerVolume);\n}\n\nfunction gaplessHandler(args: {\n    currentTime: number;\n    duration: number;\n    isFlac: boolean;\n    isTransitioning: boolean | string;\n    nextPlayer: {\n        ref: null | WaveSurfer;\n        setVolume: (volume: number) => void;\n    };\n    setIsTransitioning: Dispatch<boolean | string>;\n}) {\n    const { currentTime, duration, isFlac, isTransitioning, nextPlayer, setIsTransitioning } = args;\n\n    if (!isTransitioning) {\n        if (currentTime > duration - 2) {\n            return setIsTransitioning(true);\n        }\n\n        return null;\n    }\n\n    const durationPadding = getDurationPadding(isFlac);\n\n    if (currentTime + durationPadding >= duration) {\n        return nextPlayer.ref?.play().catch(() => {});\n    }\n\n    return null;\n}\n\nfunction getDuration(ref: null | undefined | WaveSurfer) {\n    return ref?.getDuration() || 0;\n}\n\nfunction getDurationPadding(isFlac: boolean) {\n    switch (isFlac) {\n        case false:\n            return 0.116;\n        case true:\n            return 0.065;\n    }\n}\n"
  },
  {
    "path": "src/renderer/features/player/audio-player/web-player.tsx",
    "content": "import type { Dispatch } from 'react';\nimport type ReactPlayer from 'react-player';\n\nimport { useCallback, useEffect, useRef, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport {\n    WebPlayerEngine,\n    WebPlayerEngineHandle,\n} from '/@/renderer/features/player/audio-player/engine/web-player-engine';\nimport { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';\nimport { useSongUrl } from '/@/renderer/features/player/audio-player/hooks/use-stream-url';\nimport { PlayerOnProgressProps } from '/@/renderer/features/player/audio-player/types';\nimport { usePlayer } from '/@/renderer/features/player/context/player-context';\nimport { useWebAudio } from '/@/renderer/features/player/hooks/use-webaudio';\nimport {\n    useMpvSettings,\n    usePlaybackSettings,\n    usePlayerActions,\n    usePlayerData,\n    usePlayerMuted,\n    usePlayerProperties,\n    usePlayerStoreBase,\n    usePlayerVolume,\n} from '/@/renderer/store';\nimport { toast } from '/@/shared/components/toast/toast';\nimport { QueueSong } from '/@/shared/types/domain-types';\nimport { CrossfadeStyle, PlayerStatus, PlayerStyle } from '/@/shared/types/types';\n\nconst PLAY_PAUSE_FADE_DURATION = 300;\nconst PLAY_PAUSE_FADE_INTERVAL = 10;\n\nexport function WebPlayer() {\n    const playerRef = useRef<null | WebPlayerEngineHandle>(null);\n    const { t } = useTranslation();\n    const { num, player1, player2, status } = usePlayerData();\n    const { mediaAutoNext, mediaPause, setTimestamp } = usePlayerActions();\n    const playback = useMpvSettings();\n    const { webAudio } = useWebAudio();\n\n    const { crossfadeDuration, crossfadeStyle, speed, transitionType } = usePlayerProperties();\n    const isMuted = usePlayerMuted();\n    const volume = usePlayerVolume();\n    const { audioFadeOnStatusChange, preservePitch, transcode } = usePlaybackSettings();\n\n    const [localPlayerStatus, setLocalPlayerStatus] = useState<PlayerStatus>(status);\n    const [isTransitioning, setIsTransitioning] = useState<boolean | string>(false);\n    const fadeIntervalRef = useRef<NodeJS.Timeout | null>(null);\n\n    const [player1Source, setPlayer1Source] = useState<MediaElementAudioSourceNode | null>(null);\n    const [player2Source, setPlayer2Source] = useState<MediaElementAudioSourceNode | null>(null);\n\n    const fadeAndSetStatus = useCallback(\n        async (startVolume: number, endVolume: number, duration: number, status: PlayerStatus) => {\n            // Cancel any in-progress fade\n            if (fadeIntervalRef.current) {\n                clearInterval(fadeIntervalRef.current);\n                fadeIntervalRef.current = null;\n            }\n\n            // Set initial volume immediately to ensure we start from the correct position\n            // This is especially important when cancelling a previous fade\n            playerRef.current?.setVolume(startVolume);\n\n            const steps = duration / PLAY_PAUSE_FADE_INTERVAL;\n            const volumeStep = (endVolume - startVolume) / steps;\n            let currentStep = 0;\n\n            const promise = new Promise<void>((resolve) => {\n                fadeIntervalRef.current = setInterval(() => {\n                    currentStep++;\n                    const newVolume = startVolume + volumeStep * currentStep;\n\n                    playerRef.current?.setVolume(newVolume);\n\n                    if (currentStep >= steps) {\n                        if (fadeIntervalRef.current) {\n                            clearInterval(fadeIntervalRef.current);\n                            fadeIntervalRef.current = null;\n                        }\n                        // Ensure final volume is exactly the target\n                        playerRef.current?.setVolume(endVolume);\n                        resolve();\n                    }\n                }, PLAY_PAUSE_FADE_INTERVAL);\n            });\n\n            if (status === PlayerStatus.PAUSED) {\n                await promise;\n                setLocalPlayerStatus(status);\n                playerRef.current?.setVolume(startVolume);\n            } else if (status === PlayerStatus.PLAYING) {\n                setLocalPlayerStatus(status);\n                await promise;\n            }\n        },\n        [],\n    );\n\n    const onProgressPlayer1 = useCallback(\n        (e: PlayerOnProgressProps) => {\n            if (!playerRef.current?.player1()) {\n                return;\n            }\n\n            switch (transitionType) {\n                case PlayerStyle.CROSSFADE:\n                    crossfadeHandler({\n                        crossfadeDuration: crossfadeDuration,\n                        crossfadeStyle,\n                        currentPlayer: playerRef.current.player1(),\n                        currentPlayerNum: num,\n                        currentTime: e.playedSeconds,\n                        duration: getDuration(playerRef.current.player1().ref),\n                        hasNextSong: Boolean(player2),\n                        isTransitioning,\n                        nextPlayer: playerRef.current.player2(),\n                        playerNum: 1,\n                        setIsTransitioning,\n                        volume,\n                    });\n                    break;\n                case PlayerStyle.GAPLESS:\n                    gaplessHandler({\n                        currentTime: e.playedSeconds,\n                        duration: getDuration(playerRef.current.player1().ref),\n                        isFlac: false,\n                        isTransitioning,\n                        nextPlayer: playerRef.current.player2(),\n                        setIsTransitioning,\n                    });\n                    break;\n            }\n        },\n        [crossfadeDuration, crossfadeStyle, isTransitioning, num, player2, transitionType, volume],\n    );\n\n    const onProgressPlayer2 = useCallback(\n        (e: PlayerOnProgressProps) => {\n            if (!playerRef.current?.player2()) {\n                return;\n            }\n\n            switch (transitionType) {\n                case PlayerStyle.CROSSFADE:\n                    crossfadeHandler({\n                        crossfadeDuration: crossfadeDuration,\n                        crossfadeStyle,\n                        currentPlayer: playerRef.current.player2(),\n                        currentPlayerNum: num,\n                        currentTime: e.playedSeconds,\n                        duration: getDuration(playerRef.current.player2().ref),\n                        hasNextSong: Boolean(player1),\n                        isTransitioning,\n                        nextPlayer: playerRef.current.player1(),\n                        playerNum: 2,\n                        setIsTransitioning,\n                        volume,\n                    });\n                    break;\n                case PlayerStyle.GAPLESS:\n                    gaplessHandler({\n                        currentTime: e.playedSeconds,\n                        duration: getDuration(playerRef.current.player2().ref),\n                        isFlac: false,\n                        isTransitioning,\n                        nextPlayer: playerRef.current.player1(),\n                        setIsTransitioning,\n                    });\n                    break;\n            }\n        },\n        [crossfadeDuration, crossfadeStyle, isTransitioning, num, player1, transitionType, volume],\n    );\n\n    const handleOnEndedPlayer1 = useCallback(() => {\n        const promise = new Promise((resolve) => {\n            mediaAutoNext();\n            resolve(true);\n        });\n\n        promise.then(() => {\n            playerRef.current?.player1()?.ref?.getInternalPlayer().pause();\n\n            // If mediaAutoNext resulted in a paused state (e.g. end of queue,\n            // or pauseOnNextSongEnd flag), stop all audio instead of restoring volume.\n            const currentStatus = usePlayerStoreBase.getState().player.status;\n            if (currentStatus === PlayerStatus.PAUSED) {\n                playerRef.current?.pause();\n            } else {\n                playerRef.current?.setVolume(volume);\n            }\n            setIsTransitioning(false);\n        });\n    }, [mediaAutoNext, volume]);\n\n    const handleOnEndedPlayer2 = useCallback(() => {\n        const promise = new Promise((resolve) => {\n            mediaAutoNext();\n            resolve(true);\n        });\n\n        promise.then(() => {\n            playerRef.current?.player2()?.ref?.getInternalPlayer().pause();\n\n            const currentStatus = usePlayerStoreBase.getState().player.status;\n            if (currentStatus === PlayerStatus.PAUSED) {\n                playerRef.current?.pause();\n            } else {\n                playerRef.current?.setVolume(volume);\n            }\n            setIsTransitioning(false);\n        });\n    }, [mediaAutoNext, volume]);\n\n    const player = usePlayer();\n\n    usePlayerEvents(\n        {\n            onCurrentSongChange: () => {\n                setIsTransitioning(false);\n            },\n            onPlayerSeekToTimestamp: (properties) => {\n                setIsTransitioning(false);\n\n                const timestamp = properties.timestamp;\n\n                // Reset transition state if seeking during a crossfade transition\n                if (isTransitioning && transitionType === PlayerStyle.CROSSFADE) {\n                    setIsTransitioning(false);\n\n                    if (num === 1) {\n                        playerRef.current?.player1()?.setVolume(volume);\n                        playerRef.current?.player2()?.setVolume(0);\n                        playerRef.current?.player2()?.ref?.getInternalPlayer()?.pause();\n                    } else {\n                        playerRef.current?.player2()?.setVolume(volume);\n                        playerRef.current?.player1()?.setVolume(0);\n                        playerRef.current?.player1()?.ref?.getInternalPlayer()?.pause();\n                    }\n                }\n\n                if (num === 1) {\n                    playerRef.current?.player1()?.ref?.seekTo(timestamp);\n                } else {\n                    playerRef.current?.player2()?.ref?.seekTo(timestamp);\n                }\n            },\n            onPlayerStatus: async (properties) => {\n                setIsTransitioning(false);\n\n                const status = properties.status;\n\n                // Reset crossfade transition if paused during a crossfade transition\n                if (\n                    status === PlayerStatus.PAUSED &&\n                    isTransitioning &&\n                    transitionType === PlayerStyle.CROSSFADE\n                ) {\n                    if (num === 1) {\n                        playerRef.current?.player1()?.setVolume(volume);\n                        playerRef.current?.player2()?.setVolume(0);\n                        playerRef.current?.player2()?.ref?.getInternalPlayer()?.pause();\n                    } else {\n                        playerRef.current?.player2()?.setVolume(volume);\n                        playerRef.current?.player1()?.setVolume(0);\n                        playerRef.current?.player1()?.ref?.getInternalPlayer()?.pause();\n                    }\n                }\n\n                if (audioFadeOnStatusChange) {\n                    if (status === PlayerStatus.PAUSED) {\n                        fadeAndSetStatus(volume, 0, PLAY_PAUSE_FADE_DURATION, PlayerStatus.PAUSED);\n                    } else if (status === PlayerStatus.PLAYING) {\n                        fadeAndSetStatus(0, volume, PLAY_PAUSE_FADE_DURATION, PlayerStatus.PLAYING);\n                    }\n                } else {\n                    if (status === PlayerStatus.PAUSED) {\n                        playerRef.current?.setVolume(volume);\n                        setLocalPlayerStatus(PlayerStatus.PAUSED);\n                    } else if (status === PlayerStatus.PLAYING) {\n                        playerRef.current?.setVolume(volume);\n                        setLocalPlayerStatus(PlayerStatus.PLAYING);\n                    }\n                }\n            },\n            onPlayerVolume: (properties) => {\n                const volume = properties.volume;\n                playerRef.current?.setVolume(volume);\n            },\n            onQueueCleared: () => {\n                player.mediaStop();\n            },\n        },\n        [volume, num, isTransitioning, transitionType, audioFadeOnStatusChange],\n    );\n\n    // Cleanup fade interval on unmount\n    useEffect(() => {\n        return () => {\n            if (fadeIntervalRef.current) {\n                clearInterval(fadeIntervalRef.current);\n                fadeIntervalRef.current = null;\n            }\n        };\n    }, []);\n\n    useEffect(() => {\n        if (localPlayerStatus !== PlayerStatus.PLAYING) {\n            return;\n        }\n\n        const interval = setInterval(() => {\n            const activePlayer =\n                num === 1 ? playerRef.current?.player1() : playerRef.current?.player2();\n            const internalPlayer =\n                activePlayer?.ref?.getInternalPlayer() as HTMLAudioElement | null;\n\n            if (!internalPlayer) {\n                return;\n            }\n\n            const currentTime = internalPlayer.currentTime;\n\n            if (\n                transitionType === PlayerStyle.CROSSFADE ||\n                transitionType === PlayerStyle.GAPLESS\n            ) {\n                setTimestamp(Number(currentTime.toFixed(0)));\n            }\n        }, 500);\n\n        return () => clearInterval(interval);\n    }, [localPlayerStatus, num, setTimestamp, transitionType]);\n\n    const calculateReplayGain = useCallback(\n        (song: QueueSong): number => {\n            if (playback.replayGainMode === 'no') {\n                return 1;\n            }\n\n            let gain: number | undefined;\n            let peak: number | undefined;\n\n            if (playback.replayGainMode === 'track') {\n                gain = song.gain?.track ?? song.gain?.album;\n                peak = song.peak?.track ?? song.peak?.album;\n            } else {\n                gain = song.gain?.album ?? song.gain?.track;\n                peak = song.peak?.album ?? song.peak?.track;\n            }\n\n            if (gain === undefined) {\n                gain = playback.replayGainFallbackDB;\n\n                if (!gain) {\n                    return 1;\n                }\n            }\n\n            if (peak === undefined) {\n                peak = 1;\n            }\n\n            const preAmp = playback.replayGainPreampDB ?? 0;\n\n            // https://wiki.hydrogenaud.io/index.php?title=ReplayGain_1.0_specification&section=19\n            // Normalized to max gain\n            let expectedGain = 10 ** ((gain + preAmp) / 20);\n\n            // Nothing in the system should allow this. But, in the case that preAmp is a\n            // bad value (not a number, for example), a NaN gain will cause the entire system to panic\n            if (isNaN(expectedGain)) {\n                expectedGain = 1;\n            }\n\n            if (playback.replayGainClip) {\n                return Math.min(expectedGain, 1 / peak);\n            }\n            return expectedGain;\n        },\n        [\n            playback.replayGainClip,\n            playback.replayGainFallbackDB,\n            playback.replayGainMode,\n            playback.replayGainPreampDB,\n        ],\n    );\n\n    useEffect(() => {\n        if (!webAudio) return;\n\n        if (player1 && player1Source && num === 1) {\n            const newGain = calculateReplayGain(player1);\n\n            // This error SHOULD never happen, as calculateReplayGain is expected to\n            // always return a real value. However, to prevent app crash, check this just in case\n            try {\n                webAudio.gains[0].gain.setValueAtTime(Math.max(0, newGain), 0);\n            } catch (error) {\n                console.error('Error setting gain', error);\n            }\n        }\n    }, [calculateReplayGain, num, player1, player1Source, volume, webAudio]);\n\n    useEffect(() => {\n        if (!webAudio) return;\n\n        if (player2 && player2Source && num === 2) {\n            const newGain = calculateReplayGain(player2);\n            try {\n                webAudio.gains[1].gain.setValueAtTime(Math.max(0, newGain), 0);\n            } catch (error) {\n                console.error('Error setting gain', error);\n            }\n        }\n    }, [calculateReplayGain, num, player1, player2Source, player2, volume, webAudio]);\n\n    const player1Url = useSongUrl(player1, num === 1, transcode);\n    const player2Url = useSongUrl(player2, num === 2, transcode);\n\n    const handlePlayer1Start = useCallback(\n        async (player: ReactPlayer) => {\n            if (!webAudio || player1Source) return;\n            if (player1Url) {\n                // This should fire once, only if the source is real (meaning we\n                // saw the dummy source) and the context is not ready\n                if (webAudio.context.state !== 'running') {\n                    await webAudio.context.resume();\n                }\n            }\n\n            const internal = player.getInternalPlayer() as HTMLMediaElement | undefined;\n            if (internal) {\n                const { context, gains } = webAudio;\n                const source = context.createMediaElementSource(internal);\n                source.connect(gains[0]);\n                setPlayer1Source(source);\n            }\n        },\n        [player1Source, player1Url, webAudio],\n    );\n\n    const handlePlayer2Start = useCallback(\n        async (player: ReactPlayer) => {\n            if (!webAudio || player2Source) return;\n            if (player2Url) {\n                if (webAudio.context.state !== 'running') {\n                    await webAudio.context.resume();\n                }\n            }\n\n            const internal = player.getInternalPlayer() as HTMLMediaElement | undefined;\n            if (internal) {\n                const { context, gains } = webAudio;\n                const source = context.createMediaElementSource(internal);\n                source.connect(gains[1]);\n                setPlayer2Source(source);\n            }\n        },\n        [player2Source, player2Url, webAudio],\n    );\n\n    const handleOnErrorPause = useCallback(() => {\n        mediaPause();\n        toast.error({\n            message: t('error.playbackPausedDueToError', { postProcess: 'sentenceCase' }),\n        });\n    }, [mediaPause, t]);\n\n    return (\n        <WebPlayerEngine\n            isMuted={isMuted}\n            isTransitioning={Boolean(isTransitioning)}\n            onEndedPlayer1={handleOnEndedPlayer1}\n            onEndedPlayer2={handleOnEndedPlayer2}\n            onErrorPause={handleOnErrorPause}\n            onProgressPlayer1={onProgressPlayer1}\n            onProgressPlayer2={onProgressPlayer2}\n            onStartedPlayer1={handlePlayer1Start}\n            onStartedPlayer2={handlePlayer2Start}\n            playerNum={num}\n            playerRef={playerRef}\n            playerStatus={localPlayerStatus}\n            preservesPitch={preservePitch}\n            speed={speed}\n            src1={player1Url}\n            src2={player2Url}\n            volume={volume}\n        />\n    );\n}\n\nfunction crossfadeHandler(args: {\n    crossfadeDuration: number;\n    crossfadeStyle: CrossfadeStyle;\n    currentPlayer: {\n        ref: null | ReactPlayer;\n        setVolume: (volume: number) => void;\n    };\n    currentPlayerNum: number;\n    currentTime: number;\n    duration: number;\n    hasNextSong: boolean;\n    isTransitioning: boolean | string;\n    nextPlayer: {\n        ref: null | ReactPlayer;\n        setVolume: (volume: number) => void;\n    };\n    playerNum: number;\n    setIsTransitioning: Dispatch<boolean | string>;\n    volume: number;\n}) {\n    const {\n        crossfadeDuration,\n        crossfadeStyle,\n        currentPlayer,\n        currentPlayerNum,\n        currentTime,\n        duration,\n        hasNextSong,\n        isTransitioning,\n        nextPlayer,\n        playerNum,\n        setIsTransitioning,\n        volume,\n    } = args;\n    const player = `player${playerNum}`;\n\n    // If there is no next song to transition to, ensure we don't enter or stay in a transition\n    if (!hasNextSong) {\n        currentPlayer.setVolume(volume);\n        nextPlayer.setVolume(0);\n        nextPlayer.ref?.getInternalPlayer()?.pause();\n\n        if (isTransitioning) {\n            setIsTransitioning(false);\n        }\n\n        return;\n    }\n\n    if (!isTransitioning) {\n        if (duration > 0 && currentTime > duration - crossfadeDuration) {\n            // Skip pre-starting next player if pauseOnNextSongEnd is set\n            if (usePlayerStoreBase.getState().player.pauseOnNextSongEnd) {\n                return;\n            }\n\n            nextPlayer.setVolume(0);\n            nextPlayer.ref?.getInternalPlayer().play();\n            return setIsTransitioning(player);\n        }\n\n        return;\n    }\n\n    if (isTransitioning !== player && currentPlayerNum !== playerNum) {\n        return;\n    }\n\n    const timeLeft = duration - currentTime;\n\n    const progress = (crossfadeDuration - timeLeft) / crossfadeDuration;\n\n    const { easeIn, easeOut } = getCrossfadeEasing(crossfadeStyle);\n\n    const easedProgressOut = easeOut(progress);\n    const easedProgressIn = easeIn(progress);\n\n    const currentPlayerVolume = (1 - easedProgressOut) * volume;\n    const nextPlayerVolume = easedProgressIn * volume;\n\n    // Set volumes for both players\n    currentPlayer.setVolume(currentPlayerVolume);\n    nextPlayer.setVolume(nextPlayerVolume);\n}\n\n/**\n * Equal power easing - maintains constant power during crossfade\n * Fade in: sin(π/2 * t)\n * Fade out: 1 - cos(π/2 * t) so that (1 - result) = cos(π/2 * t)\n */\nfunction equalPowerEaseIn(t: number): number {\n    const clampedT = Math.max(0, Math.min(1, t));\n    return Math.sin((Math.PI / 2) * clampedT);\n}\n\nfunction equalPowerEaseOut(t: number): number {\n    const clampedT = Math.max(0, Math.min(1, t));\n    return 1 - Math.cos((Math.PI / 2) * clampedT);\n}\n\n/**\n * Exponential easing - natural exponential decay/rise\n * Fade in: 1 - exp(-k * t) where k controls the curve steepness\n * Fade out: exp(-k * t) normalized to go from 1 to 0\n */\nfunction exponentialEaseIn(t: number): number {\n    const clampedT = Math.max(0, Math.min(1, t));\n    const k = 5;\n    return 1 - Math.exp(-k * clampedT);\n}\n\nfunction exponentialEaseOut(t: number): number {\n    const clampedT = Math.max(0, Math.min(1, t));\n    const k = 5;\n    // Exponential decay: exp(-k * t) goes from 1 (at t=0) to exp(-k) (at t=1)\n    // Normalize to go from 1 to 0\n    const startValue = Math.exp(0); // = 1\n    const endValue = Math.exp(-k);\n    return (Math.exp(-k * clampedT) - endValue) / (startValue - endValue);\n}\n\nfunction gaplessHandler(args: {\n    currentTime: number;\n    duration: number;\n    isFlac: boolean;\n    isTransitioning: boolean | string;\n    nextPlayer: {\n        ref: null | ReactPlayer;\n        setVolume: (volume: number) => void;\n    };\n    setIsTransitioning: Dispatch<boolean | string>;\n}) {\n    const { currentTime, duration, isFlac, isTransitioning, nextPlayer, setIsTransitioning } = args;\n\n    if (!isTransitioning) {\n        if (currentTime > duration - 2) {\n            return setIsTransitioning(true);\n        }\n\n        return null;\n    }\n\n    const durationPadding = getDurationPadding(isFlac);\n\n    if (currentTime + durationPadding >= duration) {\n        // Skip pre-starting next player if pauseOnNextSongEnd is set\n        if (usePlayerStoreBase.getState().player.pauseOnNextSongEnd) {\n            return null;\n        }\n\n        return nextPlayer.ref\n            ?.getInternalPlayer()\n            ?.play()\n            .catch(() => {});\n    }\n\n    return null;\n}\n\nfunction getCrossfadeEasing(style: CrossfadeStyle): {\n    easeIn: (t: number) => number;\n    easeOut: (t: number) => number;\n} {\n    switch (style) {\n        case CrossfadeStyle.EQUAL_POWER:\n            return {\n                easeIn: equalPowerEaseIn,\n                easeOut: equalPowerEaseOut,\n            };\n        case CrossfadeStyle.EXPONENTIAL:\n            return {\n                easeIn: exponentialEaseIn,\n                easeOut: exponentialEaseOut,\n            };\n        case CrossfadeStyle.LINEAR:\n            return {\n                easeIn: linearEase,\n                easeOut: linearEase,\n            };\n        case CrossfadeStyle.S_CURVE:\n            return {\n                easeIn: sCurveEase,\n                easeOut: sCurveEase,\n            };\n        // Default to equal power for other styles\n        default:\n            return {\n                easeIn: equalPowerEaseIn,\n                easeOut: equalPowerEaseOut,\n            };\n    }\n}\n\nfunction getDuration(ref: null | ReactPlayer | undefined) {\n    return ref?.getInternalPlayer()?.duration || 0;\n}\n\nfunction getDurationPadding(isFlac: boolean) {\n    switch (isFlac) {\n        case false:\n            return 0.116;\n        case true:\n            return 0.065;\n    }\n}\n\n/**\n * Linear easing - simple linear interpolation\n */\nfunction linearEase(t: number): number {\n    return Math.max(0, Math.min(1, t));\n}\n\n/**\n * S-Curve easing (smoothstep) - smooth S-shaped curve\n * Uses smoothstep function: t²(3 - 2t)\n */\nfunction sCurveEase(t: number): number {\n    const clampedT = Math.max(0, Math.min(1, t));\n    return clampedT * clampedT * (3 - 2 * clampedT);\n}\n"
  },
  {
    "path": "src/renderer/features/player/components/audio-players.tsx",
    "content": "import isElectron from 'is-electron';\nimport { useEffect } from 'react';\n\nimport { eventEmitter } from '/@/renderer/events/event-emitter';\nimport { UserFavoriteEventPayload, UserRatingEventPayload } from '/@/renderer/events/events';\nimport { DiscordRpcHook } from '/@/renderer/features/discord-rpc/use-discord-rpc';\nimport { MainPlayerListenerHook } from '/@/renderer/features/player/audio-player/hooks/use-main-player-listener';\nimport { MpvPlayer } from '/@/renderer/features/player/audio-player/mpv-player';\nimport { WebPlayer } from '/@/renderer/features/player/audio-player/web-player';\nimport { SleepTimerHook } from '/@/renderer/features/player/components/sleep-timer-button';\nimport { AutoDJHook } from '/@/renderer/features/player/hooks/use-auto-dj';\nimport { AutosaveHook } from '/@/renderer/features/player/hooks/use-autosave';\nimport { MediaSessionHook } from '/@/renderer/features/player/hooks/use-media-session';\nimport { MPRISHook } from '/@/renderer/features/player/hooks/use-mpris';\nimport { PlaybackHotkeysHook } from '/@/renderer/features/player/hooks/use-playback-hotkeys';\nimport { PowerSaveBlockerHook } from '/@/renderer/features/player/hooks/use-power-save-blocker';\nimport { QueueRestoreTimestampHook } from '/@/renderer/features/player/hooks/use-queue-restore';\nimport { ScrobbleHook } from '/@/renderer/features/player/hooks/use-scrobble';\nimport { UpdateCurrentSongHook } from '/@/renderer/features/player/hooks/use-update-current-song';\nimport { useWebAudio } from '/@/renderer/features/player/hooks/use-webaudio';\nimport { RadioWebPlayer } from '/@/renderer/features/radio/components/radio-web-player';\nimport {\n    RadioAudioInstanceHook,\n    RadioMetadataHook,\n    useIsRadioActive,\n} from '/@/renderer/features/radio/hooks/use-radio-player';\nimport { RemoteHook } from '/@/renderer/features/remote/hooks/use-remote';\nimport {\n    updateQueueFavorites,\n    updateQueueRatings,\n    useCurrentServerId,\n    usePlaybackSettings,\n    usePlaybackType,\n    useSettingsStoreActions,\n} from '/@/renderer/store';\nimport { toast } from '/@/shared/components/toast/toast';\nimport { LibraryItem } from '/@/shared/types/domain-types';\nimport { PlayerType } from '/@/shared/types/types';\n\nexport const AudioPlayers = () => {\n    const playbackType = usePlaybackType();\n    const serverId = useCurrentServerId();\n    const { resetSampleRate } = useSettingsStoreActions();\n\n    const {\n        audioDeviceId,\n        mpvProperties: { audioSampleRateHz },\n        webAudio,\n    } = usePlaybackSettings();\n    const { setWebAudio, webAudio: audioContext } = useWebAudio();\n\n    return (\n        <>\n            <SleepTimerHook />\n            <ScrobbleHook />\n            <PowerSaveBlockerHook />\n            <DiscordRpcHook />\n            <MPRISHook />\n            <MainPlayerListenerHook />\n            <MediaSessionHook />\n            <PlaybackHotkeysHook />\n            <RemoteHook />\n            <AutoDJHook />\n            <QueueRestoreTimestampHook />\n            <UpdateCurrentSongHook />\n            <RadioAudioInstanceHook />\n            <RadioMetadataHook />\n            <AutosaveHook />\n            <AudioPlayersContent\n                audioContext={audioContext}\n                audioDeviceId={audioDeviceId}\n                audioSampleRateHz={audioSampleRateHz}\n                playbackType={playbackType}\n                resetSampleRate={resetSampleRate}\n                serverId={serverId}\n                setWebAudio={setWebAudio}\n                webAudio={webAudio}\n            />\n        </>\n    );\n};\n\nconst AudioPlayersContent = ({\n    audioContext,\n    audioDeviceId,\n    audioSampleRateHz,\n    playbackType,\n    resetSampleRate,\n    serverId,\n    setWebAudio,\n    webAudio,\n}: {\n    audioContext: ReturnType<typeof useWebAudio>['webAudio'];\n    audioDeviceId: null | string | undefined;\n    audioSampleRateHz: number | undefined;\n    playbackType: PlayerType;\n    resetSampleRate: ReturnType<typeof useSettingsStoreActions>['resetSampleRate'];\n    serverId: null | string;\n    setWebAudio: ReturnType<typeof useWebAudio>['setWebAudio'];\n    webAudio: boolean;\n}) => {\n    const isRadioActive = useIsRadioActive();\n\n    useEffect(() => {\n        if (webAudio && 'AudioContext' in window) {\n            let context: AudioContext;\n\n            try {\n                context = new AudioContext({\n                    latencyHint: 'playback',\n                    sampleRate: audioSampleRateHz || undefined,\n                });\n            } catch (error) {\n                // In practice, this should never be hit because the UI should validate\n                // the range. However, the actual supported range is not guaranteed\n                toast.error({ message: (error as Error).message });\n                context = new AudioContext({ latencyHint: 'playback' });\n                resetSampleRate();\n            }\n\n            const gains = [context.createGain(), context.createGain()];\n            for (const gain of gains) {\n                gain.connect(context.destination);\n            }\n\n            setWebAudio!({ context, gains });\n        }\n\n        // Intentionally ignore the sample rate dependency, as it makes things really messy\n        // eslint-disable-next-line react-hooks/exhaustive-deps\n    }, []);\n\n    useEffect(() => {\n        // Not standard, just used in chromium-based browsers. See\n        // https://developer.chrome.com/blog/audiocontext-setsinkid/.\n\n        if (!isElectron()) {\n            return;\n        }\n\n        if (playbackType !== PlayerType.WEB) {\n            return;\n        }\n\n        if (audioContext && 'setSinkId' in audioContext.context && audioDeviceId) {\n            const setSink = async () => {\n                try {\n                    if (audioContext.context.state !== 'closed') {\n                        await (audioContext.context as any).setSinkId(audioDeviceId);\n                    }\n                } catch (error) {\n                    toast.error({ message: `Error setting sink: ${(error as Error).message}` });\n                }\n            };\n\n            setSink();\n        }\n    }, [audioContext, audioDeviceId, playbackType]);\n\n    // Listen to favorite and rating events to update queue songs\n    useEffect(() => {\n        const handleFavorite = (payload: UserFavoriteEventPayload) => {\n            if (payload.itemType !== LibraryItem.SONG || payload.serverId !== serverId) {\n                return;\n            }\n\n            updateQueueFavorites(payload.id, payload.favorite);\n        };\n\n        const handleRating = (payload: UserRatingEventPayload) => {\n            if (payload.itemType !== LibraryItem.SONG || payload.serverId !== serverId) {\n                return;\n            }\n\n            updateQueueRatings(payload.id, payload.rating);\n        };\n\n        eventEmitter.on('USER_FAVORITE', handleFavorite);\n        eventEmitter.on('USER_RATING', handleRating);\n\n        return () => {\n            eventEmitter.off('USER_FAVORITE', handleFavorite);\n            eventEmitter.off('USER_RATING', handleRating);\n        };\n    }, [serverId]);\n\n    if (isRadioActive && playbackType === PlayerType.LOCAL) {\n        return <MpvPlayer />;\n    }\n\n    if (isRadioActive && playbackType === PlayerType.WEB) {\n        return <RadioWebPlayer />;\n    }\n\n    return (\n        <>\n            {playbackType === PlayerType.WEB && <WebPlayer />}\n            {playbackType === PlayerType.LOCAL && <MpvPlayer />}\n        </>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/player/components/center-controls.module.css",
    "content": ".buttons-container {\n    display: flex;\n    gap: 0.5rem;\n    align-items: center;\n}\n\n.controls-container {\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    height: 35px;\n\n    @media (width < 768px) {\n        .buttons-container {\n            gap: 0;\n        }\n\n        .slider-value-wrapper {\n            display: none;\n        }\n    }\n}\n"
  },
  {
    "path": "src/renderer/features/player/components/center-controls.tsx",
    "content": "import { useTranslation } from 'react-i18next';\n\nimport styles from './center-controls.module.css';\n\nimport { MainPlayButton, PlayerButton } from '/@/renderer/features/player/components/player-button';\nimport { PlayerbarSlider } from '/@/renderer/features/player/components/playerbar-slider';\nimport { openShuffleAllModal } from '/@/renderer/features/player/components/shuffle-all-modal';\nimport { usePlayer } from '/@/renderer/features/player/context/player-context';\nimport {\n    useIsPlayingRadio,\n    useIsRadioActive,\n    useRadioControls,\n    useRadioPlayer,\n} from '/@/renderer/features/radio/hooks/use-radio-player';\nimport {\n    useButtonSize,\n    usePlayerRepeat,\n    usePlayerShuffle,\n    usePlayerSongProperties,\n    usePlayerStatus,\n    useSkipButtons,\n} from '/@/renderer/store';\nimport { Icon } from '/@/shared/components/icon/icon';\nimport { PlayerRepeat, PlayerShuffle, PlayerStatus } from '/@/shared/types/types';\n\nexport const CenterControls = () => {\n    const skip = useSkipButtons();\n\n    const isRadioActive = useIsRadioActive();\n\n    if (isRadioActive) {\n        return (\n            <>\n                <div className={styles.controlsContainer}>\n                    <div className={styles.buttonsContainer}>\n                        <RadioStopButton />\n                        <ShuffleButton disabled={isRadioActive} />\n                        <PreviousButton disabled={isRadioActive} />\n                        {skip?.enabled && <SkipBackwardButton disabled={isRadioActive} />}\n                        <RadioCenterPlayButton />\n                        {skip?.enabled && <SkipForwardButton disabled={isRadioActive} />}\n                        <NextButton disabled={isRadioActive} />\n                        <RepeatButton disabled={isRadioActive} />\n                        <ShuffleAllButton disabled={isRadioActive} />\n                    </div>\n                </div>\n            </>\n        );\n    }\n\n    return (\n        <>\n            <div className={styles.controlsContainer}>\n                <div className={styles.buttonsContainer}>\n                    <StopButton />\n                    <ShuffleButton />\n                    <PreviousButton />\n                    {skip?.enabled && <SkipBackwardButton />}\n                    <CenterPlayButton />\n                    {skip?.enabled && <SkipForwardButton />}\n                    <NextButton />\n                    <RepeatButton />\n                    <ShuffleAllButton />\n                </div>\n            </div>\n            <PlayerbarSlider />\n        </>\n    );\n};\n\nconst RadioCenterPlayButton = ({ disabled }: { disabled?: boolean }) => {\n    const { currentStreamUrl } = useRadioPlayer();\n    const isPlayingRadio = useIsPlayingRadio();\n    const { pause, play } = useRadioControls();\n\n    const handleClick = () => {\n        if (isPlayingRadio) {\n            pause();\n        } else if (currentStreamUrl) {\n            play();\n        }\n    };\n\n    return <MainPlayButton disabled={disabled} isPaused={!isPlayingRadio} onClick={handleClick} />;\n};\n\nconst RadioStopButton = ({ disabled }: { disabled?: boolean }) => {\n    const { t } = useTranslation();\n    const buttonSize = useButtonSize();\n    const { stop } = useRadioControls();\n\n    return (\n        <PlayerButton\n            disabled={disabled}\n            icon={<Icon fill=\"default\" icon=\"mediaStop\" size={buttonSize - 2} />}\n            onClick={stop}\n            tooltip={{\n                label: t('player.stop', { postProcess: 'sentenceCase' }),\n                openDelay: 0,\n            }}\n            variant=\"tertiary\"\n        />\n    );\n};\n\nconst StopButton = ({ disabled }: { disabled?: boolean }) => {\n    const { t } = useTranslation();\n    const buttonSize = useButtonSize();\n    const { mediaStop } = usePlayer();\n\n    return (\n        <PlayerButton\n            disabled={disabled}\n            icon={<Icon fill=\"default\" icon=\"mediaStop\" size={buttonSize - 2} />}\n            onClick={mediaStop}\n            tooltip={{\n                label: t('player.stop', { postProcess: 'sentenceCase' }),\n                openDelay: 0,\n            }}\n            variant=\"tertiary\"\n        />\n    );\n};\n\nconst ShuffleButton = ({ disabled }: { disabled?: boolean }) => {\n    const { t } = useTranslation();\n    const buttonSize = useButtonSize();\n    const shuffle = usePlayerShuffle();\n    const { toggleShuffle } = usePlayer();\n\n    return (\n        <PlayerButton\n            disabled={disabled}\n            icon={\n                <Icon\n                    fill={shuffle === PlayerShuffle.NONE ? 'default' : 'primary'}\n                    icon=\"mediaShuffle\"\n                    size={buttonSize}\n                />\n            }\n            isActive={shuffle !== PlayerShuffle.NONE}\n            onClick={toggleShuffle}\n            tooltip={{\n                label:\n                    shuffle === PlayerShuffle.NONE\n                        ? t('player.shuffle', {\n                              context: 'off',\n                              postProcess: 'sentenceCase',\n                          })\n                        : t('player.shuffle', { postProcess: 'sentenceCase' }),\n                openDelay: 0,\n            }}\n            variant=\"tertiary\"\n        />\n    );\n};\n\nconst PreviousButton = ({ disabled }: { disabled?: boolean }) => {\n    const { t } = useTranslation();\n    const buttonSize = useButtonSize();\n    const { mediaPrevious } = usePlayer();\n\n    return (\n        <PlayerButton\n            disabled={disabled}\n            icon={<Icon fill=\"default\" icon=\"mediaPrevious\" size={buttonSize} />}\n            onClick={mediaPrevious}\n            tooltip={{\n                label: t('player.previous', { postProcess: 'sentenceCase' }),\n                openDelay: 0,\n            }}\n            variant=\"secondary\"\n        />\n    );\n};\n\nconst SkipBackwardButton = ({ disabled }: { disabled?: boolean }) => {\n    const { t } = useTranslation();\n    const buttonSize = useButtonSize();\n    const { mediaSkipBackward } = usePlayer();\n\n    return (\n        <PlayerButton\n            disabled={disabled}\n            icon={<Icon fill=\"default\" icon=\"mediaStepBackward\" size={buttonSize} />}\n            onClick={mediaSkipBackward}\n            tooltip={{\n                label: t('player.skip', {\n                    context: 'back',\n                    postProcess: 'sentenceCase',\n                }),\n                openDelay: 0,\n            }}\n            variant=\"secondary\"\n        />\n    );\n};\n\nconst CenterPlayButton = ({ disabled }: { disabled?: boolean }) => {\n    const { id: currentSongId } = usePlayerSongProperties(['id']) ?? {};\n\n    const status = usePlayerStatus();\n    const { mediaTogglePlayPause } = usePlayer();\n\n    return (\n        <MainPlayButton\n            disabled={disabled || currentSongId === undefined}\n            isPaused={status === PlayerStatus.PAUSED}\n            onClick={mediaTogglePlayPause}\n        />\n    );\n};\n\nconst SkipForwardButton = ({ disabled }: { disabled?: boolean }) => {\n    const { t } = useTranslation();\n    const buttonSize = useButtonSize();\n    const { mediaSkipForward } = usePlayer();\n\n    return (\n        <PlayerButton\n            disabled={disabled}\n            icon={<Icon fill=\"default\" icon=\"mediaStepForward\" size={buttonSize} />}\n            onClick={mediaSkipForward}\n            tooltip={{\n                label: t('player.skip', {\n                    context: 'forward',\n                    postProcess: 'sentenceCase',\n                }),\n                openDelay: 0,\n            }}\n            variant=\"secondary\"\n        />\n    );\n};\n\nconst NextButton = ({ disabled }: { disabled?: boolean }) => {\n    const { t } = useTranslation();\n    const buttonSize = useButtonSize();\n    const { mediaNext } = usePlayer();\n\n    return (\n        <PlayerButton\n            disabled={disabled}\n            icon={<Icon fill=\"default\" icon=\"mediaNext\" size={buttonSize} />}\n            onClick={mediaNext}\n            tooltip={{\n                label: t('player.next', { postProcess: 'sentenceCase' }),\n                openDelay: 0,\n            }}\n            variant=\"secondary\"\n        />\n    );\n};\n\nconst RepeatButton = ({ disabled }: { disabled?: boolean }) => {\n    const { t } = useTranslation();\n    const buttonSize = useButtonSize();\n    const repeat = usePlayerRepeat();\n    const { toggleRepeat } = usePlayer();\n\n    return (\n        <PlayerButton\n            disabled={disabled}\n            icon={\n                repeat === PlayerRepeat.ONE ? (\n                    <Icon fill=\"primary\" icon=\"mediaRepeatOne\" size={buttonSize} />\n                ) : (\n                    <Icon\n                        fill={repeat === PlayerRepeat.NONE ? 'default' : 'primary'}\n                        icon=\"mediaRepeat\"\n                        size={buttonSize}\n                    />\n                )\n            }\n            isActive={repeat !== PlayerRepeat.NONE}\n            onClick={toggleRepeat}\n            tooltip={{\n                label: `${\n                    repeat === PlayerRepeat.NONE\n                        ? t('player.repeat', {\n                              context: 'off',\n                              postProcess: 'sentenceCase',\n                          })\n                        : repeat === PlayerRepeat.ALL\n                          ? t('player.repeat', {\n                                context: 'all',\n                                postProcess: 'sentenceCase',\n                            })\n                          : t('player.repeat', {\n                                context: 'one',\n                                postProcess: 'sentenceCase',\n                            })\n                }`,\n                openDelay: 0,\n            }}\n            variant=\"tertiary\"\n        />\n    );\n};\n\nconst ShuffleAllButton = ({ disabled }: { disabled?: boolean }) => {\n    const { t } = useTranslation();\n    const buttonSize = useButtonSize();\n\n    return (\n        <PlayerButton\n            disabled={disabled}\n            icon={<Icon fill=\"default\" icon=\"mediaRandom\" size={buttonSize} />}\n            onClick={() => openShuffleAllModal()}\n            tooltip={{\n                label: t('form.shuffleAll.title', { postProcess: 'sentenceCase' }),\n                openDelay: 0,\n            }}\n            variant=\"tertiary\"\n        />\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/player/components/full-screen-player-image.module.css",
    "content": ".image {\n    position: absolute;\n    max-width: 100%;\n    height: 100%;\n    border-radius: 5px;\n    filter: drop-shadow(0 0 5px rgb(0 0 0 / 40%)) drop-shadow(0 0 5px rgb(0 0 0 / 40%));\n}\n\n.censored.image {\n    filter: blur(30px);\n}\n\n.image-container {\n    position: relative;\n    display: flex;\n    align-items: flex-end;\n    justify-content: center;\n    max-width: 100%;\n    height: 65%;\n    aspect-ratio: 1/1;\n    margin-bottom: 1rem;\n}\n\n.metadata-container {\n    display: flex;\n    justify-content: center;\n    padding: 1rem;\n    text-align: center;\n    cursor: default;\n    border-radius: 5px;\n\n    a {\n        cursor: pointer;\n    }\n\n    h1 {\n        font-size: 3.5vh;\n    }\n}\n\n.player-container {\n    @media screen and (height < 640px) {\n        .full-screen-player-image-metadata {\n            display: none;\n            height: 100%;\n            margin-bottom: 0;\n        }\n\n        .image-container {\n            height: 100%;\n            margin-bottom: 0;\n        }\n    }\n}\n"
  },
  {
    "path": "src/renderer/features/player/components/full-screen-player-image.tsx",
    "content": "import clsx from 'clsx';\nimport { t } from 'i18next';\nimport { AnimatePresence, HTMLMotionProps, motion, Variants } from 'motion/react';\nimport { Fragment, useEffect, useRef } from 'react';\nimport { generatePath, Link } from 'react-router';\n\nimport styles from './full-screen-player-image.module.css';\n\nimport { useItemImageUrl } from '/@/renderer/components/item-image/item-image';\nimport {\n    useIsRadioActive,\n    useRadioPlayer,\n} from '/@/renderer/features/radio/hooks/use-radio-player';\nimport { AppRoute } from '/@/renderer/router/routes';\nimport {\n    useGeneralSettings,\n    useNativeAspectRatio,\n    usePlayerData,\n    usePlayerSong,\n} from '/@/renderer/store';\nimport { Badge } from '/@/shared/components/badge/badge';\nimport { Center } from '/@/shared/components/center/center';\nimport { Flex } from '/@/shared/components/flex/flex';\nimport { Group } from '/@/shared/components/group/group';\nimport { Icon } from '/@/shared/components/icon/icon';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { Text } from '/@/shared/components/text/text';\nimport { useSetState } from '/@/shared/hooks/use-set-state';\nimport { ExplicitStatus, LibraryItem } from '/@/shared/types/domain-types';\n\nconst imageVariants: Variants = {\n    closed: {\n        opacity: 0,\n        transition: {\n            duration: 0.8,\n            ease: 'linear',\n        },\n    },\n    initial: {\n        opacity: 0,\n    },\n    open: (custom) => {\n        const { isOpen } = custom;\n        return {\n            opacity: isOpen ? 1 : 0,\n            transition: {\n                duration: 0.4,\n                ease: 'linear',\n            },\n        };\n    },\n};\n\nconst MotionImage = motion.img;\n\nconst ImageWithPlaceholder = ({\n    className,\n    explicit,\n    placeholderIcon = 'itemAlbum',\n    ...props\n}: HTMLMotionProps<'img'> & {\n    explicit?: boolean;\n    placeholder?: string;\n    placeholderIcon?: 'itemAlbum' | 'radio';\n}) => {\n    const nativeAspectRatio = useNativeAspectRatio();\n\n    if (!props.src) {\n        return (\n            <Center\n                style={{\n                    background: 'var(--theme-colors-surface)',\n                    borderRadius: 'var(--theme-card-default-radius)',\n                    height: '100%',\n                    width: '100%',\n                }}\n            >\n                <Icon color=\"muted\" icon={placeholderIcon} size=\"25%\" />\n            </Center>\n        );\n    }\n\n    return (\n        <MotionImage\n            className={clsx(styles.image, className, {\n                [styles.censored]: explicit,\n            })}\n            style={{\n                objectFit: nativeAspectRatio ? 'contain' : 'cover',\n                width: nativeAspectRatio ? 'auto' : '100%',\n            }}\n            {...props}\n        />\n    );\n};\n\nexport const FullScreenPlayerImage = () => {\n    const mainImageRef = useRef<HTMLImageElement | null>(null);\n\n    const isRadioActive = useIsRadioActive();\n    const { isPlaying: isRadioPlaying, metadata: radioMetadata, stationName } = useRadioPlayer();\n\n    const currentSong = usePlayerSong();\n    const { nextSong } = usePlayerData();\n    const { blurExplicitImages, playerItems } = useGeneralSettings();\n\n    const isPlayingRadio = isRadioActive && isRadioPlaying;\n\n    const currentImageUrl = useItemImageUrl({\n        id: currentSong?.imageId || undefined,\n        itemType: LibraryItem.SONG,\n        serverId: currentSong?._serverId,\n        type: 'fullScreenPlayer',\n    });\n\n    const nextImageUrl = useItemImageUrl({\n        id: nextSong?.imageId || undefined,\n        itemType: LibraryItem.SONG,\n        serverId: nextSong?._serverId,\n        type: 'fullScreenPlayer',\n    });\n\n    const [imageState, setImageState] = useSetState({\n        bottomExplicit: nextSong?.explicitStatus === ExplicitStatus.EXPLICIT,\n        bottomImage: nextImageUrl,\n        current: 0,\n        topExplicit: currentSong?.explicitStatus === ExplicitStatus.EXPLICIT,\n        topImage: currentImageUrl,\n    });\n\n    // Track previous song to detect changes\n    const previousSongRef = useRef<string | undefined>(currentSong?._uniqueId);\n    const imageStateRef = useRef(imageState);\n\n    // Keep ref in sync\n    useEffect(() => {\n        imageStateRef.current = imageState;\n    }, [imageState]);\n\n    // Update images when song or size changes (skip when playing radio - no album art)\n    useEffect(() => {\n        if (isPlayingRadio) {\n            return;\n        }\n        if (currentSong?._uniqueId === previousSongRef.current) {\n            return;\n        }\n\n        const isTop = imageStateRef.current.current === 0;\n\n        setImageState({\n            bottomExplicit:\n                (isTop ? currentSong?.explicitStatus : nextSong?.explicitStatus) ===\n                ExplicitStatus.EXPLICIT,\n            bottomImage: isTop ? currentImageUrl : nextImageUrl,\n            current: isTop ? 1 : 0,\n            topExplicit:\n                (isTop ? nextSong?.explicitStatus : currentSong?.explicitStatus) ===\n                ExplicitStatus.EXPLICIT,\n            topImage: isTop ? nextImageUrl : currentImageUrl,\n        });\n\n        previousSongRef.current = currentSong?._uniqueId;\n    }, [\n        isPlayingRadio,\n        currentSong?._uniqueId,\n        currentImageUrl,\n        nextSong?._uniqueId,\n        nextImageUrl,\n        setImageState,\n        currentSong?.explicitStatus,\n        nextSong?.explicitStatus,\n    ]);\n\n    const builtDataItems = {\n        bit_depth: currentSong?.bitDepth && <Badge>{currentSong?.bitDepth} bit</Badge>,\n        bit_rate: currentSong?.bitRate && <Badge>{currentSong?.bitRate} kbps</Badge>,\n        bpm: currentSong?.bpm && (\n            <Badge>\n                {currentSong?.bpm} {t('common.bpm')}\n            </Badge>\n        ),\n        codec: currentSong?.container && <Badge>{currentSong?.container}</Badge>,\n        disc_number: currentSong?.discNumber && (\n            <Badge>\n                {t('common.disc')} {currentSong?.discNumber}\n            </Badge>\n        ),\n        genres:\n            currentSong?.genres &&\n            currentSong?.genres\n                .slice(0, 2)\n                .map((genre) => <Badge key={genre.id}>{genre.name}</Badge>),\n        release_date: currentSong?.releaseDate && <Badge>{currentSong?.releaseDate}</Badge>,\n        release_type: currentSong?.tags?.releasetype && (\n            <Badge>{currentSong?.tags?.releasetype[0]}</Badge>\n        ),\n        release_year: currentSong?.releaseYear && <Badge>{currentSong?.releaseYear}</Badge>,\n        sample_rate: currentSong?.sampleRate && <Badge>{currentSong?.sampleRate / 1000} kHz</Badge>,\n        track_number: currentSong?.trackNumber && (\n            <Badge>\n                {t('common.trackNumber')} {currentSong?.trackNumber}\n            </Badge>\n        ),\n    };\n\n    return (\n        <Flex\n            align=\"center\"\n            className={clsx(styles.playerContainer, 'full-screen-player-image-container')}\n            direction=\"column\"\n            justify=\"flex-start\"\n            p=\"1rem\"\n        >\n            <div className={styles.imageContainer} ref={mainImageRef}>\n                <AnimatePresence initial={false} mode=\"sync\">\n                    {!isPlayingRadio && imageState.current === 0 && (\n                        <ImageWithPlaceholder\n                            animate=\"open\"\n                            className=\"full-screen-player-image\"\n                            custom={{ isOpen: imageState.current === 0 }}\n                            draggable={false}\n                            exit=\"closed\"\n                            explicit={blurExplicitImages && imageState.topExplicit}\n                            initial=\"closed\"\n                            key={`top-${currentSong?._uniqueId || 'none'}`}\n                            placeholder=\"var(--theme-colors-foreground-muted)\"\n                            src={imageState.topImage || ''}\n                            variants={imageVariants}\n                        />\n                    )}\n\n                    {!isPlayingRadio && imageState.current === 1 && (\n                        <ImageWithPlaceholder\n                            animate=\"open\"\n                            className=\"full-screen-player-image\"\n                            custom={{ isOpen: imageState.current === 1 }}\n                            draggable={false}\n                            exit=\"closed\"\n                            explicit={blurExplicitImages && imageState.bottomExplicit}\n                            initial=\"closed\"\n                            key={`bottom-${currentSong?._uniqueId || 'none'}`}\n                            placeholder=\"var(--theme-colors-foreground-muted)\"\n                            src={imageState.bottomImage || ''}\n                            variants={imageVariants}\n                        />\n                    )}\n\n                    {isPlayingRadio && (\n                        <ImageWithPlaceholder\n                            animate=\"open\"\n                            className=\"full-screen-player-image\"\n                            custom={{ isOpen: true }}\n                            draggable={false}\n                            exit=\"closed\"\n                            initial=\"closed\"\n                            key=\"radio\"\n                            placeholder=\"var(--theme-colors-foreground-muted)\"\n                            placeholderIcon=\"radio\"\n                            src=\"\"\n                            variants={imageVariants}\n                        />\n                    )}\n                </AnimatePresence>\n            </div>\n            <Stack className={styles.metadataContainer} gap=\"md\" maw=\"100%\">\n                <Text fw={900} lh=\"1.2\" overflow=\"hidden\" size=\"4xl\" w=\"100%\">\n                    {isPlayingRadio\n                        ? radioMetadata?.title || stationName || 'Radio'\n                        : currentSong?.name}\n                </Text>\n                {isPlayingRadio ? (\n                    <Text overflow=\"hidden\" size=\"xl\" w=\"100%\">\n                        {stationName || 'Radio'}\n                    </Text>\n                ) : (\n                    <Text\n                        component={Link}\n                        isLink\n                        overflow=\"hidden\"\n                        size=\"xl\"\n                        to={generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {\n                            albumId: currentSong?.albumId || '',\n                        })}\n                        w=\"100%\"\n                    >\n                        {currentSong?.album}\n                    </Text>\n                )}\n                <Text key=\"fs-artists\">\n                    {isPlayingRadio\n                        ? radioMetadata?.artist || stationName || 'Radio'\n                        : currentSong?.artists?.map((artist, index) => (\n                              <Fragment key={`fs-artist-${artist.id}`}>\n                                  {index > 0 && (\n                                      <Text\n                                          style={{\n                                              display: 'inline-block',\n                                              padding: '0 0.5rem',\n                                          }}\n                                      >\n                                          •\n                                      </Text>\n                                  )}\n                                  <Text\n                                      component={Link}\n                                      isLink\n                                      to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {\n                                          albumArtistId: artist.id,\n                                      })}\n                                  >\n                                      {artist.name}\n                                  </Text>\n                              </Fragment>\n                          ))}\n                </Text>\n                {!isPlayingRadio && (\n                    <Group justify=\"center\" mt=\"sm\">\n                        {playerItems.map((i) => !i.disabled && builtDataItems[i.id])}\n                    </Group>\n                )}\n            </Stack>\n        </Flex>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/player/components/full-screen-player-queue.module.css",
    "content": ".queue-container {\n    position: relative;\n    display: flex;\n    width: 100%;\n    min-width: 0;\n    height: 100%;\n    overflow: hidden;\n\n    :global(.ag-header) {\n        display: none;\n    }\n\n    :global(.ag-theme-alpine-dark) {\n        --ag-header-background-color: rgb(0 0 0 / 0%) !important;\n        --ag-background-color: rgb(0 0 0 / 0%) !important;\n        --ag-odd-row-background-color: rgb(0 0 0 / 0%) !important;\n    }\n\n    :global(.ag-row) {\n        &::before {\n            background: rgb(0 0 0 / 10%) !important;\n            border: none !important;\n        }\n    }\n\n    :global(.ag-row-hover) {\n        background: rgb(0 0 0 / 10%) !important;\n    }\n}\n\n.active-tab-indicator {\n    position: absolute;\n    bottom: 0;\n    left: 0;\n    width: 100%;\n    height: 2px;\n    background: var(--theme-colors-foreground);\n}\n\n.header-item-wrapper {\n    position: relative;\n    z-index: 2;\n    display: flex;\n    gap: 0;\n}\n\n.grid-container {\n    position: relative;\n    display: grid;\n    grid-template-rows: auto minmax(0, 1fr);\n    grid-template-columns: 1fr;\n    width: 100%;\n    min-width: 0;\n    padding: 1rem;\n\n    &::before {\n        position: absolute;\n        top: 0;\n        left: 0;\n        width: 100%;\n        height: 100%;\n        content: '';\n        background: var(--theme-colors-background);\n        border-radius: 5px;\n        opacity: var(--opacity, 1);\n    }\n}\n"
  },
  {
    "path": "src/renderer/features/player/components/full-screen-player-queue.tsx",
    "content": "import clsx from 'clsx';\nimport { motion } from 'motion/react';\nimport { CSSProperties, lazy, Suspense, useMemo } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport styles from './full-screen-player-queue.module.css';\n\nimport { Lyrics } from '/@/renderer/features/lyrics/lyrics';\nimport { PlayQueue } from '/@/renderer/features/now-playing/components/play-queue';\nimport { FullScreenSimilarSongs } from '/@/renderer/features/player/components/full-screen-similar-songs';\nimport { usePlaybackSettings, useSettingsStore } from '/@/renderer/store';\nimport {\n    useFullScreenPlayerStore,\n    useFullScreenPlayerStoreActions,\n} from '/@/renderer/store/full-screen-player.store';\nimport { Button } from '/@/shared/components/button/button';\nimport { Group } from '/@/shared/components/group/group';\nimport { ItemListKey, PlayerType } from '/@/shared/types/types';\n\nconst AudioMotionAnalyzerVisualizer = lazy(() =>\n    import('../../visualizer/components/audiomotionanalyzer/visualizer').then((module) => ({\n        default: module.Visualizer,\n    })),\n);\n\nconst ButterchurnVisualizer = lazy(() =>\n    import('../../visualizer/components/butternchurn/visualizer').then((module) => ({\n        default: module.Visualizer,\n    })),\n);\n\nexport const FullScreenPlayerQueue = () => {\n    const { t } = useTranslation();\n    const { activeTab, opacity } = useFullScreenPlayerStore();\n    const { setStore } = useFullScreenPlayerStoreActions();\n    const { type, webAudio } = usePlaybackSettings();\n    const visualizerType = useSettingsStore((store) => store.visualizer.type);\n\n    const headerItems = useMemo(() => {\n        const items = [\n            {\n                active: activeTab === 'queue',\n                label: t('page.fullscreenPlayer.upNext'),\n                onClick: () => setStore({ activeTab: 'queue' }),\n            },\n            {\n                active: activeTab === 'related',\n                label: t('page.fullscreenPlayer.related'),\n                onClick: () => setStore({ activeTab: 'related' }),\n            },\n            {\n                active: activeTab === 'lyrics',\n                label: t('page.fullscreenPlayer.lyrics'),\n                onClick: () => setStore({ activeTab: 'lyrics' }),\n            },\n        ];\n\n        if (type === PlayerType.WEB && webAudio) {\n            items.push({\n                active: activeTab === 'visualizer',\n                label: t('page.fullscreenPlayer.visualizer', { postProcess: 'titleCase' }),\n                onClick: () => setStore({ activeTab: 'visualizer' }),\n            });\n        }\n\n        return items;\n    }, [activeTab, setStore, t, type, webAudio]);\n\n    return (\n        <div\n            className={clsx(styles.gridContainer, 'full-screen-player-queue-container')}\n            style={\n                {\n                    '--opacity': opacity / 100,\n                } as CSSProperties\n            }\n        >\n            <Group\n                align=\"center\"\n                className=\"full-screen-player-queue-header\"\n                gap={0}\n                grow\n                justify=\"center\"\n                pb=\"md\"\n            >\n                {headerItems.map((item) => (\n                    <div className={styles.headerItemWrapper} key={`tab-${item.label}`}>\n                        <Button\n                            flex={1}\n                            fw=\"600\"\n                            onClick={item.onClick}\n                            pos=\"relative\"\n                            size=\"lg\"\n                            uppercase\n                            variant=\"transparent\"\n                        >\n                            {item.label}\n                        </Button>\n                        {item.active ? (\n                            <motion.div\n                                className={styles.activeTabIndicator}\n                                layoutId=\"underline\"\n                            />\n                        ) : null}\n                    </div>\n                ))}\n            </Group>\n            {activeTab === 'queue' ? (\n                <div className={styles.queueContainer}>\n                    <PlayQueue\n                        enableScrollShadow={false}\n                        listKey={ItemListKey.FULL_SCREEN}\n                        searchTerm={undefined}\n                    />\n                </div>\n            ) : activeTab === 'related' ? (\n                <div className={styles.queueContainer}>\n                    <FullScreenSimilarSongs />\n                </div>\n            ) : activeTab === 'lyrics' ? (\n                <Lyrics fadeOutNoLyricsMessage={false} />\n            ) : activeTab === 'visualizer' && type === PlayerType.WEB && webAudio ? (\n                <Suspense fallback={<></>}>\n                    {visualizerType === 'butterchurn' ? (\n                        <ButterchurnVisualizer />\n                    ) : (\n                        <AudioMotionAnalyzerVisualizer />\n                    )}\n                </Suspense>\n            ) : null}\n        </div>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/player/components/full-screen-player.module.css",
    "content": ".container {\n    position: absolute;\n    top: 0;\n    left: 0;\n    z-index: 200;\n    display: flex;\n    justify-content: center;\n    padding: 2rem;\n\n    @media screen and (orientation: portrait) {\n        padding: 2rem 2rem 1rem;\n    }\n\n    &:hover .controls-container {\n        [data-variant='subtle'] {\n            color: var(--theme-colors-foreground);\n            background: var(--theme-colors-surface);\n            border: 1px solid transparent;\n\n            &:hover,\n            &:active,\n            &:focus-visible {\n                @mixin dark {\n                    background: lighten(var(--theme-colors-surface), 5%);\n                }\n\n                @mixin light {\n                    background: lighten(var(--theme-colors-surface), 5%);\n                }\n            }\n        }\n    }\n}\n\n.responsive-container {\n    display: grid;\n    grid-template-rows: minmax(0, 1fr);\n    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);\n    gap: 2rem;\n    width: 100%;\n    max-width: 2560px;\n    margin-top: 5rem;\n\n    @media screen and (orientation: portrait) {\n        grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);\n        grid-template-columns: minmax(0, 1fr);\n        margin-top: 0;\n    }\n}\n\n.background-image {\n    position: absolute;\n    top: 0;\n    left: 0;\n    z-index: -2;\n    width: 100%;\n    height: 100%;\n    background-repeat: no-repeat;\n    background-position: center;\n    background-size: cover;\n}\n\n.background-image-overlay {\n    position: absolute;\n    top: 0;\n    left: 0;\n    z-index: -1;\n    width: 100%;\n    height: 100%;\n    background: var(--theme-overlay-header);\n    backdrop-filter: blur(var(--image-blur));\n}\n"
  },
  {
    "path": "src/renderer/features/player/components/full-screen-player.tsx",
    "content": "import { AnimatePresence, motion, Variants } from 'motion/react';\nimport {\n    CSSProperties,\n    memo,\n    ReactNode,\n    useEffect,\n    useLayoutEffect,\n    useRef,\n    useState,\n} from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { useLocation } from 'react-router';\n\nimport styles from './full-screen-player.module.css';\n\nimport { useItemImageUrl } from '/@/renderer/components/item-image/item-image';\nimport { SONG_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns';\nimport { FullScreenPlayerImage } from '/@/renderer/features/player/components/full-screen-player-image';\nimport { FullScreenPlayerQueue } from '/@/renderer/features/player/components/full-screen-player-queue';\nimport {\n    useIsRadioActive,\n    useRadioPlayer,\n} from '/@/renderer/features/radio/hooks/use-radio-player';\nimport {\n    ListConfigMenu,\n    SONG_DISPLAY_TYPES,\n} from '/@/renderer/features/shared/components/list-config-menu';\nimport { useFastAverageColor } from '/@/renderer/hooks';\nimport {\n    useFullScreenPlayerStore,\n    useFullScreenPlayerStoreActions,\n    useLyricsDisplaySettings,\n    useLyricsSettings,\n    usePlayerData,\n    usePlayerSong,\n    useSettingsStore,\n    useSettingsStoreActions,\n    useWindowSettings,\n} from '/@/renderer/store';\nimport { ActionIcon } from '/@/shared/components/action-icon/action-icon';\nimport { Divider } from '/@/shared/components/divider/divider';\nimport { Group } from '/@/shared/components/group/group';\nimport { NumberInput } from '/@/shared/components/number-input/number-input';\nimport { Option } from '/@/shared/components/option/option';\nimport { Popover } from '/@/shared/components/popover/popover';\nimport { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control';\nimport { Slider } from '/@/shared/components/slider/slider';\nimport { Switch } from '/@/shared/components/switch/switch';\nimport { useHotkeys } from '/@/shared/hooks/use-hotkeys';\nimport { LibraryItem } from '/@/shared/types/domain-types';\nimport { ItemListKey, ListDisplayType, Platform } from '/@/shared/types/types';\n\nconst mainBackground = 'var(--theme-colors-background)';\n\nconst backgroundImageVariants: Variants = {\n    closed: {\n        opacity: 0,\n        transition: {\n            duration: 0.8,\n            ease: 'linear',\n        },\n    },\n    initial: {\n        opacity: 0,\n    },\n    open: (custom) => {\n        const { isOpen } = custom;\n        return {\n            opacity: isOpen ? 1 : 0,\n            transition: {\n                duration: 0.4,\n                ease: 'linear',\n            },\n        };\n    },\n};\n\ninterface BackgroundImageProps {\n    dynamicBackground: boolean | undefined;\n    dynamicIsImage: boolean | undefined;\n}\n\nconst BackgroundImage = memo(({ dynamicBackground, dynamicIsImage }: BackgroundImageProps) => {\n    const currentSong = usePlayerSong();\n    const { nextSong } = usePlayerData();\n\n    const currentImageUrl = useItemImageUrl({\n        id: currentSong?.imageId || undefined,\n        itemType: LibraryItem.SONG,\n        type: 'itemCard',\n    });\n\n    const nextImageUrl = useItemImageUrl({\n        id: nextSong?.imageId || undefined,\n        itemType: LibraryItem.SONG,\n        type: 'itemCard',\n    });\n\n    const [imageState, setImageState] = useState({\n        bottomImage: nextImageUrl,\n        current: 0,\n        topImage: currentImageUrl,\n    });\n\n    const previousSongRef = useRef<string | undefined>(currentSong?._uniqueId);\n    const imageStateRef = useRef(imageState);\n\n    // Keep ref in sync\n    useEffect(() => {\n        imageStateRef.current = imageState;\n    }, [imageState]);\n\n    // Update images when song changes\n    useEffect(() => {\n        if (currentSong?._uniqueId === previousSongRef.current) {\n            return;\n        }\n\n        const isTop = imageStateRef.current.current === 0;\n\n        setImageState({\n            bottomImage: isTop ? currentImageUrl : nextImageUrl,\n            current: isTop ? 1 : 0,\n            topImage: isTop ? nextImageUrl : currentImageUrl,\n        });\n\n        previousSongRef.current = currentSong?._uniqueId;\n    }, [currentSong?._uniqueId, currentImageUrl, nextSong?._uniqueId, nextImageUrl]);\n\n    if (!dynamicBackground || !dynamicIsImage) {\n        return null;\n    }\n\n    const getBackgroundImageUrl = (\n        imageUrl: string | undefined,\n        songId: string | undefined,\n        albumId: string | undefined,\n    ) => {\n        if (!imageUrl || !songId || !albumId) {\n            return imageUrl;\n        }\n        return imageUrl.replace(songId, albumId);\n    };\n\n    // Determine which song IDs to use for keys and image URLs\n    const topSongId = imageState.current === 0 ? currentSong?._uniqueId : nextSong?._uniqueId;\n    const bottomSongId = imageState.current === 0 ? nextSong?._uniqueId : currentSong?._uniqueId;\n    const topSong = imageState.current === 0 ? currentSong : nextSong;\n    const bottomSong = imageState.current === 0 ? nextSong : currentSong;\n\n    return (\n        <AnimatePresence initial={false} mode=\"sync\">\n            {imageState.current === 0 && imageState.topImage && (\n                <motion.div\n                    animate=\"open\"\n                    className={styles.backgroundImage}\n                    custom={{ isOpen: imageState.current === 0 }}\n                    exit=\"closed\"\n                    initial=\"closed\"\n                    key={`top-${topSongId || 'none'}`}\n                    style={\n                        {\n                            backgroundImage: imageState.topImage\n                                ? `url(\"${getBackgroundImageUrl(\n                                      imageState.topImage,\n                                      topSong?.id,\n                                      topSong?.albumId,\n                                  )}\"), url(\"${imageState.topImage}\")`\n                                : undefined,\n                        } as CSSProperties\n                    }\n                    variants={backgroundImageVariants}\n                />\n            )}\n\n            {imageState.current === 1 && imageState.bottomImage && (\n                <motion.div\n                    animate=\"open\"\n                    className={styles.backgroundImage}\n                    custom={{ isOpen: imageState.current === 1 }}\n                    exit=\"closed\"\n                    initial=\"closed\"\n                    key={`bottom-${bottomSongId || 'none'}`}\n                    style={\n                        {\n                            backgroundImage: imageState.bottomImage\n                                ? `url(\"${getBackgroundImageUrl(\n                                      imageState.bottomImage,\n                                      bottomSong?.id,\n                                      bottomSong?.albumId,\n                                  )}\"), url(\"${imageState.bottomImage}\")`\n                                : undefined,\n                        } as CSSProperties\n                    }\n                    variants={backgroundImageVariants}\n                />\n            )}\n        </AnimatePresence>\n    );\n});\n\nBackgroundImage.displayName = 'BackgroundImage';\n\ninterface BackgroundImageOverlayProps {\n    dynamicBackground: boolean | undefined;\n    dynamicImageBlur: number | undefined;\n}\n\nconst BackgroundImageOverlay = memo(\n    ({ dynamicBackground, dynamicImageBlur }: BackgroundImageOverlayProps) => {\n        if (!dynamicBackground) {\n            return null;\n        }\n\n        return (\n            <div\n                className={styles.backgroundImageOverlay}\n                style={\n                    {\n                        '--image-blur': `${dynamicImageBlur ?? 0}rem`,\n                    } as CSSProperties\n                }\n            />\n        );\n    },\n);\n\nBackgroundImageOverlay.displayName = 'BackgroundImageOverlay';\n\nconst Controls = () => {\n    const { t } = useTranslation();\n    const {\n        dynamicBackground,\n        dynamicImageBlur,\n        dynamicIsImage,\n        expanded,\n        opacity,\n        useImageAspectRatio,\n    } = useFullScreenPlayerStore();\n    const { setStore } = useFullScreenPlayerStoreActions();\n    const { setSettings } = useSettingsStoreActions();\n    const lyricsSettings = useLyricsSettings();\n    const displaySettings = useLyricsDisplaySettings('default');\n    const lyricConfig = { ...lyricsSettings, ...displaySettings };\n\n    const handleToggleFullScreenPlayer = () => {\n        setStore({ expanded: !expanded, visualizerExpanded: false });\n    };\n\n    const handleLyricsSettings = (property: string, value: any) => {\n        const displayProperties = ['fontSize', 'fontSizeUnsync', 'gap', 'gapUnsync'];\n        if (displayProperties.includes(property)) {\n            const currentDisplay = useSettingsStore.getState().lyricsDisplay;\n            setSettings({\n                lyricsDisplay: {\n                    ...currentDisplay,\n                    default: {\n                        ...currentDisplay.default,\n                        [property]: value,\n                    },\n                },\n            });\n        } else {\n            setSettings({\n                lyrics: {\n                    ...useSettingsStore.getState().lyrics,\n                    [property]: value,\n                },\n            });\n        }\n    };\n\n    useHotkeys([['Escape', handleToggleFullScreenPlayer]]);\n\n    return (\n        <Group\n            className={styles.controlsContainer}\n            gap=\"sm\"\n            p=\"1rem\"\n            pos=\"absolute\"\n            style={{\n                background: `rgb(var(--theme-colors-background-transparent), ${opacity}%)`,\n                left: 0,\n                top: 0,\n            }}\n        >\n            <ActionIcon\n                icon=\"arrowDownS\"\n                iconProps={{ size: 'lg' }}\n                onClick={handleToggleFullScreenPlayer}\n                tooltip={{ label: t('common.minimize', { postProcess: 'titleCase' }) }}\n                variant=\"subtle\"\n            />\n            <Popover position=\"bottom-start\">\n                <Popover.Target>\n                    <ActionIcon\n                        icon=\"settings2\"\n                        iconProps={{ size: 'lg' }}\n                        tooltip={{ label: t('common.configure', { postProcess: 'titleCase' }) }}\n                        variant=\"subtle\"\n                    />\n                </Popover.Target>\n                <Popover.Dropdown>\n                    <Option>\n                        <Option.Label>\n                            {t('page.fullscreenPlayer.config.dynamicBackground', {\n                                postProcess: 'sentenceCase',\n                            })}\n                        </Option.Label>\n                        <Option.Control>\n                            <Switch\n                                defaultChecked={dynamicBackground}\n                                onChange={(e) =>\n                                    setStore({\n                                        dynamicBackground: e.target.checked,\n                                    })\n                                }\n                            />\n                        </Option.Control>\n                    </Option>\n                    {dynamicBackground && (\n                        <Option>\n                            <Option.Label>\n                                {t('page.fullscreenPlayer.config.dynamicIsImage', {\n                                    postProcess: 'sentenceCase',\n                                })}\n                            </Option.Label>\n                            <Option.Control>\n                                <Switch\n                                    defaultChecked={dynamicIsImage}\n                                    onChange={(e) =>\n                                        setStore({\n                                            dynamicIsImage: e.target.checked,\n                                        })\n                                    }\n                                />\n                            </Option.Control>\n                        </Option>\n                    )}\n                    {dynamicBackground && dynamicIsImage && (\n                        <Option>\n                            <Option.Label>\n                                {t('page.fullscreenPlayer.config.dynamicImageBlur', {\n                                    postProcess: 'sentenceCase',\n                                })}\n                            </Option.Label>\n                            <Option.Control>\n                                <Slider\n                                    defaultValue={dynamicImageBlur}\n                                    label={(e) => `${e} rem`}\n                                    max={6}\n                                    min={0}\n                                    onChangeEnd={(e) => setStore({ dynamicImageBlur: Number(e) })}\n                                    step={0.5}\n                                    w=\"100%\"\n                                />\n                            </Option.Control>\n                        </Option>\n                    )}\n                    {dynamicBackground && (\n                        <Option>\n                            <Option.Label>\n                                {t('page.fullscreenPlayer.config.opacity', {\n                                    postProcess: 'sentenceCase',\n                                })}\n                            </Option.Label>\n                            <Option.Control>\n                                <Slider\n                                    defaultValue={opacity}\n                                    label={(e) => `${e} %`}\n                                    max={100}\n                                    min={0}\n                                    onChangeEnd={(e) => setStore({ opacity: Number(e) })}\n                                    w=\"100%\"\n                                />\n                            </Option.Control>\n                        </Option>\n                    )}\n                    <Option>\n                        <Option.Label>\n                            {t('page.fullscreenPlayer.config.useImageAspectRatio', {\n                                postProcess: 'sentenceCase',\n                            })}\n                        </Option.Label>\n                        <Option.Control>\n                            <Switch\n                                checked={useImageAspectRatio}\n                                onChange={(e) =>\n                                    setStore({\n                                        useImageAspectRatio: e.target.checked,\n                                    })\n                                }\n                            />\n                        </Option.Control>\n                    </Option>\n                    <Divider my=\"sm\" />\n                    <Option>\n                        <Option.Label>\n                            {t('page.fullscreenPlayer.config.followCurrentLyric', {\n                                postProcess: 'sentenceCase',\n                            })}\n                        </Option.Label>\n                        <Option.Control>\n                            <Switch\n                                checked={lyricConfig.follow}\n                                onChange={(e) =>\n                                    handleLyricsSettings('follow', e.currentTarget.checked)\n                                }\n                            />\n                        </Option.Control>\n                    </Option>\n                    <Option>\n                        <Option.Label>\n                            {t('page.fullscreenPlayer.config.showLyricProvider', {\n                                postProcess: 'sentenceCase',\n                            })}\n                        </Option.Label>\n                        <Option.Control>\n                            <Switch\n                                checked={lyricConfig.showProvider}\n                                onChange={(e) =>\n                                    handleLyricsSettings('showProvider', e.currentTarget.checked)\n                                }\n                            />\n                        </Option.Control>\n                    </Option>\n                    <Option>\n                        <Option.Label>\n                            {t('page.fullscreenPlayer.config.showLyricMatch', {\n                                postProcess: 'sentenceCase',\n                            })}\n                        </Option.Label>\n                        <Option.Control>\n                            <Switch\n                                checked={lyricConfig.showMatch}\n                                onChange={(e) =>\n                                    handleLyricsSettings('showMatch', e.currentTarget.checked)\n                                }\n                            />\n                        </Option.Control>\n                    </Option>\n                    <Option>\n                        <Option.Label>\n                            {t('page.fullscreenPlayer.config.lyricSize', {\n                                postProcess: 'sentenceCase',\n                            })}\n                        </Option.Label>\n                        <Option.Control>\n                            <Group w=\"100%\" wrap=\"nowrap\">\n                                <Slider\n                                    defaultValue={lyricConfig.fontSize}\n                                    label={(e) =>\n                                        `${t('page.fullscreenPlayer.config.synchronized', {\n                                            postProcess: 'titleCase',\n                                        })}: ${e}px`\n                                    }\n                                    max={72}\n                                    min={8}\n                                    onChangeEnd={(e) => handleLyricsSettings('fontSize', Number(e))}\n                                    w=\"100%\"\n                                />\n                                <Slider\n                                    defaultValue={lyricConfig.fontSize}\n                                    label={(e) =>\n                                        `${t('page.fullscreenPlayer.config.unsynchronized', {\n                                            postProcess: 'sentenceCase',\n                                        })}: ${e}px`\n                                    }\n                                    max={72}\n                                    min={8}\n                                    onChangeEnd={(e) =>\n                                        handleLyricsSettings('fontSizeUnsync', Number(e))\n                                    }\n                                    w=\"100%\"\n                                />\n                            </Group>\n                        </Option.Control>\n                    </Option>\n                    <Option>\n                        <Option.Label>\n                            {t('page.fullscreenPlayer.config.lyricGap', {\n                                postProcess: 'sentenceCase',\n                            })}\n                        </Option.Label>\n                        <Option.Control>\n                            <Group w=\"100%\" wrap=\"nowrap\">\n                                <Slider\n                                    defaultValue={lyricConfig.gap}\n                                    label={(e) => `Synchronized: ${e}px`}\n                                    max={50}\n                                    min={0}\n                                    onChangeEnd={(e) => handleLyricsSettings('gap', Number(e))}\n                                    w=\"100%\"\n                                />\n                                <Slider\n                                    defaultValue={lyricConfig.gap}\n                                    label={(e) => `Unsynchronized: ${e}px`}\n                                    max={50}\n                                    min={0}\n                                    onChangeEnd={(e) =>\n                                        handleLyricsSettings('gapUnsync', Number(e))\n                                    }\n                                    w=\"100%\"\n                                />\n                            </Group>\n                        </Option.Control>\n                    </Option>\n                    <Option>\n                        <Option.Label>\n                            {t('page.fullscreenPlayer.config.lyricAlignment', {\n                                postProcess: 'sentenceCase',\n                            })}\n                        </Option.Label>\n                        <Option.Control>\n                            <SegmentedControl\n                                data={[\n                                    {\n                                        label: t('common.left', {\n                                            postProcess: 'titleCase',\n                                        }),\n                                        value: 'left',\n                                    },\n                                    {\n                                        label: t('common.center', {\n                                            postProcess: 'titleCase',\n                                        }),\n                                        value: 'center',\n                                    },\n                                    {\n                                        label: t('common.right', {\n                                            postProcess: 'titleCase',\n                                        }),\n                                        value: 'right',\n                                    },\n                                ]}\n                                onChange={(e) => handleLyricsSettings('alignment', e)}\n                                value={lyricConfig.alignment}\n                            />\n                        </Option.Control>\n                    </Option>\n                    <Option>\n                        <Option.Label>\n                            {t('page.fullscreenPlayer.config.lyricOffset', {\n                                postProcess: 'sentenceCase',\n                            })}\n                        </Option.Label>\n                        <Option.Control>\n                            <NumberInput\n                                defaultValue={lyricConfig.delayMs}\n                                hideControls={false}\n                                onBlur={(e) =>\n                                    handleLyricsSettings('delayMs', Number(e.currentTarget.value))\n                                }\n                                step={10}\n                            />\n                        </Option.Control>\n                    </Option>\n                </Popover.Dropdown>\n            </Popover>\n            <ListConfigMenu\n                buttonProps={{\n                    variant: 'subtle',\n                }}\n                displayTypes={[\n                    { hidden: true, value: ListDisplayType.GRID },\n                    ...SONG_DISPLAY_TYPES,\n                ]}\n                listKey={ItemListKey.FULL_SCREEN}\n                optionsConfig={{\n                    table: {\n                        itemsPerPage: { hidden: true },\n                        pagination: { hidden: true },\n                    },\n                }}\n                tableColumnsData={SONG_TABLE_COLUMNS}\n            />\n        </Group>\n    );\n};\n\nconst containerVariants: Variants = {\n    closed: (custom) => {\n        const { windowBarStyle } = custom;\n        return {\n            height:\n                windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS\n                    ? 'calc(100vh - 120px)'\n                    : 'calc(100vh - 90px)',\n            position: 'absolute',\n            top: '100vh',\n            transition: {\n                duration: 0.5,\n                ease: 'easeInOut',\n            },\n            width: '100vw',\n            y: 0,\n        };\n    },\n    open: (custom) => {\n        const { background, dynamicBackground, windowBarStyle } = custom;\n        return {\n            backgroundColor: dynamicBackground ? background : mainBackground,\n            height:\n                windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS\n                    ? 'calc(100vh - 120px)'\n                    : 'calc(100vh - 90px)',\n            left: 0,\n            position: 'absolute',\n            top: 0,\n            transition: {\n                delay: 0.1,\n                duration: 0.5,\n                ease: 'easeInOut',\n            },\n            width: '100vw',\n            y: 0,\n        };\n    },\n};\n\ninterface PlayerContainerProps {\n    children: ReactNode;\n    dynamicBackground: boolean | undefined;\n    dynamicIsImage: boolean | undefined;\n    windowBarStyle: Platform;\n}\n\nconst PlayerContainer = memo(\n    ({ children, dynamicBackground, dynamicIsImage, windowBarStyle }: PlayerContainerProps) => {\n        const currentSong = usePlayerSong();\n        const imageUrl = useItemImageUrl({\n            id: currentSong?.imageId || undefined,\n            imageUrl: currentSong?.imageUrl,\n            itemType: LibraryItem.SONG,\n            type: 'itemCard',\n        });\n        const { background } = useFastAverageColor({\n            algorithm: 'dominant',\n            src: imageUrl,\n            srcLoaded: true,\n        });\n\n        return (\n            <motion.div\n                animate=\"open\"\n                className={styles.container}\n                custom={{ background, dynamicBackground, windowBarStyle }}\n                exit=\"closed\"\n                initial=\"closed\"\n                transition={{ duration: 2 }}\n                variants={containerVariants}\n            >\n                <BackgroundImage\n                    dynamicBackground={dynamicBackground}\n                    dynamicIsImage={dynamicIsImage}\n                />\n                {children}\n            </motion.div>\n        );\n    },\n);\n\nPlayerContainer.displayName = 'PlayerContainer';\n\nexport const FullScreenPlayer = () => {\n    const { dynamicBackground, dynamicImageBlur, dynamicIsImage } = useFullScreenPlayerStore();\n    const { setStore } = useFullScreenPlayerStoreActions();\n    const { windowBarStyle } = useWindowSettings();\n    const isRadioActive = useIsRadioActive();\n    const { isPlaying: isRadioPlaying } = useRadioPlayer();\n\n    const isPlayingRadio = isRadioActive && isRadioPlaying;\n    const effectiveDynamicBackground = dynamicBackground && !isPlayingRadio;\n\n    const location = useLocation();\n    const isOpenedRef = useRef<boolean | null>(null);\n\n    useLayoutEffect(() => {\n        if (isOpenedRef.current !== null) {\n            setStore({ expanded: false });\n        }\n\n        isOpenedRef.current = true;\n    }, [location, setStore]);\n\n    return (\n        <PlayerContainer\n            dynamicBackground={effectiveDynamicBackground}\n            dynamicIsImage={dynamicIsImage}\n            windowBarStyle={windowBarStyle}\n        >\n            <Controls />\n            <BackgroundImageOverlay\n                dynamicBackground={effectiveDynamicBackground}\n                dynamicImageBlur={dynamicImageBlur}\n            />\n            <div className={styles.responsiveContainer}>\n                <FullScreenPlayerImage />\n                <FullScreenPlayerQueue />\n            </div>\n        </PlayerContainer>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/player/components/full-screen-similar-songs.tsx",
    "content": "import { SimilarSongsList } from '/@/renderer/features/similar-songs/components/similar-songs-list';\nimport { usePlayerSong } from '/@/renderer/store';\n\nexport const FullScreenSimilarSongs = () => {\n    const currentSong = usePlayerSong();\n\n    return currentSong?.id ? (\n        <div style={{ height: '100%', width: '100%' }}>\n            <SimilarSongsList fullScreen song={currentSong} />\n        </div>\n    ) : null;\n};\n"
  },
  {
    "path": "src/renderer/features/player/components/full-screen-visualizer-song-info.tsx",
    "content": "import { motion } from 'motion/react';\nimport { useEffect, useRef, useState } from 'react';\n\nimport styles from './full-screen-visualizer.module.css';\n\nimport { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';\nimport { usePlayerSong } from '/@/renderer/store/player.store';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { TextTitle } from '/@/shared/components/text-title/text-title';\nimport { Text } from '/@/shared/components/text/text';\n\nexport const FullScreenVisualizerSongInfo = () => {\n    const currentSong = usePlayerSong();\n    const [showSongInfo, setShowSongInfo] = useState(false);\n    const timeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);\n\n    usePlayerEvents(\n        {\n            onCurrentSongChange: () => {\n                setShowSongInfo(true);\n\n                if (timeoutRef.current) {\n                    clearTimeout(timeoutRef.current);\n                }\n\n                timeoutRef.current = setTimeout(() => {\n                    setShowSongInfo(false);\n                }, 3000);\n            },\n        },\n        [],\n    );\n\n    useEffect(() => {\n        return () => {\n            if (timeoutRef.current) {\n                clearTimeout(timeoutRef.current);\n            }\n        };\n    }, []);\n\n    const overlayVariants = {\n        hidden: {\n            opacity: 0,\n            transition: {\n                duration: 1.5,\n                ease: 'easeInOut' as const,\n            },\n        },\n        visible: {\n            opacity: 1,\n            transition: {\n                duration: 0.5,\n                ease: 'easeInOut' as const,\n            },\n        },\n    };\n\n    if (!currentSong) {\n        return null;\n    }\n\n    return (\n        <>\n            <motion.div\n                animate={showSongInfo ? 'visible' : 'hidden'}\n                className={styles.songInfoBackdrop}\n                initial=\"hidden\"\n                variants={overlayVariants}\n            />\n            <motion.div\n                animate={showSongInfo ? 'visible' : 'hidden'}\n                className={styles.songInfoOverlay}\n                initial=\"hidden\"\n                variants={overlayVariants}\n            >\n                <Stack align=\"center\" gap=\"lg\" justify=\"center\">\n                    <TextTitle className={styles.songInfoTitle} fw=\"800\" isNoSelect order={1}>\n                        {currentSong.name}\n                    </TextTitle>\n                    {currentSong.artistName && (\n                        <Text className={styles.songInfoArtist} isNoSelect>\n                            {currentSong.artistName}\n                        </Text>\n                    )}\n                </Stack>\n            </motion.div>\n        </>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/player/components/full-screen-visualizer.module.css",
    "content": ".container {\n    position: absolute;\n    top: 0;\n    left: 0;\n    z-index: 200;\n    display: flex;\n    flex-direction: column;\n    width: 100%;\n    height: 100%;\n    overflow: hidden;\n    background: var(--theme-colors-background);\n}\n\n.controls-container {\n    z-index: 201;\n    background: rgb(var(--theme-colors-background-transparent), 80%);\n}\n\n.visualizer-container {\n    position: relative;\n    flex: 1;\n    width: 100%;\n    height: 100%;\n    overflow: hidden;\n}\n\n.song-info-backdrop {\n    position: absolute;\n    top: 0;\n    left: 0;\n    z-index: 50;\n    width: 100%;\n    height: 100%;\n    pointer-events: none;\n    background: rgb(0 0 0 / 60%);\n}\n\n.song-info-overlay {\n    position: absolute;\n    top: 50%;\n    left: 50%;\n    z-index: 51;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    width: 100%;\n    max-width: 60%;\n    padding: var(--theme-spacing-lg);\n    color: var(--theme-colors-foreground);\n    text-align: center;\n    text-shadow: 0 2px 8px rgb(0 0 0 / 50%);\n    pointer-events: none;\n    transform: translate(-50%, -50%);\n}\n\n.song-info-title {\n    font-size: clamp(2rem, 8vw, 6rem);\n    line-height: 1.2;\n    color: #ddd;\n}\n\n.song-info-artist {\n    font-size: clamp(1.25rem, 5vw, 3.5rem);\n    line-height: 1.3;\n    color: #ddd;\n}\n"
  },
  {
    "path": "src/renderer/features/player/components/full-screen-visualizer.tsx",
    "content": "import { motion, Variants } from 'motion/react';\nimport { lazy, memo, ReactNode, Suspense, useLayoutEffect, useRef } from 'react';\nimport { useLocation } from 'react-router';\n\nimport styles from './full-screen-visualizer.module.css';\n\nimport { FullScreenVisualizerSongInfo } from '/@/renderer/features/player/components/full-screen-visualizer-song-info';\nimport { useIsMobile } from '/@/renderer/hooks/use-is-mobile';\nimport { useFullScreenPlayerStoreActions } from '/@/renderer/store/full-screen-player.store';\nimport {\n    usePlaybackSettings,\n    useSettingsStore,\n    useWindowSettings,\n} from '/@/renderer/store/settings.store';\nimport { useHotkeys } from '/@/shared/hooks/use-hotkeys';\nimport { Platform, PlayerType } from '/@/shared/types/types';\n\nconst AudioMotionAnalyzerVisualizer = lazy(() =>\n    import('../../visualizer/components/audiomotionanalyzer/visualizer').then((module) => ({\n        default: module.Visualizer,\n    })),\n);\n\nconst ButterchurnVisualizer = lazy(() =>\n    import('../../visualizer/components/butternchurn/visualizer').then((module) => ({\n        default: module.Visualizer,\n    })),\n);\n\nconst containerVariants: Variants = {\n    closed: (custom) => {\n        const { isMobile, windowBarStyle } = custom;\n        const height =\n            windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS\n                ? 'calc(100vh - 120px)'\n                : 'calc(100vh - 90px)';\n\n        if (isMobile) {\n            return {\n                height,\n                position: 'absolute',\n                top: '100vh',\n                transition: {\n                    duration: 0.5,\n                    ease: 'easeInOut',\n                },\n                width: '100vw',\n                y: 0,\n            };\n        }\n        return {\n            height,\n            position: 'absolute',\n            top: '100vh',\n            transition: {\n                duration: 0.5,\n                ease: 'easeInOut',\n            },\n            width: '100vw',\n            y: 0,\n        };\n    },\n    open: (custom) => {\n        const { isMobile, windowBarStyle } = custom;\n        const height =\n            windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS\n                ? 'calc(100vh - 120px)'\n                : 'calc(100vh - 90px)';\n        const topOffset =\n            windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS\n                ? '30px'\n                : '0px';\n\n        if (isMobile) {\n            return {\n                height,\n                left: 0,\n                position: 'absolute',\n                top: topOffset,\n                transition: {\n                    delay: 0.1,\n                    duration: 0.5,\n                    ease: 'easeInOut',\n                },\n                width: '100vw',\n                y: 0,\n            };\n        }\n        return {\n            height,\n            left: 0,\n            position: 'absolute',\n            top: 0,\n            transition: {\n                delay: 0.1,\n                duration: 0.5,\n                ease: 'easeInOut',\n            },\n            width: '100vw',\n            y: 0,\n        };\n    },\n};\n\ninterface VisualizerContainerProps {\n    children: ReactNode;\n    isMobile?: boolean;\n    windowBarStyle: Platform;\n}\n\nconst VisualizerContainer = memo(\n    ({ children, isMobile, windowBarStyle }: VisualizerContainerProps) => {\n        return (\n            <motion.div\n                animate=\"open\"\n                className={styles.container}\n                custom={{ isMobile, windowBarStyle }}\n                exit=\"closed\"\n                initial=\"closed\"\n                transition={{ duration: 2 }}\n                variants={containerVariants}\n            >\n                {children}\n            </motion.div>\n        );\n    },\n);\n\nVisualizerContainer.displayName = 'VisualizerContainer';\n\nexport const FullScreenVisualizer = () => {\n    const { setStore } = useFullScreenPlayerStoreActions();\n    const { windowBarStyle } = useWindowSettings();\n    const { type, webAudio } = usePlaybackSettings();\n    const visualizerType = useSettingsStore((store) => store.visualizer.type);\n    const isMobile = useIsMobile();\n\n    const location = useLocation();\n    const isOpenedRef = useRef<boolean | null>(null);\n\n    const handleCloseVisualizer = () => {\n        setStore({ visualizerExpanded: false });\n    };\n\n    useHotkeys([['Escape', handleCloseVisualizer]]);\n\n    useLayoutEffect(() => {\n        if (isOpenedRef.current !== null) {\n            setStore({ visualizerExpanded: false });\n        }\n\n        isOpenedRef.current = true;\n    }, [location, setStore]);\n\n    return (\n        <VisualizerContainer isMobile={isMobile} windowBarStyle={windowBarStyle}>\n            <div className={styles.visualizerContainer}>\n                {type === PlayerType.WEB && webAudio ? (\n                    <Suspense fallback={<></>}>\n                        {visualizerType === 'butterchurn' ? (\n                            <ButterchurnVisualizer />\n                        ) : (\n                            <AudioMotionAnalyzerVisualizer />\n                        )}\n                    </Suspense>\n                ) : null}\n                <FullScreenVisualizerSongInfo />\n            </div>\n        </VisualizerContainer>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/player/components/left-controls.module.css",
    "content": ".image-wrapper {\n    position: relative;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    padding: var(--theme-spacing-md) var(--theme-spacing-md) var(--theme-spacing-md) 0;\n}\n\n.metadata-stack {\n    display: flex;\n    flex-direction: column;\n    gap: var(--theme-spacing-xs);\n    justify-content: center;\n    width: 100%;\n    overflow: hidden;\n}\n\n@keyframes fadein {\n    from {\n        opacity: 0;\n    }\n\n    to {\n        opacity: 1;\n    }\n}\n\n.image {\n    position: relative;\n    width: 60px;\n    height: 60px;\n    cursor: pointer;\n    animation: fadein 0.2s ease-in-out;\n\n    button {\n        display: none;\n    }\n\n    &:hover button {\n        display: block;\n    }\n}\n\n.playerbar-image {\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    width: 100%;\n    height: 100%;\n    object-fit: var(--theme-image-fit);\n}\n\n.radio-image {\n    background: var(--theme-colors-surface);\n    border-radius: var(--theme-radius-md);\n}\n\n.line-item {\n    display: inline-block;\n    width: 100%;\n    max-width: 20vw;\n    overflow: hidden;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n}\n\n.line-item.secondary {\n    color: var(--theme-colors-foreground-muted);\n\n    a {\n        color: var(--theme-colors-foreground-muted);\n    }\n}\n\n.left-controls-container {\n    display: flex;\n    width: 100%;\n    height: 100%;\n    padding-left: 1rem;\n\n    @media (width < 640px) {\n        .image-wrapper {\n            display: none;\n        }\n    }\n}\n"
  },
  {
    "path": "src/renderer/features/player/components/left-controls.tsx",
    "content": "import clsx from 'clsx';\nimport { AnimatePresence, LayoutGroup, motion } from 'motion/react';\nimport { MouseEvent } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { generatePath, Link } from 'react-router';\nimport { shallow } from 'zustand/shallow';\n\nimport styles from './left-controls.module.css';\n\nimport { ItemImage } from '/@/renderer/components/item-image/item-image';\nimport { JoinedArtists } from '/@/renderer/features/albums/components/joined-artists';\nimport { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';\nimport { RadioMetadataDisplay } from '/@/renderer/features/player/components/radio-metadata-display';\nimport { useIsRadioActive } from '/@/renderer/features/radio/hooks/use-radio-player';\nimport { AppRoute } from '/@/renderer/router/routes';\nimport {\n    useAppStore,\n    useAppStoreActions,\n    useFullScreenPlayerStore,\n    useHotkeySettings,\n    usePlayerSong,\n    useSetFullScreenPlayerStore,\n} from '/@/renderer/store';\nimport { ActionIcon } from '/@/shared/components/action-icon/action-icon';\nimport { Center } from '/@/shared/components/center/center';\nimport { Group } from '/@/shared/components/group/group';\nimport { Icon } from '/@/shared/components/icon/icon';\nimport { Text } from '/@/shared/components/text/text';\nimport { Tooltip } from '/@/shared/components/tooltip/tooltip';\nimport { PlaybackSelectors } from '/@/shared/constants/playback-selectors';\nimport { useHotkeys } from '/@/shared/hooks/use-hotkeys';\nimport { LibraryItem } from '/@/shared/types/domain-types';\n\nexport const LeftControls = () => {\n    const { t } = useTranslation();\n    const { setSideBar } = useAppStoreActions();\n    const { expanded: isFullScreenPlayerExpanded } = useFullScreenPlayerStore();\n    const setFullScreenPlayerStore = useSetFullScreenPlayerStore();\n\n    const { collapsed, image } = useAppStore(\n        (state) => ({\n            collapsed: state.sidebar.collapsed,\n            image: state.sidebar.image,\n        }),\n        shallow,\n    );\n\n    const currentSong = usePlayerSong();\n    const isRadioActive = useIsRadioActive();\n    const { bindings } = useHotkeySettings();\n\n    const isRadioMode = isRadioActive;\n    const hideImage = image && !collapsed;\n    const isSongDefined = Boolean(currentSong?.id) && !isRadioMode;\n    const title = currentSong?.name;\n    const artists = currentSong?.artists;\n\n    const handleToggleFullScreenPlayer = (e?: KeyboardEvent | MouseEvent<HTMLDivElement>) => {\n        // don't toggle if right click\n        if (e && 'button' in e && e.button === 2) {\n            return;\n        }\n\n        e?.stopPropagation();\n        setFullScreenPlayerStore({ expanded: !isFullScreenPlayerExpanded });\n    };\n\n    const handleToggleSidebarImage = (e?: MouseEvent<HTMLButtonElement>) => {\n        e?.stopPropagation();\n        setSideBar({ image: true });\n    };\n\n    const handleToggleContextMenu = (e: MouseEvent<HTMLDivElement>) => {\n        e.preventDefault();\n        e.stopPropagation();\n\n        if (!currentSong) {\n            return;\n        }\n\n        ContextMenuController.call({\n            cmd: { items: [currentSong], type: LibraryItem.SONG },\n            event: e,\n        });\n    };\n\n    const stopPropagation = (e?: MouseEvent) => e?.stopPropagation();\n\n    useHotkeys([\n        [\n            bindings.toggleFullscreenPlayer.allowGlobal\n                ? ''\n                : bindings.toggleFullscreenPlayer.hotkey,\n            handleToggleFullScreenPlayer,\n        ],\n    ]);\n\n    return (\n        <div className={styles.leftControlsContainer}>\n            <LayoutGroup>\n                <AnimatePresence initial={false} mode=\"popLayout\">\n                    {!hideImage && (\n                        <div className={styles.imageWrapper}>\n                            <motion.div\n                                animate={{ opacity: 1, scale: 1, x: 0 }}\n                                className={styles.image}\n                                exit={{ opacity: 0, x: -50 }}\n                                initial={{ opacity: 0, x: -50 }}\n                                key=\"playerbar-image\"\n                                onClick={handleToggleFullScreenPlayer}\n                                onContextMenu={handleToggleContextMenu}\n                                role=\"button\"\n                                transition={{ duration: 0.2, ease: 'easeIn' }}\n                            >\n                                <Tooltip\n                                    label={t('player.toggleFullscreenPlayer', {\n                                        postProcess: 'sentenceCase',\n                                    })}\n                                    openDelay={0}\n                                >\n                                    {isRadioMode ? (\n                                        <Center\n                                            className={clsx(\n                                                styles.playerbarImage,\n                                                styles.radioImage,\n                                            )}\n                                        >\n                                            <Icon color=\"muted\" icon=\"radio\" size=\"40%\" />\n                                        </Center>\n                                    ) : (\n                                        <ItemImage\n                                            className={clsx(\n                                                styles.playerbarImage,\n                                                PlaybackSelectors.playerCoverArt,\n                                            )}\n                                            enableDebounce={false}\n                                            enableViewport={false}\n                                            explicitStatus={currentSong?.explicitStatus}\n                                            fetchPriority=\"high\"\n                                            id={currentSong?.imageId}\n                                            itemType={LibraryItem.SONG}\n                                            serverId={currentSong?._serverId}\n                                            type=\"table\"\n                                        />\n                                    )}\n                                </Tooltip>\n                                {!collapsed && (\n                                    <ActionIcon\n                                        icon=\"arrowUpS\"\n                                        iconProps={{ size: 'xl' }}\n                                        onClick={handleToggleSidebarImage}\n                                        opacity={0.8}\n                                        radius=\"md\"\n                                        size=\"xs\"\n                                        style={{\n                                            cursor: 'default',\n                                            position: 'absolute',\n                                            right: 2,\n                                            top: 2,\n                                        }}\n                                        tooltip={{\n                                            label: t('common.expand', {\n                                                postProcess: 'titleCase',\n                                            }),\n                                            openDelay: 0,\n                                        }}\n                                    />\n                                )}\n                            </motion.div>\n                        </div>\n                    )}\n                </AnimatePresence>\n                <motion.div className={styles.metadataStack} layout=\"position\">\n                    {isRadioMode ? (\n                        <RadioMetadataDisplay\n                            onStopPropagation={stopPropagation}\n                            onToggleContextMenu={handleToggleContextMenu}\n                        />\n                    ) : (\n                        <>\n                            <div className={styles.lineItem} onClick={stopPropagation}>\n                                <Group align=\"center\" gap=\"xs\" wrap=\"nowrap\">\n                                    <Text\n                                        className={PlaybackSelectors.songTitle}\n                                        component={Link}\n                                        fw={500}\n                                        isLink\n                                        onContextMenu={handleToggleContextMenu}\n                                        overflow=\"hidden\"\n                                        to={AppRoute.NOW_PLAYING}\n                                    >\n                                        {title || '—'}\n                                        {currentSong?.trackSubtitle && (\n                                            <Text component=\"span\" isMuted size=\"sm\">\n                                                {' ('}\n                                                {currentSong.trackSubtitle}\n                                                {')'}\n                                            </Text>\n                                        )}\n                                    </Text>\n                                    {isSongDefined && (\n                                        <ActionIcon\n                                            icon=\"ellipsisVertical\"\n                                            onClick={(e) => {\n                                                e.preventDefault();\n                                                e.stopPropagation();\n                                                if (currentSong) {\n                                                    ContextMenuController.call({\n                                                        cmd: {\n                                                            items: [currentSong],\n                                                            type: LibraryItem.SONG,\n                                                        },\n                                                        event: e,\n                                                    });\n                                                }\n                                            }}\n                                            size=\"xs\"\n                                            styles={{\n                                                root: {\n                                                    '--ai-size-xs': '1.15rem',\n                                                },\n                                            }}\n                                            variant=\"subtle\"\n                                        />\n                                    )}\n                                </Group>\n                            </div>\n                            <div\n                                className={clsx(\n                                    styles.lineItem,\n                                    styles.secondary,\n                                    PlaybackSelectors.songArtist,\n                                )}\n                                onClick={stopPropagation}\n                            >\n                                <JoinedArtists\n                                    artistName={currentSong?.artistName || ''}\n                                    artists={artists || []}\n                                />\n                            </div>\n                            <div\n                                className={clsx(\n                                    styles.lineItem,\n                                    styles.secondary,\n                                    PlaybackSelectors.songAlbum,\n                                )}\n                                onClick={stopPropagation}\n                            >\n                                <Text\n                                    component={Link}\n                                    fw={500}\n                                    isLink\n                                    overflow=\"hidden\"\n                                    size=\"md\"\n                                    to={\n                                        currentSong?.albumId\n                                            ? generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {\n                                                  albumId: currentSong.albumId,\n                                              })\n                                            : ''\n                                    }\n                                >\n                                    {currentSong?.album || '—'}\n                                </Text>\n                            </div>\n                        </>\n                    )}\n                </motion.div>\n            </LayoutGroup>\n        </div>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/player/components/mobile-fullscreen-player-album-art.tsx",
    "content": "import clsx from 'clsx';\nimport { AnimatePresence, HTMLMotionProps, motion, Variants } from 'motion/react';\nimport { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';\n\nimport styles from './mobile-fullscreen-player.module.css';\n\nimport { useItemImageUrl } from '/@/renderer/components/item-image/item-image';\nimport {\n    useIsRadioActive,\n    useRadioPlayer,\n} from '/@/renderer/features/radio/hooks/use-radio-player';\nimport {\n    useFullScreenPlayerStore,\n    useImageRes,\n    usePlayerData,\n    usePlayerSong,\n} from '/@/renderer/store';\nimport { Center } from '/@/shared/components/center/center';\nimport { Icon } from '/@/shared/components/icon/icon';\nimport { PlaybackSelectors } from '/@/shared/constants/playback-selectors';\nimport { useSetState } from '/@/shared/hooks/use-set-state';\nimport { LibraryItem } from '/@/shared/types/domain-types';\n\nconst imageVariants: Variants = {\n    closed: {\n        opacity: 0,\n        transition: {\n            duration: 0.8,\n            ease: 'linear',\n        },\n    },\n    initial: {\n        opacity: 0,\n    },\n    open: (custom) => {\n        const { isOpen } = custom;\n        return {\n            opacity: isOpen ? 1 : 0,\n            transition: {\n                duration: 0.4,\n                ease: 'linear',\n            },\n        };\n    },\n};\n\nconst MotionImage = motion.img;\n\nconst ImageWithPlaceholder = ({\n    className,\n    placeholderIcon,\n    useImageAspectRatio,\n    ...props\n}: HTMLMotionProps<'img'> & {\n    placeholder?: string;\n    placeholderIcon?: 'itemAlbum' | 'radio';\n    useImageAspectRatio?: boolean;\n}) => {\n    if (!props.src) {\n        return (\n            <Center\n                style={{\n                    background: 'var(--theme-colors-surface)',\n                    borderRadius: '12px',\n                    height: '100%',\n                    width: '100%',\n                }}\n            >\n                <Icon\n                    color=\"muted\"\n                    icon={placeholderIcon === 'radio' ? 'radio' : 'itemAlbum'}\n                    size=\"25%\"\n                />\n            </Center>\n        );\n    }\n\n    return (\n        <MotionImage\n            className={clsx(styles.albumImage, className)}\n            style={{\n                objectFit: useImageAspectRatio ? 'contain' : 'cover',\n                width: useImageAspectRatio ? 'auto' : '100%',\n            }}\n            {...props}\n        />\n    );\n};\n\nexport const MobileFullscreenPlayerAlbumArt = () => {\n    const mainImageRef = useRef<HTMLImageElement | null>(null);\n    const [mainImageDimensions, setMainImageDimensions] = useState({ idealSize: 1000 });\n\n    const { fullScreenPlayer: albumArtRes } = useImageRes();\n    const { useImageAspectRatio } = useFullScreenPlayerStore();\n    const isRadioActive = useIsRadioActive();\n    const { isPlaying: isRadioPlaying } = useRadioPlayer();\n    const currentSong = usePlayerSong();\n    const { nextSong } = usePlayerData();\n\n    const isPlayingRadio = isRadioActive && isRadioPlaying;\n\n    const currentImageUrl = useItemImageUrl({\n        id: currentSong?.imageId || undefined,\n        itemType: LibraryItem.SONG,\n        size: mainImageDimensions.idealSize,\n        type: 'fullScreenPlayer',\n    });\n\n    const nextImageUrl = useItemImageUrl({\n        id: nextSong?.imageId || undefined,\n        itemType: LibraryItem.SONG,\n        size: mainImageDimensions.idealSize,\n        type: 'fullScreenPlayer',\n    });\n\n    const [imageState, setImageState] = useSetState({\n        bottomImage: nextImageUrl,\n        current: 0,\n        topImage: currentImageUrl,\n    });\n\n    const updateImageSize = useCallback(() => {\n        if (mainImageRef.current) {\n            const idealSize =\n                albumArtRes ||\n                Math.ceil((mainImageRef.current as HTMLDivElement).offsetHeight / 100) * 100;\n\n            setMainImageDimensions({ idealSize });\n        }\n    }, [albumArtRes]);\n\n    useLayoutEffect(() => {\n        updateImageSize();\n    }, [updateImageSize]);\n\n    // Track previous song to detect changes\n    const previousSongRef = useRef<string | undefined>(currentSong?._uniqueId);\n    const imageStateRef = useRef(imageState);\n\n    // Keep ref in sync\n    useEffect(() => {\n        imageStateRef.current = imageState;\n    }, [imageState]);\n\n    // Update images when song or size changes\n    useEffect(() => {\n        if (currentSong?._uniqueId === previousSongRef.current) {\n            return;\n        }\n\n        const isTop = imageStateRef.current.current === 0;\n\n        setImageState({\n            bottomImage: isTop ? currentImageUrl : nextImageUrl,\n            current: isTop ? 1 : 0,\n            topImage: isTop ? nextImageUrl : currentImageUrl,\n        });\n\n        previousSongRef.current = currentSong?._uniqueId;\n    }, [currentSong?._uniqueId, currentImageUrl, nextSong?._uniqueId, nextImageUrl, setImageState]);\n\n    return (\n        <div className={styles.imageContainer} ref={mainImageRef}>\n            <div\n                className={clsx(styles.image, {\n                    [styles.imageNativeAspectRatio]: useImageAspectRatio,\n                })}\n            >\n                <AnimatePresence initial={false} mode=\"sync\">\n                    {isPlayingRadio ? (\n                        <ImageWithPlaceholder\n                            animate=\"open\"\n                            className={PlaybackSelectors.playerCoverArt}\n                            custom={{ isOpen: true }}\n                            draggable={false}\n                            exit=\"closed\"\n                            initial=\"closed\"\n                            key=\"radio\"\n                            loading=\"eager\"\n                            placeholder=\"var(--theme-colors-foreground-muted)\"\n                            placeholderIcon=\"radio\"\n                            src=\"\"\n                            useImageAspectRatio={useImageAspectRatio}\n                            variants={imageVariants}\n                        />\n                    ) : (\n                        <>\n                            {imageState.current === 0 && (\n                                <ImageWithPlaceholder\n                                    animate=\"open\"\n                                    className={PlaybackSelectors.playerCoverArt}\n                                    custom={{ isOpen: imageState.current === 0 }}\n                                    draggable={false}\n                                    exit=\"closed\"\n                                    initial=\"closed\"\n                                    key={`top-${currentSong?._uniqueId || 'none'}`}\n                                    loading=\"eager\"\n                                    placeholder=\"var(--theme-colors-foreground-muted)\"\n                                    src={imageState.topImage || ''}\n                                    useImageAspectRatio={useImageAspectRatio}\n                                    variants={imageVariants}\n                                />\n                            )}\n\n                            {imageState.current === 1 && (\n                                <ImageWithPlaceholder\n                                    animate=\"open\"\n                                    className={PlaybackSelectors.playerCoverArt}\n                                    custom={{ isOpen: imageState.current === 1 }}\n                                    draggable={false}\n                                    exit=\"closed\"\n                                    initial=\"closed\"\n                                    key={`bottom-${currentSong?._uniqueId || 'none'}`}\n                                    loading=\"eager\"\n                                    placeholder=\"var(--theme-colors-foreground-muted)\"\n                                    src={imageState.bottomImage || ''}\n                                    useImageAspectRatio={useImageAspectRatio}\n                                    variants={imageVariants}\n                                />\n                            )}\n                        </>\n                    )}\n                </AnimatePresence>\n            </div>\n        </div>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/player/components/mobile-fullscreen-player-bottom-controls.tsx",
    "content": "import { memo, MouseEvent } from 'react';\n\nimport styles from './mobile-fullscreen-player.module.css';\n\nimport { usePlayer } from '/@/renderer/features/player/context/player-context';\nimport { usePlayerRepeat, usePlayerShuffle } from '/@/renderer/store';\nimport { ActionIcon } from '/@/shared/components/action-icon/action-icon';\nimport { Group } from '/@/shared/components/group/group';\nimport { PlayerRepeat, PlayerShuffle } from '/@/shared/types/types';\n\ninterface MobileFullscreenPlayerBottomControlsProps {\n    isLyricsActive: boolean;\n    isQueueActive: boolean;\n    onToggleContextMenu: (e: MouseEvent<HTMLButtonElement | HTMLDivElement>) => void;\n    onToggleLyrics: () => void;\n    onToggleQueue: () => void;\n}\n\nexport const MobileFullscreenPlayerBottomControls = memo(\n    ({\n        isLyricsActive,\n        isQueueActive,\n        onToggleContextMenu,\n        onToggleLyrics,\n        onToggleQueue,\n    }: MobileFullscreenPlayerBottomControlsProps) => {\n        const repeat = usePlayerRepeat();\n        const shuffle = usePlayerShuffle();\n        const { toggleRepeat, toggleShuffle } = usePlayer();\n\n        return (\n            <div className={styles.bottomControlsBar}>\n                <Group className={styles.bottomControlsGroup} gap={0}>\n                    <ActionIcon\n                        className={styles.bottomControlIcon}\n                        icon=\"mediaShuffle\"\n                        iconProps={{\n                            fill: shuffle === PlayerShuffle.NONE ? 'default' : 'primary',\n                            size: 'xl',\n                        }}\n                        onClick={toggleShuffle}\n                        variant=\"transparent\"\n                    />\n                    <ActionIcon\n                        className={styles.bottomControlIcon}\n                        icon={repeat === PlayerRepeat.ONE ? 'mediaRepeatOne' : 'mediaRepeat'}\n                        iconProps={{\n                            fill: repeat === PlayerRepeat.NONE ? 'default' : 'primary',\n                            size: 'xl',\n                        }}\n                        onClick={toggleRepeat}\n                        variant=\"transparent\"\n                    />\n                    <ActionIcon\n                        className={styles.bottomControlIcon}\n                        icon=\"queue\"\n                        iconProps={{\n                            fill: isQueueActive ? 'primary' : undefined,\n                            size: 'xl',\n                        }}\n                        onClick={onToggleQueue}\n                        variant=\"transparent\"\n                    />\n                    <ActionIcon\n                        className={styles.bottomControlIcon}\n                        icon=\"metadata\"\n                        iconProps={{\n                            fill: isLyricsActive ? 'primary' : undefined,\n                            size: 'xl',\n                        }}\n                        onClick={onToggleLyrics}\n                        variant=\"transparent\"\n                    />\n                    <ActionIcon\n                        className={styles.bottomControlIcon}\n                        icon=\"ellipsisVertical\"\n                        iconProps={{\n                            size: 'xl',\n                        }}\n                        onClick={onToggleContextMenu}\n                        variant=\"transparent\"\n                    />\n                </Group>\n            </div>\n        );\n    },\n);\n\nMobileFullscreenPlayerBottomControls.displayName = 'MobileFullscreenPlayerBottomControls';\n"
  },
  {
    "path": "src/renderer/features/player/components/mobile-fullscreen-player-controls.tsx",
    "content": "import { memo } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport styles from './mobile-fullscreen-player.module.css';\n\nimport { MainPlayButton, PlayerButton } from '/@/renderer/features/player/components/player-button';\nimport { usePlayer } from '/@/renderer/features/player/context/player-context';\nimport { usePlayerStatus } from '/@/renderer/store';\nimport { Icon } from '/@/shared/components/icon/icon';\nimport { QueueSong } from '/@/shared/types/domain-types';\nimport { PlayerStatus } from '/@/shared/types/types';\n\ninterface MobileFullscreenPlayerControlsProps {\n    currentSong?: QueueSong;\n}\n\nexport const MobileFullscreenPlayerControls = memo(\n    ({ currentSong }: MobileFullscreenPlayerControlsProps) => {\n        const currentSongId = currentSong?.id;\n        const { t } = useTranslation();\n        const status = usePlayerStatus();\n        const {\n            mediaNext,\n            mediaPrevious,\n            mediaSkipBackward,\n            mediaSkipForward,\n            mediaTogglePlayPause,\n        } = usePlayer();\n\n        return (\n            <div className={styles.controlsContainer}>\n                <PlayerButton\n                    icon={<Icon fill=\"default\" icon=\"mediaPrevious\" size=\"xl\" />}\n                    onClick={mediaPrevious}\n                    tooltip={{\n                        label: t('player.previous', { postProcess: 'sentenceCase' }),\n                        openDelay: 0,\n                    }}\n                    variant=\"secondary\"\n                />\n                <PlayerButton\n                    icon={<Icon fill=\"default\" icon=\"mediaStepBackward\" size=\"xl\" />}\n                    onClick={mediaSkipBackward}\n                    tooltip={{\n                        label: t('player.skip', {\n                            context: 'back',\n                            postProcess: 'sentenceCase',\n                        }),\n                        openDelay: 0,\n                    }}\n                    variant=\"tertiary\"\n                />\n                <MainPlayButton\n                    disabled={currentSongId === undefined}\n                    isPaused={status === PlayerStatus.PAUSED}\n                    onClick={mediaTogglePlayPause}\n                    style={{\n                        height: '50px',\n                        width: '50px',\n                    }}\n                />\n                <PlayerButton\n                    icon={<Icon fill=\"default\" icon=\"mediaStepForward\" size=\"xl\" />}\n                    onClick={mediaSkipForward}\n                    tooltip={{\n                        label: t('player.skip', {\n                            context: 'forward',\n                            postProcess: 'sentenceCase',\n                        }),\n                        openDelay: 0,\n                    }}\n                    variant=\"tertiary\"\n                />\n                <PlayerButton\n                    icon={<Icon fill=\"default\" icon=\"mediaNext\" size=\"xl\" />}\n                    onClick={mediaNext}\n                    tooltip={{\n                        label: t('player.next', { postProcess: 'sentenceCase' }),\n                        openDelay: 0,\n                    }}\n                    variant=\"secondary\"\n                />\n            </div>\n        );\n    },\n);\n\nMobileFullscreenPlayerControls.displayName = 'MobileFullscreenPlayerControls';\n"
  },
  {
    "path": "src/renderer/features/player/components/mobile-fullscreen-player-header.tsx",
    "content": "import { memo } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport styles from './mobile-fullscreen-player.module.css';\n\nimport { SONG_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns';\nimport {\n    ListConfigMenu,\n    SONG_DISPLAY_TYPES,\n} from '/@/renderer/features/shared/components/list-config-menu';\nimport {\n    useFullScreenPlayerStore,\n    useFullScreenPlayerStoreActions,\n    useLyricsDisplaySettings,\n    useLyricsSettings,\n    useSettingsStore,\n    useSettingsStoreActions,\n} from '/@/renderer/store';\nimport { ActionIcon } from '/@/shared/components/action-icon/action-icon';\nimport { Divider } from '/@/shared/components/divider/divider';\nimport { Group } from '/@/shared/components/group/group';\nimport { NumberInput } from '/@/shared/components/number-input/number-input';\nimport { Option } from '/@/shared/components/option/option';\nimport { Popover } from '/@/shared/components/popover/popover';\nimport { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control';\nimport { Slider } from '/@/shared/components/slider/slider';\nimport { Switch } from '/@/shared/components/switch/switch';\nimport { QueueSong } from '/@/shared/types/domain-types';\nimport { ItemListKey, ListDisplayType } from '/@/shared/types/types';\n\ninterface MobileFullscreenPlayerHeaderProps {\n    currentSong?: QueueSong;\n    isPageHovered: boolean;\n    onClose: () => void;\n}\n\nexport const MobileFullscreenPlayerHeader = memo(\n    ({ isPageHovered, onClose }: MobileFullscreenPlayerHeaderProps) => {\n        const { t } = useTranslation();\n        const {\n            dynamicBackground,\n            dynamicImageBlur,\n            dynamicIsImage,\n            opacity,\n            useImageAspectRatio,\n        } = useFullScreenPlayerStore();\n        const { setStore } = useFullScreenPlayerStoreActions();\n        const { setSettings } = useSettingsStoreActions();\n        const lyricsSettings = useLyricsSettings();\n        const displaySettings = useLyricsDisplaySettings('default');\n        const lyricConfig = { ...lyricsSettings, ...displaySettings };\n\n        const handleLyricsSettings = (property: string, value: any) => {\n            const displayProperties = ['fontSize', 'fontSizeUnsync', 'gap', 'gapUnsync'];\n            if (displayProperties.includes(property)) {\n                const currentDisplay = useSettingsStore.getState().lyricsDisplay;\n                setSettings({\n                    lyricsDisplay: {\n                        ...currentDisplay,\n                        default: {\n                            ...currentDisplay.default,\n                            [property]: value,\n                        },\n                    },\n                });\n            } else {\n                setSettings({\n                    lyrics: {\n                        ...useSettingsStore.getState().lyrics,\n                        [property]: value,\n                    },\n                });\n            }\n        };\n\n        return (\n            <div\n                className={styles.header}\n                style={{\n                    background: `rgb(var(--theme-colors-background-transparent), ${opacity}%)`,\n                }}\n            >\n                <ActionIcon\n                    icon=\"arrowDownS\"\n                    iconProps={{ size: 'lg' }}\n                    onClick={onClose}\n                    tooltip={{ label: t('common.minimize', { postProcess: 'titleCase' }) }}\n                    variant={isPageHovered ? 'default' : 'subtle'}\n                />\n                <Popover position=\"bottom-end\">\n                    <Popover.Target>\n                        <ActionIcon\n                            icon=\"settings2\"\n                            iconProps={{ size: 'lg' }}\n                            tooltip={{ label: t('common.configure', { postProcess: 'titleCase' }) }}\n                            variant={isPageHovered ? 'default' : 'subtle'}\n                        />\n                    </Popover.Target>\n                    <Popover.Dropdown>\n                        <Option>\n                            <Option.Label>\n                                {t('page.fullscreenPlayer.config.dynamicBackground', {\n                                    postProcess: 'sentenceCase',\n                                })}\n                            </Option.Label>\n                            <Option.Control>\n                                <Switch\n                                    defaultChecked={dynamicBackground}\n                                    onChange={(e) =>\n                                        setStore({\n                                            dynamicBackground: e.target.checked,\n                                        })\n                                    }\n                                />\n                            </Option.Control>\n                        </Option>\n                        {dynamicBackground && (\n                            <Option>\n                                <Option.Label>\n                                    {t('page.fullscreenPlayer.config.dynamicIsImage', {\n                                        postProcess: 'sentenceCase',\n                                    })}\n                                </Option.Label>\n                                <Option.Control>\n                                    <Switch\n                                        defaultChecked={dynamicIsImage}\n                                        onChange={(e) =>\n                                            setStore({\n                                                dynamicIsImage: e.target.checked,\n                                            })\n                                        }\n                                    />\n                                </Option.Control>\n                            </Option>\n                        )}\n                        {dynamicBackground && dynamicIsImage && (\n                            <Option>\n                                <Option.Label>\n                                    {t('page.fullscreenPlayer.config.dynamicImageBlur', {\n                                        postProcess: 'sentenceCase',\n                                    })}\n                                </Option.Label>\n                                <Option.Control>\n                                    <Slider\n                                        defaultValue={dynamicImageBlur}\n                                        label={(e) => `${e} rem`}\n                                        max={6}\n                                        min={0}\n                                        onChangeEnd={(e) =>\n                                            setStore({ dynamicImageBlur: Number(e) })\n                                        }\n                                        step={0.5}\n                                        w=\"100%\"\n                                    />\n                                </Option.Control>\n                            </Option>\n                        )}\n                        {dynamicBackground && (\n                            <Option>\n                                <Option.Label>\n                                    {t('page.fullscreenPlayer.config.opacity', {\n                                        postProcess: 'sentenceCase',\n                                    })}\n                                </Option.Label>\n                                <Option.Control>\n                                    <Slider\n                                        defaultValue={opacity}\n                                        label={(e) => `${e} %`}\n                                        max={100}\n                                        min={0}\n                                        onChangeEnd={(e) => setStore({ opacity: Number(e) })}\n                                        w=\"100%\"\n                                    />\n                                </Option.Control>\n                            </Option>\n                        )}\n                        <Option>\n                            <Option.Label>\n                                {t('page.fullscreenPlayer.config.useImageAspectRatio', {\n                                    postProcess: 'sentenceCase',\n                                })}\n                            </Option.Label>\n                            <Option.Control>\n                                <Switch\n                                    checked={useImageAspectRatio}\n                                    onChange={(e) =>\n                                        setStore({\n                                            useImageAspectRatio: e.target.checked,\n                                        })\n                                    }\n                                />\n                            </Option.Control>\n                        </Option>\n                        <Divider my=\"sm\" />\n                        <Option>\n                            <Option.Label>\n                                {t('page.fullscreenPlayer.config.followCurrentLyric', {\n                                    postProcess: 'sentenceCase',\n                                })}\n                            </Option.Label>\n                            <Option.Control>\n                                <Switch\n                                    checked={lyricConfig.follow}\n                                    onChange={(e) =>\n                                        handleLyricsSettings('follow', e.currentTarget.checked)\n                                    }\n                                />\n                            </Option.Control>\n                        </Option>\n                        <Option>\n                            <Option.Label>\n                                {t('page.fullscreenPlayer.config.showLyricProvider', {\n                                    postProcess: 'sentenceCase',\n                                })}\n                            </Option.Label>\n                            <Option.Control>\n                                <Switch\n                                    checked={lyricConfig.showProvider}\n                                    onChange={(e) =>\n                                        handleLyricsSettings(\n                                            'showProvider',\n                                            e.currentTarget.checked,\n                                        )\n                                    }\n                                />\n                            </Option.Control>\n                        </Option>\n                        <Option>\n                            <Option.Label>\n                                {t('page.fullscreenPlayer.config.showLyricMatch', {\n                                    postProcess: 'sentenceCase',\n                                })}\n                            </Option.Label>\n                            <Option.Control>\n                                <Switch\n                                    checked={lyricConfig.showMatch}\n                                    onChange={(e) =>\n                                        handleLyricsSettings('showMatch', e.currentTarget.checked)\n                                    }\n                                />\n                            </Option.Control>\n                        </Option>\n                        <Option>\n                            <Option.Label>\n                                {t('page.fullscreenPlayer.config.lyricSize', {\n                                    postProcess: 'sentenceCase',\n                                })}\n                            </Option.Label>\n                            <Option.Control>\n                                <Group w=\"100%\" wrap=\"nowrap\">\n                                    <Slider\n                                        defaultValue={lyricConfig.fontSize}\n                                        label={(e) =>\n                                            `${t('page.fullscreenPlayer.config.synchronized', {\n                                                postProcess: 'titleCase',\n                                            })}: ${e}px`\n                                        }\n                                        max={72}\n                                        min={8}\n                                        onChangeEnd={(e) =>\n                                            handleLyricsSettings('fontSize', Number(e))\n                                        }\n                                        w=\"100%\"\n                                    />\n                                    <Slider\n                                        defaultValue={lyricConfig.fontSizeUnsync}\n                                        label={(e) =>\n                                            `${t('page.fullscreenPlayer.config.unsynchronized', {\n                                                postProcess: 'sentenceCase',\n                                            })}: ${e}px`\n                                        }\n                                        max={72}\n                                        min={8}\n                                        onChangeEnd={(e) =>\n                                            handleLyricsSettings('fontSizeUnsync', Number(e))\n                                        }\n                                        w=\"100%\"\n                                    />\n                                </Group>\n                            </Option.Control>\n                        </Option>\n                        <Option>\n                            <Option.Label>\n                                {t('page.fullscreenPlayer.config.lyricGap', {\n                                    postProcess: 'sentenceCase',\n                                })}\n                            </Option.Label>\n                            <Option.Control>\n                                <Group w=\"100%\" wrap=\"nowrap\">\n                                    <Slider\n                                        defaultValue={lyricConfig.gap}\n                                        label={(e) => `Synchronized: ${e}px`}\n                                        max={50}\n                                        min={0}\n                                        onChangeEnd={(e) => handleLyricsSettings('gap', Number(e))}\n                                        w=\"100%\"\n                                    />\n                                    <Slider\n                                        defaultValue={lyricConfig.gapUnsync}\n                                        label={(e) => `Unsynchronized: ${e}px`}\n                                        max={50}\n                                        min={0}\n                                        onChangeEnd={(e) =>\n                                            handleLyricsSettings('gapUnsync', Number(e))\n                                        }\n                                        w=\"100%\"\n                                    />\n                                </Group>\n                            </Option.Control>\n                        </Option>\n                        <Option>\n                            <Option.Label>\n                                {t('page.fullscreenPlayer.config.lyricAlignment', {\n                                    postProcess: 'sentenceCase',\n                                })}\n                            </Option.Label>\n                            <Option.Control>\n                                <SegmentedControl\n                                    data={[\n                                        {\n                                            label: t('common.left', {\n                                                postProcess: 'titleCase',\n                                            }),\n                                            value: 'left',\n                                        },\n                                        {\n                                            label: t('common.center', {\n                                                postProcess: 'titleCase',\n                                            }),\n                                            value: 'center',\n                                        },\n                                        {\n                                            label: t('common.right', {\n                                                postProcess: 'titleCase',\n                                            }),\n                                            value: 'right',\n                                        },\n                                    ]}\n                                    onChange={(e) => handleLyricsSettings('alignment', e)}\n                                    value={lyricConfig.alignment}\n                                />\n                            </Option.Control>\n                        </Option>\n                        <Option>\n                            <Option.Label>\n                                {t('page.fullscreenPlayer.config.lyricOffset', {\n                                    postProcess: 'sentenceCase',\n                                })}\n                            </Option.Label>\n                            <Option.Control>\n                                <NumberInput\n                                    defaultValue={lyricConfig.delayMs}\n                                    hideControls={false}\n                                    onBlur={(e) =>\n                                        handleLyricsSettings(\n                                            'delayMs',\n                                            Number(e.currentTarget.value),\n                                        )\n                                    }\n                                    step={10}\n                                />\n                            </Option.Control>\n                        </Option>\n                        <Divider my=\"sm\" />\n                    </Popover.Dropdown>\n                </Popover>\n                <ListConfigMenu\n                    buttonProps={{\n                        variant: isPageHovered ? 'default' : 'subtle',\n                    }}\n                    displayTypes={[\n                        { hidden: true, value: ListDisplayType.GRID },\n                        ...SONG_DISPLAY_TYPES,\n                    ]}\n                    listKey={ItemListKey.FULL_SCREEN}\n                    optionsConfig={{\n                        table: {\n                            itemsPerPage: { hidden: true },\n                            pagination: { hidden: true },\n                        },\n                    }}\n                    tableColumnsData={SONG_TABLE_COLUMNS}\n                />\n            </div>\n        );\n    },\n);\n\nMobileFullscreenPlayerHeader.displayName = 'MobileFullscreenPlayerHeader';\n"
  },
  {
    "path": "src/renderer/features/player/components/mobile-fullscreen-player-metadata.tsx",
    "content": "import clsx from 'clsx';\nimport { memo, MouseEvent } from 'react';\n\nimport styles from './mobile-fullscreen-player.module.css';\n\nimport { ActionIcon } from '/@/shared/components/action-icon/action-icon';\nimport { Group } from '/@/shared/components/group/group';\nimport { Rating } from '/@/shared/components/rating/rating';\nimport { Separator } from '/@/shared/components/separator/separator';\nimport { TextTitle } from '/@/shared/components/text-title/text-title';\nimport { Text } from '/@/shared/components/text/text';\nimport { PlaybackSelectors } from '/@/shared/constants/playback-selectors';\nimport { QueueSong } from '/@/shared/types/domain-types';\n\ninterface MobileFullscreenPlayerMetadataProps {\n    currentSong?: QueueSong;\n    onToggleFavorite: (e: MouseEvent<HTMLButtonElement>) => void;\n    onUpdateRating: (rating: number) => void;\n    radioArtist?: string;\n    radioStationName?: string;\n    radioTitle?: string;\n    showRating?: boolean;\n}\n\nexport const MobileFullscreenPlayerMetadata = memo(\n    ({\n        currentSong,\n        onToggleFavorite,\n        onUpdateRating,\n        radioArtist,\n        radioStationName,\n        radioTitle,\n        showRating,\n    }: MobileFullscreenPlayerMetadataProps) => {\n        const isRadio = radioTitle !== undefined || radioStationName !== undefined;\n\n        const title = isRadio ? radioTitle || radioStationName || 'Radio' : currentSong?.name;\n        const artistsDisplay = isRadio\n            ? radioArtist || radioStationName || '—'\n            : currentSong?.artists?.map((a) => a.name).join(', ');\n        const album = isRadio ? radioStationName || '—' : currentSong?.album;\n        const container = currentSong?.container;\n        const year = currentSong?.releaseYear;\n        const isFavorite = currentSong?.userFavorite;\n        const rating = currentSong?.userRating;\n\n        const hasMetadata = !isRadio && (container || year);\n\n        return (\n            <div className={styles.metadataContainer}>\n                <div className={styles.titleRow}>\n                    <TextTitle\n                        className={PlaybackSelectors.songTitle}\n                        fw={700}\n                        order={2}\n                        ta=\"center\"\n                    >\n                        {title || '—'}\n                    </TextTitle>\n                </div>\n                <Text className={clsx(PlaybackSelectors.songArtist)} size=\"md\" truncate>\n                    {artistsDisplay || '—'}\n                </Text>\n                <Text className={clsx(PlaybackSelectors.songAlbum)} size=\"md\" truncate>\n                    {album || '—'}\n                </Text>\n                {hasMetadata && (\n                    <Group align=\"center\" className={styles.metadataRow} gap=\"xs\" wrap=\"nowrap\">\n                        {container && <Text size=\"xs\">{container}</Text>}\n                        {year && (\n                            <>\n                                {container && <Separator />}\n                                <Text size=\"xs\">{year}</Text>\n                            </>\n                        )}\n                    </Group>\n                )}\n                {!isRadio && (\n                    <Group align=\"center\" className={styles.actionsRow} gap=\"xs\">\n                        <ActionIcon\n                            icon=\"favorite\"\n                            iconProps={{\n                                fill: isFavorite ? 'primary' : undefined,\n                                size: 'md',\n                            }}\n                            onClick={onToggleFavorite}\n                            size=\"sm\"\n                            variant=\"subtle\"\n                        />\n                        {showRating && (\n                            <Rating onChange={onUpdateRating} size=\"sm\" value={rating || 0} />\n                        )}\n                    </Group>\n                )}\n            </div>\n        );\n    },\n);\n\nMobileFullscreenPlayerMetadata.displayName = 'MobileFullscreenPlayerMetadata';\n"
  },
  {
    "path": "src/renderer/features/player/components/mobile-fullscreen-player-progress.tsx",
    "content": "import formatDuration from 'format-duration';\nimport { lazy, memo, Suspense } from 'react';\n\nimport styles from './mobile-fullscreen-player.module.css';\n\nimport { PlayerbarSeekSlider } from '/@/renderer/features/player/components/playerbar-seek-slider';\nimport { usePlayerTimestamp } from '/@/renderer/store';\nimport { PlayerbarSliderType, usePlayerbarSlider } from '/@/renderer/store/settings.store';\nimport { Spinner } from '/@/shared/components/spinner/spinner';\nimport { Text } from '/@/shared/components/text/text';\nimport { PlaybackSelectors } from '/@/shared/constants/playback-selectors';\nimport { QueueSong } from '/@/shared/types/domain-types';\n\nconst PlayerbarWaveform = lazy(() =>\n    import('/@/renderer/features/player/components/playerbar-waveform').then((module) => ({\n        default: module.PlayerbarWaveform,\n    })),\n);\n\ninterface MobileFullscreenPlayerProgressProps {\n    currentSong?: QueueSong;\n}\n\nexport const MobileFullscreenPlayerProgress = memo(\n    ({ currentSong }: MobileFullscreenPlayerProgressProps) => {\n        const currentTime = usePlayerTimestamp();\n        const playerbarSlider = usePlayerbarSlider();\n        const songDuration = currentSong?.duration ? currentSong.duration / 1000 : 0;\n        const formattedDuration = formatDuration(songDuration * 1000 || 0);\n        const formattedTime = formatDuration(currentTime * 1000 || 0);\n\n        const isWaveform = playerbarSlider?.type === PlayerbarSliderType.WAVEFORM;\n\n        return (\n            <div className={styles.progressContainer}>\n                <div className={styles.timeContainer}>\n                    <Text\n                        className={PlaybackSelectors.elapsedTime}\n                        size=\"xs\"\n                        style={{ textAlign: 'right' }}\n                    >\n                        {formattedTime}\n                    </Text>\n                </div>\n                <div className={styles.sliderWrapper}>\n                    {isWaveform ? (\n                        <Suspense fallback={<Spinner />}>\n                            <PlayerbarWaveform />\n                        </Suspense>\n                    ) : (\n                        <PlayerbarSeekSlider max={songDuration} min={0} />\n                    )}\n                </div>\n                <div className={styles.timeContainer}>\n                    <Text\n                        className={PlaybackSelectors.totalDuration}\n                        size=\"xs\"\n                        style={{ textAlign: 'left' }}\n                    >\n                        {formattedDuration}\n                    </Text>\n                </div>\n            </div>\n        );\n    },\n);\n\nMobileFullscreenPlayerProgress.displayName = 'MobileFullscreenPlayerProgress';\n"
  },
  {
    "path": "src/renderer/features/player/components/mobile-fullscreen-player.module.css",
    "content": ".container {\n    position: absolute;\n    top: 0;\n    left: 0;\n    width: 100%;\n    height: 100%;\n    overflow: hidden;\n    background: var(--theme-colors-background);\n}\n\n.background-image {\n    position: absolute;\n    top: 0;\n    left: 0;\n    z-index: -2;\n    width: 100%;\n    height: 100%;\n    background-repeat: no-repeat;\n    background-position: center;\n    background-size: cover;\n}\n\n.background-image-overlay {\n    position: absolute;\n    top: 0;\n    left: 0;\n    z-index: -1;\n    width: 100%;\n    height: 100%;\n    background: var(--theme-overlay-header);\n    backdrop-filter: blur(var(--image-blur));\n}\n\n.player-state,\n.queue-state,\n.lyrics-state {\n    position: absolute;\n    top: 0;\n    right: 0;\n    bottom: 0;\n    left: 0;\n    z-index: 1;\n    display: flex;\n    flex-direction: column;\n    width: 100%;\n    height: 100%;\n    overflow: hidden;\n    pointer-events: none;\n    background: transparent;\n}\n\n.player-state[style*='z-index: 2'],\n.queue-state[style*='z-index: 2'],\n.lyrics-state[style*='z-index: 2'] {\n    pointer-events: auto;\n}\n\n.header {\n    display: flex;\n    flex-shrink: 0;\n    gap: 0.5rem;\n    align-items: center;\n    justify-content: flex-start;\n    width: 100%;\n    padding: 1rem;\n}\n\n.header-info {\n    display: flex;\n    flex: 1;\n    flex-direction: column;\n    gap: 0.25rem;\n    align-items: center;\n}\n\n.image-container {\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    width: 100%;\n    min-height: 0;\n    padding: 1rem;\n    padding-top: 0.5rem;\n}\n\n.image {\n    position: relative;\n    width: 100%;\n    max-width: 400px;\n    max-height: 100%;\n    aspect-ratio: 1;\n    overflow: hidden;\n    border-radius: 12px;\n    box-shadow: 0 8px 24px rgb(0 0 0 / 30%);\n}\n\n.image-native-aspect-ratio {\n    height: auto;\n    max-height: 100%;\n    aspect-ratio: auto;\n}\n\n.album-image {\n    position: absolute;\n    top: 0;\n    left: 0;\n    width: 100%;\n    height: 100%;\n    object-fit: contain;\n}\n\n.metadata-container {\n    display: flex;\n    flex-shrink: 0;\n    flex-direction: column;\n    gap: 0.5rem;\n    align-items: center;\n    width: 100%;\n    padding: 1rem;\n    padding-top: 1.5rem;\n}\n\n.title-row {\n    display: flex;\n    gap: 0.75rem;\n    align-items: center;\n    justify-content: center;\n    width: 100%;\n}\n\n.metadata-row {\n    justify-content: center;\n    width: 100%;\n}\n\n.actions-row {\n    justify-content: center;\n    width: 100%;\n}\n\n.progress-container {\n    display: flex;\n    flex-shrink: 0;\n    gap: 1rem;\n    align-items: center;\n    width: 100%;\n    padding: 0 1rem;\n    padding-bottom: 1.5rem;\n}\n\n.time-container {\n    flex-shrink: 0;\n    min-width: 3.5ch;\n}\n\n.slider-wrapper {\n    flex: 1;\n}\n\n.controls-container {\n    display: flex;\n    flex-shrink: 0;\n    gap: 1rem;\n    align-items: center;\n    justify-content: center;\n    width: 100%;\n    padding: 0 1rem;\n    padding-bottom: 1.5rem;\n}\n\n.bottom-controls-bar {\n    display: flex;\n    flex-shrink: 0;\n    align-items: stretch;\n    justify-content: center;\n    width: 100%;\n    margin-top: auto;\n    border-top: 1px solid var(--theme-colors-border);\n}\n\n.bottom-controls-group {\n    display: flex;\n    width: 100%;\n    height: 100%;\n\n    button {\n        padding: 1.5rem;\n    }\n}\n\n.bottom-control-icon {\n    flex: 1;\n    width: 100%;\n    height: 100%;\n    min-height: 100%;\n}\n\n.queue-header,\n.lyrics-header {\n    display: flex;\n    flex-shrink: 0;\n    align-items: center;\n    justify-content: space-between;\n    width: 100%;\n    padding: 1rem;\n}\n\n.queue-content,\n.lyrics-content {\n    flex: 1 1 auto;\n    min-height: 0;\n    padding: 1rem;\n    overflow-y: auto;\n    -webkit-overflow-scrolling: touch;\n}\n"
  },
  {
    "path": "src/renderer/features/player/components/mobile-fullscreen-player.tsx",
    "content": "import { AnimatePresence, motion } from 'motion/react';\nimport { Variants } from 'motion/react';\nimport {\n    CSSProperties,\n    memo,\n    MouseEvent,\n    ReactNode,\n    useCallback,\n    useEffect,\n    useRef,\n    useState,\n} from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport styles from './mobile-fullscreen-player.module.css';\n\nimport { useItemImageUrl } from '/@/renderer/components/item-image/item-image';\nimport { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';\nimport { Lyrics } from '/@/renderer/features/lyrics/lyrics';\nimport { PlayQueue } from '/@/renderer/features/now-playing/components/play-queue';\nimport { MobileFullscreenPlayerAlbumArt } from '/@/renderer/features/player/components/mobile-fullscreen-player-album-art';\nimport { MobileFullscreenPlayerBottomControls } from '/@/renderer/features/player/components/mobile-fullscreen-player-bottom-controls';\nimport { MobileFullscreenPlayerControls } from '/@/renderer/features/player/components/mobile-fullscreen-player-controls';\nimport { MobileFullscreenPlayerHeader } from '/@/renderer/features/player/components/mobile-fullscreen-player-header';\nimport { MobileFullscreenPlayerMetadata } from '/@/renderer/features/player/components/mobile-fullscreen-player-metadata';\nimport { MobileFullscreenPlayerProgress } from '/@/renderer/features/player/components/mobile-fullscreen-player-progress';\nimport {\n    useIsRadioActive,\n    useRadioPlayer,\n} from '/@/renderer/features/radio/hooks/use-radio-player';\nimport { useSetFavorite } from '/@/renderer/features/shared/hooks/use-set-favorite';\nimport { useSetRating } from '/@/renderer/features/shared/hooks/use-set-rating';\nimport { useFastAverageColor } from '/@/renderer/hooks';\nimport {\n    useCurrentServer,\n    useFullScreenPlayerStore,\n    useFullScreenPlayerStoreActions,\n    useGeneralSettings,\n    usePlayerData,\n    usePlayerSong,\n    useSetFullScreenPlayerStore,\n} from '/@/renderer/store';\nimport { ActionIcon } from '/@/shared/components/action-icon/action-icon';\nimport { Text } from '/@/shared/components/text/text';\nimport { LibraryItem, ServerType } from '/@/shared/types/domain-types';\nimport { ItemListKey } from '/@/shared/types/types';\n\nconst mainBackground = 'var(--theme-colors-background)';\n\nconst backgroundImageVariants: Variants = {\n    closed: {\n        opacity: 0,\n        transition: {\n            duration: 0.8,\n            ease: 'linear',\n        },\n    },\n    initial: {\n        opacity: 0,\n    },\n    open: (custom) => {\n        const { isOpen } = custom;\n        return {\n            opacity: isOpen ? 1 : 0,\n            transition: {\n                duration: 0.4,\n                ease: 'linear',\n            },\n        };\n    },\n};\n\ninterface BackgroundImageProps {\n    dynamicBackground: boolean | undefined;\n    dynamicIsImage: boolean | undefined;\n}\n\nconst BackgroundImage = memo(({ dynamicBackground, dynamicIsImage }: BackgroundImageProps) => {\n    const currentSong = usePlayerSong();\n    const { nextSong } = usePlayerData();\n\n    const currentImageUrl = useItemImageUrl({\n        id: currentSong?.imageId || undefined,\n        itemType: LibraryItem.SONG,\n        type: 'itemCard',\n    });\n\n    const nextImageUrl = useItemImageUrl({\n        id: nextSong?.imageId || undefined,\n        itemType: LibraryItem.SONG,\n        type: 'itemCard',\n    });\n\n    const [imageState, setImageState] = useState({\n        bottomImage: nextImageUrl,\n        current: 0,\n        topImage: currentImageUrl,\n    });\n\n    const previousSongRef = useRef<string | undefined>(currentSong?._uniqueId);\n    const imageStateRef = useRef(imageState);\n\n    useEffect(() => {\n        imageStateRef.current = imageState;\n    }, [imageState]);\n\n    // Update images when song changes\n    useEffect(() => {\n        if (currentSong?._uniqueId === previousSongRef.current) {\n            return;\n        }\n\n        const isTop = imageStateRef.current.current === 0;\n\n        setImageState({\n            bottomImage: isTop ? currentImageUrl : nextImageUrl,\n            current: isTop ? 1 : 0,\n            topImage: isTop ? nextImageUrl : currentImageUrl,\n        });\n\n        previousSongRef.current = currentSong?._uniqueId;\n    }, [currentSong?._uniqueId, currentImageUrl, nextSong?._uniqueId, nextImageUrl]);\n\n    if (!dynamicBackground || !dynamicIsImage) {\n        return null;\n    }\n\n    const getBackgroundImageUrl = (\n        imageUrl: string | undefined,\n        songId: string | undefined,\n        albumId: string | undefined,\n    ) => {\n        if (!imageUrl || !songId || !albumId) {\n            return imageUrl;\n        }\n        return imageUrl.replace(songId, albumId);\n    };\n\n    // Determine which song IDs to use for keys and image URLs\n    const topSongId = imageState.current === 0 ? currentSong?._uniqueId : nextSong?._uniqueId;\n    const bottomSongId = imageState.current === 0 ? nextSong?._uniqueId : currentSong?._uniqueId;\n    const topSong = imageState.current === 0 ? currentSong : nextSong;\n    const bottomSong = imageState.current === 0 ? nextSong : currentSong;\n\n    return (\n        <AnimatePresence initial={false} mode=\"sync\">\n            {imageState.current === 0 && imageState.topImage && (\n                <motion.div\n                    animate=\"open\"\n                    className={styles.backgroundImage}\n                    custom={{ isOpen: imageState.current === 0 }}\n                    exit=\"closed\"\n                    initial=\"open\"\n                    key={`top-${topSongId || 'none'}`}\n                    style={\n                        {\n                            backgroundImage: imageState.topImage\n                                ? `url(\"${getBackgroundImageUrl(\n                                      imageState.topImage,\n                                      topSong?.id,\n                                      topSong?.albumId,\n                                  )}\"), url(\"${imageState.topImage}\")`\n                                : undefined,\n                        } as CSSProperties\n                    }\n                    variants={backgroundImageVariants}\n                />\n            )}\n\n            {imageState.current === 1 && imageState.bottomImage && (\n                <motion.div\n                    animate=\"open\"\n                    className={styles.backgroundImage}\n                    custom={{ isOpen: imageState.current === 1 }}\n                    exit=\"closed\"\n                    initial=\"open\"\n                    key={`bottom-${bottomSongId || 'none'}`}\n                    style={\n                        {\n                            backgroundImage: imageState.bottomImage\n                                ? `url(\"${getBackgroundImageUrl(\n                                      imageState.bottomImage,\n                                      bottomSong?.id,\n                                      bottomSong?.albumId,\n                                  )}\"), url(\"${imageState.bottomImage}\")`\n                                : undefined,\n                        } as CSSProperties\n                    }\n                    variants={backgroundImageVariants}\n                />\n            )}\n        </AnimatePresence>\n    );\n});\n\nBackgroundImage.displayName = 'BackgroundImage';\n\nconst overlayVariants: Variants = {\n    closed: {\n        opacity: 0,\n        transition: {\n            duration: 0,\n        },\n    },\n    initial: {\n        opacity: 1,\n    },\n    open: {\n        opacity: 1,\n        transition: {\n            duration: 0,\n        },\n    },\n};\n\ninterface BackgroundImageOverlayProps {\n    dynamicBackground: boolean | undefined;\n    dynamicImageBlur: number | undefined;\n}\n\nconst BackgroundImageOverlay = memo(\n    ({ dynamicBackground, dynamicImageBlur }: BackgroundImageOverlayProps) => {\n        const currentSong = usePlayerSong();\n        const { nextSong } = usePlayerData();\n\n        const [overlayState, setOverlayState] = useState({\n            bottomSongId: nextSong?._uniqueId,\n            current: 0,\n            topSongId: currentSong?._uniqueId,\n        });\n\n        const previousSongRef = useRef<string | undefined>(currentSong?._uniqueId);\n        const overlayStateRef = useRef(overlayState);\n\n        useEffect(() => {\n            overlayStateRef.current = overlayState;\n        }, [overlayState]);\n\n        // Update overlays when song changes\n        useEffect(() => {\n            if (currentSong?._uniqueId === previousSongRef.current) {\n                return;\n            }\n\n            const isTop = overlayStateRef.current.current === 0;\n\n            setOverlayState({\n                bottomSongId: isTop ? currentSong?._uniqueId : nextSong?._uniqueId,\n                current: isTop ? 1 : 0,\n                topSongId: isTop ? nextSong?._uniqueId : currentSong?._uniqueId,\n            });\n\n            previousSongRef.current = currentSong?._uniqueId;\n        }, [currentSong?._uniqueId, nextSong?._uniqueId]);\n\n        if (!dynamicBackground) {\n            return null;\n        }\n\n        return (\n            <AnimatePresence initial={false} mode=\"sync\">\n                {overlayState.current === 0 && (\n                    <motion.div\n                        animate=\"open\"\n                        className={styles.backgroundImageOverlay}\n                        exit=\"closed\"\n                        initial=\"open\"\n                        key={`top-${overlayState.topSongId || 'none'}`}\n                        style={\n                            {\n                                '--image-blur': `${dynamicImageBlur ?? 0}rem`,\n                            } as CSSProperties\n                        }\n                        variants={overlayVariants}\n                    />\n                )}\n\n                {overlayState.current === 1 && (\n                    <motion.div\n                        animate=\"open\"\n                        className={styles.backgroundImageOverlay}\n                        exit=\"closed\"\n                        initial=\"open\"\n                        key={`bottom-${overlayState.bottomSongId || 'none'}`}\n                        style={\n                            {\n                                '--image-blur': `${dynamicImageBlur ?? 0}rem`,\n                            } as CSSProperties\n                        }\n                        variants={overlayVariants}\n                    />\n                )}\n            </AnimatePresence>\n        );\n    },\n);\n\nBackgroundImageOverlay.displayName = 'BackgroundImageOverlay';\n\ninterface MobilePlayerContainerProps {\n    children: ReactNode;\n    dynamicBackground: boolean | undefined;\n    dynamicIsImage: boolean | undefined;\n}\n\nconst MobilePlayerContainer = memo(\n    ({ children, dynamicBackground, dynamicIsImage }: MobilePlayerContainerProps) => {\n        const currentSong = usePlayerSong();\n        const imageUrl = useItemImageUrl({\n            id: currentSong?.imageId || undefined,\n            imageUrl: currentSong?.imageUrl,\n            itemType: LibraryItem.SONG,\n            type: 'itemCard',\n        });\n        const { background } = useFastAverageColor({\n            algorithm: 'dominant',\n            src: imageUrl,\n            srcLoaded: true,\n        });\n\n        let backgroundColor = mainBackground;\n        if (dynamicBackground) {\n            if (dynamicIsImage && background) {\n                const rgbMatch = background.match(/rgb\\((\\d+),\\s*(\\d+),\\s*(\\d+)\\)/);\n                if (rgbMatch) {\n                    backgroundColor = `rgba(${rgbMatch[1]}, ${rgbMatch[2]}, ${rgbMatch[3]}, 0.3)`;\n                } else {\n                    backgroundColor = background;\n                }\n            } else {\n                backgroundColor = background || mainBackground;\n            }\n        }\n\n        return (\n            <motion.div\n                animate=\"open\"\n                className={styles.container}\n                exit=\"closed\"\n                initial=\"closed\"\n                style={{\n                    backgroundColor,\n                }}\n                variants={mobileContainerVariants}\n            >\n                <BackgroundImage\n                    dynamicBackground={dynamicBackground}\n                    dynamicIsImage={dynamicIsImage}\n                />\n                {children}\n            </motion.div>\n        );\n    },\n);\n\nMobilePlayerContainer.displayName = 'MobilePlayerContainer';\n\nconst mobileContainerVariants: Variants = {\n    closed: {\n        transition: {\n            duration: 0.5,\n            ease: 'easeInOut',\n        },\n        y: '100%',\n    },\n    open: {\n        transition: {\n            duration: 0.5,\n            ease: 'easeInOut',\n        },\n        y: 0,\n    },\n};\n\nexport const MobileFullscreenPlayer = () => {\n    const { t } = useTranslation();\n    const setFullScreenPlayerStore = useSetFullScreenPlayerStore();\n    const { setStore } = useFullScreenPlayerStoreActions();\n    const { activeTab, dynamicBackground, dynamicImageBlur, dynamicIsImage } =\n        useFullScreenPlayerStore();\n    const currentSong = usePlayerSong();\n    const { currentSong: currentSongData } = usePlayerData();\n    const isRadioActive = useIsRadioActive();\n    const { isPlaying: isRadioPlaying, metadata: radioMetadata, stationName } = useRadioPlayer();\n    const server = useCurrentServer();\n\n    const isPlayingRadio = isRadioActive && isRadioPlaying;\n    const effectiveDynamicBackground = dynamicBackground && !isPlayingRadio;\n    const setFavorite = useSetFavorite();\n    const { showRatings: showRatingsSetting } = useGeneralSettings();\n    const setRating = useSetRating();\n\n    const [isPageHovered, setIsPageHovered] = useState(false);\n\n    const handleToggleFullScreenPlayer = useCallback(() => {\n        setFullScreenPlayerStore({ expanded: false });\n    }, [setFullScreenPlayerStore]);\n\n    const handleToggleContextMenu = useCallback(\n        (e: MouseEvent<HTMLButtonElement | HTMLDivElement>) => {\n            e.preventDefault();\n            e.stopPropagation();\n\n            if (!currentSong) {\n                return;\n            }\n\n            ContextMenuController.call({\n                cmd: { items: [currentSong], type: LibraryItem.SONG },\n                event: e as unknown as MouseEvent<HTMLDivElement>,\n            });\n        },\n        [currentSong],\n    );\n\n    const handleToggleQueue = useCallback(() => {\n        setStore({ activeTab: activeTab === 'queue' ? 'player' : 'queue' });\n    }, [activeTab, setStore]);\n\n    const handleToggleFavorite = useCallback(\n        (e: MouseEvent<HTMLButtonElement>) => {\n            e.stopPropagation();\n            const song = currentSongData;\n            if (!song?.id) return;\n\n            setFavorite(song._serverId, [song.id], LibraryItem.SONG, !song.userFavorite);\n        },\n        [currentSongData, setFavorite],\n    );\n\n    const handleToggleLyrics = useCallback(() => {\n        setStore({ activeTab: activeTab === 'lyrics' ? 'player' : 'lyrics' });\n    }, [activeTab, setStore]);\n\n    const handleUpdateRating = useCallback(\n        (rating: number) => {\n            if (!currentSong?.id) return;\n\n            setRating(currentSong._serverId, [currentSong.id], LibraryItem.SONG, rating);\n        },\n        [currentSong, setRating],\n    );\n\n    const isPlayerState = activeTab !== 'queue' && activeTab !== 'lyrics';\n    const isQueueState = activeTab === 'queue';\n    const isLyricsState = activeTab === 'lyrics';\n    const isSongDefined = Boolean(currentSong?.id);\n    const showRating =\n        showRatingsSetting &&\n        isSongDefined &&\n        (server?.type === ServerType.NAVIDROME || server?.type === ServerType.SUBSONIC);\n\n    return (\n        <MobilePlayerContainer\n            dynamicBackground={effectiveDynamicBackground}\n            dynamicIsImage={dynamicIsImage}\n        >\n            <BackgroundImageOverlay\n                dynamicBackground={effectiveDynamicBackground}\n                dynamicImageBlur={dynamicImageBlur}\n            />\n            <motion.div\n                animate={{\n                    opacity: isPlayerState ? 1 : 0,\n                    zIndex: isPlayerState ? 2 : 1,\n                }}\n                className={styles.playerState}\n                onMouseEnter={() => setIsPageHovered(true)}\n                onMouseLeave={() => setIsPageHovered(false)}\n                transition={{ duration: 0.3, ease: 'easeInOut' }}\n            >\n                <MobileFullscreenPlayerHeader\n                    currentSong={currentSong}\n                    isPageHovered={isPageHovered}\n                    onClose={handleToggleFullScreenPlayer}\n                />\n                <MobileFullscreenPlayerAlbumArt />\n                <MobileFullscreenPlayerMetadata\n                    currentSong={currentSong}\n                    onToggleFavorite={handleToggleFavorite}\n                    onUpdateRating={handleUpdateRating}\n                    radioArtist={isPlayingRadio ? (radioMetadata?.artist ?? undefined) : undefined}\n                    radioStationName={isPlayingRadio ? (stationName ?? undefined) : undefined}\n                    radioTitle={isPlayingRadio ? (radioMetadata?.title ?? undefined) : undefined}\n                    showRating={showRating}\n                />\n                <MobileFullscreenPlayerProgress currentSong={currentSong} />\n                <MobileFullscreenPlayerControls currentSong={currentSong} />\n                <MobileFullscreenPlayerBottomControls\n                    isLyricsActive={isLyricsState}\n                    isQueueActive={isQueueState}\n                    onToggleContextMenu={handleToggleContextMenu}\n                    onToggleLyrics={handleToggleLyrics}\n                    onToggleQueue={handleToggleQueue}\n                />\n            </motion.div>\n\n            <AnimatePresence>\n                {isQueueState && (\n                    <motion.div\n                        animate={{ opacity: 1 }}\n                        className={styles.queueState}\n                        exit={{ opacity: 0 }}\n                        initial={{ opacity: 0 }}\n                        style={{ zIndex: 2 }}\n                        transition={{ duration: 0.3, ease: 'easeInOut' }}\n                    >\n                        <div className={styles.queueHeader}>\n                            <ActionIcon\n                                icon=\"arrowDownS\"\n                                onClick={handleToggleFullScreenPlayer}\n                                size=\"sm\"\n                                variant={isPageHovered ? 'default' : 'subtle'}\n                            />\n                            <ActionIcon\n                                icon=\"x\"\n                                iconProps={{ size: 'xl' }}\n                                onClick={handleToggleQueue}\n                                size=\"sm\"\n                                variant={isPageHovered ? 'default' : 'subtle'}\n                            />\n                        </div>\n                        <div className={styles.queueContent}>\n                            <PlayQueue listKey={ItemListKey.FULL_SCREEN} searchTerm={undefined} />\n                        </div>\n                    </motion.div>\n                )}\n            </AnimatePresence>\n\n            <AnimatePresence>\n                {isLyricsState && (\n                    <motion.div\n                        animate={{ opacity: 1 }}\n                        className={styles.lyricsState}\n                        exit={{ opacity: 0 }}\n                        initial={{ opacity: 0 }}\n                        style={{ zIndex: 2 }}\n                        transition={{ duration: 0.3, ease: 'easeInOut' }}\n                    >\n                        <div className={styles.lyricsHeader}>\n                            <ActionIcon\n                                icon=\"arrowDownS\"\n                                onClick={handleToggleFullScreenPlayer}\n                                size=\"sm\"\n                                variant={isPageHovered ? 'default' : 'subtle'}\n                            />\n                            <Text fw={600} size=\"lg\">\n                                {t('page.fullscreenPlayer.lyrics', { postProcess: 'sentenceCase' })}\n                            </Text>\n                            <ActionIcon\n                                icon=\"x\"\n                                iconProps={{ size: 'xl' }}\n                                onClick={handleToggleLyrics}\n                                size=\"sm\"\n                                variant={isPageHovered ? 'default' : 'subtle'}\n                            />\n                        </div>\n                        <div className={styles.lyricsContent}>\n                            <Lyrics fadeOutNoLyricsMessage={false} />\n                        </div>\n                    </motion.div>\n                )}\n            </AnimatePresence>\n        </MobilePlayerContainer>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/player/components/mobile-playerbar.module.css",
    "content": ".container {\n    display: grid;\n    grid-template-columns: 1fr auto;\n    gap: var(--theme-spacing-sm);\n    align-items: center;\n    width: 100%;\n    height: 90px;\n    padding: 0.5rem 1rem;\n    overflow: hidden;\n}\n\n.content-wrapper {\n    display: flex;\n    gap: var(--theme-spacing-sm);\n    align-items: center;\n    min-width: 0;\n    overflow: hidden;\n}\n\n.image-wrapper {\n    position: relative;\n    display: flex;\n    flex-shrink: 0;\n    align-items: center;\n    justify-content: center;\n}\n\n.image {\n    position: relative;\n    width: 60px;\n    height: 60px;\n    overflow: hidden;\n    cursor: pointer;\n    border-radius: 4px;\n}\n\n.playerbar-image {\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    width: 100%;\n    height: 100%;\n    object-fit: cover;\n}\n\n.metadata-stack {\n    display: flex;\n    flex: 1 1 auto;\n    flex-direction: column;\n    gap: 0;\n    justify-content: center;\n    min-width: 0;\n    max-width: 50%;\n    overflow: hidden;\n}\n\n.line-item {\n    display: inline-block;\n    width: 100%;\n    overflow: hidden;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n    cursor: pointer;\n}\n\n.line-item.secondary {\n    color: var(--theme-colors-foreground-muted);\n}\n\n.controls-wrapper {\n    display: flex;\n    gap: 0.5rem;\n    align-items: center;\n    justify-content: center;\n}\n"
  },
  {
    "path": "src/renderer/features/player/components/mobile-playerbar.tsx",
    "content": "import clsx from 'clsx';\nimport { AnimatePresence, LayoutGroup, motion } from 'motion/react';\nimport React, { MouseEvent } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { generatePath, Link } from 'react-router';\n\nimport styles from './mobile-playerbar.module.css';\n\nimport { ItemImage } from '/@/renderer/components/item-image/item-image';\nimport { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';\nimport { MainPlayButton, PlayerButton } from '/@/renderer/features/player/components/player-button';\nimport { usePlayer } from '/@/renderer/features/player/context/player-context';\nimport { AppRoute } from '/@/renderer/router/routes';\nimport {\n    useFullScreenPlayerStore,\n    useFullScreenPlayerStoreActions,\n    usePlayerSong,\n    usePlayerStatus,\n    useSetFullScreenPlayerStore,\n} from '/@/renderer/store';\nimport { ActionIcon } from '/@/shared/components/action-icon/action-icon';\nimport { Group } from '/@/shared/components/group/group';\nimport { Icon } from '/@/shared/components/icon/icon';\nimport { Separator } from '/@/shared/components/separator/separator';\nimport { Text } from '/@/shared/components/text/text';\nimport { Tooltip } from '/@/shared/components/tooltip/tooltip';\nimport { PlaybackSelectors } from '/@/shared/constants/playback-selectors';\nimport { LibraryItem } from '/@/shared/types/domain-types';\nimport { PlayerStatus } from '/@/shared/types/types';\n\nexport const MobilePlayerbar = () => {\n    const { t } = useTranslation();\n    const { expanded: isFullScreenPlayerExpanded } = useFullScreenPlayerStore();\n    const setFullScreenPlayerStore = useSetFullScreenPlayerStore();\n    const { setStore } = useFullScreenPlayerStoreActions();\n    const currentSong = usePlayerSong();\n    const status = usePlayerStatus();\n    const { mediaNext, mediaPrevious, mediaTogglePlayPause } = usePlayer();\n    const title = currentSong?.name;\n    const artists = currentSong?.artists;\n    const isSongDefined = Boolean(currentSong?.id);\n\n    const handleToggleFullScreenPlayer = (e?: KeyboardEvent | MouseEvent<HTMLDivElement>) => {\n        e?.stopPropagation();\n        // Set active tab to player when opening fullscreen player\n        setStore({ activeTab: 'player' });\n        setFullScreenPlayerStore({ expanded: !isFullScreenPlayerExpanded });\n    };\n\n    const handleToggleContextMenu = (e: MouseEvent<HTMLButtonElement | HTMLDivElement>) => {\n        e.preventDefault();\n        e.stopPropagation();\n\n        if (!currentSong) {\n            return;\n        }\n\n        ContextMenuController.call({\n            cmd: { items: [currentSong], type: LibraryItem.SONG },\n            event: e as MouseEvent<HTMLDivElement>,\n        });\n    };\n\n    const stopPropagation = (e?: MouseEvent) => e?.stopPropagation();\n\n    return (\n        <div className={clsx(styles.container, PlaybackSelectors.mediaPlayer)}>\n            <div className={styles.contentWrapper}>\n                <LayoutGroup>\n                    <AnimatePresence initial={false} mode=\"popLayout\">\n                        {currentSong?.id && (\n                            <div className={styles.imageWrapper}>\n                                <motion.div\n                                    animate={{ opacity: 1, scale: 1 }}\n                                    className={styles.image}\n                                    exit={{ opacity: 0 }}\n                                    initial={{ opacity: 0 }}\n                                    key=\"mobile-playerbar-image\"\n                                    onClick={handleToggleFullScreenPlayer}\n                                    onContextMenu={handleToggleContextMenu}\n                                    role=\"button\"\n                                    transition={{ duration: 0.2, ease: 'easeIn' }}\n                                >\n                                    <Tooltip\n                                        label={t('player.toggleFullscreenPlayer', {\n                                            postProcess: 'sentenceCase',\n                                        })}\n                                        openDelay={0}\n                                    >\n                                        <ItemImage\n                                            className={clsx(\n                                                styles.playerbarImage,\n                                                PlaybackSelectors.playerCoverArt,\n                                            )}\n                                            enableDebounce={false}\n                                            enableViewport={false}\n                                            explicitStatus={currentSong.explicitStatus}\n                                            fetchPriority=\"high\"\n                                            id={currentSong.imageId}\n                                            itemType={LibraryItem.SONG}\n                                            type=\"table\"\n                                        />\n                                    </Tooltip>\n                                </motion.div>\n                            </div>\n                        )}\n                    </AnimatePresence>\n                    <motion.div className={styles.metadataStack} layout=\"position\">\n                        <div className={styles.lineItem} onClick={stopPropagation}>\n                            <Group align=\"center\" gap=\"xs\" wrap=\"nowrap\">\n                                <Text\n                                    className={PlaybackSelectors.songTitle}\n                                    component={Link}\n                                    fw={500}\n                                    isLink\n                                    onClick={handleToggleFullScreenPlayer}\n                                    onContextMenu={handleToggleContextMenu}\n                                    overflow=\"hidden\"\n                                    size=\"sm\"\n                                    to={AppRoute.NOW_PLAYING}\n                                    truncate\n                                >\n                                    {title || '—'}\n                                </Text>\n                                {isSongDefined && (\n                                    <ActionIcon\n                                        icon=\"ellipsisVertical\"\n                                        onClick={handleToggleContextMenu}\n                                        size=\"xs\"\n                                        styles={{\n                                            root: {\n                                                '--ai-size-xs': '1.15rem',\n                                            },\n                                        }}\n                                        variant=\"subtle\"\n                                    />\n                                )}\n                            </Group>\n                        </div>\n                        <div\n                            className={clsx(\n                                styles.lineItem,\n                                styles.secondary,\n                                PlaybackSelectors.songArtist,\n                            )}\n                            onClick={stopPropagation}\n                        >\n                            {artists?.map((artist, index) => (\n                                <React.Fragment key={`bar-${artist.id}`}>\n                                    {index > 0 && <Separator />}\n                                    <Text\n                                        component={artist.id ? Link : undefined}\n                                        fw={500}\n                                        isLink={artist.id !== ''}\n                                        onClick={handleToggleFullScreenPlayer}\n                                        overflow=\"hidden\"\n                                        size=\"xs\"\n                                        to={\n                                            artist.id\n                                                ? generatePath(\n                                                      AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,\n                                                      {\n                                                          albumArtistId: artist.id,\n                                                      },\n                                                  )\n                                                : undefined\n                                        }\n                                    >\n                                        {artist.name || '—'}\n                                    </Text>\n                                </React.Fragment>\n                            ))}\n                        </div>\n                        <div\n                            className={clsx(\n                                styles.lineItem,\n                                styles.secondary,\n                                PlaybackSelectors.songAlbum,\n                            )}\n                            onClick={stopPropagation}\n                        >\n                            <Text\n                                component={Link}\n                                fw={500}\n                                isLink\n                                onClick={handleToggleFullScreenPlayer}\n                                overflow=\"hidden\"\n                                size=\"xs\"\n                                to={\n                                    currentSong?.albumId\n                                        ? generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {\n                                              albumId: currentSong.albumId,\n                                          })\n                                        : ''\n                                }\n                            >\n                                {currentSong?.album || '—'}\n                            </Text>\n                        </div>\n                    </motion.div>\n                </LayoutGroup>\n            </div>\n            <div className={styles.controlsWrapper}>\n                <PlayerButton\n                    icon={<Icon fill=\"default\" icon=\"mediaPrevious\" size=\"md\" />}\n                    onClick={(e) => {\n                        e.stopPropagation();\n                        mediaPrevious();\n                    }}\n                    tooltip={{\n                        label: t('player.previous', { postProcess: 'sentenceCase' }),\n                        openDelay: 0,\n                    }}\n                    variant=\"tertiary\"\n                />\n                <MainPlayButton\n                    disabled={currentSong?.id === undefined}\n                    isPaused={status === PlayerStatus.PAUSED}\n                    onClick={(e) => {\n                        e.stopPropagation();\n                        mediaTogglePlayPause();\n                    }}\n                />\n                <PlayerButton\n                    icon={<Icon fill=\"default\" icon=\"mediaNext\" size=\"md\" />}\n                    onClick={(e) => {\n                        e.stopPropagation();\n                        mediaNext();\n                    }}\n                    tooltip={{\n                        label: t('player.next', { postProcess: 'sentenceCase' }),\n                        openDelay: 0,\n                    }}\n                    variant=\"tertiary\"\n                />\n            </div>\n        </div>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/player/components/player-button.module.css",
    "content": ".motion-wrapper {\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    cursor: pointer;\n}\n\n.player-button {\n    all: unset;\n    display: flex;\n    align-items: center;\n    width: 100%;\n    padding: 0.5rem;\n    overflow: visible;\n\n    button {\n        display: flex;\n    }\n\n    &:focus-visible {\n        outline: 1px var(--theme-colors-primary-filled) solid;\n    }\n\n    &:disabled {\n        opacity: 0.5;\n    }\n\n    svg {\n        display: flex;\n    }\n}\n\n.player-button.active {\n    svg {\n        fill: var(--theme-colors-primary-filled);\n    }\n}\n\n.main {\n    background: var(--theme-colors-foreground) !important;\n    border-radius: 50%;\n\n    svg {\n        display: flex;\n        color: var(--theme-colors-background);\n        fill: var(--theme-colors-background);\n    }\n}\n\n.secondary {\n    color: var(--theme-colors-foreground);\n\n    svg {\n        color: var(--theme-colors-foreground);\n    }\n}\n\n.tertiary {\n    svg {\n        display: flex;\n    }\n}\n"
  },
  {
    "path": "src/renderer/features/player/components/player-button.tsx",
    "content": "import clsx from 'clsx';\nimport { t } from 'i18next';\nimport { forwardRef, ReactNode } from 'react';\n\nimport styles from './player-button.module.css';\n\nimport { ActionIcon, ActionIconProps } from '/@/shared/components/action-icon/action-icon';\nimport { Tooltip, TooltipProps } from '/@/shared/components/tooltip/tooltip';\nimport { PlaybackSelectors } from '/@/shared/constants/playback-selectors';\n\ninterface PlayerButtonProps extends Omit<ActionIconProps, 'icon' | 'variant'> {\n    icon: ReactNode;\n    isActive?: boolean;\n    tooltip?: Omit<TooltipProps, 'children'>;\n    variant: 'main' | 'secondary' | 'tertiary';\n}\n\nexport const PlayerButton = forwardRef<HTMLButtonElement, PlayerButtonProps>(\n    ({ icon, isActive, tooltip, variant, ...rest }: PlayerButtonProps, ref) => {\n        if (tooltip) {\n            return (\n                <Tooltip {...tooltip}>\n                    <ActionIcon\n                        className={clsx({\n                            [styles.active]: isActive,\n                        })}\n                        ref={ref}\n                        {...rest}\n                        onClick={(e) => {\n                            e.stopPropagation();\n                            rest.onClick?.(e);\n                        }}\n                        variant=\"subtle\"\n                    >\n                        {icon}\n                    </ActionIcon>\n                </Tooltip>\n            );\n        }\n\n        return (\n            <ActionIcon\n                className={clsx(styles.playerButton, styles[variant], {\n                    [styles.active]: isActive,\n                })}\n                ref={ref}\n                {...rest}\n                onClick={(e) => {\n                    e.stopPropagation();\n                    rest.onClick?.(e);\n                }}\n                variant=\"subtle\"\n            >\n                {icon}\n            </ActionIcon>\n        );\n    },\n);\n\ninterface PlayButtonProps extends Omit<ActionIconProps, 'icon' | 'variant'> {\n    isPaused?: boolean;\n}\n\nexport const MainPlayButton = forwardRef<HTMLButtonElement, PlayButtonProps>(\n    ({ isPaused, onClick, ...props }: PlayButtonProps, ref) => {\n        const playerStateClass = isPaused\n            ? PlaybackSelectors.playerStatePaused\n            : PlaybackSelectors.playerStatePlaying;\n\n        return (\n            <ActionIcon\n                className={clsx(styles.main, playerStateClass)}\n                icon={isPaused ? 'mediaPlay' : 'mediaPause'}\n                iconProps={{\n                    size: 'lg',\n                }}\n                onClick={(e) => {\n                    e.stopPropagation();\n                    onClick?.(e);\n                }}\n                ref={ref}\n                tooltip={{\n                    label: isPaused\n                        ? (t('player.play', { postProcess: 'sentenceCase' }) as string)\n                        : (t('player.pause', { postProcess: 'sentenceCase' }) as string),\n                    openDelay: 0,\n                }}\n                {...props}\n            />\n        );\n    },\n);\n"
  },
  {
    "path": "src/renderer/features/player/components/player-config.tsx",
    "content": "import isElectron from 'is-electron';\nimport { useCallback, useMemo } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport { useAudioDevices } from '/@/renderer/features/settings/components/playback/audio-settings';\nimport { ListConfigTable } from '/@/renderer/features/shared/components/list-config-menu';\nimport {\n    usePlaybackType,\n    usePlayerActions,\n    usePlayerProperties,\n    usePlayerSongProperties,\n    usePlayerSpeed,\n    usePlayerStatus,\n} from '/@/renderer/store';\nimport {\n    useCombinedLyricsAndVisualizer,\n    usePlaybackSettings,\n    useSettingsStore,\n    useSettingsStoreActions,\n    useShowLyricsInSidebar,\n    useShowVisualizerInSidebar,\n} from '/@/renderer/store/settings.store';\nimport { ActionIcon } from '/@/shared/components/action-icon/action-icon';\nimport { Popover } from '/@/shared/components/popover/popover';\nimport { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control';\nimport { Select } from '/@/shared/components/select/select';\nimport { Slider } from '/@/shared/components/slider/slider';\nimport { Switch } from '/@/shared/components/switch/switch';\nimport { CrossfadeStyle, PlayerStatus, PlayerStyle, PlayerType } from '/@/shared/types/types';\n\nconst ipc = isElectron() ? window.api.ipc : null;\n\nexport const PlayerConfig = () => {\n    const { t } = useTranslation();\n    const preservePitch = useSettingsStore((state) => state.playback.preservePitch);\n    const showLyricsInSidebar = useShowLyricsInSidebar();\n    const showVisualizerInSidebar = useShowVisualizerInSidebar();\n    const combinedLyricsAndVisualizer = useCombinedLyricsAndVisualizer();\n\n    const playbackSettings = usePlaybackSettings();\n    const { setSettings } = useSettingsStoreActions();\n\n    const setPreservePitch = useCallback(\n        (value: boolean) => {\n            setSettings({\n                playback: { ...playbackSettings, preservePitch: value },\n            });\n        },\n        [playbackSettings, setSettings],\n    );\n\n    const options = useMemo(() => {\n        const allOptions = [\n            {\n                component: <AudioPlayerTypeConfig />,\n                id: 'audioPlayerType',\n                label: t('setting.audioPlayer', { postProcess: 'titleCase' }),\n            },\n            {\n                component: <AudioDeviceConfig />,\n                id: 'audioDevice',\n                label: t('setting.audioDevice', { postProcess: 'titleCase' }),\n            },\n            {\n                component: null,\n                id: 'divider-1',\n                isDivider: true,\n                label: '',\n            },\n            {\n                component: <TransitionTypeConfig />,\n                id: 'transitionType',\n                label: t('setting.playbackStyle', {\n                    postProcess: 'titleCase',\n                }),\n            },\n            {\n                component: <CrossfadeStyleConfig />,\n                id: 'crossfadeStyle',\n                label: t('setting.crossfadeStyle', {\n                    postProcess: 'titleCase',\n                }),\n            },\n            {\n                component: <CrossfadeDurationConfig />,\n                id: 'crossfadeDuration',\n                label: t('setting.crossfadeDuration', {\n                    postProcess: 'titleCase',\n                }),\n            },\n            {\n                component: null,\n                id: 'divider-2',\n                isDivider: true,\n                label: '',\n            },\n            {\n                component: <PlaybackSpeedSlider />,\n                id: 'playbackSpeed',\n                label: t('player.playbackSpeed', { postProcess: 'titleCase' }),\n            },\n            {\n                component: (\n                    <Switch\n                        defaultChecked={preservePitch}\n                        onChange={(e) => setPreservePitch(e.currentTarget.checked)}\n                    />\n                ),\n                id: 'preservePitch',\n                label: t('setting.preservePitch', { postProcess: 'titleCase' }),\n            },\n            {\n                component: null,\n                id: 'divider-3',\n                isDivider: true,\n                label: '',\n            },\n            {\n                component: (\n                    <Switch\n                        defaultChecked={showLyricsInSidebar}\n                        onChange={(e) => {\n                            setSettings({\n                                general: {\n                                    showLyricsInSidebar: e.currentTarget.checked,\n                                },\n                            });\n                        }}\n                    />\n                ),\n                id: 'showLyricsInSidebar',\n                label: t('setting.showLyricsInSidebar', { postProcess: 'titleCase' }),\n            },\n            {\n                component: (\n                    <Switch\n                        defaultChecked={showVisualizerInSidebar}\n                        onChange={(e) => {\n                            setSettings({\n                                general: {\n                                    showVisualizerInSidebar: e.currentTarget.checked,\n                                },\n                            });\n                        }}\n                    />\n                ),\n                id: 'showVisualizerInSidebar',\n                label: t('setting.showVisualizerInSidebar', { postProcess: 'titleCase' }),\n            },\n            {\n                component: (\n                    <Switch\n                        defaultChecked={combinedLyricsAndVisualizer}\n                        onChange={(e) => {\n                            setSettings({\n                                general: {\n                                    combinedLyricsAndVisualizer: e.currentTarget.checked,\n                                },\n                            });\n                        }}\n                    />\n                ),\n                id: 'combinedLyricsAndVisualizer',\n                label: t('setting.combinedLyricsAndVisualizer', { postProcess: 'titleCase' }),\n            },\n        ];\n\n        return allOptions;\n    }, [\n        t,\n        preservePitch,\n        setSettings,\n        setPreservePitch,\n        showLyricsInSidebar,\n        showVisualizerInSidebar,\n        combinedLyricsAndVisualizer,\n    ]);\n\n    return (\n        <Popover position=\"top\" width={500}>\n            <Popover.Target>\n                <ActionIcon\n                    icon=\"mediaSettings\"\n                    iconProps={{\n                        size: 'lg',\n                    }}\n                    size=\"sm\"\n                    stopsPropagation\n                    tooltip={{\n                        label: t('common.setting', { count: 2, postProcess: 'titleCase' }),\n                        openDelay: 0,\n                    }}\n                    variant=\"subtle\"\n                />\n            </Popover.Target>\n            <Popover.Dropdown>\n                <ListConfigTable options={options} />\n            </Popover.Dropdown>\n        </Popover>\n    );\n};\n\nconst AudioPlayerTypeConfig = () => {\n    const status = usePlayerStatus();\n    const playbackSettings = usePlaybackSettings();\n    const { setSettings } = useSettingsStoreActions();\n\n    return (\n        <Select\n            comboboxProps={{ withinPortal: false }}\n            data={[\n                {\n                    disabled: !isElectron(),\n                    label: 'MPV',\n                    value: PlayerType.LOCAL,\n                },\n                { label: 'Web', value: PlayerType.WEB },\n            ]}\n            defaultValue={playbackSettings.type}\n            disabled={status === PlayerStatus.PLAYING}\n            onChange={(e) => {\n                setSettings({\n                    playback: { ...playbackSettings, type: e as PlayerType },\n                });\n                ipc?.send('settings-set', {\n                    property: 'playbackType',\n                    value: e,\n                });\n            }}\n            width=\"100%\"\n        />\n    );\n};\n\nconst AudioDeviceConfig = () => {\n    const status = usePlayerStatus();\n    const playbackType = usePlaybackType();\n    const playbackSettings = usePlaybackSettings();\n    const { setSettings } = useSettingsStoreActions();\n\n    const audioDevices = useAudioDevices(playbackType);\n    const audioDeviceId =\n        playbackType === PlayerType.LOCAL\n            ? playbackSettings.mpvAudioDeviceId\n            : playbackSettings.audioDeviceId;\n\n    return (\n        <Select\n            clearable\n            comboboxProps={{ withinPortal: false }}\n            data={audioDevices}\n            defaultValue={audioDeviceId}\n            disabled={status === PlayerStatus.PLAYING}\n            onChange={(e) => {\n                setSettings({\n                    playback: {\n                        ...playbackSettings,\n                        ...(playbackType === PlayerType.LOCAL\n                            ? { mpvAudioDeviceId: e }\n                            : { audioDeviceId: e }),\n                    },\n                });\n            }}\n            width=\"100%\"\n        />\n    );\n};\n\nconst TransitionTypeConfig = () => {\n    const { t } = useTranslation();\n    const status = usePlayerStatus();\n    const playbackSettings = usePlaybackSettings();\n    const { transitionType } = usePlayerProperties();\n    const { setTransitionType } = usePlayerActions();\n\n    return (\n        <SegmentedControl\n            data={[\n                {\n                    label: t('setting.playbackStyle', {\n                        context: 'optionNormal',\n                        postProcess: 'titleCase',\n                    }),\n                    value: PlayerStyle.GAPLESS,\n                },\n                {\n                    label: t('setting.playbackStyle', {\n                        context: 'optionCrossFade',\n                        postProcess: 'titleCase',\n                    }),\n                    value: PlayerStyle.CROSSFADE,\n                },\n            ]}\n            disabled={playbackSettings.type !== PlayerType.WEB || status === PlayerStatus.PLAYING}\n            onChange={(value) => setTransitionType(value as PlayerStyle)}\n            size=\"sm\"\n            value={transitionType}\n            w=\"100%\"\n        />\n    );\n};\n\nconst CrossfadeStyleConfig = () => {\n    const status = usePlayerStatus();\n    const playbackSettings = usePlaybackSettings();\n    const { crossfadeStyle, transitionType } = usePlayerProperties();\n    const { setCrossfadeStyle } = usePlayerActions();\n\n    return (\n        <Select\n            comboboxProps={{ withinPortal: false }}\n            data={[\n                { label: 'Linear', value: CrossfadeStyle.LINEAR },\n                { label: 'Equal Power', value: CrossfadeStyle.EQUAL_POWER },\n                { label: 'S-Curve', value: CrossfadeStyle.S_CURVE },\n                { label: 'Exponential', value: CrossfadeStyle.EXPONENTIAL },\n            ]}\n            defaultValue={crossfadeStyle}\n            disabled={\n                playbackSettings.type !== PlayerType.WEB ||\n                transitionType !== PlayerStyle.CROSSFADE ||\n                status === PlayerStatus.PLAYING\n            }\n            onChange={(e) => {\n                if (e) {\n                    setCrossfadeStyle(e as CrossfadeStyle);\n                }\n            }}\n            width=\"100%\"\n        />\n    );\n};\n\nconst CrossfadeDurationConfig = () => {\n    const status = usePlayerStatus();\n    const playbackSettings = usePlaybackSettings();\n    const { crossfadeDuration, transitionType } = usePlayerProperties();\n    const { setCrossfadeDuration } = usePlayerActions();\n\n    return (\n        <Slider\n            defaultValue={crossfadeDuration}\n            disabled={\n                playbackSettings.type !== PlayerType.WEB ||\n                transitionType !== PlayerStyle.CROSSFADE ||\n                status === PlayerStatus.PLAYING\n            }\n            marks={[\n                { label: '3', value: 3 },\n                { label: '6', value: 6 },\n                { label: '9', value: 9 },\n                { label: '12', value: 12 },\n                { label: '15', value: 15 },\n            ]}\n            max={15}\n            min={3}\n            onChangeEnd={setCrossfadeDuration}\n            styles={{\n                root: {},\n            }}\n            w=\"100%\"\n        />\n    );\n};\n\nexport const PlaybackSpeedSlider = () => {\n    const speed = usePlayerSpeed();\n    const { setSpeed } = usePlayerActions();\n    const { bpm } = usePlayerSongProperties(['bpm']) ?? {};\n\n    const formatPlaybackSpeedSliderLabel = useMemo(\n        () => (value: number) => {\n            const bpmValue = Number(bpm);\n            if (bpmValue > 0) {\n                return `${value} x / ${(bpmValue * value).toFixed(1)} BPM`;\n            }\n            return `${value} x`;\n        },\n        [bpm],\n    );\n\n    return (\n        <Slider\n            defaultValue={speed}\n            label={formatPlaybackSpeedSliderLabel}\n            marks={[\n                { label: '0.5', value: 0.5 },\n                { label: '0.75', value: 0.75 },\n                { label: '1', value: 1 },\n                { label: '1.25', value: 1.25 },\n                { label: '1.5', value: 1.5 },\n                { label: '1.75', value: 1.75 },\n                { label: '2', value: 2 },\n            ]}\n            max={2}\n            min={0.5}\n            onChangeEnd={setSpeed}\n            onDoubleClick={() => setSpeed(1)}\n            step={0.01}\n            styles={{\n                markLabel: {},\n                root: {},\n            }}\n            w=\"100%\"\n        />\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/player/components/playerbar-seek-slider.tsx",
    "content": "import formatDuration from 'format-duration';\nimport { useEffect, useRef, useState } from 'react';\n\nimport { CustomPlayerbarSlider } from './playerbar-slider';\n\nimport { usePlayer } from '/@/renderer/features/player/context/player-context';\nimport { usePlayerTimestamp } from '/@/renderer/store';\n\ninterface PlayerbarSeekSliderProps {\n    max: number;\n    min: number;\n}\n\nexport const PlayerbarSeekSlider = ({ max, min }: PlayerbarSeekSliderProps) => {\n    const [isSeeking, setIsSeeking] = useState(false);\n    const [seekValue, setSeekValue] = useState(0);\n    const currentTime = usePlayerTimestamp();\n    const seekTimeoutRef = useRef<NodeJS.Timeout | null>(null);\n    const lastSeekValueRef = useRef<null | number>(null);\n\n    const { mediaSeekToTimestamp } = usePlayer();\n\n    const handleSeekToTimestamp = (timestamp: number) => {\n        mediaSeekToTimestamp(timestamp);\n    };\n\n    // Sync isSeeking state when currentTime catches up to seek value\n    useEffect(() => {\n        if (isSeeking && lastSeekValueRef.current !== null) {\n            const timeDiff = Math.abs(currentTime - lastSeekValueRef.current);\n            if (timeDiff < 0.5) {\n                setIsSeeking(false);\n                lastSeekValueRef.current = null;\n                if (seekTimeoutRef.current) {\n                    clearTimeout(seekTimeoutRef.current);\n                    seekTimeoutRef.current = null;\n                }\n            }\n        }\n    }, [currentTime, isSeeking]);\n\n    useEffect(() => {\n        return () => {\n            if (seekTimeoutRef.current) {\n                clearTimeout(seekTimeoutRef.current);\n            }\n        };\n    }, []);\n\n    return (\n        <CustomPlayerbarSlider\n            label={(value) => formatDuration(value * 1000)}\n            max={max}\n            min={min}\n            onChange={(e) => {\n                // Cancel any pending timeout if user starts seeking again\n                if (seekTimeoutRef.current) {\n                    clearTimeout(seekTimeoutRef.current);\n                    seekTimeoutRef.current = null;\n                }\n                setIsSeeking(true);\n                setSeekValue(e);\n            }}\n            onChangeEnd={(e) => {\n                setSeekValue(e);\n                lastSeekValueRef.current = e;\n                handleSeekToTimestamp(e);\n\n                if (seekTimeoutRef.current) {\n                    clearTimeout(seekTimeoutRef.current);\n                }\n\n                // Keep isSeeking true to prevent slider from snapping back.\n                // The useEffect will detect when currentTime catches up and clear isSeeking.\n                // Also set a fallback timeout to clear isSeeking after a max delay\n                // in case the seek doesn't complete (e.g., network issues).\n                seekTimeoutRef.current = setTimeout(() => {\n                    setIsSeeking(false);\n                    lastSeekValueRef.current = null;\n                    seekTimeoutRef.current = null;\n                }, 1000);\n            }}\n            onClick={(e) => {\n                e?.stopPropagation();\n            }}\n            size={6}\n            value={\n                isSeeking\n                    ? seekValue\n                    : lastSeekValueRef.current !== null &&\n                        Math.abs(currentTime - lastSeekValueRef.current) > 0.5\n                      ? lastSeekValueRef.current\n                      : currentTime\n            }\n            w=\"100%\"\n        />\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/player/components/playerbar-slider.module.css",
    "content": ".bar {\n    background-color: var(--theme-colors-foreground);\n    transition: background-color 0.2s ease-in-out;\n}\n\n.label {\n    max-width: 200px;\n    padding: var(--theme-spacing-sm) var(--theme-spacing-md);\n    font-size: var(--theme-font-size-md);\n    font-weight: 550;\n    color: var(--theme-colors-surface-foreground);\n    background: var(--theme-colors-surface);\n    box-shadow: 4px 4px 10px 0 rgb(0 0 0 / 20%);\n}\n\n.root {\n    &:hover {\n        .bar {\n            background-color: var(--theme-colors-primary-filled);\n        }\n\n        .thumb {\n            opacity: 1;\n        }\n    }\n\n    &:focus {\n        .bar {\n            background-color: var(--theme-colors-primary-filled);\n        }\n\n        .thumb {\n            opacity: 1;\n        }\n    }\n}\n\n.thumb {\n    width: 1rem;\n    height: 1rem;\n    border-color: var(--theme-colors-primary-filled);\n    border-width: 1px;\n    opacity: 0;\n    transition: opacity 0.2s ease-in-out;\n}\n\n.track {\n    &::before {\n        right: calc(0.1rem * -1);\n    }\n}\n\n.slider-container {\n    display: flex;\n    width: 95%;\n    height: 20px;\n}\n\n.slider-value-wrapper {\n    display: flex;\n    flex: 1;\n    align-self: center;\n    justify-content: center;\n    max-width: 50px;\n\n    @media (width < 768px) {\n        display: none;\n    }\n}\n\n.slider-wrapper {\n    display: flex;\n    flex: 6;\n    align-items: center;\n    height: 100%;\n}\n"
  },
  {
    "path": "src/renderer/features/player/components/playerbar-slider.tsx",
    "content": "import formatDuration from 'format-duration';\nimport { lazy, Suspense } from 'react';\n\nimport { PlayerbarSeekSlider } from './playerbar-seek-slider';\nimport styles from './playerbar-slider.module.css';\n\nimport {\n    useAppStore,\n    useAppStoreActions,\n    usePlayerSong,\n    usePlayerTimestamp,\n} from '/@/renderer/store';\nimport { PlayerbarSliderType, usePlayerbarSlider } from '/@/renderer/store/settings.store';\nimport { Slider, SliderProps } from '/@/shared/components/slider/slider';\nimport { Spinner } from '/@/shared/components/spinner/spinner';\nimport { Text } from '/@/shared/components/text/text';\nimport { PlaybackSelectors } from '/@/shared/constants/playback-selectors';\n\nconst PlayerbarWaveform = lazy(() =>\n    import('./playerbar-waveform').then((module) => ({\n        default: module.PlayerbarWaveform,\n    })),\n);\n\nexport const PlayerbarSlider = () => {\n    const currentSong = usePlayerSong();\n    const playerbarSlider = usePlayerbarSlider();\n\n    const songDuration = currentSong?.duration ? currentSong.duration / 1000 : 0;\n    const currentTime = usePlayerTimestamp();\n\n    const formattedDuration = formatDuration(songDuration * 1000 || 0);\n    const formattedTimeRemaining = formatDuration((currentTime - songDuration) * 1000 || 0);\n    const formattedTime = formatDuration(currentTime * 1000 || 0);\n\n    const showTimeRemaining = useAppStore((state) => state.showTimeRemaining);\n    const { setShowTimeRemaining } = useAppStoreActions();\n\n    const isWaveform = playerbarSlider?.type === PlayerbarSliderType.WAVEFORM;\n\n    return (\n        <>\n            <div className={styles.sliderContainer}>\n                <div className={styles.sliderValueWrapper}>\n                    <Text\n                        className={PlaybackSelectors.elapsedTime}\n                        fw={600}\n                        isMuted\n                        isNoSelect\n                        size=\"xs\"\n                        style={{ userSelect: 'none' }}\n                    >\n                        {formattedTime}\n                    </Text>\n                </div>\n                <div className={styles.sliderWrapper}>\n                    {isWaveform ? (\n                        <Suspense fallback={<Spinner />}>\n                            <PlayerbarWaveform />\n                        </Suspense>\n                    ) : (\n                        <PlayerbarSeekSlider max={songDuration} min={0} />\n                    )}\n                </div>\n                <div className={styles.sliderValueWrapper}>\n                    <Text\n                        className={PlaybackSelectors.totalDuration}\n                        fw={600}\n                        isMuted\n                        isNoSelect\n                        onClick={() => setShowTimeRemaining(!showTimeRemaining)}\n                        role=\"button\"\n                        size=\"xs\"\n                        style={{ cursor: 'pointer', userSelect: 'none' }}\n                    >\n                        {showTimeRemaining ? formattedTimeRemaining : formattedDuration}\n                    </Text>\n                </div>\n            </div>\n        </>\n    );\n};\n\nexport const CustomPlayerbarSlider = ({ ...props }: SliderProps) => {\n    return (\n        <Slider\n            classNames={{\n                bar: styles.bar,\n                label: styles.label,\n                root: styles.root,\n                thumb: styles.thumb,\n                track: styles.track,\n            }}\n            {...props}\n            size={6}\n        />\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/player/components/playerbar-waveform.module.css",
    "content": ".wavesurfer-container {\n    width: 100%;\n    cursor: pointer;\n}\n\n.waveform {\n    align-items: center;\n    width: 100%;\n    height: 100%;\n}\n\n.tooltip {\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    max-width: 200px;\n    padding: var(--theme-spacing-sm) var(--theme-spacing-md);\n    font-size: var(--theme-font-size-md);\n    font-weight: 550;\n    color: var(--theme-colors-surface-foreground);\n    white-space: nowrap;\n    pointer-events: none;\n    background: var(--theme-colors-surface);\n    box-shadow: 4px 4px 10px 0 rgb(0 0 0 / 20%);\n}\n"
  },
  {
    "path": "src/renderer/features/player/components/playerbar-waveform.tsx",
    "content": "import { useWavesurfer } from '@wavesurfer/react';\nimport formatDuration from 'format-duration';\nimport { AnimatePresence, motion } from 'motion/react';\nimport { useEffect, useMemo, useRef, useState } from 'react';\n\nimport { CustomPlayerbarSlider } from './playerbar-slider';\nimport styles from './playerbar-waveform.module.css';\n\nimport { useSongUrl } from '/@/renderer/features/player/audio-player/hooks/use-stream-url';\nimport { usePlayer } from '/@/renderer/features/player/context/player-context';\nimport { BarAlign, usePlayerbarSlider, usePlayerSong, usePlayerTimestamp } from '/@/renderer/store';\nimport { useAppThemeColors, useColorScheme } from '/@/renderer/themes/use-app-theme';\nimport { Spinner } from '/@/shared/components/spinner/spinner';\nimport { Text } from '/@/shared/components/text/text';\n\nexport const PlayerbarWaveform = () => {\n    const currentSong = usePlayerSong();\n    const playerbarSlider = usePlayerbarSlider();\n    const currentTime = usePlayerTimestamp();\n    const containerRef = useRef<HTMLDivElement>(null);\n    const { mediaSeekToTimestamp } = usePlayer();\n    const [isLoading, setIsLoading] = useState(true);\n    const [isDragging, setIsDragging] = useState(false);\n    const [tooltipPosition, setTooltipPosition] = useState<null | { x: number; y: number }>(null);\n    const [tooltipValue, setTooltipValue] = useState(0);\n    const seekTimeoutRef = useRef<NodeJS.Timeout | null>(null);\n    const lastSeekValueRef = useRef<null | number>(null);\n    const containerPositionRef = useRef<DOMRect | null>(null);\n\n    const songDuration = currentSong?.duration ? currentSong.duration / 1000 : 0;\n\n    const streamUrl = useSongUrl(currentSong, true, { bitrate: 64, enabled: true, format: 'mp3' });\n\n    const { color } = useAppThemeColors();\n    const primaryColor = (color['--theme-colors-primary'] as string) || 'rgb(53, 116, 252)';\n\n    const colorScheme = useColorScheme();\n\n    const waveColor = useMemo(() => {\n        return colorScheme === 'dark' ? 'rgba(96, 96, 96, 1)' : 'rgba(96, 96, 96, 1)';\n    }, [colorScheme]);\n\n    const cursorColor = useMemo(() => {\n        return colorScheme === 'dark' ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.5)';\n    }, [colorScheme]);\n\n    const { wavesurfer } = useWavesurfer({\n        barAlign:\n            playerbarSlider?.barAlign === BarAlign.CENTER ? undefined : playerbarSlider?.barAlign,\n        barGap: playerbarSlider?.barGap,\n        barRadius: playerbarSlider?.barRadius,\n        barWidth: playerbarSlider?.barWidth,\n        container: containerRef,\n        cursorColor,\n        cursorWidth: 2,\n        fillParent: true,\n        height: 18,\n        interact: false,\n        normalize: false,\n        progressColor: primaryColor,\n        url: streamUrl || undefined,\n        waveColor,\n    });\n\n    // Reset loading state when stream URL changes and ensure media is muted\n    useEffect(() => {\n        setIsLoading(true);\n        if (wavesurfer) {\n            wavesurfer.setVolume(0);\n            const mediaElement = wavesurfer.getMediaElement();\n            if (mediaElement) {\n                mediaElement.muted = true;\n                mediaElement.volume = 0;\n            }\n        }\n    }, [streamUrl, wavesurfer]);\n\n    // Handle waveform ready state\n    useEffect(() => {\n        if (!wavesurfer) return;\n\n        const handleReady = () => {\n            setIsLoading(false);\n            const mediaElement = wavesurfer.getMediaElement();\n            if (mediaElement) {\n                mediaElement.muted = true;\n                mediaElement.volume = 0;\n            }\n        };\n\n        wavesurfer.on('ready', handleReady);\n\n        // Check if already loaded\n        if (wavesurfer.getDuration() > 0) {\n            setIsLoading(false);\n            const mediaElement = wavesurfer.getMediaElement();\n            if (mediaElement) {\n                mediaElement.muted = true;\n                mediaElement.volume = 0;\n            }\n        }\n\n        return () => {\n            wavesurfer.un('ready', handleReady);\n        };\n    }, [wavesurfer]);\n\n    useEffect(() => {\n        if (!wavesurfer) return;\n\n        // Ensure waveform never plays - it's just for visualization\n        wavesurfer.setVolume(0);\n\n        const muteMediaElement = () => {\n            const mediaElement = wavesurfer.getMediaElement();\n            if (mediaElement) {\n                mediaElement.muted = true;\n                mediaElement.volume = 0;\n            }\n        };\n\n        muteMediaElement();\n\n        const preventPlay = () => {\n            wavesurfer.pause();\n            muteMediaElement(); // Ensure it stays muted\n        };\n\n        wavesurfer.on('play', preventPlay);\n\n        return () => {\n            wavesurfer.un('play', preventPlay);\n        };\n    }, [wavesurfer]);\n\n    // Handle drag start on waveform\n    useEffect(() => {\n        if (!wavesurfer || !songDuration || !containerRef.current) return;\n\n        const container = containerRef.current;\n        let isDraggingLocal = false;\n\n        const handleMouseDown = (e: MouseEvent) => {\n            if (!wavesurfer) return;\n            const duration = wavesurfer.getDuration();\n            if (duration <= 0) return;\n\n            isDraggingLocal = true;\n            setIsDragging(true);\n\n            // Cancel any pending timeout\n            if (seekTimeoutRef.current) {\n                clearTimeout(seekTimeoutRef.current);\n                seekTimeoutRef.current = null;\n            }\n\n            const rect = container.getBoundingClientRect();\n            containerPositionRef.current = rect;\n            const clickX = e.clientX - rect.left;\n            const ratio = Math.max(0, Math.min(1, clickX / rect.width));\n            const seekTime = ratio * duration;\n            lastSeekValueRef.current = seekTime;\n            setTooltipPosition({ x: rect.left + clickX, y: rect.top });\n            setTooltipValue(seekTime);\n            wavesurfer.seekTo(ratio);\n        };\n\n        const handleMouseMove = (e: MouseEvent) => {\n            if (!isDraggingLocal || !wavesurfer) return;\n\n            const duration = wavesurfer.getDuration();\n            if (duration <= 0) return;\n\n            const rect = container.getBoundingClientRect();\n            containerPositionRef.current = rect;\n            const clickX = e.clientX - rect.left;\n            const ratio = Math.max(0, Math.min(1, clickX / rect.width));\n            const seekTime = ratio * duration;\n            lastSeekValueRef.current = seekTime;\n            setTooltipPosition({ x: rect.left + clickX, y: rect.top });\n            setTooltipValue(seekTime);\n            wavesurfer.seekTo(ratio);\n        };\n\n        const handleMouseUp = () => {\n            if (!isDraggingLocal || !wavesurfer) return;\n\n            isDraggingLocal = false;\n            const duration = wavesurfer.getDuration();\n            const seekTime = wavesurfer.getCurrentTime();\n\n            setTooltipPosition(null);\n\n            if (duration > 0 && seekTime >= 0) {\n                mediaSeekToTimestamp(seekTime);\n                lastSeekValueRef.current = seekTime;\n\n                // Set a fallback timeout to clear dragging state\n                seekTimeoutRef.current = setTimeout(() => {\n                    setIsDragging(false);\n                    lastSeekValueRef.current = null;\n                    seekTimeoutRef.current = null;\n                }, 1000);\n            } else {\n                setIsDragging(false);\n            }\n        };\n\n        // Handle touch events for mobile\n        const handleTouchStart = (e: TouchEvent) => {\n            if (!wavesurfer) return;\n            const duration = wavesurfer.getDuration();\n            if (duration <= 0) return;\n\n            isDraggingLocal = true;\n            setIsDragging(true);\n\n            if (seekTimeoutRef.current) {\n                clearTimeout(seekTimeoutRef.current);\n                seekTimeoutRef.current = null;\n            }\n\n            const touch = e.touches[0];\n            const rect = container.getBoundingClientRect();\n            containerPositionRef.current = rect;\n            const clickX = touch.clientX - rect.left;\n            const ratio = Math.max(0, Math.min(1, clickX / rect.width));\n            const seekTime = ratio * duration;\n            lastSeekValueRef.current = seekTime;\n            setTooltipPosition({ x: rect.left + clickX, y: rect.top });\n            setTooltipValue(seekTime);\n            wavesurfer.seekTo(ratio);\n        };\n\n        const handleTouchMove = (e: TouchEvent) => {\n            if (!isDraggingLocal || !wavesurfer) return;\n            e.preventDefault();\n\n            const duration = wavesurfer.getDuration();\n            if (duration <= 0) return;\n\n            const touch = e.touches[0];\n            const rect = container.getBoundingClientRect();\n            containerPositionRef.current = rect;\n            const clickX = touch.clientX - rect.left;\n            const ratio = Math.max(0, Math.min(1, clickX / rect.width));\n            const seekTime = ratio * duration;\n            lastSeekValueRef.current = seekTime;\n            setTooltipPosition({ x: rect.left + clickX, y: rect.top });\n            setTooltipValue(seekTime);\n            wavesurfer.seekTo(ratio);\n        };\n\n        const handleTouchEnd = () => {\n            if (!isDraggingLocal || !wavesurfer) return;\n\n            isDraggingLocal = false;\n            const duration = wavesurfer.getDuration();\n            const seekTime = wavesurfer.getCurrentTime();\n\n            setTooltipPosition(null);\n\n            if (duration > 0 && seekTime >= 0) {\n                mediaSeekToTimestamp(seekTime);\n                lastSeekValueRef.current = seekTime;\n\n                seekTimeoutRef.current = setTimeout(() => {\n                    setIsDragging(false);\n                    lastSeekValueRef.current = null;\n                    seekTimeoutRef.current = null;\n                }, 1000);\n            } else {\n                setIsDragging(false);\n            }\n        };\n\n        container.addEventListener('mousedown', handleMouseDown);\n        document.addEventListener('mousemove', handleMouseMove);\n        document.addEventListener('mouseup', handleMouseUp);\n        container.addEventListener('touchstart', handleTouchStart, { passive: false });\n        container.addEventListener('touchmove', handleTouchMove, { passive: false });\n        container.addEventListener('touchend', handleTouchEnd);\n\n        return () => {\n            container.removeEventListener('mousedown', handleMouseDown);\n            document.removeEventListener('mousemove', handleMouseMove);\n            document.removeEventListener('mouseup', handleMouseUp);\n            container.removeEventListener('touchstart', handleTouchStart);\n            container.removeEventListener('touchmove', handleTouchMove);\n            container.removeEventListener('touchend', handleTouchEnd);\n            if (seekTimeoutRef.current) {\n                clearTimeout(seekTimeoutRef.current);\n            }\n        };\n    }, [wavesurfer, songDuration, mediaSeekToTimestamp]);\n\n    // Sync dragging state when currentTime catches up to seek value\n    useEffect(() => {\n        if (isDragging && lastSeekValueRef.current !== null) {\n            const timeDiff = Math.abs(currentTime - lastSeekValueRef.current);\n            if (timeDiff < 0.5) {\n                setIsDragging(false);\n                setTooltipPosition(null);\n                lastSeekValueRef.current = null;\n                if (seekTimeoutRef.current) {\n                    clearTimeout(seekTimeoutRef.current);\n                    seekTimeoutRef.current = null;\n                }\n            }\n        }\n    }, [currentTime, isDragging]);\n\n    // Update waveform progress based on player current time (only when not dragging)\n    useEffect(() => {\n        if (!wavesurfer || !songDuration || isDragging) return;\n\n        const duration = wavesurfer.getDuration();\n        if (duration > 0 && currentTime >= 0) {\n            const ratio = currentTime / duration;\n            wavesurfer.seekTo(ratio);\n        }\n    }, [wavesurfer, currentTime, songDuration, isDragging]);\n\n    // Show disabled slider when there's no current song\n    if (!currentSong) {\n        return (\n            <CustomPlayerbarSlider\n                disabled\n                max={100}\n                min={0}\n                onClick={(e) => {\n                    e?.stopPropagation();\n                }}\n                size={6}\n                value={0}\n                w=\"100%\"\n            />\n        );\n    }\n\n    return (\n        <div\n            className={styles.wavesurferContainer}\n            onClick={(e) => {\n                e?.stopPropagation();\n            }}\n            style={{ position: 'relative' }}\n        >\n            <motion.div\n                animate={{ opacity: isLoading ? 0 : 1 }}\n                className={styles.waveform}\n                initial={{ opacity: 0 }}\n                ref={containerRef}\n                transition={{ duration: 0.2 }}\n            />\n            <AnimatePresence>\n                {isLoading && (\n                    <motion.div\n                        animate={{ opacity: 1 }}\n                        exit={{ opacity: 0 }}\n                        initial={{ opacity: 0 }}\n                        style={{\n                            height: '100%',\n                            left: 0,\n                            position: 'absolute',\n                            top: 0,\n                            width: '100%',\n                        }}\n                        transition={{ duration: 0.2 }}\n                    >\n                        <Spinner container />\n                    </motion.div>\n                )}\n            </AnimatePresence>\n            {tooltipPosition && isDragging && (\n                <motion.div\n                    animate={{ opacity: 1, scale: 1, x: '-50%' }}\n                    className={styles.tooltip}\n                    initial={{ opacity: 0, scale: 0.8, x: '-50%' }}\n                    style={{\n                        left: `${tooltipPosition.x}px`,\n                        position: 'fixed',\n                        top: `${tooltipPosition.y - 40}px`,\n                        zIndex: 1000,\n                    }}\n                    transition={{ duration: 0.15 }}\n                >\n                    <Text isNoSelect size=\"md\">\n                        {formatDuration(tooltipValue * 1000)}\n                    </Text>\n                </motion.div>\n            )}\n        </div>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/player/components/playerbar.module.css",
    "content": ".container {\n    width: 100vw;\n    height: 100%;\n    border-top: 1px solid alpha(var(--theme-colors-border), 0.5);\n}\n\n.controls-grid {\n    display: grid;\n    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr);\n    gap: 1rem;\n    height: 100%;\n\n    @media (width < 768px) {\n        grid-template-columns: minmax(0, 0.5fr) minmax(0, 1fr) minmax(0, 0.5fr);\n    }\n}\n\n.right-grid-item {\n    align-self: center;\n    width: 100%;\n    height: 100%;\n    overflow: hidden;\n}\n\n.left-grid-item {\n    width: 100%;\n    height: 100%;\n    overflow: hidden;\n}\n\n.center-grid-item {\n    display: flex;\n    flex-direction: column;\n    gap: 0.5rem;\n    align-items: center;\n    justify-content: center;\n    width: 100%;\n    height: 100%;\n    overflow: hidden;\n}\n"
  },
  {
    "path": "src/renderer/features/player/components/playerbar.tsx",
    "content": "import clsx from 'clsx';\nimport { lazy, MouseEvent, Suspense } from 'react';\n\nimport styles from './playerbar.module.css';\n\nimport { CenterControls } from '/@/renderer/features/player/components/center-controls';\nimport { LeftControls } from '/@/renderer/features/player/components/left-controls';\nimport { RightControls } from '/@/renderer/features/player/components/right-controls';\nimport { useIsMobile } from '/@/renderer/hooks/use-is-mobile';\nimport { Spinner } from '/@/shared/components/spinner/spinner';\n\nconst MobilePlayerbar = lazy(() =>\n    import('./mobile-playerbar').then((module) => ({\n        default: module.MobilePlayerbar,\n    })),\n);\nimport { useFullScreenPlayerStore, useSetFullScreenPlayerStore } from '/@/renderer/store';\nimport { usePlayerbarOpenDrawer } from '/@/renderer/store';\nimport { PlaybackSelectors } from '/@/shared/constants/playback-selectors';\n\nexport const Playerbar = () => {\n    const playerbarOpenDrawer = usePlayerbarOpenDrawer();\n    const { expanded: isFullScreenPlayerExpanded } = useFullScreenPlayerStore();\n    const setFullScreenPlayerStore = useSetFullScreenPlayerStore();\n    const isMobile = useIsMobile();\n\n    const handleToggleFullScreenPlayer = (e?: KeyboardEvent | MouseEvent<HTMLDivElement>) => {\n        e?.stopPropagation();\n        setFullScreenPlayerStore({ expanded: !isFullScreenPlayerExpanded });\n    };\n\n    if (isMobile) {\n        return (\n            <Suspense fallback={<Spinner />}>\n                <MobilePlayerbar />\n            </Suspense>\n        );\n    }\n\n    return (\n        <div\n            className={clsx(styles.container, PlaybackSelectors.mediaPlayer)}\n            onClick={playerbarOpenDrawer ? handleToggleFullScreenPlayer : undefined}\n        >\n            <div className={styles.controlsGrid}>\n                <div className={styles.leftGridItem}>\n                    <LeftControls />\n                </div>\n                <div className={styles.centerGridItem}>\n                    <CenterControls />\n                </div>\n                <div className={styles.rightGridItem}>\n                    <RightControls />\n                </div>\n            </div>\n        </div>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/player/components/radio-metadata-display.tsx",
    "content": "import clsx from 'clsx';\nimport React from 'react';\nimport { Link } from 'react-router';\n\nimport styles from './left-controls.module.css';\n\nimport { useIsRadioActive, useRadioStore } from '/@/renderer/features/radio/hooks/use-radio-player';\nimport { AppRoute } from '/@/renderer/router/routes';\nimport { Group } from '/@/shared/components/group/group';\nimport { Icon } from '/@/shared/components/icon/icon';\nimport { Text } from '/@/shared/components/text/text';\nimport { PlaybackSelectors } from '/@/shared/constants/playback-selectors';\n\ninterface RadioMetadataDisplayProps {\n    onStopPropagation: (e?: React.MouseEvent) => void;\n    onToggleContextMenu: (e: React.MouseEvent<HTMLDivElement>) => void;\n}\n\nexport const RadioMetadataDisplay = ({\n    onStopPropagation,\n    onToggleContextMenu,\n}: RadioMetadataDisplayProps) => {\n    const radioMetadata = useRadioStore((state) => state.metadata);\n    const stationName = useRadioStore((state) => state.stationName);\n\n    const isRadioActive = useIsRadioActive();\n\n    if (!isRadioActive) {\n        return null;\n    }\n\n    return (\n        <>\n            <div className={styles.lineItem} onClick={onStopPropagation}>\n                <Text\n                    className={PlaybackSelectors.songTitle}\n                    fw={500}\n                    isNoSelect\n                    onContextMenu={onToggleContextMenu}\n                    overflow=\"hidden\"\n                >\n                    {radioMetadata?.title || '—'}\n                </Text>\n            </div>\n            <div\n                className={clsx(styles.lineItem, styles.secondary, PlaybackSelectors.songArtist)}\n                onClick={onStopPropagation}\n            >\n                <Text isMuted isNoSelect overflow=\"hidden\" size=\"md\">\n                    {radioMetadata?.artist || '—'}\n                </Text>\n            </div>\n            <div\n                className={clsx(styles.lineItem, styles.secondary, PlaybackSelectors.songAlbum)}\n                onClick={onStopPropagation}\n            >\n                <Group align=\"center\" gap=\"xs\" wrap=\"nowrap\">\n                    <Icon color=\"muted\" icon=\"radio\" size=\"sm\" />\n                    <Text\n                        component={Link}\n                        fw={500}\n                        isLink\n                        isMuted\n                        isNoSelect\n                        overflow=\"hidden\"\n                        size=\"md\"\n                        to={AppRoute.RADIO}\n                    >\n                        {stationName || '—'}\n                    </Text>\n                </Group>\n            </div>\n        </>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/player/components/right-controls.tsx",
    "content": "import { t } from 'i18next';\nimport { useCallback, useEffect, useState, WheelEvent } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport { PopoverPlayQueue } from '/@/renderer/features/now-playing/components/popover-play-queue';\nimport { PlayerConfig } from '/@/renderer/features/player/components/player-config';\nimport { CustomPlayerbarSlider } from '/@/renderer/features/player/components/playerbar-slider';\nimport { SleepTimerButton } from '/@/renderer/features/player/components/sleep-timer-button';\nimport { usePlayer } from '/@/renderer/features/player/context/player-context';\nimport { useSetRating } from '/@/renderer/features/shared/hooks/use-set-rating';\nimport { useCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation';\nimport { useDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation';\nimport {\n    useAppStoreActions,\n    useAutoDJSettings,\n    useCurrentServer,\n    useFullScreenPlayerStore,\n    useGeneralSettings,\n    useHotkeySettings,\n    usePlayerData,\n    usePlayerMuted,\n    usePlayerSong,\n    usePlayerVolume,\n    useSetFullScreenPlayerStore,\n    useSettingsStoreActions,\n    useSidebarRightExpanded,\n    useSideQueueType,\n    useVolumeWheelStep,\n    useVolumeWidth,\n} from '/@/renderer/store';\nimport { useFullScreenPlayerStoreActions } from '/@/renderer/store/full-screen-player.store';\nimport { ActionIcon } from '/@/shared/components/action-icon/action-icon';\nimport { Button } from '/@/shared/components/button/button';\nimport { Flex } from '/@/shared/components/flex/flex';\nimport { Group } from '/@/shared/components/group/group';\nimport { Rating } from '/@/shared/components/rating/rating';\nimport { useHotkeys } from '/@/shared/hooks/use-hotkeys';\nimport { useMediaQuery } from '/@/shared/hooks/use-media-query';\nimport { useThrottledCallback } from '/@/shared/hooks/use-throttled-callback';\nimport { useThrottledValue } from '/@/shared/hooks/use-throttled-value';\nimport { LibraryItem, QueueSong, ServerType } from '/@/shared/types/domain-types';\n\nconst calculateVolumeUp = (volume: number, volumeWheelStep: number) => {\n    let volumeToSet: number;\n    const newVolumeGreaterThanHundred = volume + volumeWheelStep > 100;\n    if (newVolumeGreaterThanHundred) {\n        volumeToSet = 100;\n    } else {\n        volumeToSet = volume + volumeWheelStep;\n    }\n\n    return volumeToSet;\n};\n\nconst calculateVolumeDown = (volume: number, volumeWheelStep: number) => {\n    let volumeToSet: number;\n    const newVolumeLessThanZero = volume - volumeWheelStep < 0;\n    if (newVolumeLessThanZero) {\n        volumeToSet = 0;\n    } else {\n        volumeToSet = volume - volumeWheelStep;\n    }\n\n    return volumeToSet;\n};\n\nexport const RightControls = () => {\n    const { showRatings } = useGeneralSettings();\n    return (\n        <Flex align=\"flex-end\" direction=\"column\" h=\"100%\" px=\"1rem\" py=\"0.5rem\">\n            <Group h=\"calc(100% / 3)\">\n                {showRatings && <RatingButton />}\n                <AutoDJButton />\n            </Group>\n            <Group align=\"center\" gap=\"xs\" wrap=\"nowrap\">\n                <SleepTimerButton />\n                <PlayerConfig />\n                <LyricsButton />\n                <FavoriteButton />\n                <QueueButton />\n                <VolumeButton />\n            </Group>\n            <Group h=\"calc(100% / 3)\" />\n        </Flex>\n    );\n};\n\nconst AutoDJButton = () => {\n    const { t } = useTranslation();\n    const settings = useAutoDJSettings();\n    const { setSettings } = useSettingsStoreActions();\n\n    const toggleAutoDJ = () => {\n        setSettings({\n            autoDJ: {\n                ...settings,\n                enabled: !settings.enabled,\n            },\n        });\n    };\n\n    return (\n        <Button\n            onClick={(e) => {\n                e.stopPropagation();\n                toggleAutoDJ();\n            }}\n            size=\"compact-xs\"\n            style={{ color: settings.enabled ? 'var(--theme-colors-primary)' : undefined }}\n            uppercase\n            variant=\"transparent\"\n        >\n            {t('setting.autoDJ')}\n        </Button>\n    );\n};\n\nconst QueueButton = () => {\n    const { t } = useTranslation();\n    const isSidebarRightExpanded = useSidebarRightExpanded();\n    const { setSideBar } = useAppStoreActions();\n    const sideQueueType = useSideQueueType();\n\n    const { bindings } = useHotkeySettings();\n\n    const [popoverOpened, setPopoverOpened] = useState(false);\n\n    const handleToggleQueue = () => {\n        if (sideQueueType === 'sideQueue') {\n            setSideBar({ rightExpanded: !isSidebarRightExpanded });\n        } else {\n            setPopoverOpened((prev) => !prev);\n        }\n    };\n\n    const handlePopoverClose = () => {\n        setPopoverOpened(false);\n    };\n\n    useHotkeys([\n        [bindings.toggleQueue.isGlobal ? '' : bindings.toggleQueue.hotkey, handleToggleQueue],\n    ]);\n\n    const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {\n        e.stopPropagation();\n\n        if (sideQueueType === 'sideQueue') {\n            return handleToggleQueue();\n        }\n    };\n\n    if (sideQueueType === 'sideQueue') {\n        return (\n            <ActionIcon\n                icon={isSidebarRightExpanded ? 'panelRightClose' : 'panelRightOpen'}\n                iconProps={{\n                    size: 'lg',\n                }}\n                onClick={handleClick}\n                size=\"sm\"\n                tooltip={{\n                    label: t('player.viewQueue', { postProcess: 'titleCase' }),\n                    openDelay: 0,\n                }}\n                variant=\"subtle\"\n            />\n        );\n    }\n\n    return (\n        <PopoverPlayQueue\n            onClose={handlePopoverClose}\n            onToggle={(e) => {\n                e.stopPropagation();\n                handleToggleQueue();\n            }}\n            opened={popoverOpened}\n        />\n    );\n};\n\nconst LyricsButton = () => {\n    const setFullScreenPlayerStore = useSetFullScreenPlayerStore();\n    const activeTab = useFullScreenPlayerStore((state) => state.activeTab);\n\n    const { setStore } = useFullScreenPlayerStoreActions();\n    const { expanded: isFullScreenPlayerExpanded } = useFullScreenPlayerStore();\n\n    const expandFullScreenPlayer = () => {\n        setFullScreenPlayerStore({ expanded: !isFullScreenPlayerExpanded });\n    };\n\n    return (\n        <ActionIcon\n            icon=\"microphone\"\n            iconProps={{\n                color: activeTab === 'lyrics' && isFullScreenPlayerExpanded ? 'primary' : undefined,\n                size: 'lg',\n            }}\n            onClick={(e) => {\n                e.stopPropagation();\n                if (!isFullScreenPlayerExpanded) setStore({ activeTab: 'lyrics' });\n                expandFullScreenPlayer();\n            }}\n            role=\"button\"\n            size=\"sm\"\n            tooltip={{\n                label: t('player.lyrics', { postProcess: 'titleCase' }),\n                openDelay: 0,\n            }}\n            variant=\"subtle\"\n        />\n    );\n};\n\nconst FavoriteButton = () => {\n    const currentSong = usePlayerSong();\n    const { bindings } = useHotkeySettings();\n\n    const addToFavoritesMutation = useCreateFavorite({});\n    const removeFromFavoritesMutation = useDeleteFavorite({});\n\n    const handleAddToFavorites = (song: QueueSong | undefined) => {\n        if (!song?.id) return;\n\n        addToFavoritesMutation.mutate({\n            apiClientProps: { serverId: song?._serverId || '' },\n            query: {\n                id: [song.id],\n                type: LibraryItem.SONG,\n            },\n        });\n    };\n\n    const handleRemoveFromFavorites = (song: QueueSong | undefined) => {\n        if (!song?.id) return;\n\n        removeFromFavoritesMutation.mutate({\n            apiClientProps: { serverId: song?._serverId || '' },\n            query: {\n                id: [song.id],\n                type: LibraryItem.SONG,\n            },\n        });\n    };\n\n    const handleToggleFavorite = (song: QueueSong | undefined) => {\n        if (!song?.id) return;\n\n        if (song.userFavorite) {\n            handleRemoveFromFavorites(song);\n        } else {\n            handleAddToFavorites(song);\n        }\n    };\n\n    useFavoritePreviousSongHotkeys({\n        handleAddToFavorites,\n        handleRemoveFromFavorites,\n        handleToggleFavorite,\n    });\n\n    useHotkeys([\n        [\n            bindings.favoriteCurrentAdd.isGlobal ? '' : bindings.favoriteCurrentAdd.hotkey,\n            () => handleAddToFavorites(currentSong),\n        ],\n        [\n            bindings.favoriteCurrentRemove.isGlobal ? '' : bindings.favoriteCurrentRemove.hotkey,\n            () => handleRemoveFromFavorites(currentSong),\n        ],\n        [\n            bindings.favoriteCurrentToggle.isGlobal ? '' : bindings.favoriteCurrentToggle.hotkey,\n            () => handleToggleFavorite(currentSong),\n        ],\n    ]);\n\n    return (\n        <ActionIcon\n            icon=\"favorite\"\n            iconProps={{\n                fill: currentSong?.userFavorite ? 'primary' : undefined,\n                size: 'lg',\n            }}\n            onClick={(e) => {\n                e.stopPropagation();\n                handleToggleFavorite(currentSong);\n            }}\n            size=\"sm\"\n            tooltip={{\n                label: currentSong?.userFavorite\n                    ? t('player.unfavorite', { postProcess: 'titleCase' })\n                    : t('player.favorite', { postProcess: 'titleCase' }),\n                openDelay: 0,\n            }}\n            variant=\"subtle\"\n        />\n    );\n};\n\nconst useFavoritePreviousSongHotkeys = ({\n    handleAddToFavorites,\n    handleRemoveFromFavorites,\n    handleToggleFavorite,\n}: {\n    handleAddToFavorites: (song: QueueSong | undefined) => void;\n    handleRemoveFromFavorites: (song: QueueSong | undefined) => void;\n    handleToggleFavorite: (song: QueueSong | undefined) => void;\n}) => {\n    const { bindings } = useHotkeySettings();\n    const { previousSong } = usePlayerData();\n\n    useHotkeys([\n        [\n            bindings.favoritePreviousAdd.isGlobal ? '' : bindings.favoritePreviousAdd.hotkey,\n            () => handleAddToFavorites(previousSong),\n        ],\n        [\n            bindings.favoritePreviousRemove.isGlobal ? '' : bindings.favoritePreviousRemove.hotkey,\n            () => handleRemoveFromFavorites(previousSong),\n        ],\n        [\n            bindings.favoritePreviousToggle.isGlobal ? '' : bindings.favoritePreviousToggle.hotkey,\n            () => handleToggleFavorite(previousSong),\n        ],\n    ]);\n\n    return null;\n};\n\nconst RatingButton = () => {\n    const server = useCurrentServer();\n    const currentSong = usePlayerSong();\n    const setRating = useSetRating();\n\n    const isSongDefined = Boolean(currentSong?.id);\n    const showRating =\n        isSongDefined &&\n        (server?.type === ServerType.NAVIDROME || server?.type === ServerType.SUBSONIC);\n\n    const handleUpdateRating = (rating: number) => {\n        if (!currentSong) return;\n\n        setRating(currentSong._serverId, [currentSong.id], LibraryItem.SONG, rating);\n    };\n\n    const { bindings } = useHotkeySettings();\n\n    useHotkeys([\n        [bindings.rate0.isGlobal ? '' : bindings.rate0.hotkey, () => handleUpdateRating(0)],\n        [bindings.rate1.isGlobal ? '' : bindings.rate1.hotkey, () => handleUpdateRating(1)],\n        [bindings.rate2.isGlobal ? '' : bindings.rate2.hotkey, () => handleUpdateRating(2)],\n        [bindings.rate3.isGlobal ? '' : bindings.rate3.hotkey, () => handleUpdateRating(3)],\n        [bindings.rate4.isGlobal ? '' : bindings.rate4.hotkey, () => handleUpdateRating(4)],\n        [bindings.rate5.isGlobal ? '' : bindings.rate5.hotkey, () => handleUpdateRating(5)],\n    ]);\n\n    return (\n        <>\n            {showRating && (\n                <Rating\n                    onChange={handleUpdateRating}\n                    size=\"xs\"\n                    value={currentSong?.userRating || 0}\n                />\n            )}\n        </>\n    );\n};\n\nconst VolumeButton = () => {\n    const { bindings } = useHotkeySettings();\n    const volume = usePlayerVolume();\n    const muted = usePlayerMuted();\n    const volumeWheelStep = useVolumeWheelStep();\n    const volumeWidth = useVolumeWidth();\n    const { decreaseVolume, increaseVolume, mediaToggleMute, setVolume } = usePlayer();\n    const isMinWidth = useMediaQuery('(max-width: 480px)');\n\n    const [sliderValue, setSliderValue] = useState(volume);\n\n    const throttledVolume = useThrottledValue(sliderValue, 100);\n\n    // Sync throttled value to actual volume\n    useEffect(() => {\n        setVolume(throttledVolume);\n    }, [throttledVolume, setVolume]);\n\n    // Sync external volume changes to local state\n    useEffect(() => {\n        setSliderValue(volume);\n    }, [volume]);\n\n    const handleVolumeDown = useCallback(() => {\n        decreaseVolume(volumeWheelStep);\n    }, [decreaseVolume, volumeWheelStep]);\n\n    const handleVolumeUp = useCallback(() => {\n        increaseVolume(volumeWheelStep);\n    }, [increaseVolume, volumeWheelStep]);\n\n    const handleVolumeSlider = useCallback((e: number) => {\n        setSliderValue(e);\n    }, []);\n\n    const handleMute = useCallback(() => {\n        mediaToggleMute();\n    }, [mediaToggleMute]);\n\n    const handleVolumeWheel = useCallback(\n        (e: WheelEvent<HTMLButtonElement | HTMLDivElement>) => {\n            let volumeToSet;\n            if (e.deltaY > 0 || e.deltaX > 0) {\n                volumeToSet = calculateVolumeDown(volume, volumeWheelStep);\n            } else {\n                volumeToSet = calculateVolumeUp(volume, volumeWheelStep);\n            }\n\n            setVolume(volumeToSet);\n        },\n        [setVolume, volume, volumeWheelStep],\n    );\n\n    const handleVolumeDownThrottled = useThrottledCallback(handleVolumeDown, 100);\n    const handleVolumeUpThrottled = useThrottledCallback(handleVolumeUp, 100);\n\n    useHotkeys([\n        [bindings.volumeDown.isGlobal ? '' : bindings.volumeDown.hotkey, handleVolumeDownThrottled],\n        [bindings.volumeUp.isGlobal ? '' : bindings.volumeUp.hotkey, handleVolumeUpThrottled],\n        [bindings.volumeMute.isGlobal ? '' : bindings.volumeMute.hotkey, handleMute],\n    ]);\n\n    return (\n        <>\n            <ActionIcon\n                icon={muted ? 'volumeMute' : volume > 50 ? 'volumeMax' : 'volumeNormal'}\n                iconProps={{\n                    color: muted ? 'muted' : undefined,\n                    size: 'xl',\n                }}\n                onClick={(e) => {\n                    e.stopPropagation();\n                    handleMute();\n                }}\n                onWheel={handleVolumeWheel}\n                size=\"sm\"\n                tooltip={{\n                    label: muted ? t('player.muted', { postProcess: 'titleCase' }) : volume,\n                    openDelay: 0,\n                }}\n                variant=\"subtle\"\n            />\n            {!isMinWidth ? (\n                <CustomPlayerbarSlider\n                    max={100}\n                    min={0}\n                    onChange={handleVolumeSlider}\n                    onClick={(e) => {\n                        e.stopPropagation();\n                    }}\n                    onWheel={handleVolumeWheel}\n                    size={6}\n                    value={sliderValue}\n                    w={volumeWidth}\n                />\n            ) : null}\n        </>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/player/components/shuffle-all-modal.tsx",
    "content": "import { closeAllModals, openContextModal } from '@mantine/modals';\nimport { queryOptions, useQuery } from '@tanstack/react-query';\nimport merge from 'lodash/merge';\nimport { Suspense, useMemo, useRef } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { persist } from 'zustand/middleware';\nimport { immer } from 'zustand/middleware/immer';\nimport { createWithEqualityFn } from 'zustand/traditional';\n\nimport i18n from '/@/i18n/i18n';\nimport { api } from '/@/renderer/api';\nimport { queryKeys } from '/@/renderer/api/query-keys';\nimport { useGenreList } from '/@/renderer/features/genres/api/genres-api';\nimport { usePlayer } from '/@/renderer/features/player/context/player-context';\nimport { PlayButtonGroup } from '/@/renderer/features/shared/components/play-button-group';\nimport { useCurrentServer } from '/@/renderer/store';\nimport { Checkbox } from '/@/shared/components/checkbox/checkbox';\nimport { Divider } from '/@/shared/components/divider/divider';\nimport { Group } from '/@/shared/components/group/group';\nimport { NumberInput } from '/@/shared/components/number-input/number-input';\nimport { Select } from '/@/shared/components/select/select';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { Played, RandomSongListQuery, ServerType } from '/@/shared/types/domain-types';\nimport { Play } from '/@/shared/types/types';\n\ninterface ShuffleAllSlice extends RandomSongListQuery {\n    actions: {\n        setStore: (data: Partial<ShuffleAllSlice>) => void;\n    };\n    enableMaxYear: boolean;\n    enableMinYear: boolean;\n}\n\nconst useShuffleAllStore = createWithEqualityFn<ShuffleAllSlice>()(\n    persist(\n        immer((set, get) => ({\n            actions: {\n                setStore: (data) => {\n                    set({ ...get(), ...data });\n                },\n            },\n            enableMaxYear: false,\n            enableMinYear: false,\n            genre: '',\n            maxYear: 2020,\n            minYear: 2000,\n            musicFolder: '',\n            played: Played.All,\n            songCount: 100,\n        })),\n        {\n            merge: (persistedState, currentState) => merge(currentState, persistedState),\n            name: 'store_shuffle_all',\n            version: 1,\n        },\n    ),\n);\n\nconst PLAYED_DATA: { label: string; value: Played }[] = [\n    { label: 'all tracks', value: Played.All },\n    { label: 'only unplayed tracks', value: Played.Never },\n    { label: 'only played tracks', value: Played.Played },\n];\n\nexport const useShuffleAllStoreActions = () => useShuffleAllStore((state) => state.actions);\n\nexport const ShuffleAllContextModal = () => {\n    const server = useCurrentServer();\n    const { addToQueueByData } = usePlayer();\n    const { t } = useTranslation();\n    const { enableMaxYear, enableMinYear, genre, limit, maxYear, minYear, musicFolderId, played } =\n        useShuffleAllStore();\n    const { setStore } = useShuffleAllStoreActions();\n\n    const { isFetching, refetch } = useQuery({\n        ...randomFetchQuery({\n            query: {\n                genre: genre || undefined,\n                limit: limit || 100,\n                maxYear: enableMaxYear ? maxYear || undefined : undefined,\n                minYear: enableMinYear ? minYear || undefined : undefined,\n                musicFolderId: musicFolderId || undefined,\n                played,\n            },\n            serverId: server.id,\n        }),\n        enabled: false,\n        gcTime: 0,\n        staleTime: 0,\n    });\n\n    const fetchTypeRef = useRef<Play>(null);\n\n    const handlePlay = async (playType: Play) => {\n        fetchTypeRef.current = playType;\n\n        const { data } = await refetch();\n\n        addToQueueByData(data?.items || [], playType);\n\n        closeAllModals();\n    };\n\n    return (\n        <Stack gap=\"md\">\n            <NumberInput\n                label={t('form.shuffleAll.input_limit', { postProcess: 'sentenceCase' })}\n                max={500}\n                min={1}\n                onChange={(e) => setStore({ limit: e ? Number(e) : 500 })}\n                required\n                value={limit}\n            />\n            <Group grow>\n                <NumberInput\n                    label={t('form.shuffleAll.input_minYear', { postProcess: 'sentenceCase' })}\n                    max={2050}\n                    min={1850}\n                    onChange={(e) => setStore({ minYear: e ? Number(e) : 0 })}\n                    rightSection={\n                        <Checkbox\n                            checked={enableMinYear}\n                            onChange={(e) => setStore({ enableMinYear: e.currentTarget.checked })}\n                            style={{ marginRight: '0.5rem' }}\n                        />\n                    }\n                    value={minYear}\n                />\n                <NumberInput\n                    label={t('form.shuffleAll.input_maxYear', { postProcess: 'sentenceCase' })}\n                    max={2050}\n                    min={1850}\n                    onChange={(e) => setStore({ maxYear: e ? Number(e) : 0 })}\n                    rightSection={\n                        <Checkbox\n                            checked={enableMaxYear}\n                            onChange={(e) => setStore({ enableMaxYear: e.currentTarget.checked })}\n                            style={{ marginRight: '0.5rem' }}\n                        />\n                    }\n                    value={maxYear}\n                />\n            </Group>\n            <Suspense fallback={<Select data={[]} />}>\n                <GenreSelect />\n            </Suspense>\n            {server?.type === ServerType.JELLYFIN && (\n                <Select\n                    clearable\n                    data={PLAYED_DATA}\n                    label={t('form.shuffleAll.input_played', { postProcess: 'sentenceCase' })}\n                    onChange={(e) => {\n                        setStore({ played: e as Played });\n                    }}\n                    value={played}\n                />\n            )}\n            <Divider />\n            <PlayButtonGroup\n                loading={(isFetching && fetchTypeRef.current) || false}\n                onPlay={handlePlay}\n            />\n        </Stack>\n    );\n};\n\nconst randomFetchQuery = (args: {\n    query: {\n        genre?: string;\n        limit: number;\n        maxYear?: number;\n        minYear?: number;\n        musicFolderId?: string | string[];\n        played: Played;\n    };\n    serverId: string;\n}) => {\n    return queryOptions({\n        queryFn: async ({ signal }) => {\n            return api.controller.getRandomSongList({\n                apiClientProps: { serverId: args.serverId, signal },\n                query: args.query,\n            });\n        },\n        queryKey: queryKeys.player.fetch(),\n    });\n};\n\nexport const openShuffleAllModal = async () => {\n    openContextModal({\n        innerProps: {},\n        modalKey: 'shuffleAll',\n        size: 'sm',\n        title: i18n.t('player.playRandom', { postProcess: 'sentenceCase' }) as string,\n    });\n};\n\nconst GenreSelect = () => {\n    const { t } = useTranslation();\n    const server = useCurrentServer();\n    const { genre } = useShuffleAllStore();\n    const { data: genres } = useGenreList();\n    const { setStore } = useShuffleAllStoreActions();\n\n    const genreData = useMemo(() => {\n        if (!genres) return [];\n\n        return genres.items.map((genre) => {\n            const value =\n                server?.type === ServerType.NAVIDROME || server?.type === ServerType.SUBSONIC\n                    ? genre.name\n                    : genre.id;\n            return {\n                label: genre.name,\n                value,\n            };\n        });\n    }, [genres, server.type]);\n\n    return (\n        <Select\n            clearable\n            data={genreData}\n            label={t('form.shuffleAll.input_genre', { postProcess: 'sentenceCase' })}\n            onChange={(e) => setStore({ genre: e || '' })}\n            searchable\n            value={genre}\n        />\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/player/components/sleep-timer-button.tsx",
    "content": "import React, { useCallback, useEffect, useRef, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';\nimport { usePlayer } from '/@/renderer/features/player/context/player-context';\nimport { usePlayerStatus, usePlayerStoreBase } from '/@/renderer/store/player.store';\nimport {\n    useSleepTimerActions,\n    useSleepTimerActive,\n    useSleepTimerMode,\n    useSleepTimerRemaining,\n    useSleepTimerStore,\n} from '/@/renderer/store/sleep-timer.store';\nimport { ActionIcon } from '/@/shared/components/action-icon/action-icon';\nimport { Button } from '/@/shared/components/button/button';\nimport { Divider } from '/@/shared/components/divider/divider';\nimport { Flex } from '/@/shared/components/flex/flex';\nimport { Grid } from '/@/shared/components/grid/grid';\nimport { Group } from '/@/shared/components/group/group';\nimport { NumberInput } from '/@/shared/components/number-input/number-input';\nimport { Popover } from '/@/shared/components/popover/popover';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { Text } from '/@/shared/components/text/text';\nimport { PlayerStatus } from '/@/shared/types/types';\n\nconst PRESET_OPTIONS = [\n    { minutes: 0, mode: 'endOfSong' as const },\n    { minutes: 5, mode: 'timed' as const },\n    { minutes: 10, mode: 'timed' as const },\n    { minutes: 15, mode: 'timed' as const },\n    { minutes: 30, mode: 'timed' as const },\n    { minutes: 45, mode: 'timed' as const },\n    { minutes: 60, mode: 'timed' as const },\n    { minutes: 120, mode: 'timed' as const },\n    { minutes: 180, mode: 'timed' as const },\n    { minutes: 240, mode: 'timed' as const },\n];\n\nfunction formatRemaining(totalSeconds: number): string {\n    const h = Math.floor(totalSeconds / 3600);\n    const m = Math.floor((totalSeconds % 3600) / 60);\n    const s = Math.floor(totalSeconds % 60);\n\n    if (h > 0) {\n        return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;\n    }\n    return `${m}:${String(s).padStart(2, '0')}`;\n}\n\nconst useSleepTimer = () => {\n    const active = useSleepTimerActive();\n    const mode = useSleepTimerMode();\n    const { cancelTimer, setRemaining } = useSleepTimerActions();\n    const { mediaPause } = usePlayer();\n\n    const mediaPauseRef = useRef(mediaPause);\n    mediaPauseRef.current = mediaPause;\n\n    const handleOnCurrentSongChange = useCallback(() => {\n        if (!active) {\n            return;\n        }\n\n        // Cancel and pause on song change in end-of-song mode\n        if (mode === 'endOfSong') {\n            cancelTimer();\n            mediaPauseRef.current();\n        }\n    }, [active, mode, cancelTimer, mediaPauseRef]);\n\n    const status = usePlayerStatus();\n\n    const handleOnPlayerProgress = useCallback(() => {\n        if (!active) {\n            return;\n        }\n\n        if (status !== PlayerStatus.PLAYING) {\n            return;\n        }\n\n        // Count down in timed mode\n        if (mode === 'timed') {\n            const remaining = useSleepTimerStore.getState().remaining;\n\n            if (remaining <= 0) {\n                cancelTimer();\n                mediaPauseRef.current();\n            } else {\n                setRemaining(Math.max(0, remaining - 1));\n            }\n        }\n    }, [active, cancelTimer, mode, setRemaining, status]);\n\n    usePlayerEvents(\n        {\n            onCurrentSongChange: handleOnCurrentSongChange,\n            onPlayerProgress: handleOnPlayerProgress,\n        },\n        [handleOnCurrentSongChange, handleOnPlayerProgress],\n    );\n\n    // End-of-song mode: set the pauseOnNextSongEnd flag so that\n    // mediaAutoNext returns PAUSED status when the current song ends.\n    // This is a generic player mechanism — the web player handles it\n    // without needing to know about the sleep timer.\n    useEffect(() => {\n        if (!active || mode !== 'endOfSong') return;\n\n        usePlayerStoreBase.getState().setPauseOnNextSongEnd(true);\n\n        return () => {\n            usePlayerStoreBase.getState().setPauseOnNextSongEnd(false);\n        };\n    }, [active, mode]);\n};\n\nexport const SleepTimerHookInner = () => {\n    useSleepTimer();\n    return null;\n};\n\nexport const SleepTimerHook = () => {\n    const active = useSleepTimerActive();\n\n    if (!active) {\n        return null;\n    }\n\n    return React.createElement(SleepTimerHookInner);\n};\n\nexport const SleepTimerButton = () => {\n    const { t } = useTranslation();\n    const active = useSleepTimerActive();\n    const mode = useSleepTimerMode();\n    const remaining = useSleepTimerRemaining();\n    const { cancelTimer, startEndOfSongTimer, startTimedTimer } = useSleepTimerActions();\n    const { mediaPause } = usePlayer();\n\n    const [showCustom, setShowCustom] = useState(false);\n    const [customHours, setCustomHours] = useState<number>(0);\n    const [customMinutes, setCustomMinutes] = useState<number>(20);\n    const [customSeconds, setCustomSeconds] = useState<number>(0);\n    const [opened, setOpened] = useState(false);\n\n    const mediaPauseRef = useRef(mediaPause);\n    mediaPauseRef.current = mediaPause;\n\n    const handlePreset = useCallback(\n        (option: (typeof PRESET_OPTIONS)[number]) => {\n            if (option.mode === 'endOfSong') {\n                startEndOfSongTimer();\n            } else {\n                startTimedTimer(option.minutes * 60);\n            }\n            setShowCustom(false);\n            setOpened(false);\n        },\n        [startEndOfSongTimer, startTimedTimer],\n    );\n\n    const handleCustomStart = useCallback(() => {\n        const totalSeconds = customHours * 3600 + customMinutes * 60 + customSeconds;\n        if (totalSeconds > 0) {\n            startTimedTimer(totalSeconds);\n            setShowCustom(false);\n            setOpened(false);\n        }\n    }, [customHours, customMinutes, customSeconds, startTimedTimer]);\n\n    const handleCancel = useCallback(() => {\n        cancelTimer();\n        setShowCustom(false);\n    }, [cancelTimer]);\n\n    const getPresetLabel = (option: (typeof PRESET_OPTIONS)[number]) => {\n        if (option.mode === 'endOfSong') {\n            return t('player.sleepTimer_endOfSong', { postProcess: 'sentenceCase' });\n        }\n        if (option.minutes >= 60) {\n            return t('player.sleepTimer_hours', {\n                count: option.minutes / 60,\n                postProcess: 'sentenceCase',\n            });\n        }\n        return t('player.sleepTimer_minutes', {\n            count: option.minutes,\n            postProcess: 'sentenceCase',\n        });\n    };\n\n    return (\n        <Popover onChange={setOpened} opened={opened} position=\"top\" width={260}>\n            <Popover.Target>\n                <ActionIcon\n                    icon={active ? 'sleepTimer' : 'sleepTimerOff'}\n                    iconProps={{\n                        color: active ? 'primary' : undefined,\n                        size: 'lg',\n                    }}\n                    onClick={(e) => {\n                        e.stopPropagation();\n                        setOpened((prev) => !prev);\n                    }}\n                    size=\"sm\"\n                    tooltip={{\n                        label: t('player.sleepTimer', { postProcess: 'titleCase' }),\n                        openDelay: 0,\n                    }}\n                    variant=\"subtle\"\n                />\n            </Popover.Target>\n            <Popover.Dropdown>\n                <Stack gap=\"xs\" p=\"xs\">\n                    <Text fw=\"600\" pb=\"md\" size=\"sm\" ta=\"center\">\n                        {t('player.sleepTimer', { postProcess: 'titleCase' })}\n                    </Text>\n\n                    {active && (\n                        <Flex\n                            align=\"center\"\n                            direction=\"column\"\n                            gap={4}\n                            mb=\"xs\"\n                            style={{\n                                background: 'var(--theme-colors-surface)',\n                                borderRadius: 'var(--theme-radius-md)',\n                                padding: 'var(--theme-spacing-sm) var(--theme-spacing-md)',\n                            }}\n                        >\n                            {mode === 'endOfSong' ? (\n                                <Text c=\"primary\" size=\"sm\">\n                                    {t('player.sleepTimer_endOfSong', {\n                                        postProcess: 'sentenceCase',\n                                    })}\n                                </Text>\n                            ) : (\n                                <Text c=\"primary\" fw=\"600\" size=\"lg\">\n                                    {formatRemaining(remaining)}\n                                </Text>\n                            )}\n                            <Button\n                                onClick={(e) => {\n                                    e.stopPropagation();\n                                    handleCancel();\n                                }}\n                                size=\"compact-xs\"\n                                variant=\"subtle\"\n                            >\n                                {t('player.sleepTimer_cancel', { postProcess: 'titleCase' })}\n                            </Button>\n                        </Flex>\n                    )}\n\n                    {PRESET_OPTIONS.filter((option) => option.mode === 'endOfSong').map(\n                        (option, index) => (\n                            <Button\n                                fullWidth\n                                justify=\"flex-start\"\n                                key={index}\n                                onClick={(e) => {\n                                    e.stopPropagation();\n                                    handlePreset(option);\n                                }}\n                                size=\"xs\"\n                                variant=\"outline\"\n                            >\n                                {getPresetLabel(option)}\n                            </Button>\n                        ),\n                    )}\n\n                    <Divider my=\"md\" />\n\n                    <Grid gutter=\"xs\">\n                        {PRESET_OPTIONS.filter((option) => option.mode === 'timed').map(\n                            (option, index) => (\n                                <Grid.Col key={index} span={4}>\n                                    <Button\n                                        fullWidth\n                                        justify=\"flex-start\"\n                                        key={index}\n                                        onClick={(e) => {\n                                            e.stopPropagation();\n                                            handlePreset(option);\n                                        }}\n                                        size=\"xs\"\n                                        variant=\"outline\"\n                                    >\n                                        {getPresetLabel(option)}\n                                    </Button>\n                                </Grid.Col>\n                            ),\n                        )}\n                    </Grid>\n\n                    <Divider my=\"md\" />\n\n                    {!showCustom ? (\n                        <Button\n                            fullWidth\n                            justify=\"flex-start\"\n                            onClick={(e) => {\n                                e.stopPropagation();\n                                setShowCustom(true);\n                            }}\n                            size=\"xs\"\n                            ta=\"center\"\n                            variant=\"outline\"\n                        >\n                            {t('player.sleepTimer_custom', { postProcess: 'sentenceCase' })}\n                        </Button>\n                    ) : (\n                        <Stack gap=\"xs\">\n                            <Group gap={4} wrap=\"nowrap\">\n                                <NumberInput\n                                    max={23}\n                                    min={0}\n                                    onChange={(val) => setCustomHours(Number(val) || 0)}\n                                    placeholder=\"hr\"\n                                    size=\"xs\"\n                                    value={customHours}\n                                />\n                                <Text>:</Text>\n                                <NumberInput\n                                    max={59}\n                                    min={0}\n                                    onChange={(val) => setCustomMinutes(Number(val) || 0)}\n                                    placeholder=\"min\"\n                                    size=\"xs\"\n                                    value={customMinutes}\n                                />\n                                <Text>:</Text>\n                                <NumberInput\n                                    max={59}\n                                    min={0}\n                                    onChange={(val) => setCustomSeconds(Number(val) || 0)}\n                                    placeholder=\"sec\"\n                                    size=\"xs\"\n                                    value={customSeconds}\n                                />\n                            </Group>\n                            <Group gap=\"xs\" grow>\n                                <Button\n                                    onClick={(e) => {\n                                        e.stopPropagation();\n                                        handleCustomStart();\n                                    }}\n                                    size=\"xs\"\n                                    variant=\"filled\"\n                                >\n                                    {t('player.sleepTimer_setCustom', { postProcess: 'titleCase' })}\n                                </Button>\n                                <Button\n                                    onClick={(e) => {\n                                        e.stopPropagation();\n                                        setShowCustom(false);\n                                    }}\n                                    size=\"xs\"\n                                    variant=\"default\"\n                                >\n                                    {t('common.cancel', { postProcess: 'titleCase' })}\n                                </Button>\n                            </Group>\n                        </Stack>\n                    )}\n                </Stack>\n            </Popover.Dropdown>\n        </Popover>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/player/context/player-context.tsx",
    "content": "import { closeAllModals, openModal } from '@mantine/modals';\nimport { QueryClient, useIsFetching, useQueryClient } from '@tanstack/react-query';\nimport { nanoid } from 'nanoid/non-secure';\nimport { createContext, useCallback, useContext, useMemo, useRef } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport { queryKeys } from '/@/renderer/api/query-keys';\nimport { albumQueries } from '/@/renderer/features/albums/api/album-api';\nimport { artistsQueries } from '/@/renderer/features/artists/api/artists-api';\nimport {\n    filterSongsByPlayerFilters,\n    getAlbumArtistSongsById,\n    getAlbumSongsById,\n    getGenreSongsById,\n    getPlaylistSongsById,\n    getSongsByFolder,\n} from '/@/renderer/features/player/utils';\nimport { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';\nimport { songsQueries } from '/@/renderer/features/songs/api/songs-api';\nimport { AddToQueueType, usePlayerActions, useSettingsStore } from '/@/renderer/store';\nimport { LogCategory, logFn } from '/@/renderer/utils/logger';\nimport { logMsg } from '/@/renderer/utils/logger-message';\nimport { shuffle as shuffleArray } from '/@/renderer/utils/shuffle';\nimport { sortSongsByFetchedOrder } from '/@/shared/api/utils';\nimport { Checkbox } from '/@/shared/components/checkbox/checkbox';\nimport { ConfirmModal } from '/@/shared/components/modal/modal';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { Text } from '/@/shared/components/text/text';\nimport { toast } from '/@/shared/components/toast/toast';\nimport { useLocalStorage } from '/@/shared/hooks/use-local-storage';\nimport {\n    AlbumListSort,\n    instanceOfCancellationError,\n    LibraryItem,\n    PlaylistSongListResponse,\n    QueueSong,\n    Song,\n} from '/@/shared/types/domain-types';\nimport { Play, PlayerRepeat, PlayerShuffle } from '/@/shared/types/types';\n\nexport interface PlayerContext {\n    addToQueueByData: (data: Song[], type: AddToQueueType, playSongId?: string) => void;\n    addToQueueByFetch: (\n        serverId: string,\n        id: string[],\n        itemType: LibraryItem,\n        type: AddToQueueType,\n    ) => void;\n    addToQueueByListQuery: (\n        serverId: string,\n        query: any,\n        itemType: LibraryItem,\n        type: AddToQueueType,\n    ) => Promise<void>;\n    clearQueue: () => void;\n    clearSelected: (items: QueueSong[]) => void;\n    decreaseVolume: (amount: number) => void;\n    increaseVolume: (amount: number) => void;\n    mediaNext: () => void;\n    mediaPause: () => void;\n    mediaPlay: (id?: string) => void;\n    mediaPlayByIndex: (index: number) => void;\n    mediaPrevious: () => void;\n    mediaSeekToTimestamp: (timestamp: number) => void;\n    mediaSkipBackward: () => void;\n    mediaSkipForward: () => void;\n    mediaStop: () => void;\n    mediaToggleMute: () => void;\n    mediaTogglePlayPause: () => void;\n    moveSelectedTo: (items: QueueSong[], edge: 'bottom' | 'top', uniqueId: string) => void;\n    moveSelectedToBottom: (items: QueueSong[]) => void;\n    moveSelectedToNext: (items: QueueSong[]) => void;\n    moveSelectedToTop: (items: QueueSong[]) => void;\n    setQueue: (data: Song[], index?: number, position?: number) => void;\n    setRepeat: (repeat: PlayerRepeat) => void;\n    setShuffle: (shuffle: PlayerShuffle) => void;\n    setSpeed: (speed: number) => void;\n    setVolume: (volume: number) => void;\n    shuffle: () => void;\n    shuffleAll: () => void;\n    shuffleSelected: (items: QueueSong[]) => void;\n    toggleRepeat: () => void;\n    toggleShuffle: () => void;\n}\n\nexport const PlayerContext = createContext<PlayerContext>({\n    addToQueueByData: () => {},\n    addToQueueByFetch: () => {},\n    addToQueueByListQuery: async () => {},\n    clearQueue: () => {},\n    clearSelected: () => {},\n    decreaseVolume: () => {},\n    increaseVolume: () => {},\n    mediaNext: () => {},\n    mediaPause: () => {},\n    mediaPlay: () => {},\n    mediaPlayByIndex: () => {},\n    mediaPrevious: () => {},\n    mediaSeekToTimestamp: () => {},\n    mediaSkipBackward: () => {},\n    mediaSkipForward: () => {},\n    mediaStop: () => {},\n    mediaToggleMute: () => {},\n    mediaTogglePlayPause: () => {},\n    moveSelectedTo: () => {},\n    moveSelectedToBottom: () => {},\n    moveSelectedToNext: () => {},\n    moveSelectedToTop: () => {},\n    setQueue: () => {},\n    setRepeat: () => {},\n    setShuffle: () => {},\n    setSpeed: () => {},\n    setVolume: () => {},\n    shuffle: () => {},\n    shuffleAll: () => {},\n    shuffleSelected: () => {},\n    toggleRepeat: () => {},\n    toggleShuffle: () => {},\n});\n\nconst getRootQueryKey = (itemType: LibraryItem, serverId: string) => {\n    switch (itemType) {\n        case LibraryItem.ALBUM:\n            return queryKeys.songs.root(serverId);\n        case LibraryItem.ALBUM_ARTIST:\n            return queryKeys.songs.root(serverId);\n        case LibraryItem.ARTIST:\n            return queryKeys.songs.root(serverId);\n        case LibraryItem.GENRE:\n            return queryKeys.songs.root(serverId);\n        case LibraryItem.PLAYLIST:\n            return queryKeys.playlists.root(serverId);\n        case LibraryItem.SONG:\n            return queryKeys.songs.root(serverId);\n        default:\n            return queryKeys.songs.root(serverId);\n    }\n};\n\nexport const PlayerProvider = ({ children }: { children: React.ReactNode }) => {\n    const { t } = useTranslation();\n    const queryClient = useQueryClient();\n    const storeActions = usePlayerActions();\n    const timeoutIds = useRef<null | Record<string, ReturnType<typeof setTimeout>>>({});\n\n    const [doNotShowAgain, setDoNotShowAgain] = useLocalStorage({\n        defaultValue: false,\n        key: 'large_fetch_confirmation',\n    });\n\n    const confirmLargeFetch = useCallback((): Promise<boolean> => {\n        if (doNotShowAgain) {\n            return Promise.resolve(true);\n        }\n\n        return new Promise((resolve) => {\n            openModal({\n                children: (\n                    <ConfirmModal\n                        labels={{\n                            cancel: t('common.cancel', { postProcess: 'titleCase' }),\n                            confirm: t('common.confirm', { postProcess: 'titleCase' }),\n                        }}\n                        onCancel={() => {\n                            resolve(false);\n                            closeAllModals();\n                        }}\n                        onConfirm={() => {\n                            resolve(true);\n                            closeAllModals();\n                        }}\n                    >\n                        <Stack>\n                            <Text>\n                                {t('form.largeFetchConfirmation.description', {\n                                    postProcess: 'sentenceCase',\n                                })}\n                            </Text>\n                            <Checkbox\n                                label={t('common.doNotShowAgain', {\n                                    postProcess: 'sentenceCase',\n                                })}\n                                onChange={(event) => {\n                                    setDoNotShowAgain(event.currentTarget.checked);\n                                }}\n                            />\n                        </Stack>\n                    </ConfirmModal>\n                ),\n                title: t('form.largeFetchConfirmation.title', {\n                    postProcess: 'sentenceCase',\n                }),\n            });\n        });\n    }, [doNotShowAgain, setDoNotShowAgain, t]);\n\n    const addToQueueByData = useCallback(\n        (data: Song[], type: AddToQueueType, playSongId?: string) => {\n            const filters = useSettingsStore.getState().playback.filters;\n            const filteredData = filterSongsByPlayerFilters(data, filters);\n\n            if (typeof type === 'object' && 'edge' in type && type.edge !== null) {\n                const edge = type.edge === 'top' ? 'top' : 'bottom';\n\n                logFn.debug(logMsg[LogCategory.PLAYER].addToQueueByData, {\n                    category: LogCategory.PLAYER,\n                    meta: {\n                        data: data.length,\n                        edge,\n                        filtered: filteredData.length,\n                        type,\n                        uniqueId: type.uniqueId,\n                    },\n                });\n\n                storeActions.addToQueueByUniqueId(filteredData, type.uniqueId, edge, playSongId);\n            } else {\n                logFn.debug(logMsg[LogCategory.PLAYER].addToQueueByType, {\n                    category: LogCategory.PLAYER,\n                    meta: { data: data.length, filtered: filteredData.length, type },\n                });\n\n                storeActions.addToQueueByType(filteredData, type as Play, playSongId);\n            }\n        },\n        [storeActions],\n    );\n\n    const addToQueueByFetch = useCallback(\n        async (serverId: string, id: string[], itemType: LibraryItem, type: AddToQueueType) => {\n            let toastId: null | string = null;\n            const fetchId = nanoid();\n\n            timeoutIds.current = {\n                ...timeoutIds.current,\n                [fetchId]: setTimeout(() => {\n                    toastId = toast.info({\n                        autoClose: false,\n                        message: t('player.playbackFetchCancel', {\n                            postProcess: 'sentenceCase',\n                        }),\n                        onClose: () => {\n                            queryClient.cancelQueries({\n                                exact: false,\n                                queryKey: getRootQueryKey(itemType, serverId),\n                            });\n\n                            queryClient.cancelQueries({\n                                exact: false,\n                                queryKey: queryKeys.player.fetch(),\n                            });\n                        },\n                        title: t('player.playbackFetchInProgress', {\n                            postProcess: 'sentenceCase',\n                        }),\n                    });\n                }, 2000),\n            };\n\n            try {\n                logFn.debug(logMsg[LogCategory.PLAYER].addToQueueByFetch, {\n                    category: LogCategory.PLAYER,\n                    meta: { ids: id, itemType, serverId, type },\n                });\n\n                const songs = await queryClient.fetchQuery({\n                    gcTime: 0,\n                    queryFn: () => {\n                        return fetchSongsByItemType(queryClient, serverId, {\n                            id,\n                            itemType,\n                        });\n                    },\n                    queryKey: queryKeys.player.fetch(),\n                    staleTime: 0,\n                });\n\n                clearTimeout(timeoutIds.current[fetchId] as ReturnType<typeof setTimeout>);\n                delete timeoutIds.current[fetchId];\n                if (toastId) {\n                    toast.hide(toastId);\n                }\n\n                let sortedSongs: Song[] = [];\n\n                // Playlists should use the native order of the playlist\n                if (itemType === LibraryItem.PLAYLIST) {\n                    sortedSongs = songs;\n                } else {\n                    sortedSongs = sortSongsByFetchedOrder(songs, id, itemType);\n                }\n\n                const filters = useSettingsStore.getState().playback.filters;\n                const filteredSongs = filterSongsByPlayerFilters(sortedSongs, filters);\n\n                if (typeof type === 'object' && 'edge' in type && type.edge !== null) {\n                    const edge = type.edge === 'top' ? 'top' : 'bottom';\n                    storeActions.addToQueueByUniqueId(filteredSongs, type.uniqueId, edge);\n                } else {\n                    storeActions.addToQueueByType(filteredSongs, type as Play);\n                }\n            } catch (err: any) {\n                if (instanceOfCancellationError(err)) {\n                    return;\n                }\n\n                clearTimeout(timeoutIds.current[fetchId] as ReturnType<typeof setTimeout>);\n                delete timeoutIds.current[fetchId];\n                if (toastId) {\n                    toast.hide(toastId);\n                }\n\n                toast.error({\n                    message: err.message,\n                    title: t('error.genericError', { postProcess: 'sentenceCase' }) as string,\n                });\n            }\n        },\n        [queryClient, storeActions, t],\n    );\n\n    const addToQueueByListQuery = useCallback(\n        async (serverId: string, query: any, itemType: LibraryItem, type: AddToQueueType) => {\n            let toastId: null | string = null;\n            let fetchId: null | string = null;\n\n            logFn.debug(logMsg[LogCategory.PLAYER].addToQueueByListQuery, {\n                category: LogCategory.PLAYER,\n                meta: { itemType, query, serverId, type },\n            });\n\n            try {\n                let totalCount = 0;\n                let listQueryFn: any;\n                let listCountQueryFn: any;\n\n                // Special handling for albums with random sort: fetch in name order, then shuffle client-side\n                const isAlbumRandomSort =\n                    itemType === LibraryItem.ALBUM && query.sortBy === AlbumListSort.RANDOM;\n\n                const fetchQuery = isAlbumRandomSort\n                    ? { ...query, sortBy: AlbumListSort.NAME }\n                    : query;\n\n                switch (itemType) {\n                    case LibraryItem.ALBUM: {\n                        listQueryFn = albumQueries.list;\n                        listCountQueryFn = albumQueries.listCount;\n                        break;\n                    }\n                    case LibraryItem.ALBUM_ARTIST: {\n                        listQueryFn = artistsQueries.albumArtistList;\n                        listCountQueryFn = artistsQueries.albumArtistListCount;\n                        break;\n                    }\n                    case LibraryItem.ARTIST: {\n                        listQueryFn = artistsQueries.artistList;\n                        listCountQueryFn = artistsQueries.artistListCount;\n                        break;\n                    }\n                    case LibraryItem.PLAYLIST: {\n                        listQueryFn = playlistsQueries.list;\n                        listCountQueryFn = playlistsQueries.listCount;\n                        break;\n                    }\n                    case LibraryItem.SONG: {\n                        listQueryFn = songsQueries.list;\n                        listCountQueryFn = songsQueries.listCount;\n                        break;\n                    }\n                    default: {\n                        throw new Error(`Unsupported item type: ${itemType}`);\n                    }\n                }\n\n                // Get total count\n                const countResult = (await queryClient.fetchQuery({\n                    ...listCountQueryFn({\n                        query: { ...fetchQuery },\n                        serverId,\n                    }),\n                    gcTime: 0,\n                    queryKey: queryKeys.player.fetch(),\n                    staleTime: 0,\n                })) as number;\n                totalCount = countResult || 0;\n\n                const allResults: Song[] | string[] = [];\n                const pageSize = 500;\n\n                const confirmed = await confirmLargeFetch();\n                if (!confirmed) {\n                    return;\n                }\n\n                // Start timeout only after confirmation (if needed)\n                fetchId = nanoid();\n\n                timeoutIds.current = {\n                    ...timeoutIds.current,\n                    [fetchId]: setTimeout(() => {\n                        toastId = toast.info({\n                            autoClose: false,\n                            message: t('player.playbackFetchCancel', {\n                                postProcess: 'sentenceCase',\n                            }),\n                            onClose: () => {\n                                logFn.debug(logMsg[LogCategory.PLAYER].cancelledFetch, {\n                                    category: LogCategory.PLAYER,\n                                    meta: { itemType, serverId },\n                                });\n\n                                queryClient.cancelQueries({\n                                    exact: false,\n                                    queryKey: getRootQueryKey(itemType, serverId),\n                                });\n\n                                queryClient.cancelQueries({\n                                    exact: false,\n                                    queryKey: queryKeys.player.fetch(),\n                                });\n                            },\n                            title: t('player.playbackFetchInProgress', {\n                                postProcess: 'sentenceCase',\n                            }),\n                        });\n                    }, 2000),\n                };\n                let startIndex = 0;\n\n                while (startIndex < totalCount) {\n                    const pageQuery = {\n                        ...fetchQuery,\n                        limit: pageSize,\n                        startIndex,\n                    };\n\n                    const pageResult = (await queryClient.fetchQuery({\n                        ...listQueryFn({\n                            query: pageQuery,\n                            serverId,\n                        }),\n                        gcTime: 0,\n                        queryKey: queryKeys.player.fetch({ startIndex }),\n                        staleTime: 0,\n                    })) as { items: any[] };\n\n                    if (pageResult?.items) {\n                        if (itemType === LibraryItem.SONG) {\n                            allResults.push(...pageResult.items);\n                        } else {\n                            const pageIds = pageResult.items.map((item: any) => item.id);\n                            allResults.push(...pageIds);\n                        }\n                    }\n\n                    // If we got fewer items than requested, we've reached the end\n                    if (!pageResult?.items || pageResult.items.length < pageSize) {\n                        break;\n                    }\n\n                    startIndex += pageSize;\n                }\n\n                if (fetchId && timeoutIds.current) {\n                    clearTimeout(timeoutIds.current[fetchId] as ReturnType<typeof setTimeout>);\n                    delete timeoutIds.current[fetchId];\n                }\n\n                if (toastId) {\n                    toast.hide(toastId);\n                }\n\n                // Shuffle album IDs client-side if this was a random sort request\n                let finalResults = allResults;\n                if (isAlbumRandomSort && itemType === LibraryItem.ALBUM) {\n                    finalResults = shuffleArray(allResults as string[]) as typeof allResults;\n                }\n\n                if (itemType === LibraryItem.SONG) {\n                    addToQueueByData(finalResults as Song[], type);\n                } else {\n                    await addToQueueByFetch(serverId, finalResults as string[], itemType, type);\n                }\n            } catch (err: any) {\n                if (instanceOfCancellationError(err)) {\n                    return;\n                }\n\n                if (fetchId && timeoutIds.current) {\n                    clearTimeout(timeoutIds.current[fetchId] as ReturnType<typeof setTimeout>);\n                    delete timeoutIds.current[fetchId];\n                }\n                if (toastId) {\n                    toast.hide(toastId);\n                }\n\n                toast.error({\n                    message: err.message,\n                    title: t('error.genericError', { postProcess: 'sentenceCase' }) as string,\n                });\n            }\n        },\n        [queryClient, confirmLargeFetch, t, addToQueueByData, addToQueueByFetch],\n    );\n\n    const clearQueue = useCallback(() => {\n        logFn.debug(logMsg[LogCategory.PLAYER].clearQueue, {\n            category: LogCategory.PLAYER,\n        });\n\n        storeActions.clearQueue();\n    }, [storeActions]);\n\n    const clearSelected = useCallback(\n        (items: QueueSong[]) => {\n            logFn.debug(logMsg[LogCategory.PLAYER].clearSelected, {\n                category: LogCategory.PLAYER,\n                meta: { items: items.length },\n            });\n\n            storeActions.clearSelected(items);\n        },\n        [storeActions],\n    );\n\n    const decreaseVolume = useCallback(\n        (amount: number) => {\n            logFn.debug(logMsg[LogCategory.PLAYER].decreaseVolume, {\n                category: LogCategory.PLAYER,\n                meta: { amount },\n            });\n\n            storeActions.decreaseVolume(amount);\n        },\n        [storeActions],\n    );\n\n    const increaseVolume = useCallback(\n        (amount: number) => {\n            logFn.debug(logMsg[LogCategory.PLAYER].increaseVolume, {\n                category: LogCategory.PLAYER,\n                meta: { amount },\n            });\n\n            storeActions.increaseVolume(amount);\n        },\n        [storeActions],\n    );\n\n    const mediaNext = useCallback(() => {\n        logFn.debug(logMsg[LogCategory.PLAYER].mediaNext, {\n            category: LogCategory.PLAYER,\n        });\n\n        storeActions.mediaNext();\n    }, [storeActions]);\n\n    const mediaPause = useCallback(() => {\n        logFn.debug(logMsg[LogCategory.PLAYER].mediaPause, {\n            category: LogCategory.PLAYER,\n        });\n\n        storeActions.mediaPause();\n    }, [storeActions]);\n\n    const mediaPlay = useCallback(\n        (id?: string) => {\n            logFn.debug(logMsg[LogCategory.PLAYER].mediaPlay, {\n                category: LogCategory.PLAYER,\n                meta: { id },\n            });\n\n            storeActions.mediaPlay(id);\n        },\n        [storeActions],\n    );\n\n    const mediaPlayByIndex = useCallback(\n        (index: number) => {\n            logFn.debug(logMsg[LogCategory.PLAYER].mediaPlayByIndex, {\n                category: LogCategory.PLAYER,\n                meta: { index },\n            });\n\n            storeActions.mediaPlayByIndex(index);\n        },\n        [storeActions],\n    );\n\n    const mediaPrevious = useCallback(() => {\n        logFn.debug(logMsg[LogCategory.PLAYER].mediaPrevious, {\n            category: LogCategory.PLAYER,\n        });\n\n        storeActions.mediaPrevious();\n    }, [storeActions]);\n\n    const mediaStop = useCallback(() => {\n        logFn.debug(logMsg[LogCategory.PLAYER].mediaStop, {\n            category: LogCategory.PLAYER,\n        });\n\n        storeActions.mediaStop();\n    }, [storeActions]);\n\n    const mediaSeekToTimestamp = useCallback(\n        (timestamp: number) => {\n            logFn.debug(logMsg[LogCategory.PLAYER].mediaSeekToTimestamp, {\n                category: LogCategory.PLAYER,\n                meta: { timestamp },\n            });\n\n            storeActions.mediaSeekToTimestamp(timestamp);\n        },\n        [storeActions],\n    );\n\n    const mediaSkipBackward = useCallback(() => {\n        logFn.debug(logMsg[LogCategory.PLAYER].mediaSkipBackward, {\n            category: LogCategory.PLAYER,\n        });\n\n        storeActions.mediaSkipBackward();\n    }, [storeActions]);\n\n    const mediaSkipForward = useCallback(() => {\n        logFn.debug(logMsg[LogCategory.PLAYER].mediaSkipForward, {\n            category: LogCategory.PLAYER,\n        });\n\n        storeActions.mediaSkipForward();\n    }, [storeActions]);\n\n    const setQueue = useCallback(\n        (data: Song[], index?: number, position?: number) => {\n            logFn.debug(logMsg[LogCategory.PLAYER].setQueue, {\n                category: LogCategory.PLAYER,\n                meta: {\n                    data: data.length,\n                    index,\n                    position,\n                },\n            });\n\n            storeActions.setQueue(data, index, position);\n        },\n        [storeActions],\n    );\n\n    const setSpeed = useCallback(\n        (speed: number) => {\n            logFn.debug(logMsg[LogCategory.PLAYER].setSpeed, {\n                category: LogCategory.PLAYER,\n                meta: { speed },\n            });\n\n            storeActions.setSpeed(speed);\n        },\n        [storeActions],\n    );\n\n    const mediaToggleMute = useCallback(() => {\n        logFn.debug(logMsg[LogCategory.PLAYER].mediaToggleMute, {\n            category: LogCategory.PLAYER,\n        });\n\n        storeActions.mediaToggleMute();\n    }, [storeActions]);\n\n    const mediaTogglePlayPause = useCallback(() => {\n        logFn.debug(logMsg[LogCategory.PLAYER].mediaTogglePlayPause, {\n            category: LogCategory.PLAYER,\n        });\n\n        storeActions.mediaTogglePlayPause();\n    }, [storeActions]);\n\n    const moveSelectedTo = useCallback(\n        (items: QueueSong[], edge: 'bottom' | 'top', uniqueId: string) => {\n            logFn.debug(logMsg[LogCategory.PLAYER].moveSelectedTo, {\n                category: LogCategory.PLAYER,\n                meta: { edge, items, uniqueId },\n            });\n\n            storeActions.moveSelectedTo(items, uniqueId, edge);\n        },\n        [storeActions],\n    );\n\n    const moveSelectedToBottom = useCallback(\n        (items: QueueSong[]) => {\n            logFn.debug(logMsg[LogCategory.PLAYER].moveSelectedToBottom, {\n                category: LogCategory.PLAYER,\n                meta: { items },\n            });\n\n            storeActions.moveSelectedToBottom(items);\n        },\n        [storeActions],\n    );\n\n    const moveSelectedToNext = useCallback(\n        (items: QueueSong[]) => {\n            logFn.debug(logMsg[LogCategory.PLAYER].moveSelectedToNext, {\n                category: LogCategory.PLAYER,\n                meta: { items },\n            });\n\n            storeActions.moveSelectedToNext(items);\n        },\n        [storeActions],\n    );\n\n    const moveSelectedToTop = useCallback(\n        (items: QueueSong[]) => {\n            logFn.debug(logMsg[LogCategory.PLAYER].moveSelectedToTop, {\n                category: LogCategory.PLAYER,\n                meta: { items },\n            });\n\n            storeActions.moveSelectedToTop(items);\n        },\n        [storeActions],\n    );\n\n    const setVolume = useCallback(\n        (volume: number) => {\n            logFn.debug(logMsg[LogCategory.PLAYER].setVolume, {\n                category: LogCategory.PLAYER,\n                meta: { volume },\n            });\n\n            storeActions.setVolume(volume);\n        },\n        [storeActions],\n    );\n\n    const setRepeat = useCallback(\n        (repeat: PlayerRepeat) => {\n            logFn.debug(logMsg[LogCategory.PLAYER].setRepeat, {\n                category: LogCategory.PLAYER,\n                meta: { repeat },\n            });\n\n            storeActions.setRepeat(repeat);\n        },\n        [storeActions],\n    );\n\n    const setShuffle = useCallback(\n        (shuffle: PlayerShuffle) => {\n            logFn.debug(logMsg[LogCategory.PLAYER].setShuffle, {\n                category: LogCategory.PLAYER,\n                meta: { shuffle },\n            });\n\n            storeActions.setShuffle(shuffle);\n        },\n        [storeActions],\n    );\n\n    const shuffle = useCallback(() => {\n        logFn.debug(logMsg[LogCategory.PLAYER].shuffle, {\n            category: LogCategory.PLAYER,\n        });\n\n        storeActions.shuffle();\n    }, [storeActions]);\n\n    const shuffleAll = useCallback(() => {\n        logFn.debug(logMsg[LogCategory.PLAYER].shuffleAll, {\n            category: LogCategory.PLAYER,\n        });\n\n        storeActions.shuffleAll();\n    }, [storeActions]);\n\n    const shuffleSelected = useCallback(\n        (items: QueueSong[]) => {\n            logFn.debug(logMsg[LogCategory.PLAYER].shuffleSelected, {\n                category: LogCategory.PLAYER,\n                meta: { items },\n            });\n\n            storeActions.shuffleSelected(items);\n        },\n        [storeActions],\n    );\n\n    const toggleRepeat = useCallback(() => {\n        logFn.debug(logMsg[LogCategory.PLAYER].toggleRepeat, {\n            category: LogCategory.PLAYER,\n        });\n\n        storeActions.toggleRepeat();\n    }, [storeActions]);\n\n    const toggleShuffle = useCallback(() => {\n        logFn.debug(logMsg[LogCategory.PLAYER].toggleShuffle, {\n            category: LogCategory.PLAYER,\n        });\n\n        storeActions.toggleShuffle();\n    }, [storeActions]);\n\n    const contextValue: PlayerContext = useMemo(\n        () => ({\n            addToQueueByData,\n            addToQueueByFetch,\n            addToQueueByListQuery,\n            clearQueue,\n            clearSelected,\n            decreaseVolume,\n            increaseVolume,\n            mediaNext,\n            mediaPause,\n            mediaPlay,\n            mediaPlayByIndex,\n            mediaPrevious,\n            mediaSeekToTimestamp,\n            mediaSkipBackward,\n            mediaSkipForward,\n            mediaStop,\n            mediaToggleMute,\n            mediaTogglePlayPause,\n            moveSelectedTo,\n            moveSelectedToBottom,\n            moveSelectedToNext,\n            moveSelectedToTop,\n            setQueue,\n            setRepeat,\n            setShuffle,\n            setSpeed,\n            setVolume,\n            shuffle,\n            shuffleAll,\n            shuffleSelected,\n            toggleRepeat,\n            toggleShuffle,\n        }),\n        [\n            addToQueueByData,\n            addToQueueByFetch,\n            addToQueueByListQuery,\n            clearQueue,\n            clearSelected,\n            decreaseVolume,\n            increaseVolume,\n            mediaNext,\n            mediaPause,\n            mediaPlay,\n            mediaPlayByIndex,\n            mediaPrevious,\n            mediaSeekToTimestamp,\n            mediaSkipBackward,\n            mediaSkipForward,\n            mediaStop,\n            mediaToggleMute,\n            mediaTogglePlayPause,\n            moveSelectedTo,\n            moveSelectedToBottom,\n            moveSelectedToNext,\n            moveSelectedToTop,\n            setQueue,\n            setRepeat,\n            setShuffle,\n            setSpeed,\n            setVolume,\n            shuffle,\n            shuffleAll,\n            shuffleSelected,\n            toggleRepeat,\n            toggleShuffle,\n        ],\n    );\n\n    return <PlayerContext.Provider value={contextValue}>{children}</PlayerContext.Provider>;\n};\n\nexport const usePlayer = () => {\n    return useContext(PlayerContext);\n};\n\n/**\n * Fetches the songs from the server\n * @param queryClient - The query client to use to fetch the data\n * @param serverId - The library id to use to fetch the data\n * @param type - The type of the item to add to the queue\n * @param args - The arguments to use to fetch the data\n * @returns The songs to add to the queue\n */\nexport async function fetchSongsByItemType(\n    queryClient: QueryClient,\n    serverId: string,\n    args: {\n        id: string[];\n        itemType: LibraryItem;\n        params?: Record<string, any>;\n    },\n) {\n    const songs: Song[] = [];\n\n    switch (args.itemType) {\n        case LibraryItem.ALBUM: {\n            const albumSongsResponse = await getAlbumSongsById({\n                id: args.id,\n                query: args.params,\n                queryClient,\n                serverId,\n            });\n            songs.push(...albumSongsResponse.items);\n            break;\n        }\n\n        case LibraryItem.ALBUM_ARTIST: {\n            const albumArtistSongsResponse = await getAlbumArtistSongsById({\n                id: args.id,\n                query: args.params,\n                queryClient,\n                serverId,\n            });\n            songs.push(...albumArtistSongsResponse.items);\n            break;\n        }\n\n        case LibraryItem.ARTIST: {\n            const artistSongsResponse = await getAlbumArtistSongsById({\n                id: args.id,\n                query: args.params,\n                queryClient,\n                serverId,\n            });\n            songs.push(...artistSongsResponse.items);\n            break;\n        }\n\n        case LibraryItem.FOLDER: {\n            const folderSongsResponse = await getSongsByFolder({\n                id: args.id,\n                query: args.params,\n                queryClient,\n                serverId,\n            });\n            songs.push(...folderSongsResponse.items);\n            break;\n        }\n\n        case LibraryItem.GENRE: {\n            const genreSongsResponse = await getGenreSongsById({\n                id: args.id,\n                query: args.params,\n                queryClient,\n                serverId,\n            });\n            songs.push(...genreSongsResponse.items);\n            break;\n        }\n\n        case LibraryItem.PLAYLIST: {\n            const promises: Promise<PlaylistSongListResponse>[] = [];\n\n            for (const id of args.id) {\n                promises.push(\n                    getPlaylistSongsById({\n                        id,\n                        query: args.params,\n                        queryClient,\n                        serverId,\n                    }),\n                );\n            }\n\n            const results = await Promise.all(promises);\n            songs.push(...results.flatMap((r) => r.items));\n            break;\n        }\n    }\n\n    return songs;\n}\n\nexport const useIsPlayerFetching = () => {\n    const playerFetchCount = useIsFetching({ queryKey: queryKeys.player.fetch() });\n\n    return playerFetchCount > 0;\n};\n"
  },
  {
    "path": "src/renderer/features/player/context/webaudio-context.ts",
    "content": "import { createContext } from 'react';\n\nimport { WebAudio } from '/@/shared/types/types';\n\nexport const WebAudioContext = createContext<{\n    setWebAudio?: (audio: WebAudio) => void;\n    webAudio?: WebAudio;\n}>({});\n"
  },
  {
    "path": "src/renderer/features/player/hooks/use-auto-dj.ts",
    "content": "import { useQueryClient } from '@tanstack/react-query';\nimport React, { useEffect } from 'react';\n\nimport { queryKeys } from '/@/renderer/api/query-keys';\nimport { eventEmitter } from '/@/renderer/events/event-emitter';\nimport { useIsPlayerFetching, usePlayer } from '/@/renderer/features/player/context/player-context';\nimport { songsQueries } from '/@/renderer/features/songs/api/songs-api';\nimport {\n    isShuffleEnabled,\n    mapShuffledToQueueIndex,\n    useAutoDJSettings,\n    useCurrentServer,\n    useCurrentServerId,\n    usePlayerStore,\n    usePlayerStoreBase,\n    useSettingsStore,\n} from '/@/renderer/store';\nimport { LogCategory, logFn } from '/@/renderer/utils/logger';\nimport { logMsg } from '/@/renderer/utils/logger-message';\nimport { shuffleInPlace } from '/@/renderer/utils/shuffle';\nimport { hasFeature } from '/@/shared/api/utils';\nimport { Played, Song, SongListSort, SortOrder } from '/@/shared/types/domain-types';\nimport { ServerFeature } from '/@/shared/types/features-types';\nimport { Play } from '/@/shared/types/types';\n\nexport const useAutoDJ = () => {\n    const queryClient = useQueryClient();\n    const serverId = useCurrentServerId();\n    const server = useCurrentServer();\n    const player = usePlayer();\n    const settings = useAutoDJSettings();\n    const isFetching = useIsPlayerFetching();\n\n    const hasSimilarSongsMusicFolder = hasFeature(server, ServerFeature.SIMILAR_SONGS_MUSIC_FOLDER);\n\n    useEffect(() => {\n        const unsubscribe = usePlayerStoreBase.subscribe(\n            (state) => {\n                const queue = state.getQueue();\n                let index = state.player.index;\n                let remaining: number;\n\n                if (isShuffleEnabled(state)) {\n                    remaining = state.queue.shuffled.length - index - 1;\n                    index = mapShuffledToQueueIndex(index, state.queue.shuffled);\n                } else {\n                    remaining = queue.items.slice(index + 1).length;\n                }\n\n                return { index, remaining, song: queue.items[index] };\n            },\n            async (properties) => {\n                if (!settings.enabled) {\n                    return;\n                }\n\n                // If no current song, don't autoplay\n                if (!properties.song?.id) {\n                    return;\n                }\n\n                if (properties.remaining >= settings.timing) {\n                    return;\n                }\n\n                logFn.debug(logMsg[LogCategory.PLAYER].autoPlayTriggered, {\n                    category: LogCategory.PLAYER,\n                    meta: { remaining: properties.remaining, songId: properties.song?.id },\n                });\n\n                try {\n                    const queue = usePlayerStore.getState().getQueue();\n                    const queueSongIdSet = new Set(queue.items.map((item) => item.id));\n                    let uniqueSimilarSongs: Song[] = [];\n\n                    const hasMusicFolder = server?.musicFolderId && server.musicFolderId.length > 0;\n                    const trySimilarSongs =\n                        !hasMusicFolder || (hasMusicFolder && hasSimilarSongsMusicFolder);\n\n                    // Skip similar songs fetch if a music folder is selected and does not support musicFolderId on similar songs\n                    if (trySimilarSongs) {\n                        // First, try to fetch similar songs based on the current song\n                        const similarSongs = await queryClient.fetchQuery({\n                            ...songsQueries.similar({\n                                query: {\n                                    count: settings.itemCount,\n                                    songId: properties.song?.id,\n                                },\n                                serverId,\n                            }),\n                            queryKey: queryKeys.player.fetch({ similarSongs: properties.song?.id }),\n                        });\n\n                        uniqueSimilarSongs = similarSongs.filter(\n                            (song) => !queueSongIdSet.has(song.id),\n                        );\n                    }\n\n                    // If not enough songs, try to fetch more similar songs based on the genre of the current song\n                    if (uniqueSimilarSongs.length < settings.itemCount) {\n                        const genre = properties.song?.genres?.[0];\n\n                        if (genre) {\n                            const genreLimit = 50;\n                            const genreSimilarSongs = await queryClient.fetchQuery({\n                                ...songsQueries.random({\n                                    query: {\n                                        genre: genre.id,\n                                        limit: genreLimit,\n                                        played: Played.All,\n                                    },\n                                    serverId,\n                                }),\n                                queryKey: queryKeys.player.fetch({\n                                    genre,\n                                    similarSongs: properties.song?.id,\n                                }),\n                            });\n\n                            const genreSongs = genreSimilarSongs.items.filter(\n                                (song) => !queueSongIdSet.has(song.id),\n                            );\n\n                            // If trySimilarSongs is false, add variation by mixing in random songs\n                            if (!trySimilarSongs) {\n                                // Calculate how many random songs we need: 20% or at least 1\n                                const randomSongCount = Math.max(1, Math.ceil(genreLimit * 0.2));\n\n                                const randomSongs = await queryClient.fetchQuery({\n                                    ...songsQueries.random({\n                                        query: { limit: randomSongCount, played: Played.All },\n                                        serverId,\n                                    }),\n                                });\n\n                                const uniqueRandomSongs = randomSongs.items.filter(\n                                    (song) => !queueSongIdSet.has(song.id),\n                                );\n\n                                // Add minimum required random songs for variation\n                                const randomSongsToAdd = uniqueRandomSongs.slice(\n                                    0,\n                                    randomSongCount,\n                                );\n                                uniqueSimilarSongs.push(...randomSongsToAdd, ...genreSongs);\n                            } else {\n                                uniqueSimilarSongs.push(...genreSongs);\n                            }\n                        }\n                    }\n\n                    // If not enough songs, try to fetch more similar songs based on the album artist of the current song\n                    if (uniqueSimilarSongs.length < settings.itemCount) {\n                        const albumArtist = properties.song?.albumArtists?.[0];\n\n                        if (albumArtist) {\n                            const albumArtistSimilarSongs = await queryClient.fetchQuery({\n                                ...songsQueries.list({\n                                    query: {\n                                        albumArtistIds: [albumArtist.id],\n                                        limit: 50,\n                                        sortBy: SongListSort.RANDOM,\n                                        sortOrder: SortOrder.ASC,\n                                        startIndex: 0,\n                                    },\n                                    serverId,\n                                }),\n                                queryKey: queryKeys.player.fetch({\n                                    albumArtist,\n                                    similarSongs: properties.song?.id,\n                                }),\n                            });\n\n                            uniqueSimilarSongs.push(\n                                ...albumArtistSimilarSongs.items.filter(\n                                    (song) => !queueSongIdSet.has(song.id),\n                                ),\n                            );\n                        }\n                    }\n\n                    // If not enough songs, just fetch fully random songs\n                    if (uniqueSimilarSongs.length < settings.itemCount) {\n                        const randomSongs = await queryClient.fetchQuery({\n                            ...songsQueries.random({\n                                query: { limit: 50, played: Played.All },\n                                serverId,\n                            }),\n                        });\n\n                        uniqueSimilarSongs.push(\n                            ...randomSongs.items.filter((song) => !queueSongIdSet.has(song.id)),\n                        );\n                    }\n\n                    // Shuffle the songs and then add to the queue\n                    const shuffledSongs = shuffleInPlace(uniqueSimilarSongs);\n\n                    // Splice the first itemCount songs and add to the queue\n                    const songsToAdd = shuffledSongs.slice(0, settings.itemCount);\n\n                    // Add to the end of the queue\n                    player.addToQueueByData(songsToAdd, Play.LAST);\n\n                    // Emit event to trigger queue follow\n                    eventEmitter.emit('AUTODJ_QUEUE_ADDED', {\n                        songCount: songsToAdd.length,\n                    });\n                } catch (error) {\n                    logFn.error(logMsg[LogCategory.PLAYER].autoPlayFailed, {\n                        category: LogCategory.PLAYER,\n                        meta: { error: (error as Error).message, songId: properties.song?.id },\n                    });\n                }\n            },\n            {\n                equalityFn: (a, b) => {\n                    return a.song?._uniqueId === b.song?._uniqueId && a.remaining === b.remaining;\n                },\n            },\n        );\n\n        return () => unsubscribe();\n    }, [\n        hasSimilarSongsMusicFolder,\n        isFetching,\n        player,\n        queryClient,\n        server,\n        serverId,\n        settings.enabled,\n        settings.itemCount,\n        settings.timing,\n    ]);\n};\n\nconst AutoDJHookInner = () => {\n    useAutoDJ();\n    return null;\n};\n\nexport const AutoDJHook = () => {\n    const isAutoDJEnabled = useSettingsStore((state) => state.autoDJ.enabled);\n\n    if (!isAutoDJEnabled) {\n        return null;\n    }\n\n    return React.createElement(AutoDJHookInner);\n};\n"
  },
  {
    "path": "src/renderer/features/player/hooks/use-autosave.ts",
    "content": "import { useEffect, useRef } from 'react';\n\nimport { useSaveQueue } from '/@/renderer/features/player/hooks/use-queue-restore';\nimport { useCurrentServer, usePlayerSong, useSettingsStore } from '/@/renderer/store';\nimport { ServerType } from '/@/shared/types/domain-types';\n\nexport const useAutosave = () => {\n    const server = useCurrentServer();\n    const currentSong = usePlayerSong();\n    const priorSongId = useRef<string | undefined>(undefined);\n    const songCount = useRef(0);\n    const { count, enabled } = useSettingsStore((state) => state.general.autoSave);\n    const { mutate: savePlayQueue } = useSaveQueue();\n\n    useEffect(() => {\n        if (enabled && server?.type && server.type !== ServerType.JELLYFIN) {\n            if (currentSong?._uniqueId !== priorSongId.current) {\n                if (songCount.current === count) {\n                    savePlayQueue();\n                    songCount.current = 1;\n                } else {\n                    songCount.current += 1;\n                }\n\n                priorSongId.current = currentSong?._uniqueId;\n            }\n        }\n    }, [enabled, count, currentSong?._uniqueId, savePlayQueue, server?.type]);\n};\n\nexport const AutosaveHook = () => {\n    useAutosave();\n    return null;\n};\n"
  },
  {
    "path": "src/renderer/features/player/hooks/use-is-current-song.ts",
    "content": "import { useMemo } from 'react';\n\nimport { usePlayerSong } from '/@/renderer/store';\nimport { QueueSong, Song } from '/@/shared/types/domain-types';\n\nexport const useIsCurrentSong = (song: QueueSong | Song) => {\n    const currentSong = usePlayerSong();\n\n    const isActive = useMemo(() => {\n        const queueSong = song as QueueSong;\n\n        if (queueSong._uniqueId != null && queueSong._uniqueId !== '') {\n            return queueSong._uniqueId === currentSong?._uniqueId;\n        }\n\n        return song.id === currentSong?.id;\n    }, [song, currentSong?.id, currentSong?._uniqueId]);\n\n    return { isActive };\n};\n"
  },
  {
    "path": "src/renderer/features/player/hooks/use-media-session.ts",
    "content": "import isElectron from 'is-electron';\nimport React, { useCallback, useEffect, useMemo } from 'react';\n\nimport { getItemImageUrl } from '/@/renderer/components/item-image/item-image';\nimport { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';\nimport { usePlayer } from '/@/renderer/features/player/context/player-context';\nimport {\n    useIsRadioActive,\n    useRadioPlayer,\n} from '/@/renderer/features/radio/hooks/use-radio-player';\nimport {\n    usePlaybackSettings,\n    usePlaybackType,\n    usePlayerStore,\n    useSettingsStore,\n    useSkipButtons,\n    useTimestampStoreBase,\n} from '/@/renderer/store';\nimport { LibraryItem, QueueSong } from '/@/shared/types/domain-types';\nimport { PlayerStatus, PlayerType } from '/@/shared/types/types';\n\nconst mediaSession = navigator.mediaSession;\n\nexport const useMediaSession = () => {\n    const { mediaSession: mediaSessionEnabled } = usePlaybackSettings();\n    const player = usePlayer();\n    const skip = useSkipButtons();\n    const playbackType = useSettingsStore((state) => state.playback.type);\n    const isRadioActive = useIsRadioActive();\n    const { isPlaying: isRadioPlaying, metadata: radioMetadata, stationName } = useRadioPlayer();\n\n    const isMediaSessionEnabled = useMemo(() => {\n        // Always enable media session on web\n        if (!isElectron()) {\n            return true;\n        }\n\n        return Boolean(mediaSessionEnabled && playbackType === PlayerType.WEB);\n    }, [mediaSessionEnabled, playbackType]);\n\n    useEffect(() => {\n        if (!isMediaSessionEnabled) {\n            return;\n        }\n\n        mediaSession.setActionHandler('nexttrack', () => {\n            if (isRadioActive && isRadioPlaying) {\n                return;\n            }\n\n            player.mediaNext();\n        });\n\n        mediaSession.setActionHandler('pause', () => {\n            player.mediaPause();\n        });\n\n        mediaSession.setActionHandler('play', () => {\n            player.mediaPlay();\n        });\n\n        mediaSession.setActionHandler('previoustrack', () => {\n            if (isRadioActive && isRadioPlaying) {\n                return;\n            }\n\n            player.mediaPrevious();\n        });\n\n        mediaSession.setActionHandler('seekto', (e) => {\n            if (isRadioActive && isRadioPlaying) {\n                return;\n            }\n\n            if (e.seekTime) {\n                player.mediaSeekToTimestamp(e.seekTime);\n            } else if (e.seekOffset) {\n                const currentTimestamp = useTimestampStoreBase.getState().timestamp;\n                player.mediaSeekToTimestamp(currentTimestamp + e.seekOffset);\n            }\n        });\n\n        mediaSession.setActionHandler('stop', () => {\n            player.mediaStop();\n        });\n\n        mediaSession.setActionHandler('seekbackward', (e) => {\n            if (isRadioActive && isRadioPlaying) {\n                return;\n            }\n\n            const currentTimestamp = useTimestampStoreBase.getState().timestamp;\n            player.mediaSeekToTimestamp(\n                currentTimestamp - (e.seekOffset || skip?.skipBackwardSeconds || 5),\n            );\n        });\n\n        mediaSession.setActionHandler('seekforward', (e) => {\n            if (isRadioActive && isRadioPlaying) {\n                return;\n            }\n\n            const currentTimestamp = useTimestampStoreBase.getState().timestamp;\n            player.mediaSeekToTimestamp(\n                currentTimestamp + (e.seekOffset || skip?.skipForwardSeconds || 5),\n            );\n        });\n\n        return () => {\n            mediaSession.setActionHandler('nexttrack', null);\n            mediaSession.setActionHandler('pause', null);\n            mediaSession.setActionHandler('play', null);\n            mediaSession.setActionHandler('previoustrack', null);\n            mediaSession.setActionHandler('seekto', null);\n            mediaSession.setActionHandler('stop', null);\n            mediaSession.setActionHandler('seekbackward', null);\n            mediaSession.setActionHandler('seekforward', null);\n        };\n    }, [\n        player,\n        skip?.skipBackwardSeconds,\n        skip?.skipForwardSeconds,\n        isMediaSessionEnabled,\n        isRadioActive,\n        isRadioPlaying,\n    ]);\n\n    const updateMediaSessionMetadata = useCallback(\n        (song: QueueSong | undefined) => {\n            if (!isMediaSessionEnabled) {\n                return;\n            }\n\n            // Handle radio metadata when radio is active and playing\n            if (isRadioActive && isRadioPlaying) {\n                const title = radioMetadata?.title || stationName || 'Radio';\n                const artist = radioMetadata?.artist || stationName || '';\n\n                mediaSession.metadata = new MediaMetadata({\n                    album: stationName || '',\n                    artist: artist,\n                    artwork: [],\n                    title: title,\n                });\n                return;\n            }\n\n            // Handle regular song metadata\n            if (!song) {\n                return;\n            }\n\n            const imageUrl = getItemImageUrl({\n                id: song?.imageId || undefined,\n                imageUrl: song?.imageUrl,\n                itemType: LibraryItem.SONG,\n                type: 'itemCard',\n            });\n\n            mediaSession.metadata = new MediaMetadata({\n                album: song?.album ?? '',\n                artist: song?.artistName ?? '',\n                artwork: imageUrl ? [{ src: imageUrl, type: 'image/png' }] : [],\n                title: song?.name ?? '',\n            });\n        },\n        [isMediaSessionEnabled, isRadioActive, isRadioPlaying, radioMetadata, stationName],\n    );\n\n    // Update metadata when radio metadata changes\n    useEffect(() => {\n        if (!isMediaSessionEnabled) {\n            return;\n        }\n\n        if (isRadioActive && isRadioPlaying) {\n            updateMediaSessionMetadata(undefined);\n        }\n    }, [\n        isMediaSessionEnabled,\n        isRadioActive,\n        isRadioPlaying,\n        radioMetadata,\n        stationName,\n        updateMediaSessionMetadata,\n    ]);\n\n    usePlayerEvents(\n        {\n            onCurrentSongChange: (properties) => {\n                if (!isMediaSessionEnabled) {\n                    return;\n                }\n\n                if (isRadioActive && isRadioPlaying) {\n                    return;\n                }\n\n                updateMediaSessionMetadata(properties.song);\n            },\n            onPlayerRepeated: () => {\n                if (!isMediaSessionEnabled) {\n                    return;\n                }\n\n                if (isRadioActive && isRadioPlaying) {\n                    return;\n                }\n\n                const currentSong = usePlayerStore.getState().getCurrentSong();\n                updateMediaSessionMetadata(currentSong);\n            },\n            onPlayerStatus: (properties) => {\n                if (!isMediaSessionEnabled) {\n                    return;\n                }\n\n                const status = properties.status;\n                mediaSession.playbackState = status === PlayerStatus.PLAYING ? 'playing' : 'paused';\n            },\n        },\n        [isMediaSessionEnabled, isRadioActive, isRadioPlaying, updateMediaSessionMetadata],\n    );\n};\n\nconst MediaSessionHookInner = () => {\n    useMediaSession();\n    return null;\n};\n\nexport const MediaSessionHook = () => {\n    const isElectronEnv = isElectron();\n    const playbackType = usePlaybackType();\n    const isMediaSessionEnabled = useSettingsStore((state) => state.playback.mediaSession);\n\n    // We always want to enable media session when on web\n    // Otherwise, only enable if it is explicitly enabled in the settings AND using the web player\n    const shouldUseMediaSession =\n        !isElectronEnv || (isMediaSessionEnabled && playbackType === PlayerType.WEB);\n\n    if (!shouldUseMediaSession) {\n        return null;\n    }\n\n    return React.createElement(MediaSessionHookInner);\n};\n"
  },
  {
    "path": "src/renderer/features/player/hooks/use-mpris.ts",
    "content": "import isElectron from 'is-electron';\nimport React, { useEffect, useMemo } from 'react';\n\nimport { useItemImageUrl } from '/@/renderer/components/item-image/item-image';\nimport { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';\nimport {\n    useIsRadioActive,\n    useRadioPlayer,\n} from '/@/renderer/features/radio/hooks/use-radio-player';\nimport { usePlayerSong, usePlayerStore } from '/@/renderer/store';\nimport { LibraryItem, QueueSong } from '/@/shared/types/domain-types';\nimport { PlayerShuffle, ServerType } from '/@/shared/types/types';\n\nconst ipc = isElectron() ? window.api.ipc : null;\nconst utils = isElectron() ? window.api.utils : null;\nconst mpris = isElectron() && (utils?.isLinux() || utils?.isMacOS()) ? window.api.mpris : null;\n\nexport const useMPRIS = () => {\n    const player = usePlayerStore();\n    const currentSong = usePlayerSong();\n    const isRadioActive = useIsRadioActive();\n    const { isPlaying: isRadioPlaying, metadata: radioMetadata, stationName } = useRadioPlayer();\n\n    const imageUrl = useItemImageUrl({\n        id: currentSong?.imageId || undefined,\n        imageUrl: currentSong?.imageUrl,\n        itemType: LibraryItem.SONG,\n        type: 'itemCard',\n    });\n\n    const radioSong = useMemo((): QueueSong | undefined => {\n        if (!isRadioActive || !isRadioPlaying) {\n            return undefined;\n        }\n\n        const title = radioMetadata?.title || stationName || 'Radio';\n        const artist = radioMetadata?.artist || stationName || null;\n        const album = stationName || null;\n\n        const radioId = `radio-${stationName || 'unknown'}`;\n\n        return {\n            _itemType: LibraryItem.SONG,\n            _serverId: '',\n            _serverType: ServerType.NAVIDROME,\n            _uniqueId: radioId,\n            album: album || null,\n            albumArtistName: artist || '',\n            albumArtists: artist\n                ? [\n                      {\n                          id: '',\n                          imageId: null,\n                          imageUrl: null,\n                          name: artist,\n                          userFavorite: false,\n                          userRating: null,\n                      },\n                  ]\n                : [],\n            albumId: '',\n            artistName: artist || '',\n            artists: artist\n                ? [\n                      {\n                          id: '',\n                          imageId: null,\n                          imageUrl: null,\n                          name: artist,\n                          userFavorite: false,\n                          userRating: null,\n                      },\n                  ]\n                : [],\n            bitDepth: null,\n            bitRate: 0,\n            bpm: null,\n            channels: null,\n            comment: null,\n            compilation: null,\n            container: null,\n            createdAt: '',\n            discNumber: 0,\n            discSubtitle: null,\n            duration: 0,\n            explicitStatus: null,\n            gain: null,\n            genres: [],\n            id: radioId,\n            imageId: null,\n            imageUrl: null,\n            lastPlayedAt: null,\n            lyrics: null,\n            mbzRecordingId: null,\n            mbzTrackId: null,\n            name: title,\n            participants: null,\n            path: null,\n            peak: null,\n            playCount: 0,\n            releaseDate: null,\n            releaseYear: null,\n            sampleRate: null,\n            size: 0,\n            sortName: title,\n            tags: null,\n            trackNumber: 0,\n            trackSubtitle: null,\n            updatedAt: new Date().toISOString(),\n            userFavorite: false,\n            userRating: null,\n        };\n    }, [isRadioActive, isRadioPlaying, radioMetadata, stationName]);\n\n    useEffect(() => {\n        if (!mpris) {\n            return;\n        }\n\n        mpris?.requestPosition((_e: unknown, data: { position: number }) => {\n            player.mediaSeekToTimestamp(data.position);\n        });\n\n        mpris?.requestSeek((_e: unknown, data: { offset: number }) => {\n            player.mediaSkipForward(data.offset);\n        });\n\n        mpris?.requestToggleRepeat(() => {\n            player.toggleRepeat();\n        });\n\n        mpris?.requestToggleShuffle(() => {\n            player.toggleShuffle();\n        });\n\n        mpris?.requestVolume((_e: unknown, data: { volume: number }) => {\n            player.setVolume(data.volume);\n        });\n\n        return () => {\n            ipc?.removeAllListeners('mpris-request-toggle-repeat');\n            ipc?.removeAllListeners('mpris-request-toggle-shuffle');\n            ipc?.removeAllListeners('request-position');\n            ipc?.removeAllListeners('request-seek');\n            ipc?.removeAllListeners('request-volume');\n        };\n    }, [player]);\n\n    // Update MPRIS when song, imageUrl, or radio metadata changes\n    useEffect(() => {\n        if (!mpris) {\n            return;\n        }\n\n        // Use radio song if radio is active and playing, otherwise use current song\n        const songToUpdate = isRadioActive && isRadioPlaying ? radioSong : currentSong;\n        const imageUrlToUpdate = isRadioActive && isRadioPlaying ? null : imageUrl;\n\n        mpris?.updateSong(songToUpdate, imageUrlToUpdate);\n    }, [currentSong, imageUrl, isRadioActive, isRadioPlaying, radioSong]);\n\n    usePlayerEvents(\n        {\n            onCurrentSongChange: () => {\n                // The effect above will handle the update when currentSong changes\n            },\n            onPlayerProgress: (properties) => {\n                if (!mpris) {\n                    return;\n                }\n\n                const timestamp = properties.timestamp;\n                mpris?.updatePosition(timestamp);\n            },\n            onPlayerRepeat: (properties) => {\n                if (!mpris) {\n                    return;\n                }\n\n                mpris?.updateRepeat(properties.repeat);\n            },\n            onPlayerSeekToTimestamp: (properties) => {\n                if (!mpris) {\n                    return;\n                }\n\n                const timestamp = properties.timestamp;\n                mpris?.updateSeek(timestamp);\n            },\n            onPlayerShuffle: (properties) => {\n                if (!mpris) {\n                    return;\n                }\n\n                const isShuffleEnabled = properties.shuffle !== PlayerShuffle.NONE;\n                mpris?.updateShuffle(isShuffleEnabled);\n            },\n            onPlayerStatus: (properties) => {\n                if (!mpris) {\n                    return;\n                }\n\n                mpris?.updateStatus(properties.status);\n            },\n            onPlayerVolume: (properties) => {\n                if (!mpris) {\n                    return;\n                }\n\n                mpris?.updateVolume(properties.volume);\n            },\n        },\n        [],\n    );\n};\n\nconst MPRISHookInner = () => {\n    useMPRIS();\n    return null;\n};\n\nexport const MPRISHook = () => {\n    const isElectronEnv = isElectron();\n    const utils = isElectronEnv ? window.api.utils : null;\n    const mpris = isElectronEnv && (utils?.isLinux() || utils?.isMacOS()) ? window.api.mpris : null;\n\n    if (mpris === null) {\n        return null;\n    }\n\n    return React.createElement(MPRISHookInner);\n};\n"
  },
  {
    "path": "src/renderer/features/player/hooks/use-playback-hotkeys.ts",
    "content": "import { useMemo } from 'react';\n\nimport { useHotkeySettings, usePlayerStore } from '/@/renderer/store';\nimport { HotkeyItem, useHotkeys } from '/@/shared/hooks/use-hotkeys';\n\nexport const usePlaybackHotkeys = () => {\n    const { bindings } = useHotkeySettings();\n    const player = usePlayerStore();\n\n    const playbackHotkeysItems = useMemo(() => {\n        const hotkeyItems: HotkeyItem[] = [];\n\n        const bindingHandlers: Array<{\n            binding: (typeof bindings)[keyof typeof bindings];\n            handler: () => void;\n        }> = [\n            { binding: bindings.next, handler: () => player.mediaNext() },\n            { binding: bindings.pause, handler: () => player.mediaPause() },\n            { binding: bindings.play, handler: () => player.mediaPlay() },\n            { binding: bindings.playPause, handler: () => player.mediaTogglePlayPause() },\n            { binding: bindings.previous, handler: () => player.mediaPrevious() },\n            { binding: bindings.skipBackward, handler: () => player.mediaSkipBackward() },\n            { binding: bindings.skipForward, handler: () => player.mediaSkipForward() },\n            { binding: bindings.stop, handler: () => player.mediaStop() },\n            { binding: bindings.toggleRepeat, handler: () => player.toggleRepeat() },\n            { binding: bindings.toggleShuffle, handler: () => player.toggleShuffle() },\n        ];\n\n        // Filter and map to hotkey items\n        bindingHandlers.forEach(({ binding, handler }) => {\n            if (!binding.isGlobal && binding.hotkey && binding.hotkey !== '') {\n                hotkeyItems.push([binding.hotkey, handler]);\n            }\n        });\n\n        return hotkeyItems;\n    }, [bindings, player]);\n\n    useHotkeys(playbackHotkeysItems);\n};\n\nexport const PlaybackHotkeysHook = () => {\n    usePlaybackHotkeys();\n    return null;\n};\n"
  },
  {
    "path": "src/renderer/features/player/hooks/use-power-save-blocker.ts",
    "content": "import isElectron from 'is-electron';\nimport React, { useCallback, useEffect } from 'react';\n\nimport { usePlayerStatus, useSettingsStore, useWindowSettings } from '/@/renderer/store';\nimport { PlayerStatus } from '/@/shared/types/types';\n\nconst ipc = isElectron() ? window.api.ipc : null;\n\nexport const usePowerSaveBlocker = () => {\n    const status = usePlayerStatus();\n    const { preventSleepOnPlayback } = useWindowSettings();\n\n    const startPowerSaveBlocker = useCallback(async () => {\n        if (!ipc) return;\n\n        try {\n            await ipc.invoke('power-save-blocker-start');\n        } catch (error) {\n            console.error('Failed to start power save blocker:', error);\n        }\n    }, []);\n\n    const stopPowerSaveBlocker = useCallback(async () => {\n        if (!ipc) return;\n\n        try {\n            await ipc.invoke('power-save-blocker-stop');\n        } catch (error) {\n            console.error('Failed to stop power save blocker:', error);\n        }\n    }, []);\n\n    useEffect(() => {\n        if (!preventSleepOnPlayback) return;\n\n        if (status === PlayerStatus.PLAYING) {\n            startPowerSaveBlocker();\n        } else {\n            stopPowerSaveBlocker();\n        }\n    }, [status, preventSleepOnPlayback, startPowerSaveBlocker, stopPowerSaveBlocker]);\n\n    // Clean up on unmount\n    useEffect(() => {\n        return () => {\n            stopPowerSaveBlocker();\n        };\n    }, [stopPowerSaveBlocker]);\n};\n\nconst PowerSaveBlockerHookInner = () => {\n    usePowerSaveBlocker();\n    return null;\n};\n\nexport const PowerSaveBlockerHook = () => {\n    const isElectronEnv = isElectron();\n    const preventSleepOnPlayback = useSettingsStore((state) => state.window.preventSleepOnPlayback);\n\n    if (!isElectronEnv || !preventSleepOnPlayback) {\n        return null;\n    }\n\n    return React.createElement(PowerSaveBlockerHookInner);\n};\n"
  },
  {
    "path": "src/renderer/features/player/hooks/use-queue-restore.ts",
    "content": "import { useMutation, useQueryClient } from '@tanstack/react-query';\nimport { t } from 'i18next';\nimport { useCallback } from 'react';\n\nimport { api } from '/@/renderer/api';\nimport { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';\nimport { usePlayer } from '/@/renderer/features/player/context/player-context';\nimport { songsQueries } from '/@/renderer/features/songs/api/songs-api';\nimport {\n    setTimestamp,\n    useCurrentServerId,\n    usePlayerStore,\n    useTimestampStoreBase,\n} from '/@/renderer/store';\nimport { toast } from '/@/shared/components/toast/toast';\n\nexport const useQueueRestoreTimestamp = () => {\n    const player = usePlayerStore();\n\n    usePlayerEvents(\n        {\n            onQueueRestored: (properties) => {\n                const { position } = properties;\n\n                setTimeout(() => {\n                    setTimestamp(position);\n                    player.mediaSeekToTimestamp(position);\n                }, 100);\n            },\n        },\n        [],\n    );\n};\n\nexport const QueueRestoreTimestampHook = () => {\n    useQueueRestoreTimestamp();\n    return null;\n};\n\nexport const useSaveQueue = () => {\n    const serverId = useCurrentServerId();\n\n    const mutation = useMutation({\n        mutationFn: async () => {\n            if (!serverId) {\n                throw new Error(t('error.serverRequired', { postProcess: 'sentenceCase' }));\n            }\n\n            const state = usePlayerStore.getState();\n            const queue = state.getQueue();\n\n            if (queue.items.some((item) => item._serverId !== serverId)) {\n                toast.error({\n                    message: t('error.multipleServerSaveQueueError', {\n                        postProcess: 'sentenceCase',\n                    }),\n                    title: t('error.genericError', { postProcess: 'sentenceCase' }),\n                });\n\n                throw new Error(\n                    `${t('error.multipleServerSaveQueueError', { postProcess: 'sentenceCase' })}`,\n                );\n            }\n\n            try {\n                await api.controller.savePlayQueue({\n                    apiClientProps: { serverId },\n                    query: {\n                        currentIndex: queue.items.length > 0 ? state.player.index : undefined,\n                        positionMs: useTimestampStoreBase.getState().timestamp * 1000,\n                        songs: queue.items.map((item) => item.id),\n                    },\n                });\n\n                toast.success({\n                    message: t('form.saveQueue.success', { postProcess: 'sentenceCase' }),\n                });\n            } catch (error) {\n                toast.error({\n                    message: (error as Error).message,\n                    title: t('error.saveQueueFailed', { postProcess: 'sentenceCase' }),\n                });\n                throw error;\n            }\n        },\n    });\n\n    return mutation;\n};\n\nexport const useRestoreQueue = () => {\n    const serverId = useCurrentServerId();\n    const player = usePlayer();\n    const queryClient = useQueryClient();\n\n    const handleRestoreQueue = useCallback(async () => {\n        if (!serverId) return;\n\n        try {\n            const queue = await queryClient.fetchQuery(\n                songsQueries.getQueue({ query: {}, serverId }),\n            );\n\n            if (queue) {\n                player.setQueue(\n                    queue.entry,\n                    queue.currentIndex,\n                    queue.positionMs !== undefined ? queue.positionMs / 1000 : undefined,\n                );\n            }\n        } catch (error) {\n            toast.error({\n                message: (error as Error).message,\n                title: t('error.genericError', { postProcess: 'sentenceCase' }),\n            });\n        }\n    }, [player, queryClient, serverId]);\n\n    return handleRestoreQueue;\n};\n"
  },
  {
    "path": "src/renderer/features/player/hooks/use-scrobble.ts",
    "content": "import React, { useCallback, useEffect, useRef, useState } from 'react';\n\nimport { useItemImageUrl } from '/@/renderer/components/item-image/item-image';\nimport { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';\nimport { useSendScrobble } from '/@/renderer/features/player/mutations/scrobble-mutation';\nimport {\n    useAppStore,\n    usePlaybackSettings,\n    usePlayerSong,\n    usePlayerStore,\n    useSettingsStore,\n    useTimestampStoreBase,\n} from '/@/renderer/store';\nimport { LogCategory, logFn } from '/@/renderer/utils/logger';\nimport { logMsg } from '/@/renderer/utils/logger-message';\nimport { LibraryItem, QueueSong, ServerType } from '/@/shared/types/domain-types';\nimport { PlayerStatus } from '/@/shared/types/types';\n\n/*\n Scrobble Conditions (match any):\n  - If the song has been played for the required percentage\n  - If the song has been played for the required duration\n\nScrobble Events:\n  - On song timestamp update:\n      - If the song has been played for the required percentage\n      - If the song has been played for the required duration\n\n  - When the song changes (or is completed):\n    - Current song: Sends the 'playing' scrobble event\n    - Resets the 'isCurrentSongScrobbled' state to false\n\n  - When the song is restarted:\n    - Sends the 'submission' scrobble event if conditions are met AND the 'isCurrentSongScrobbled' state is false\n    - Resets the 'isCurrentSongScrobbled' state to false\n\n  - When the song is seeked:\n    - Sends the 'timeupdate' scrobble event (Jellyfin only)\n\n\nProgress Events:\n  - When the song is playing (Jellyfin only):\n    - Sends the 'progress' scrobble event on an interval\n\n*/\n\nconst checkScrobbleConditions = (args: {\n    scrobbleAtDurationMs: number;\n    scrobbleAtPercentage: number;\n    songCompletedDurationMs: number;\n    songDurationMs: number;\n}) => {\n    const { scrobbleAtDurationMs, scrobbleAtPercentage, songCompletedDurationMs, songDurationMs } =\n        args;\n    const percentageOfSongCompleted = songDurationMs\n        ? (songCompletedDurationMs / songDurationMs) * 100\n        : 0;\n\n    const shouldScrobbleBasedOnPercetange = percentageOfSongCompleted >= scrobbleAtPercentage;\n    const shouldScrobbleBasedOnDuration = songCompletedDurationMs >= scrobbleAtDurationMs;\n\n    return shouldScrobbleBasedOnPercetange || shouldScrobbleBasedOnDuration;\n};\n\nexport const useScrobble = () => {\n    const scrobbleSettings = usePlaybackSettings().scrobble;\n    const isScrobbleEnabled = scrobbleSettings?.enabled;\n    const isPrivateModeEnabled = useAppStore((state) => state.privateMode);\n    const sendScrobble = useSendScrobble();\n    const currentSong = usePlayerSong();\n\n    const imageUrl = useItemImageUrl({\n        id: currentSong?.imageId || undefined,\n        imageUrl: currentSong?.imageUrl,\n        itemType: LibraryItem.SONG,\n        type: 'itemCard',\n    });\n\n    const imageUrlRef = useRef<null | string | undefined>(imageUrl);\n    const [isCurrentSongScrobbled, setIsCurrentSongScrobbled] = useState(false);\n    const previousSongRef = useRef<QueueSong | undefined>(undefined);\n    const previousTimestampRef = useRef<number>(0);\n    const lastProgressEventRef = useRef<number>(0);\n    const lastSeekEventRef = useRef<number>(0);\n    const songChangeTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n    const notifyTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n\n    useEffect(() => {\n        imageUrlRef.current = imageUrl;\n    }, [imageUrl]);\n\n    const handleScrobbleFromProgress = useCallback(\n        (properties: { timestamp: number }, prev: { timestamp: number }) => {\n            if (!isScrobbleEnabled || isPrivateModeEnabled) return;\n\n            const currentSong = usePlayerStore.getState().getCurrentSong();\n            const currentStatus = usePlayerStore.getState().player.status;\n\n            if (!currentSong?.id || currentStatus !== PlayerStatus.PLAYING) return;\n\n            const currentTime = properties.timestamp;\n            const previousTime = prev.timestamp;\n\n            // Detect song restart: when timestamp resets to near 0 and was playing for at least 10 seconds\n            if (\n                currentTime < previousTime &&\n                currentTime < 5 && // Reset to near 0\n                previousTime >= 10 // Was playing for at least 10 seconds\n            ) {\n                setIsCurrentSongScrobbled(false);\n                lastProgressEventRef.current = 0;\n                previousTimestampRef.current = 0;\n                return;\n            }\n\n            // Send Jellyfin progress events every 10 seconds\n            if (currentSong._serverType === ServerType.JELLYFIN) {\n                const timeSinceLastProgress = currentTime - lastProgressEventRef.current;\n                if (timeSinceLastProgress >= 10) {\n                    const position = currentTime * 1e7;\n                    sendScrobble.mutate(\n                        {\n                            apiClientProps: { serverId: currentSong._serverId || '' },\n                            query: {\n                                albumId: currentSong.albumId,\n                                event: 'timeupdate',\n                                id: currentSong.id,\n                                position,\n                                submission: false,\n                            },\n                        },\n                        {\n                            onSuccess: () => {\n                                logFn.debug(logMsg[LogCategory.SCROBBLE].scrobbledTimeupdate, {\n                                    category: LogCategory.SCROBBLE,\n                                    meta: {\n                                        id: currentSong.id,\n                                    },\n                                });\n                            },\n                        },\n                    );\n                    lastProgressEventRef.current = currentTime;\n                }\n            }\n\n            // Check if we should submit scrobble based on conditions\n            if (!isCurrentSongScrobbled) {\n                const shouldSubmitScrobble = checkScrobbleConditions({\n                    scrobbleAtDurationMs: (scrobbleSettings?.scrobbleAtDuration ?? 0) * 1000,\n                    scrobbleAtPercentage: scrobbleSettings?.scrobbleAtPercentage,\n                    songCompletedDurationMs: currentTime * 1000,\n                    songDurationMs: currentSong.duration,\n                });\n\n                if (shouldSubmitScrobble) {\n                    // Since jellyfin-plugin-lastfm uses the submission Position to determine if the song should actually scrobble\n                    // we just send the full duration of the song when it matches the local scrobble conditions\n                    const position =\n                        currentSong._serverType === ServerType.JELLYFIN\n                            ? currentSong.duration * 1e7\n                            : undefined;\n\n                    sendScrobble.mutate(\n                        {\n                            apiClientProps: { serverId: currentSong._serverId || '' },\n                            query: {\n                                albumId: currentSong.albumId,\n                                id: currentSong.id,\n                                position,\n                                submission: true,\n                            },\n                        },\n                        {\n                            onSuccess: () => {\n                                logFn.debug(logMsg[LogCategory.SCROBBLE].scrobbledSubmission, {\n                                    category: LogCategory.SCROBBLE,\n                                    meta: {\n                                        id: currentSong.id,\n                                        reason: 'from song progress',\n                                    },\n                                });\n                            },\n                        },\n                    );\n\n                    setIsCurrentSongScrobbled(true);\n                }\n            }\n        },\n        [\n            isScrobbleEnabled,\n            isPrivateModeEnabled,\n            scrobbleSettings?.scrobbleAtDuration,\n            scrobbleSettings?.scrobbleAtPercentage,\n            isCurrentSongScrobbled,\n            sendScrobble,\n        ],\n    );\n\n    const handleScrobbleFromSongChange = useCallback(\n        (\n            properties: { index: number; song: QueueSong | undefined },\n            prev: { index: number; song: QueueSong | undefined },\n        ) => {\n            const currentSong = properties.song;\n            const previousSong = previousSongRef.current;\n\n            // Handle notifications\n            if (scrobbleSettings?.notify && currentSong?.id) {\n                clearTimeout(notifyTimeoutRef.current);\n                notifyTimeoutRef.current = setTimeout(() => {\n                    if (\n                        currentSong._uniqueId !== previousSong?._uniqueId ||\n                        properties.index !== prev.index\n                    ) {\n                        const artists =\n                            currentSong.artists?.length > 0\n                                ? currentSong.artists.map((artist) => artist.name).join(' · ')\n                                : currentSong.artistName;\n\n                        try {\n                            new Notification(`${currentSong.name}`, {\n                                body: `${artists}\\n${currentSong.album}`,\n                                icon: imageUrlRef.current || undefined,\n                                silent: true,\n                            });\n                        } catch (error) {\n                            logFn.error('an error occurred while sending a desktop notification', {\n                                category: LogCategory.SCROBBLE,\n                                meta: {\n                                    error: error as Error,\n                                },\n                            });\n                        }\n                    }\n                }, 1000);\n            }\n\n            if (!isScrobbleEnabled || isPrivateModeEnabled) {\n                previousSongRef.current = currentSong;\n                previousTimestampRef.current = 0;\n                return;\n            }\n\n            setIsCurrentSongScrobbled(false);\n            lastProgressEventRef.current = 0;\n\n            // Use a timeout to prevent spamming the server when switching songs quickly\n            clearTimeout(songChangeTimeoutRef.current);\n            songChangeTimeoutRef.current = setTimeout(() => {\n                const currentStatus = usePlayerStore.getState().player.status;\n\n                // Send start scrobble when song changes and the new song is playing\n                if (currentStatus === PlayerStatus.PLAYING && currentSong?.id) {\n                    sendScrobble.mutate(\n                        {\n                            apiClientProps: { serverId: currentSong._serverId || '' },\n                            query: {\n                                albumId: currentSong.albumId,\n                                event: 'start',\n                                id: currentSong.id,\n                                position: 0,\n                                submission: false,\n                            },\n                        },\n                        {\n                            onSuccess: () => {\n                                logFn.debug(logMsg[LogCategory.SCROBBLE].scrobbledStart, {\n                                    category: LogCategory.SCROBBLE,\n                                    meta: {\n                                        id: currentSong.id,\n                                    },\n                                });\n                            },\n                        },\n                    );\n                }\n            }, 2000);\n\n            previousSongRef.current = currentSong;\n            previousTimestampRef.current = 0;\n        },\n        [scrobbleSettings?.notify, isScrobbleEnabled, isPrivateModeEnabled, sendScrobble],\n    );\n\n    const handleScrobbleFromSeek = useCallback(\n        // eslint-disable-next-line @typescript-eslint/no-unused-vars\n        (properties: { timestamp: number }, _prev: { timestamp: number }) => {\n            if (!isScrobbleEnabled || isPrivateModeEnabled) {\n                return;\n            }\n\n            const currentSong = usePlayerStore.getState().getCurrentSong();\n\n            if (!currentSong?.id) {\n                return;\n            }\n\n            // Position scrobbles are only relevant for Jellyfin\n            if (currentSong._serverType !== ServerType.JELLYFIN) {\n                return;\n            }\n\n            const now = Date.now();\n            const timeSinceLastSeek = now - lastSeekEventRef.current;\n\n            // Only allow seek scrobble once per second\n            if (timeSinceLastSeek < 1000) {\n                return;\n            }\n\n            const position = properties.timestamp * 1e7;\n\n            lastProgressEventRef.current = properties.timestamp;\n            lastSeekEventRef.current = now;\n\n            sendScrobble.mutate(\n                {\n                    apiClientProps: { serverId: currentSong._serverId || '' },\n                    query: {\n                        albumId: currentSong.albumId,\n                        event: 'timeupdate',\n                        id: currentSong.id,\n                        position,\n                        submission: false,\n                    },\n                },\n                {\n                    onSuccess: () => {\n                        logFn.debug(logMsg[LogCategory.SCROBBLE].scrobbledTimeupdate, {\n                            category: LogCategory.SCROBBLE,\n                            meta: {\n                                id: currentSong.id,\n                            },\n                        });\n                    },\n                },\n            );\n        },\n        [isScrobbleEnabled, isPrivateModeEnabled, sendScrobble],\n    );\n\n    const handleScrobbleFromStatus = useCallback(\n        (properties: { status: PlayerStatus }, prev: { status: PlayerStatus }) => {\n            if (!isScrobbleEnabled || isPrivateModeEnabled) {\n                return;\n            }\n\n            const currentSong = usePlayerStore.getState().getCurrentSong();\n\n            if (!currentSong?.id) {\n                return;\n            }\n\n            // Only apply to Jellyfin controller scrobble\n            if (currentSong._serverType !== ServerType.JELLYFIN) {\n                return;\n            }\n\n            const currentTimestamp = useTimestampStoreBase.getState().timestamp;\n            const position = currentTimestamp * 1e7;\n\n            // Send pause event when status changes to paused\n            if (properties.status === PlayerStatus.PAUSED && prev.status === PlayerStatus.PLAYING) {\n                sendScrobble.mutate(\n                    {\n                        apiClientProps: { serverId: currentSong._serverId || '' },\n                        query: {\n                            albumId: currentSong.albumId,\n                            event: 'pause',\n                            id: currentSong.id,\n                            position,\n                            submission: false,\n                        },\n                    },\n                    {\n                        onSuccess: () => {\n                            logFn.debug(logMsg[LogCategory.SCROBBLE].scrobbledPause, {\n                                category: LogCategory.SCROBBLE,\n                                meta: {\n                                    id: currentSong.id,\n                                },\n                            });\n                        },\n                    },\n                );\n            }\n\n            // Send unpause event when status changes to playing (from paused)\n            if (properties.status === PlayerStatus.PLAYING && prev.status === PlayerStatus.PAUSED) {\n                sendScrobble.mutate(\n                    {\n                        apiClientProps: { serverId: currentSong._serverId || '' },\n                        query: {\n                            albumId: currentSong.albumId,\n                            event: 'unpause',\n                            id: currentSong.id,\n                            position,\n                            submission: false,\n                        },\n                    },\n                    {\n                        onSuccess: () => {\n                            logFn.debug(logMsg[LogCategory.SCROBBLE].scrobbledUnpause, {\n                                category: LogCategory.SCROBBLE,\n                                meta: {\n                                    id: currentSong.id,\n                                },\n                            });\n                        },\n                    },\n                );\n            }\n        },\n        [isScrobbleEnabled, isPrivateModeEnabled, sendScrobble],\n    );\n\n    const handleScrobbleFromRepeat = useCallback(() => {\n        if (!isScrobbleEnabled || isPrivateModeEnabled) {\n            return;\n        }\n\n        const currentSong = usePlayerStore.getState().getCurrentSong();\n        const currentStatus = usePlayerStore.getState().player.status;\n\n        if (currentStatus !== PlayerStatus.PLAYING || !currentSong?.id) {\n            return;\n        }\n\n        setIsCurrentSongScrobbled(false);\n        lastProgressEventRef.current = 0;\n        previousTimestampRef.current = 0;\n\n        sendScrobble.mutate(\n            {\n                apiClientProps: { serverId: currentSong._serverId || '' },\n                query: {\n                    albumId: currentSong.albumId,\n                    event: 'start',\n                    id: currentSong.id,\n                    position: 0,\n                    submission: false,\n                },\n            },\n            {\n                onSuccess: () => {\n                    logFn.debug(logMsg[LogCategory.SCROBBLE].scrobbledStart, {\n                        category: LogCategory.SCROBBLE,\n                        meta: {\n                            id: currentSong.id,\n                            reason: 'from repeat',\n                        },\n                    });\n                },\n            },\n        );\n    }, [isScrobbleEnabled, isPrivateModeEnabled, sendScrobble]);\n\n    // Update previous timestamp on progress for use in status change handler\n    const handleProgressUpdate = useCallback(\n        (properties: { timestamp: number }, prev: { timestamp: number }) => {\n            previousTimestampRef.current = properties.timestamp;\n            handleScrobbleFromProgress(properties, prev);\n        },\n        [handleScrobbleFromProgress],\n    );\n\n    usePlayerEvents(\n        {\n            onCurrentSongChange: handleScrobbleFromSongChange,\n            onPlayerProgress: handleProgressUpdate,\n            onPlayerRepeated: handleScrobbleFromRepeat,\n            onPlayerSeekToTimestamp: handleScrobbleFromSeek,\n            onPlayerStatus: handleScrobbleFromStatus,\n        },\n        [\n            handleScrobbleFromSongChange,\n            handleProgressUpdate,\n            handleScrobbleFromRepeat,\n            handleScrobbleFromSeek,\n            handleScrobbleFromStatus,\n        ],\n    );\n};\n\nconst ScrobbleHookInner = () => {\n    useScrobble();\n    return null;\n};\n\nexport const ScrobbleHook = () => {\n    const isScrobbleEnabled = useSettingsStore((state) => state.playback.scrobble.enabled);\n    const privateMode = useAppStore((state) => state.privateMode);\n\n    if (!isScrobbleEnabled || privateMode) {\n        return null;\n    }\n\n    return React.createElement(ScrobbleHookInner);\n};\n"
  },
  {
    "path": "src/renderer/features/player/hooks/use-update-current-song.ts",
    "content": "import { useQueryClient } from '@tanstack/react-query';\nimport isEqual from 'lodash/isEqual';\nimport { useCallback } from 'react';\n\nimport { api } from '/@/renderer/api';\nimport { queryKeys } from '/@/renderer/api/query-keys';\nimport { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';\nimport { updateQueueSong } from '/@/renderer/store/player.store';\nimport { LogCategory, logFn } from '/@/renderer/utils/logger';\nimport { QueueSong, SongDetailQuery } from '/@/shared/types/domain-types';\n\nexport const useUpdateCurrentSong = () => {\n    const queryClient = useQueryClient();\n\n    const handleSongChange = useCallback(\n        async (properties: { index: number; song: QueueSong | undefined }) => {\n            const currentSong = properties.song;\n\n            if (!currentSong?.id || !currentSong?._serverId) {\n                return;\n            }\n\n            try {\n                const queryFilter: SongDetailQuery = { id: currentSong.id };\n                const queryKey = queryKeys.songs.detail(currentSong._serverId, queryFilter);\n\n                const updatedSong = await queryClient.fetchQuery({\n                    queryFn: async ({ signal }) =>\n                        api.controller.getSongDetail({\n                            apiClientProps: {\n                                serverId: currentSong._serverId,\n                                signal,\n                            },\n                            query: queryFilter,\n                        }),\n                    queryKey,\n                });\n\n                if (updatedSong) {\n                    // eslint-disable-next-line @typescript-eslint/no-unused-vars\n                    const { _uniqueId, ...currentSongData } = currentSong;\n\n                    if (!isEqual(currentSongData, updatedSong)) {\n                        updateQueueSong(currentSong.id, updatedSong);\n\n                        logFn.debug('Song updated in queue', {\n                            category: LogCategory.PLAYER,\n                            meta: {\n                                id: currentSong.id,\n                                name: updatedSong.name,\n                            },\n                        });\n                    }\n                }\n            } catch (error) {\n                logFn.error('Failed to update song in queue', {\n                    category: LogCategory.PLAYER,\n                    meta: {\n                        error: error instanceof Error ? error.message : String(error),\n                        id: currentSong.id,\n                    },\n                });\n            }\n        },\n        [queryClient],\n    );\n\n    usePlayerEvents(\n        {\n            onCurrentSongChange: (properties, prev) => {\n                // Only update if the song actually changed\n                if (\n                    properties.song?.id !== prev.song?.id ||\n                    properties.song?._uniqueId !== prev.song?._uniqueId\n                ) {\n                    handleSongChange(properties);\n                }\n            },\n        },\n        [handleSongChange],\n    );\n};\n\nexport const UpdateCurrentSongHook = () => {\n    useUpdateCurrentSong();\n    return null;\n};\n"
  },
  {
    "path": "src/renderer/features/player/hooks/use-webaudio.ts",
    "content": "import { useContext } from 'react';\n\nimport { WebAudioContext } from '/@/renderer/features/player/context/webaudio-context';\n\nexport const useWebAudio = () => {\n    const { setWebAudio, webAudio } = useContext(WebAudioContext);\n    return { setWebAudio, webAudio };\n};\n"
  },
  {
    "path": "src/renderer/features/player/mutations/scrobble-mutation.ts",
    "content": "import { useMutation, useQueryClient } from '@tanstack/react-query';\nimport { AxiosError } from 'axios';\n\nimport { api } from '/@/renderer/api';\nimport { queryKeys } from '/@/renderer/api/query-keys';\nimport { MutationOptions } from '/@/renderer/lib/react-query';\nimport { incrementQueuePlayCount } from '/@/renderer/store/player.store';\nimport { ScrobbleArgs, ScrobbleResponse } from '/@/shared/types/domain-types';\n\nexport const useSendScrobble = (options?: MutationOptions) => {\n    const queryClient = useQueryClient();\n\n    return useMutation<ScrobbleResponse, AxiosError, ScrobbleArgs, null>({\n        mutationFn: (args) => {\n            return api.controller.scrobble({\n                ...args,\n                apiClientProps: { serverId: args.apiClientProps.serverId },\n            });\n        },\n        onSuccess: (_data, variables) => {\n            // Manually increment the play count for the song in the queue if scrobble was submitted\n            if (variables.query.submission) {\n                const serverId = variables.apiClientProps.serverId;\n                incrementQueuePlayCount([variables.query.id]);\n\n                // Invalidate the album detail query for the song's album\n                if (variables.query.albumId) {\n                    queryClient.invalidateQueries({\n                        queryKey: queryKeys.albums.detail(serverId, {\n                            id: variables.query.albumId,\n                        }),\n                    });\n                }\n\n                // Invalidate recently played carousel on home route\n                queryClient.invalidateQueries({\n                    queryKey: ['home', 'recentlyPlayed'],\n                });\n\n                // Invalidate most played carousel on home route\n                queryClient.invalidateQueries({\n                    queryKey: ['home', 'mostPlayed'],\n                });\n\n                // Invalidate album artist top songs\n                queryClient.invalidateQueries({\n                    queryKey: queryKeys.albumArtists.topSongs(serverId),\n                });\n\n                // Invalidate album artist favorite songs\n                queryClient.invalidateQueries({\n                    queryKey: queryKeys.albumArtists.favoriteSongs(serverId),\n                });\n            }\n        },\n        ...options,\n    });\n};\n"
  },
  {
    "path": "src/renderer/features/player/ref/players-ref.tsx",
    "content": "import { createRef } from 'react';\n\nexport const PlayersRef = createRef<any>();\n"
  },
  {
    "path": "src/renderer/features/player/update-remote-song.tsx",
    "content": "import isElectron from 'is-electron';\n\nimport { QueueSong } from '/@/shared/types/domain-types';\n\nconst remote = isElectron() ? window.api.remote : null;\nconst mediaSession = navigator.mediaSession;\n\nexport const updateSong = (song: QueueSong | undefined, imageUrl?: null | string) => {\n    if (mediaSession) {\n        let metadata: MediaMetadata;\n\n        if (song?.id) {\n            let artwork: MediaImage[];\n\n            if (imageUrl) {\n                artwork = [{ sizes: '300x300', src: imageUrl, type: 'image/png' }];\n            } else {\n                artwork = [];\n            }\n\n            metadata = new MediaMetadata({\n                album: song.album ?? '',\n                artist: song.artistName,\n                artwork,\n                title: song.name,\n            });\n        } else {\n            metadata = new MediaMetadata();\n        }\n\n        mediaSession.metadata = metadata;\n    }\n\n    remote?.updateSong(song, imageUrl);\n};\n"
  },
  {
    "path": "src/renderer/features/player/utils/open-visualizer-settings-modal.ts",
    "content": "import { openContextModal } from '@mantine/modals';\n\nimport i18n from '/@/i18n/i18n';\n\nexport const openVisualizerSettingsModal = () => {\n    openContextModal({\n        innerProps: {},\n        modalKey: 'visualizerSettings',\n        overlayProps: {\n            blur: 0,\n            opacity: 0,\n        },\n        size: 'xl',\n        styles: {\n            content: {\n                height: '90%',\n                maxWidth: '1400px',\n                minHeight: '600px',\n                width: '100%',\n            },\n        },\n        title: i18n.t('common.setting', { count: 2, postProcess: 'titleCase' }),\n        transitionProps: {\n            transition: 'pop',\n        },\n    });\n};\n"
  },
  {
    "path": "src/renderer/features/player/utils.ts",
    "content": "import { QueryClient } from '@tanstack/react-query';\n\nimport { api } from '/@/renderer/api';\nimport { queryKeys } from '/@/renderer/api/query-keys';\nimport { folderQueries } from '/@/renderer/features/folders/api/folder-api';\nimport { PlayerFilter, useSettingsStore } from '/@/renderer/store';\nimport { LogCategory, logFn } from '/@/renderer/utils/logger';\nimport { logMsg } from '/@/renderer/utils/logger-message';\nimport { sortSongList } from '/@/shared/api/utils';\nimport {\n    PlaylistSongListQuery,\n    PlaylistSongListQueryClientSide,\n    Song,\n    SongDetailQuery,\n    SongListQuery,\n    SongListResponse,\n    SongListSort,\n    SortOrder,\n} from '/@/shared/types/domain-types';\n\nexport const getPlaylistSongsById = async (args: {\n    id: string;\n    query?: Partial<PlaylistSongListQueryClientSide>;\n    queryClient: QueryClient;\n    serverId: string;\n}) => {\n    const { id, query, queryClient, serverId } = args;\n\n    const queryFilter: PlaylistSongListQuery = {\n        id,\n    };\n\n    const queryKey = queryKeys.playlists.songList(serverId, id);\n\n    const res = await queryClient.fetchQuery({\n        gcTime: 1000 * 60,\n        queryFn: async ({ signal }) =>\n            api.controller.getPlaylistSongList({\n                apiClientProps: {\n                    serverId,\n                    signal,\n                },\n                query: queryFilter,\n            }),\n        queryKey,\n        staleTime: 1000 * 60,\n    });\n\n    if (res) {\n        res.items = sortSongList(\n            res.items,\n            query?.sortBy || SongListSort.ID,\n            query?.sortOrder || SortOrder.ASC,\n        );\n    }\n\n    return res;\n};\n\nexport const getAlbumSongsById = async (args: {\n    id: string[];\n    orderByIds?: boolean;\n    query?: Partial<SongListQuery>;\n    queryClient: QueryClient;\n    serverId: string;\n}) => {\n    const { id, query, queryClient, serverId } = args;\n\n    const queryFilter: SongListQuery = {\n        albumIds: id,\n        sortBy: SongListSort.ALBUM,\n        sortOrder: SortOrder.ASC,\n        startIndex: 0,\n        ...query,\n    };\n\n    const queryKey = queryKeys.songs.list(serverId, queryFilter);\n\n    const res = await queryClient.fetchQuery({\n        gcTime: 1000 * 60,\n        queryFn: async ({ signal }) =>\n            api.controller.getSongList({\n                apiClientProps: {\n                    serverId,\n                    signal,\n                },\n                query: queryFilter,\n            }),\n        queryKey,\n        staleTime: 1000 * 60,\n    });\n\n    return res;\n};\n\nexport const getGenreSongsById = async (args: {\n    id: string[];\n    orderByIds?: boolean;\n    query?: Partial<SongListQuery>;\n    queryClient: QueryClient;\n    serverId: string;\n}) => {\n    const { id, query, queryClient, serverId } = args;\n\n    const data: SongListResponse = {\n        items: [],\n        startIndex: 0,\n        totalRecordCount: 0,\n    };\n    for (const genreId of id) {\n        const queryFilter: SongListQuery = {\n            genreIds: [genreId],\n            sortBy: SongListSort.GENRE,\n            sortOrder: SortOrder.ASC,\n            startIndex: 0,\n            ...query,\n        };\n\n        const queryKey = queryKeys.songs.list(serverId, queryFilter);\n\n        const res = await queryClient.fetchQuery({\n            gcTime: 1000 * 60,\n            queryFn: async ({ signal }) =>\n                api.controller.getSongList({\n                    apiClientProps: {\n                        serverId,\n                        signal,\n                    },\n                    query: queryFilter,\n                }),\n            queryKey,\n            staleTime: 1000 * 60,\n        });\n\n        data.items.push(...res!.items);\n        if (data.totalRecordCount) {\n            data.totalRecordCount += res!.totalRecordCount || 0;\n        }\n    }\n\n    return data;\n};\n\nexport const getAlbumArtistSongsById = async (args: {\n    id: string[];\n    orderByIds?: boolean;\n    query?: Partial<SongListQuery>;\n    queryClient: QueryClient;\n    serverId: string;\n}) => {\n    const { id, query, queryClient, serverId } = args;\n\n    const queryFilter: SongListQuery = {\n        albumArtistIds: id || [],\n        sortBy: SongListSort.ALBUM_ARTIST,\n        sortOrder: SortOrder.ASC,\n        startIndex: 0,\n        ...query,\n    };\n\n    const queryKey = queryKeys.songs.list(serverId, queryFilter);\n\n    const res = await queryClient.fetchQuery({\n        gcTime: 1000 * 60,\n        queryFn: async ({ signal }) =>\n            api.controller.getSongList({\n                apiClientProps: {\n                    serverId,\n                    signal,\n                },\n                query: queryFilter,\n            }),\n        queryKey,\n        staleTime: 1000 * 60,\n    });\n\n    return res;\n};\n\nexport const getArtistSongsById = async (args: {\n    id: string[];\n    query?: Partial<SongListQuery>;\n    queryClient: QueryClient;\n    serverId: string;\n}) => {\n    const { id, query, queryClient, serverId } = args;\n\n    const queryFilter: SongListQuery = {\n        artistIds: id,\n        sortBy: SongListSort.ALBUM,\n        sortOrder: SortOrder.ASC,\n        startIndex: 0,\n        ...query,\n    };\n\n    const queryKey = queryKeys.songs.list(serverId, queryFilter);\n\n    const res = await queryClient.fetchQuery({\n        gcTime: 1000 * 60,\n        queryFn: async ({ signal }) =>\n            api.controller.getSongList({\n                apiClientProps: {\n                    serverId,\n                    signal,\n                },\n                query: queryFilter,\n            }),\n        queryKey,\n        staleTime: 1000 * 60,\n    });\n\n    return res;\n};\n\nexport const getSongsByQuery = async (args: {\n    query?: Partial<SongListQuery>;\n    queryClient: QueryClient;\n    serverId: string;\n}) => {\n    const { query, queryClient, serverId } = args;\n\n    const queryFilter: SongListQuery = {\n        sortBy: SongListSort.ALBUM,\n        sortOrder: SortOrder.ASC,\n        startIndex: 0,\n        ...query,\n    };\n\n    const queryKey = queryKeys.songs.list(serverId, queryFilter);\n\n    const res = await queryClient.fetchQuery({\n        gcTime: 1000 * 60,\n        queryFn: async ({ signal }) => {\n            return api.controller.getSongList({\n                apiClientProps: {\n                    serverId,\n                    signal,\n                },\n                query: queryFilter,\n            });\n        },\n        queryKey,\n        staleTime: 1000 * 60,\n    });\n\n    return res;\n};\n\nexport const getSongsByFolder = async (args: {\n    id: string[];\n    orderByIds?: boolean;\n    query?: Partial<SongListQuery>;\n    queryClient: QueryClient;\n    serverId: string;\n}) => {\n    const { id, queryClient, serverId } = args;\n\n    const collectSongsFromFolder = async (folderId: string): Promise<Song[]> => {\n        const folderSongs: Song[] = [];\n        const folder = await queryClient.fetchQuery({\n            ...folderQueries.folder({\n                query: {\n                    id: folderId,\n                    sortBy: SongListSort.ID,\n                    sortOrder: SortOrder.ASC,\n                },\n                serverId,\n            }),\n            gcTime: 0,\n            staleTime: 0,\n        });\n\n        if (folder.children?.songs) {\n            folderSongs.push(...folder.children.songs);\n        }\n\n        if (folder.children?.folders) {\n            for (const subFolder of folder.children.folders) {\n                const subFolderSongs = await collectSongsFromFolder(subFolder.id);\n                folderSongs.push(...subFolderSongs);\n            }\n        }\n\n        return folderSongs;\n    };\n\n    const data: SongListResponse = {\n        items: [],\n        startIndex: 0,\n        totalRecordCount: 0,\n    };\n\n    // Process folders sequentially to maintain order\n    for (const folderId of id) {\n        const folderSongs = await collectSongsFromFolder(folderId);\n        data.items.push(...folderSongs);\n        data.totalRecordCount = (data.totalRecordCount || 0) + folderSongs.length;\n    }\n\n    return data;\n};\n\nexport const getSongById = async (args: {\n    id: string;\n    queryClient: QueryClient;\n    serverId: string;\n}): Promise<SongListResponse> => {\n    const { id, queryClient, serverId } = args;\n\n    const queryFilter: SongDetailQuery = { id };\n\n    const queryKey = queryKeys.songs.detail(serverId, queryFilter);\n\n    const res = await queryClient.fetchQuery({\n        gcTime: 1000 * 60,\n        queryFn: async ({ signal }) =>\n            api.controller.getSongDetail({\n                apiClientProps: {\n                    serverId,\n                    signal,\n                },\n                query: queryFilter,\n            }),\n        queryKey,\n        staleTime: 1000 * 60,\n    });\n\n    if (!res) throw new Error('Song not found');\n\n    return {\n        items: [res],\n        startIndex: 0,\n        totalRecordCount: 1,\n    };\n};\n\nconst getSongFieldValue = (song: Song, field: string): boolean | null | number | string => {\n    switch (field) {\n        case 'albumArtist':\n            return song.albumArtists[0]?.name || '';\n        case 'artist':\n            return song.artistName || song.artists[0]?.name || '';\n        case 'duration':\n            return song.duration;\n        case 'favorite':\n            return song.userFavorite;\n        case 'genre':\n            return song.genres[0]?.name || '';\n        case 'name':\n            return song.name;\n        case 'note':\n            return song.comment || '';\n        case 'path':\n            return song.path || '';\n        case 'playCount':\n            return song.playCount;\n        case 'rating':\n            return song.userRating || 0;\n        case 'year':\n            return song.releaseYear || 0;\n        default:\n            return null;\n    }\n};\n\nconst matchesFilter = (song: Song, filter: PlayerFilter): boolean => {\n    const songValue = getSongFieldValue(song, filter.field);\n    const filterValue = filter.value;\n\n    // Handle null/undefined values\n    if (songValue === null || songValue === undefined) {\n        return false;\n    }\n\n    switch (filter.operator) {\n        case 'contains':\n            return String(songValue).toLowerCase().includes(String(filterValue).toLowerCase());\n        case 'endsWith':\n            return String(songValue).toLowerCase().endsWith(String(filterValue).toLowerCase());\n        case 'is':\n            return String(songValue).toLowerCase() === String(filterValue).toLowerCase();\n        case 'isNot':\n            return String(songValue).toLowerCase() !== String(filterValue).toLowerCase();\n        case 'lt':\n            return Number(songValue) < Number(filterValue);\n        case 'notContains':\n            return !String(songValue).toLowerCase().includes(String(filterValue).toLowerCase());\n        case 'regex': {\n            try {\n                const regex = new RegExp(String(filterValue), 'i');\n                return regex.test(String(songValue));\n            } catch {\n                // Invalid regex pattern, don't match\n                return false;\n            }\n        }\n        case 'gt':\n            return Number(songValue) > Number(filterValue);\n        case 'startsWith':\n            return String(songValue).toLowerCase().startsWith(String(filterValue).toLowerCase());\n        default:\n            return true;\n    }\n};\n\nexport const filterSongsByPlayerFilters = (songs: Song[], filters: PlayerFilter[]): Song[] => {\n    // Filter out invalid filters (missing field, operator, or value)\n    const validFilters = filters.filter(\n        (filter) =>\n            Boolean(filter.isEnabled) &&\n            filter.field &&\n            filter.operator &&\n            filter.value !== undefined &&\n            filter.value !== null &&\n            filter.value !== '',\n    );\n\n    // If no valid filters, return all songs\n    if (validFilters.length === 0) {\n        return songs;\n    }\n\n    // Track filtered songs and their matching conditions\n    const filteredSongs: Array<{ filter: PlayerFilter; song: Song }> = [];\n\n    // Filter OUT songs that match any of the filters (exclude matching songs)\n    const filtered = songs.filter((song) => {\n        const matchingFilter = validFilters.find((filter) => matchesFilter(song, filter));\n        if (matchingFilter) {\n            filteredSongs.push({ filter: matchingFilter, song });\n            return false;\n        }\n        return true;\n    });\n\n    if (filteredSongs.length > 0) {\n        logFn.debug(logMsg[LogCategory.PLAYER].playerFiltersApplied, {\n            category: LogCategory.PLAYER,\n            meta: {\n                filteredCount: filteredSongs.length,\n                filteredSongs: filteredSongs.map(({ filter, song }) => ({\n                    artist: song.artistName,\n                    condition: {\n                        field: filter.field,\n                        operator: filter.operator,\n                        value: filter.value,\n                    },\n                    songId: song.id,\n                    songName: song.name,\n                })),\n                originalCount: songs.length,\n                remainingCount: filtered.length,\n            },\n        });\n    }\n\n    return filtered;\n};\n\nexport const getPlayerFiltersAndFilterSongs = (songs: Song[]): Song[] => {\n    const state = useSettingsStore.getState();\n    const filters = state.playback.filters;\n    return filterSongsByPlayerFilters(songs, filters);\n};\n"
  },
  {
    "path": "src/renderer/features/playlists/api/playlists-api.ts",
    "content": "import { queryOptions } from '@tanstack/react-query';\n\nimport { api } from '/@/renderer/api';\nimport { queryKeys } from '/@/renderer/api/query-keys';\nimport { QueryHookArgs } from '/@/renderer/lib/react-query';\nimport {\n    ListCountQuery,\n    PlaylistDetailQuery,\n    PlaylistListQuery,\n    PlaylistSongListQuery,\n} from '/@/shared/types/domain-types';\n\nexport const playlistsQueries = {\n    detail: (args: QueryHookArgs<PlaylistDetailQuery>) => {\n        return queryOptions({\n            queryFn: ({ signal }) => {\n                return api.controller.getPlaylistDetail({\n                    apiClientProps: { serverId: args.serverId, signal },\n                    query: args.query,\n                });\n            },\n            queryKey: queryKeys.playlists.detail(args.serverId, args.query.id, args.query),\n            ...args.options,\n        });\n    },\n    list: (args: QueryHookArgs<PlaylistListQuery>) => {\n        return queryOptions({\n            gcTime: 1000 * 60 * 60,\n            queryFn: ({ signal }) => {\n                return api.controller.getPlaylistList({\n                    apiClientProps: { serverId: args.serverId, signal },\n                    query: args.query,\n                });\n            },\n            queryKey: queryKeys.playlists.list(args.serverId || '', args.query),\n            ...args.options,\n        });\n    },\n    listCount: (args: QueryHookArgs<ListCountQuery<PlaylistListQuery>>) => {\n        return queryOptions({\n            gcTime: 1000 * 60 * 60,\n            queryFn: ({ signal }) => {\n                return api.controller.getPlaylistListCount({\n                    apiClientProps: { serverId: args.serverId, signal },\n                    query: args.query,\n                });\n            },\n            queryKey: queryKeys.playlists.count(\n                args.serverId || '',\n                Object.keys(args.query).length === 0 ? undefined : args.query,\n            ),\n            staleTime: 1000 * 60 * 60,\n            ...args.options,\n        });\n    },\n    songList: (args: QueryHookArgs<PlaylistSongListQuery>) => {\n        return queryOptions({\n            queryFn: ({ signal }) => {\n                return api.controller.getPlaylistSongList({\n                    apiClientProps: { serverId: args.serverId, signal },\n                    query: args.query,\n                });\n            },\n            queryKey: queryKeys.playlists.songList(args.serverId || '', args.query.id),\n            ...args.options,\n        });\n    },\n};\n"
  },
  {
    "path": "src/renderer/features/playlists/components/add-to-playlist-context-modal.module.css",
    "content": ".container {\n    width: 100%;\n    max-width: 100%;\n    overflow: hidden;\n}\n\n.grid-col {\n    min-width: 0;\n    max-width: 100%;\n    overflow: hidden;\n}\n\n.image-container {\n    width: 3rem;\n    height: 3rem;\n}\n\n.label-text {\n    text-overflow: ellipsis;\n    white-space: nowrap;\n}\n\n.status-text {\n    flex-shrink: 0;\n}\n"
  },
  {
    "path": "src/renderer/features/playlists/components/add-to-playlist-context-modal.tsx",
    "content": "import { closeModal, ContextModalProps } from '@mantine/modals';\nimport { useQuery } from '@tanstack/react-query';\nimport { memo, useCallback, useMemo, useRef, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport styles from './add-to-playlist-context-modal.module.css';\n\nimport { api } from '/@/renderer/api';\nimport { queryKeys } from '/@/renderer/api/query-keys';\nimport { ItemImage } from '/@/renderer/components/item-image/item-image';\nimport {\n    getAlbumSongsById,\n    getArtistSongsById,\n    getGenreSongsById,\n    getPlaylistSongsById,\n    getSongsByFolder,\n} from '/@/renderer/features/player/utils';\nimport { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';\nimport { useAddToPlaylist } from '/@/renderer/features/playlists/mutations/add-to-playlist-mutation';\nimport { queryClient } from '/@/renderer/lib/react-query';\nimport { useCurrentServerId } from '/@/renderer/store';\nimport { formatDurationString } from '/@/renderer/utils';\nimport { Box } from '/@/shared/components/box/box';\nimport { Button } from '/@/shared/components/button/button';\nimport { Checkbox } from '/@/shared/components/checkbox/checkbox';\nimport { Flex } from '/@/shared/components/flex/flex';\nimport { Grid } from '/@/shared/components/grid/grid';\nimport { Group } from '/@/shared/components/group/group';\nimport { Icon } from '/@/shared/components/icon/icon';\nimport { ModalButton } from '/@/shared/components/modal/model-shared';\nimport { Pill } from '/@/shared/components/pill/pill';\nimport { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { Switch } from '/@/shared/components/switch/switch';\nimport { Table } from '/@/shared/components/table/table';\nimport { TextInput } from '/@/shared/components/text-input/text-input';\nimport { Text } from '/@/shared/components/text/text';\nimport { toast } from '/@/shared/components/toast/toast';\nimport { useForm } from '/@/shared/hooks/use-form';\nimport { useLocalStorage } from '/@/shared/hooks/use-local-storage';\nimport { LibraryItem, Playlist, PlaylistListSort, SortOrder } from '/@/shared/types/domain-types';\n\nexport const AddToPlaylistContextModal = ({\n    id,\n    innerProps,\n}: ContextModalProps<{\n    albumId?: string[];\n    artistId?: string[];\n    folderId?: string[];\n    genreId?: string[];\n    initialSelectedIds?: string[];\n    playlistId?: string[];\n    songId?: string[];\n}>) => {\n    const { t } = useTranslation();\n    const { albumId, artistId, folderId, genreId, initialSelectedIds, playlistId, songId } =\n        innerProps;\n    const serverId = useCurrentServerId();\n    const [isLoading, setIsLoading] = useState(false);\n    const [search, setSearch] = useState<string>('');\n    const [focusedRowIndex, setFocusedRowIndex] = useState<null | number>(null);\n    const rowRefs = useRef<(HTMLTableRowElement | null)[]>([]);\n    const formRef = useRef<HTMLFormElement>(null);\n\n    const [skipDuplicates, setSkipDuplicates] = useLocalStorage({\n        defaultValue: true,\n        key: 'playlist-skip-duplicate',\n    });\n\n    const form = useForm({\n        initialValues: {\n            newPlaylists: [] as string[],\n            selectedPlaylistIds: initialSelectedIds || [],\n            skipDuplicates: skipDuplicates,\n        },\n    });\n\n    form.watch('skipDuplicates', (event) => {\n        setSkipDuplicates(event.value);\n    });\n\n    const addToPlaylistMutation = useAddToPlaylist({});\n\n    const playlistList = useQuery(\n        playlistsQueries.list({\n            query: {\n                excludeSmartPlaylists: true,\n                sortBy: PlaylistListSort.NAME,\n                sortOrder: SortOrder.ASC,\n                startIndex: 0,\n            },\n            serverId,\n        }),\n    );\n\n    const [playlistSelect, playlistMap] = useMemo(() => {\n        const existingPlaylists = new Array<Playlist & { label: string; value: string }>();\n        const playlistMap = new Map<string, string>();\n\n        for (const playlist of playlistList.data?.items ?? []) {\n            existingPlaylists.push({ ...playlist, label: playlist.name, value: playlist.id });\n            playlistMap.set(playlist.id, playlist.name);\n        }\n\n        return [existingPlaylists, playlistMap];\n    }, [playlistList.data]);\n\n    const filteredItems = useMemo(() => {\n        if (search) {\n            return playlistSelect.filter((item) =>\n                item.label.toLocaleLowerCase().includes(search.toLocaleLowerCase()),\n            );\n        }\n\n        return playlistSelect;\n    }, [playlistSelect, search]);\n\n    const getSongsByAlbum = useCallback(\n        async (albumId: string) => {\n            return getAlbumSongsById({\n                id: [albumId],\n                queryClient,\n                serverId,\n            });\n        },\n        [serverId],\n    );\n\n    const getSongsByArtist = useCallback(\n        async (artistId: string) => {\n            return getArtistSongsById({\n                id: [artistId],\n                queryClient,\n                serverId,\n            });\n        },\n        [serverId],\n    );\n\n    const getSongsByPlaylist = useCallback(\n        async (playlistId: string) => {\n            return getPlaylistSongsById({\n                id: playlistId,\n                queryClient,\n                serverId,\n            });\n        },\n        [serverId],\n    );\n\n    const handleSubmit = form.onSubmit(async (values) => {\n        if (isLoading) {\n            return;\n        }\n\n        setIsLoading(true);\n        const allSongIds: string[] = [];\n        let totalUniquesAdded = 0;\n\n        try {\n            if (albumId && albumId.length > 0) {\n                for (const id of albumId) {\n                    const songs = await getSongsByAlbum(id);\n                    allSongIds.push(...(songs?.items?.map((song) => song.id) || []));\n                }\n            }\n\n            if (artistId && artistId.length > 0) {\n                for (const id of artistId) {\n                    const songs = await getSongsByArtist(id);\n                    allSongIds.push(...(songs?.items?.map((song) => song.id) || []));\n                }\n            }\n\n            if (genreId && genreId.length > 0) {\n                const songs = await getGenreSongsById({\n                    id: genreId,\n                    queryClient,\n                    serverId,\n                });\n\n                allSongIds.push(...(songs?.items?.map((song) => song.id) || []));\n            }\n\n            if (folderId && folderId.length > 0) {\n                const songs = await getSongsByFolder({\n                    id: folderId,\n                    queryClient,\n                    serverId,\n                });\n                allSongIds.push(...(songs?.items?.map((song) => song.id) || []));\n            }\n\n            if (playlistId && playlistId.length > 0) {\n                for (const id of playlistId) {\n                    const songs = await getSongsByPlaylist(id);\n                    allSongIds.push(...(songs?.items?.map((song) => song.id) || []));\n                }\n            }\n\n            if (songId && songId.length > 0) {\n                allSongIds.push(...songId);\n            }\n\n            const playlistIds = [...values.selectedPlaylistIds];\n\n            if (values.newPlaylists) {\n                for (const playlist of values.newPlaylists) {\n                    try {\n                        const response = await api.controller.createPlaylist({\n                            apiClientProps: { serverId },\n                            body: {\n                                name: playlist,\n                                public: false,\n                            },\n                        });\n\n                        if (response?.id) {\n                            playlistIds.push(response?.id);\n                        }\n                    } catch (error: any) {\n                        toast.error({\n                            message: `[${playlist}] ${error?.message}`,\n                            title: t('error.genericError', { postProcess: 'sentenceCase' }),\n                        });\n                    }\n                }\n            }\n\n            for (const playlistId of playlistIds) {\n                const uniqueSongIds: string[] = [];\n\n                if (values.skipDuplicates) {\n                    const queryKey = queryKeys.playlists.songList(serverId, playlistId);\n\n                    const playlistSongsRes = await queryClient.fetchQuery({\n                        queryFn: ({ signal }) => {\n                            return api.controller.getPlaylistSongList({\n                                apiClientProps: {\n                                    serverId,\n                                    signal,\n                                },\n                                query: {\n                                    id: playlistId,\n                                },\n                            });\n                        },\n                        queryKey,\n                    });\n\n                    const playlistSongIds = playlistSongsRes?.items?.map((song) => song.id);\n\n                    for (const songId of allSongIds) {\n                        if (!playlistSongIds?.includes(songId)) {\n                            uniqueSongIds.push(songId);\n                        }\n                    }\n                    totalUniquesAdded += uniqueSongIds.length;\n                }\n\n                if (values.skipDuplicates ? uniqueSongIds.length > 0 : allSongIds.length > 0) {\n                    addToPlaylistMutation.mutate(\n                        {\n                            apiClientProps: { serverId },\n                            body: { songId: values.skipDuplicates ? uniqueSongIds : allSongIds },\n                            query: { id: playlistId },\n                        },\n                        {\n                            onError: (err) => {\n                                toast.error({\n                                    message: `[${\n                                        playlistSelect.find(\n                                            (playlist) => playlist.value === playlistId,\n                                        )?.label\n                                    }] ${err.message}`,\n                                    title: t('error.genericError', { postProcess: 'sentenceCase' }),\n                                });\n                            },\n                        },\n                    );\n                }\n            }\n\n            const addMessage =\n                values.skipDuplicates &&\n                allSongIds.length * playlistIds.length !== totalUniquesAdded\n                    ? Math.floor(totalUniquesAdded / playlistIds.length)\n                    : allSongIds.length;\n\n            setIsLoading(false);\n            toast.success({\n                message: t('form.addToPlaylist.success', {\n                    message: addMessage,\n                    numOfPlaylists: playlistIds.length,\n                    postProcess: 'sentenceCase',\n                }),\n            });\n            closeModal(id);\n        } catch (error: any) {\n            setIsLoading(false);\n            toast.error({\n                message: error?.message || t('error.genericError', { postProcess: 'sentenceCase' }),\n                title: t('error.genericError', { postProcess: 'sentenceCase' }),\n            });\n        }\n    });\n\n    const handleSelectItem = useCallback(\n        (item: { value: string }) => {\n            const currentIds = form.values.selectedPlaylistIds;\n            if (currentIds.includes(item.value)) {\n                form.setFieldValue(\n                    'selectedPlaylistIds',\n                    currentIds.filter((id) => id !== item.value),\n                );\n            } else {\n                form.setFieldValue('selectedPlaylistIds', [...currentIds, item.value]);\n            }\n        },\n        [form],\n    );\n\n    const handleCheckboxChange = useCallback(\n        (itemValue: string, checked: boolean) => {\n            const currentIds = form.values.selectedPlaylistIds;\n            if (checked) {\n                form.setFieldValue('selectedPlaylistIds', [...currentIds, itemValue]);\n            } else {\n                form.setFieldValue(\n                    'selectedPlaylistIds',\n                    currentIds.filter((id) => id !== itemValue),\n                );\n            }\n        },\n        [form],\n    );\n\n    const handleCreatePlaylist = useCallback(() => {\n        form.setFieldValue('newPlaylists', [...form.values.newPlaylists, search]);\n        setSearch('');\n    }, [form, search]);\n\n    const handleRemoveSelectedPlaylist = useCallback(\n        (playlistId: string) => {\n            form.setFieldValue(\n                'selectedPlaylistIds',\n                form.values.selectedPlaylistIds.filter((id) => id !== playlistId),\n            );\n        },\n        [form],\n    );\n\n    const handleRemoveNewPlaylist = useCallback(\n        (index: number) => {\n            form.setFieldValue(\n                'newPlaylists',\n                form.values.newPlaylists.filter((_, existingIdx) => index !== existingIdx),\n            );\n        },\n        [form],\n    );\n\n    const handleKeyDown = useCallback(\n        (\n            event: React.KeyboardEvent<HTMLTableRowElement>,\n            index: number,\n            item: { value: string },\n        ) => {\n            const totalRows = filteredItems.length;\n\n            switch (event.key) {\n                case ' ': {\n                    event.preventDefault();\n                    event.stopPropagation();\n                    handleSelectItem(item);\n                    break;\n                }\n                case 'ArrowDown': {\n                    event.preventDefault();\n                    const nextIndex = index < totalRows - 1 ? index + 1 : index;\n                    setFocusedRowIndex(nextIndex);\n                    rowRefs.current[nextIndex]?.focus();\n                    break;\n                }\n                case 'ArrowUp': {\n                    event.preventDefault();\n                    const prevIndex = index > 0 ? index - 1 : 0;\n                    setFocusedRowIndex(prevIndex);\n                    rowRefs.current[prevIndex]?.focus();\n                    break;\n                }\n                case 'Enter': {\n                    event.preventDefault();\n                    if (formRef.current) {\n                        formRef.current.requestSubmit();\n                    }\n                    break;\n                }\n                case 'Tab': {\n                    // Allow Tab to exit the table naturally - don't prevent default\n                    setFocusedRowIndex(null);\n                    break;\n                }\n                default:\n                    break;\n            }\n        },\n        [filteredItems.length, handleSelectItem],\n    );\n\n    const setRowRef = useCallback(\n        (index: number) => (el: HTMLTableRowElement | null) => {\n            rowRefs.current[index] = el;\n        },\n        [],\n    );\n\n    return (\n        <Box>\n            <form onSubmit={handleSubmit} ref={formRef}>\n                <Stack>\n                    <TextInput\n                        data-autofocus\n                        onChange={(e) => setSearch(e.target.value)}\n                        placeholder={t('form.addToPlaylist.searchOrCreate', {\n                            postProcess: 'sentenceCase',\n                        })}\n                        value={search}\n                    />\n                    <ScrollArea style={{ maxHeight: '18rem' }}>\n                        <Table styles={{ td: { padding: 'var(--theme-spacing-sm)' } }}>\n                            <Table.Tbody>\n                                {filteredItems.map((item, index) => (\n                                    <Table.Tr\n                                        key={item.value}\n                                        onBlur={() => setFocusedRowIndex(null)}\n                                        onClick={() => handleSelectItem(item)}\n                                        onFocus={() => setFocusedRowIndex(index)}\n                                        onKeyDown={(e) => handleKeyDown(e, index, item)}\n                                        ref={setRowRef(index)}\n                                        role=\"button\"\n                                        style={{\n                                            background:\n                                                focusedRowIndex === index\n                                                    ? 'var(--theme-colors-surface)'\n                                                    : 'transparent',\n                                            cursor: 'pointer',\n                                            outline: 'none',\n                                        }}\n                                        tabIndex={index === 0 ? 0 : -1}\n                                    >\n                                        <Table.Td w={10}>\n                                            <Checkbox\n                                                checked={form.values.selectedPlaylistIds.includes(\n                                                    item.value,\n                                                )}\n                                                onChange={(event) => {\n                                                    handleCheckboxChange(\n                                                        item.value,\n                                                        event.target.checked,\n                                                    );\n                                                    event.preventDefault();\n                                                }}\n                                                onClick={(e) => e.stopPropagation()}\n                                                tabIndex={-1}\n                                            />\n                                        </Table.Td>\n                                        <Table.Td style={{ maxWidth: 0, width: '100%' }}>\n                                            <PlaylistTableItem item={item} />\n                                        </Table.Td>\n                                    </Table.Tr>\n                                ))}\n                            </Table.Tbody>\n                        </Table>\n                    </ScrollArea>\n                    {search && (\n                        <Button\n                            leftSection={<Icon icon=\"add\" size=\"lg\" />}\n                            onClick={handleCreatePlaylist}\n                            variant=\"subtle\"\n                            w=\"100%\"\n                        >\n                            {t('form.addToPlaylist.create', {\n                                playlist: search,\n                                postProcess: 'sentenceCase',\n                            })}\n                        </Button>\n                    )}\n                    <Pill.Group>\n                        {form.values.selectedPlaylistIds.map((item) => (\n                            <Pill\n                                key={item}\n                                onRemove={() => handleRemoveSelectedPlaylist(item)}\n                                withRemoveButton\n                            >\n                                {playlistMap.get(item)}\n                            </Pill>\n                        ))}\n                        {form.values.newPlaylists.map((item, idx) => (\n                            <Pill\n                                key={idx}\n                                onRemove={() => handleRemoveNewPlaylist(idx)}\n                                withRemoveButton\n                            >\n                                <Flex align=\"center\" gap=\"lg\" wrap=\"nowrap\">\n                                    <Icon icon=\"plus\" />\n                                    {item}\n                                </Flex>\n                            </Pill>\n                        ))}\n                    </Pill.Group>\n                    <Switch\n                        label={t('form.addToPlaylist.input', {\n                            context: 'skipDuplicates',\n                            postProcess: 'titleCase',\n                        })}\n                        {...form.getInputProps('skipDuplicates', { type: 'checkbox' })}\n                    />\n                    <Group justify=\"flex-end\">\n                        <ModalButton\n                            disabled={isLoading || addToPlaylistMutation.isPending}\n                            onClick={() => closeModal(id)}\n                            uppercase\n                            variant=\"subtle\"\n                        >\n                            {t('common.cancel', { postProcess: 'titleCase' })}\n                        </ModalButton>\n                        <ModalButton\n                            disabled={\n                                isLoading ||\n                                addToPlaylistMutation.isPending ||\n                                (form.values.selectedPlaylistIds.length === 0 &&\n                                    form.values.newPlaylists.length === 0)\n                            }\n                            loading={isLoading}\n                            type=\"submit\"\n                            uppercase\n                            variant=\"filled\"\n                        >\n                            {t('common.add', { postProcess: 'titleCase' })}\n                        </ModalButton>\n                    </Group>\n                </Stack>\n            </form>\n        </Box>\n    );\n};\n\nconst PlaylistTableItem = memo(\n    ({ item }: { item: Playlist & { label: string; value: string } }) => {\n        const { t } = useTranslation();\n\n        return (\n            <Box className={styles.container} w=\"100%\">\n                <Grid align=\"center\" gutter=\"xs\" w=\"100%\">\n                    <Grid.Col span=\"content\">\n                        <Flex align=\"center\" justify=\"center\" px=\"sm\">\n                            <ItemImage\n                                id={item.imageId}\n                                imageContainerProps={{\n                                    className: styles.imageContainer,\n                                }}\n                                itemType={LibraryItem.PLAYLIST}\n                                type=\"table\"\n                            />\n                        </Flex>\n                    </Grid.Col>\n                    <Grid.Col className={styles.gridCol} span=\"auto\">\n                        <Stack gap=\"xs\" w=\"100%\">\n                            <Text className={styles.labelText} isNoSelect overflow=\"hidden\">\n                                {item.label}\n                            </Text>\n                            <Group justify=\"space-between\" wrap=\"nowrap\">\n                                <Group gap=\"md\" wrap=\"nowrap\">\n                                    <Group align=\"center\" gap=\"xs\" wrap=\"nowrap\">\n                                        <Icon color=\"muted\" icon=\"track\" size=\"sm\" />\n                                        <Text isMuted size=\"sm\">\n                                            {item.songCount}\n                                        </Text>\n                                    </Group>\n                                    <Group align=\"center\" gap=\"xs\" wrap=\"nowrap\">\n                                        <Icon color=\"muted\" icon=\"duration\" size=\"sm\" />\n                                        <Text isMuted size=\"sm\">\n                                            {formatDurationString(item.duration ?? 0)}\n                                        </Text>\n                                    </Group>\n                                </Group>\n\n                                <Text className={styles.statusText} isMuted size=\"sm\">\n                                    {item.public\n                                        ? t('common.public', {\n                                              postProcess: 'titleCase',\n                                          })\n                                        : t('common.private', {\n                                              postProcess: 'titleCase',\n                                          })}\n                                </Text>\n                            </Group>\n                        </Stack>\n                    </Grid.Col>\n                </Grid>\n            </Box>\n        );\n    },\n);\n"
  },
  {
    "path": "src/renderer/features/playlists/components/client-side-song-filters.tsx",
    "content": "import type { RowComponentProps } from 'react-window-v2';\n\nimport { useSuspenseQuery } from '@tanstack/react-query';\nimport { useCallback, useMemo } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { useParams } from 'react-router';\n\nimport { getItemImageUrl } from '/@/renderer/components/item-image/item-image';\nimport { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';\nimport { usePlaylistSongListFilters } from '/@/renderer/features/playlists/hooks/use-playlist-song-list-filters';\nimport { applyClientSideSongFilters } from '/@/renderer/features/playlists/hooks/use-playlist-track-list';\nimport {\n    ArtistMultiSelectRow,\n    GenreMultiSelectRow,\n} from '/@/renderer/features/shared/components/multi-select-rows';\nimport { FILTER_KEYS } from '/@/renderer/features/shared/utils';\nimport { useCurrentServer } from '/@/renderer/store';\nimport { useAppStore, useAppStoreActions } from '/@/renderer/store/app.store';\nimport { Divider } from '/@/shared/components/divider/divider';\nimport { Group } from '/@/shared/components/group/group';\nimport {\n    VirtualMultiSelect,\n    type VirtualMultiSelectOption,\n} from '/@/shared/components/multi-select/virtual-multi-select';\nimport { NumberInput } from '/@/shared/components/number-input/number-input';\nimport { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { Text } from '/@/shared/components/text/text';\nimport { useDebouncedCallback } from '/@/shared/hooks/use-debounced-callback';\nimport { LibraryItem, Song } from '/@/shared/types/domain-types';\n\ninterface BooleanSegmentFilterProps {\n    label: string;\n    onChange: (value: boolean | null) => void;\n    segmentData: Array<{ label: string; value: string }>;\n    value: boolean | null | undefined;\n}\n\nfunction booleanToSegmentValue(value: boolean | null | undefined): string {\n    if (value === true) return 'true';\n    if (value === false) return 'false';\n    return 'none';\n}\n\nfunction segmentValueToBoolean(value: string): boolean | null {\n    if (value === 'true') return true;\n    if (value === 'false') return false;\n    return null;\n}\n\nconst BooleanSegmentFilter = ({\n    label,\n    onChange,\n    segmentData,\n    value,\n}: BooleanSegmentFilterProps) => (\n    <Stack gap=\"xs\">\n        <Text size=\"sm\" weight={500}>\n            {label}\n        </Text>\n        <SegmentedControl\n            data={segmentData}\n            onChange={(v) => onChange(segmentValueToBoolean(v))}\n            size=\"sm\"\n            value={booleanToSegmentValue(value)}\n            w=\"100%\"\n        />\n    </Stack>\n);\n\ninterface MultiSelectFilterOption {\n    albumCount: null | number;\n    imageUrl: string | undefined;\n    label: string;\n    songCount: number;\n    value: string;\n}\n\ninterface MultiSelectFilterProps {\n    displayCountType?: 'song';\n    height: number;\n    label: React.ReactNode;\n    onChange: (value: null | string[]) => void;\n    options: MultiSelectFilterOption[];\n    RowComponent: (props: RowComponentProps<MultiSelectRowContext>) => React.ReactElement;\n    singleSelect: boolean;\n    value: string[];\n}\n\ntype MultiSelectRowContext = {\n    disabled?: boolean;\n    displayCountType?: 'album' | 'song';\n    focusedIndex: null | number;\n    onToggle: (value: string) => void;\n    options: VirtualMultiSelectOption<MultiSelectFilterOption>[];\n    value: string[];\n};\n\nconst MultiSelectFilter = ({\n    displayCountType = 'song',\n    height,\n    label,\n    onChange,\n    options,\n    RowComponent,\n    singleSelect,\n    value,\n}: MultiSelectFilterProps) => (\n    <VirtualMultiSelect\n        displayCountType={displayCountType}\n        height={height}\n        label={label}\n        onChange={onChange}\n        options={options}\n        RowComponent={RowComponent}\n        singleSelect={singleSelect}\n        value={value}\n    />\n);\n\ninterface YearRangeFilterProps {\n    fromYearLabel: string;\n    maxYear: number | undefined;\n    minYear: number | undefined;\n    onMaxYear: (e: number | string) => void;\n    onMinYear: (e: number | string) => void;\n    toYearLabel: string;\n}\n\nconst YearRangeFilter = ({\n    fromYearLabel,\n    maxYear,\n    minYear,\n    onMaxYear,\n    onMinYear,\n    toYearLabel,\n}: YearRangeFilterProps) => (\n    <Group gap=\"sm\" wrap=\"nowrap\">\n        <NumberInput\n            hideControls={false}\n            label={fromYearLabel}\n            max={5000}\n            min={0}\n            onChange={(e) => onMinYear(e)}\n            style={{ flex: 1 }}\n            value={minYear != null ? minYear : ''}\n        />\n        <NumberInput\n            hideControls={false}\n            label={toYearLabel}\n            max={5000}\n            min={0}\n            onChange={(e) => onMaxYear(e)}\n            style={{ flex: 1 }}\n            value={maxYear != null ? maxYear : ''}\n        />\n    </Group>\n);\n\ninterface MultiSelectFilterLabelProps {\n    andOrValue: 'and' | 'or';\n    entityLabel: string;\n    filterMultipleLabel: string;\n    filterSingleLabel: string;\n    matchAndLabel: string;\n    matchOrLabel: string;\n    onAndOrChange: (value: 'and' | 'or') => void;\n    onSingleMultiChange: (value: string) => void;\n    showAndOr: boolean;\n    singleMultiValue: 'multi' | 'single';\n}\n\nconst MultiSelectFilterLabel = ({\n    andOrValue,\n    entityLabel,\n    filterMultipleLabel,\n    filterSingleLabel,\n    matchAndLabel,\n    matchOrLabel,\n    onAndOrChange,\n    onSingleMultiChange,\n    showAndOr,\n    singleMultiValue,\n}: MultiSelectFilterLabelProps) => (\n    <Group gap=\"xs\" justify=\"space-between\" w=\"100%\">\n        <Text fw={500} size=\"sm\">\n            {entityLabel}\n        </Text>\n        <Group gap=\"xs\">\n            {showAndOr && (\n                <SegmentedControl\n                    data={[\n                        { label: matchAndLabel, value: 'and' },\n                        { label: matchOrLabel, value: 'or' },\n                    ]}\n                    onChange={(value) => onAndOrChange(value === 'or' ? 'or' : 'and')}\n                    size=\"xs\"\n                    value={andOrValue}\n                />\n            )}\n            <SegmentedControl\n                data={[\n                    { label: filterSingleLabel, value: 'single' },\n                    { label: filterMultipleLabel, value: 'multi' },\n                ]}\n                onChange={onSingleMultiChange}\n                size=\"xs\"\n                value={singleMultiValue}\n            />\n        </Group>\n    </Group>\n);\n\nexport const ClientSideSongFilters = () => {\n    const { t } = useTranslation();\n    const { playlistId } = useParams() as { playlistId: string };\n    const server = useCurrentServer();\n    const {\n        query,\n        setAlbumArtistIds,\n        setAlbumArtistIdsMode,\n        setArtistIds,\n        setArtistIdsMode,\n        setFavorite,\n        setGenreId,\n        setGenreIdsMode,\n        setHasRating,\n        setMaxYear,\n        setMinYear,\n    } = usePlaylistSongListFilters();\n\n    const playlistSongsQuery = useSuspenseQuery(\n        playlistsQueries.songList({\n            query: { id: playlistId },\n            serverId: server?.id,\n        }),\n    );\n\n    const albumArtistSelectMode = useAppStore((state) => state.albumArtistSelectMode);\n    const artistSelectMode = useAppStore((state) => state.artistSelectMode);\n    const genreSelectMode = useAppStore((state) => state.genreSelectMode);\n    const { setAlbumArtistSelectMode, setArtistSelectMode, setGenreSelectMode } =\n        useAppStoreActions();\n\n    const songs = useMemo(() => {\n        return (playlistSongsQuery.data?.items ?? []) as Song[];\n    }, [playlistSongsQuery.data]);\n\n    const filteredSongs = useMemo(\n        () => applyClientSideSongFilters(songs, query as Record<string, unknown>),\n        [songs, query],\n    );\n\n    const songsForAlbumArtistOptions = useMemo(() => {\n        const idsMode =\n            (query[FILTER_KEYS.SONG.ALBUM_ARTIST_IDS_MODE] as 'and' | 'or' | undefined) ?? 'and';\n        const useFilteredResult = albumArtistSelectMode === 'multi' && idsMode === 'and';\n        if (!useFilteredResult) {\n            const queryWithoutAlbumArtist = {\n                ...query,\n                [FILTER_KEYS.SONG.ALBUM_ARTIST_IDS]: undefined,\n            } as Record<string, unknown>;\n            return applyClientSideSongFilters(songs, queryWithoutAlbumArtist);\n        }\n        return filteredSongs;\n    }, [albumArtistSelectMode, filteredSongs, query, songs]);\n\n    const songsForArtistOptions = useMemo(() => {\n        const idsMode =\n            (query[FILTER_KEYS.SONG.ARTIST_IDS_MODE] as 'and' | 'or' | undefined) ?? 'and';\n        const useFilteredResult = artistSelectMode === 'multi' && idsMode === 'and';\n        if (!useFilteredResult) {\n            const queryWithoutArtist = {\n                ...query,\n                [FILTER_KEYS.SONG.ARTIST_IDS]: undefined,\n            } as Record<string, unknown>;\n            return applyClientSideSongFilters(songs, queryWithoutArtist);\n        }\n        return filteredSongs;\n    }, [artistSelectMode, filteredSongs, query, songs]);\n\n    const songsForGenreOptions = useMemo(() => {\n        const idsMode =\n            (query[FILTER_KEYS.SONG.GENRE_ID_MODE] as 'and' | 'or' | undefined) ?? 'and';\n        const useFilteredResult = genreSelectMode === 'multi' && idsMode === 'and';\n        if (!useFilteredResult) {\n            const queryWithoutGenre = {\n                ...query,\n                [FILTER_KEYS.SONG.GENRE_ID]: undefined,\n            } as Record<string, unknown>;\n            return applyClientSideSongFilters(songs, queryWithoutGenre);\n        }\n        return filteredSongs;\n    }, [filteredSongs, genreSelectMode, query, songs]);\n\n    const albumArtistOptions = useMemo(() => {\n        const byId = new Map<\n            string,\n            { id: string; imageUrl: string | undefined; name: string; songCount: number }\n        >();\n        for (const song of songsForAlbumArtistOptions) {\n            for (const artist of song.albumArtists ?? []) {\n                if (!artist.id) continue;\n                const existing = byId.get(artist.id);\n                if (existing) {\n                    existing.songCount += 1;\n                } else {\n                    byId.set(artist.id, {\n                        id: artist.id,\n                        imageUrl:\n                            artist.imageUrl ??\n                            getItemImageUrl({\n                                id: artist.id,\n                                itemType: LibraryItem.ALBUM_ARTIST,\n                                type: 'table',\n                            }),\n                        name: artist.name,\n                        songCount: 1,\n                    });\n                }\n            }\n        }\n        return Array.from(byId.values())\n            .sort((a, b) => a.name.localeCompare(b.name))\n            .map((a) => ({\n                albumCount: null as null | number,\n                imageUrl: a.imageUrl,\n                label: a.name,\n                songCount: a.songCount,\n                value: a.id,\n            }));\n    }, [songsForAlbumArtistOptions]);\n\n    const artistOptions = useMemo(() => {\n        const byId = new Map<\n            string,\n            { id: string; imageUrl: string | undefined; name: string; songCount: number }\n        >();\n        for (const song of songsForArtistOptions) {\n            for (const artist of song.artists ?? []) {\n                if (!artist.id) continue;\n                const existing = byId.get(artist.id);\n                if (existing) {\n                    existing.songCount += 1;\n                } else {\n                    byId.set(artist.id, {\n                        id: artist.id,\n                        imageUrl:\n                            artist.imageUrl ??\n                            getItemImageUrl({\n                                id: artist.id,\n                                itemType: LibraryItem.ARTIST,\n                                type: 'table',\n                            }),\n                        name: artist.name,\n                        songCount: 1,\n                    });\n                }\n            }\n        }\n        return Array.from(byId.values())\n            .sort((a, b) => a.name.localeCompare(b.name))\n            .map((a) => ({\n                albumCount: null as null | number,\n                imageUrl: a.imageUrl,\n                label: a.name,\n                songCount: a.songCount,\n                value: a.id,\n            }));\n    }, [songsForArtistOptions]);\n\n    const genreOptions = useMemo(() => {\n        const byId = new Map<string, { id: string; name: string; songCount: number }>();\n        for (const song of songsForGenreOptions) {\n            for (const genre of song.genres ?? []) {\n                if (!genre.id) continue;\n                const existing = byId.get(genre.id);\n                if (existing) {\n                    existing.songCount += 1;\n                } else {\n                    byId.set(genre.id, {\n                        id: genre.id,\n                        name: genre.name,\n                        songCount: 1,\n                    });\n                }\n            }\n        }\n        return Array.from(byId.values())\n            .sort((a, b) => a.name.localeCompare(b.name))\n            .map((g) => ({\n                albumCount: null as null | number,\n                imageUrl: undefined,\n                label: g.name,\n                songCount: g.songCount,\n                value: g.id,\n            }));\n    }, [songsForGenreOptions]);\n\n    const segmentedControlData = useMemo(\n        () => [\n            { label: t('common.none', { postProcess: 'titleCase' }), value: 'none' },\n            { label: t('common.yes', { postProcess: 'titleCase' }), value: 'true' },\n            { label: t('common.no', { postProcess: 'titleCase' }), value: 'false' },\n        ],\n        [t],\n    );\n\n    const handleMinYear = useMemo(\n        () => (e: number | string) => {\n            if (e === '' || e === null || e === undefined) {\n                setMinYear(null);\n                return;\n            }\n            const year = typeof e === 'number' ? e : Number(e);\n            setMinYear(!isNaN(year) && isFinite(year) && year > 0 ? year : null);\n        },\n        [setMinYear],\n    );\n\n    const handleMaxYear = useMemo(\n        () => (e: number | string) => {\n            if (e === '' || e === null || e === undefined) {\n                setMaxYear(null);\n                return;\n            }\n            const year = typeof e === 'number' ? e : Number(e);\n            setMaxYear(!isNaN(year) && isFinite(year) && year > 0 ? year : null);\n        },\n        [setMaxYear],\n    );\n\n    const debouncedHandleMinYear = useDebouncedCallback(handleMinYear, 300);\n    const debouncedHandleMaxYear = useDebouncedCallback(handleMaxYear, 300);\n\n    const selectedGenreIds = useMemo(\n        () => (query[FILTER_KEYS.SONG.GENRE_ID] as string[] | undefined) ?? [],\n        [query],\n    );\n\n    const handleGenreSelectModeChange = useCallback(\n        (value: string) => {\n            const newMode = value as 'multi' | 'single';\n            setGenreSelectMode(newMode);\n            if (newMode === 'single' && selectedGenreIds.length > 1) {\n                setGenreId([selectedGenreIds[0]]);\n            }\n        },\n        [selectedGenreIds, setGenreId, setGenreSelectMode],\n    );\n\n    const genreIdsMode =\n        (query[FILTER_KEYS.SONG.GENRE_ID_MODE] as 'and' | 'or' | undefined) ?? 'and';\n\n    const handleGenreChange = useCallback(\n        (e: null | string[]) => {\n            if (e && e.length > 0) {\n                setGenreId(e);\n            } else {\n                setGenreId(null);\n            }\n        },\n        [setGenreId],\n    );\n\n    const selectedArtistIds = useMemo(\n        () => (query[FILTER_KEYS.SONG.ARTIST_IDS] as string[] | undefined) ?? [],\n        [query],\n    );\n\n    const handleArtistSelectModeChange = useCallback(\n        (value: string) => {\n            const newMode = value as 'multi' | 'single';\n            setArtistSelectMode(newMode);\n            if (newMode === 'single' && selectedArtistIds.length > 1) {\n                setArtistIds([selectedArtistIds[0]]);\n            }\n        },\n        [selectedArtistIds, setArtistIds, setArtistSelectMode],\n    );\n\n    const artistIdsMode =\n        (query[FILTER_KEYS.SONG.ARTIST_IDS_MODE] as 'and' | 'or' | undefined) ?? 'and';\n\n    const handleArtistChange = useCallback(\n        (e: null | string[]) => {\n            if (e && e.length > 0) {\n                setArtistIds(e);\n            } else {\n                setArtistIds(null);\n            }\n        },\n        [setArtistIds],\n    );\n\n    const selectedAlbumArtistIds = useMemo(\n        () => (query[FILTER_KEYS.SONG.ALBUM_ARTIST_IDS] as string[] | undefined) ?? [],\n        [query],\n    );\n\n    const handleAlbumArtistSelectModeChange = useCallback(\n        (value: string) => {\n            const newMode = value as 'multi' | 'single';\n            setAlbumArtistSelectMode(newMode);\n            if (newMode === 'single' && selectedAlbumArtistIds.length > 1) {\n                setAlbumArtistIds([selectedAlbumArtistIds[0]]);\n            }\n        },\n        [selectedAlbumArtistIds, setAlbumArtistIds, setAlbumArtistSelectMode],\n    );\n\n    const albumArtistIdsMode =\n        (query[FILTER_KEYS.SONG.ALBUM_ARTIST_IDS_MODE] as 'and' | 'or' | undefined) ?? 'and';\n\n    const handleAlbumArtistChange = useCallback(\n        (e: null | string[]) => {\n            if (e && e.length > 0) {\n                setAlbumArtistIds(e);\n            } else {\n                setAlbumArtistIds(null);\n            }\n        },\n        [setAlbumArtistIds],\n    );\n\n    const queryFavorite = query[FILTER_KEYS.SONG.FAVORITE] as boolean | undefined;\n    const queryHasRating = query[FILTER_KEYS.SONG.HAS_RATING] as boolean | undefined;\n    const queryMinYear = query[FILTER_KEYS.SONG.MIN_YEAR] as number | undefined;\n    const queryMaxYear = query[FILTER_KEYS.SONG.MAX_YEAR] as number | undefined;\n\n    const matchAndLabel = t('filter.matchAnd', { postProcess: 'titleCase' });\n    const matchOrLabel = t('filter.matchOr', { postProcess: 'titleCase' });\n    const filterSingleLabel = t('common.filter_single', { postProcess: 'titleCase' });\n    const filterMultipleLabel = t('common.filter_multiple', { postProcess: 'titleCase' });\n\n    return (\n        <Stack px=\"md\" py=\"md\">\n            <BooleanSegmentFilter\n                label={t('filter.isFavorited', { postProcess: 'sentenceCase' })}\n                onChange={setFavorite}\n                segmentData={segmentedControlData}\n                value={queryFavorite}\n            />\n            <Stack gap=\"xs\" mt=\"md\">\n                <BooleanSegmentFilter\n                    label={t('filter.isRated', { postProcess: 'sentenceCase' })}\n                    onChange={setHasRating}\n                    segmentData={segmentedControlData}\n                    value={queryHasRating}\n                />\n            </Stack>\n            <Divider my=\"md\" />\n            <MultiSelectFilter\n                height={300}\n                label={\n                    <MultiSelectFilterLabel\n                        andOrValue={artistIdsMode}\n                        entityLabel={t('entity.artist', { count: 2, postProcess: 'sentenceCase' })}\n                        filterMultipleLabel={filterMultipleLabel}\n                        filterSingleLabel={filterSingleLabel}\n                        matchAndLabel={matchAndLabel}\n                        matchOrLabel={matchOrLabel}\n                        onAndOrChange={setArtistIdsMode}\n                        onSingleMultiChange={handleArtistSelectModeChange}\n                        showAndOr={artistSelectMode === 'multi'}\n                        singleMultiValue={artistSelectMode}\n                    />\n                }\n                onChange={handleArtistChange}\n                options={artistOptions}\n                RowComponent={ArtistMultiSelectRow}\n                singleSelect={artistSelectMode === 'single'}\n                value={selectedArtistIds}\n            />\n            <Divider my=\"md\" />\n            <MultiSelectFilter\n                height={300}\n                label={\n                    <MultiSelectFilterLabel\n                        andOrValue={albumArtistIdsMode}\n                        entityLabel={t('entity.albumArtist', {\n                            count: 2,\n                            postProcess: 'sentenceCase',\n                        })}\n                        filterMultipleLabel={filterMultipleLabel}\n                        filterSingleLabel={filterSingleLabel}\n                        matchAndLabel={matchAndLabel}\n                        matchOrLabel={matchOrLabel}\n                        onAndOrChange={setAlbumArtistIdsMode}\n                        onSingleMultiChange={handleAlbumArtistSelectModeChange}\n                        showAndOr={albumArtistSelectMode === 'multi'}\n                        singleMultiValue={albumArtistSelectMode}\n                    />\n                }\n                onChange={handleAlbumArtistChange}\n                options={albumArtistOptions}\n                RowComponent={ArtistMultiSelectRow}\n                singleSelect={albumArtistSelectMode === 'single'}\n                value={selectedAlbumArtistIds}\n            />\n            <Divider my=\"md\" />\n            <MultiSelectFilter\n                height={220}\n                label={\n                    <MultiSelectFilterLabel\n                        andOrValue={genreIdsMode}\n                        entityLabel={t('entity.genre', { count: 2, postProcess: 'sentenceCase' })}\n                        filterMultipleLabel={filterMultipleLabel}\n                        filterSingleLabel={filterSingleLabel}\n                        matchAndLabel={matchAndLabel}\n                        matchOrLabel={matchOrLabel}\n                        onAndOrChange={setGenreIdsMode}\n                        onSingleMultiChange={handleGenreSelectModeChange}\n                        showAndOr={genreSelectMode === 'multi'}\n                        singleMultiValue={genreSelectMode}\n                    />\n                }\n                onChange={handleGenreChange}\n                options={genreOptions}\n                RowComponent={GenreMultiSelectRow}\n                singleSelect={genreSelectMode === 'single'}\n                value={selectedGenreIds}\n            />\n            <Divider my=\"md\" />\n            <YearRangeFilter\n                fromYearLabel={t('filter.fromYear', { postProcess: 'titleCase' })}\n                maxYear={queryMaxYear}\n                minYear={queryMinYear}\n                onMaxYear={debouncedHandleMaxYear}\n                onMinYear={debouncedHandleMinYear}\n                toYearLabel={t('filter.toYear', { postProcess: 'titleCase' })}\n            />\n        </Stack>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/playlists/components/create-playlist-form.tsx",
    "content": "import { t } from 'i18next';\nimport { MouseEvent, useRef, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport {\n    PlaylistQueryBuilder,\n    PlaylistQueryBuilderRef,\n} from '/@/renderer/features/playlists/components/playlist-query-builder';\nimport { useCreatePlaylist } from '/@/renderer/features/playlists/mutations/create-playlist-mutation';\nimport { convertQueryGroupToNDQuery } from '/@/renderer/features/playlists/utils';\nimport { useCurrentServer } from '/@/renderer/store';\nimport { hasFeature } from '/@/shared/api/utils';\nimport { Group } from '/@/shared/components/group/group';\nimport { closeAllModals, openModal } from '/@/shared/components/modal/modal';\nimport { ModalButton } from '/@/shared/components/modal/model-shared';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { Switch } from '/@/shared/components/switch/switch';\nimport { TextInput } from '/@/shared/components/text-input/text-input';\nimport { Text } from '/@/shared/components/text/text';\nimport { Textarea } from '/@/shared/components/textarea/textarea';\nimport { toast } from '/@/shared/components/toast/toast';\nimport { useForm } from '/@/shared/hooks/use-form';\nimport {\n    CreatePlaylistBody,\n    ServerListItem,\n    ServerType,\n    SongListSort,\n} from '/@/shared/types/domain-types';\nimport { ServerFeature } from '/@/shared/types/features-types';\n\ninterface CreatePlaylistFormProps {\n    onCancel: () => void;\n}\n\nexport const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {\n    const { t } = useTranslation();\n    const mutation = useCreatePlaylist({});\n    const server = useCurrentServer();\n    const queryBuilderRef = useRef<PlaylistQueryBuilderRef>(null);\n\n    const form = useForm<CreatePlaylistBody>({\n        initialValues: {\n            comment: '',\n            name: '',\n            queryBuilderRules: undefined,\n        },\n    });\n    const [isSmartPlaylist, setIsSmartPlaylist] = useState(false);\n    const [step, setStep] = useState<1 | 2>(1);\n\n    const handleSubmit = form.onSubmit((values) => {\n        if (!server) return;\n\n        // If creating a smart playlist and we're on the first step, advance to step 2\n        // to configure the query instead of submitting immediately.\n        if (isSmartPlaylist && step === 1) {\n            setStep(2);\n            return;\n        }\n\n        const smartPlaylist = queryBuilderRef.current?.getFilters();\n\n        // New syntax: sortBy is now a single string with comma-separated fields and +/- prefix\n        // e.g., \"+album,-year\" means sort by album ascending, then year descending\n        const sortValue =\n            isSmartPlaylist && smartPlaylist?.extraFilters?.sortBy?.[0]\n                ? smartPlaylist.extraFilters.sortBy[0]\n                : undefined;\n\n        const rules =\n            isSmartPlaylist && smartPlaylist?.filters\n                ? {\n                      ...convertQueryGroupToNDQuery(smartPlaylist.filters),\n                      limit: smartPlaylist.extraFilters.limit,\n                      // order field is now optional - sort direction is embedded in sort field\n                      sort: sortValue || '+dateAdded',\n                  }\n                : undefined;\n\n        mutation.mutate(\n            {\n                apiClientProps: { serverId: server.id },\n                body: {\n                    ...values,\n                    ...(rules ? { queryBuilderRules: rules } : {}),\n                },\n            },\n            {\n                onError: (err) => {\n                    toast.error({\n                        message: err.message,\n                        title: t('error.genericError', { postProcess: 'sentenceCase' }),\n                    });\n                },\n                onSuccess: () => {\n                    toast.success({\n                        message: t('form.createPlaylist.success', { postProcess: 'sentenceCase' }),\n                    });\n                    onCancel();\n                },\n            },\n        );\n    });\n\n    const isPublicDisplayed = hasFeature(server, ServerFeature.PUBLIC_PLAYLIST);\n    const isSubmitDisabled = !form.values.name || mutation.isPending;\n\n    return (\n        <form onSubmit={handleSubmit}>\n            <Stack>\n                {step === 1 && (\n                    <>\n                        <TextInput\n                            data-autofocus\n                            label={t('form.createPlaylist.input', {\n                                context: 'name',\n                                postProcess: 'titleCase',\n                            })}\n                            required\n                            {...form.getInputProps('name')}\n                        />\n                        {server?.type === ServerType.NAVIDROME && (\n                            <Textarea\n                                autosize\n                                label={t('form.createPlaylist.input', {\n                                    context: 'description',\n                                    postProcess: 'titleCase',\n                                })}\n                                minRows={5}\n                                {...form.getInputProps('comment')}\n                            />\n                        )}\n                        <Group>\n                            {isPublicDisplayed && (\n                                <Switch\n                                    label={t('form.createPlaylist.input', {\n                                        context: 'public',\n                                        postProcess: 'titleCase',\n                                    })}\n                                    {...form.getInputProps('public', {\n                                        type: 'checkbox',\n                                    })}\n                                />\n                            )}\n                            {server?.type === ServerType.NAVIDROME &&\n                                hasFeature(server, ServerFeature.PLAYLISTS_SMART) && (\n                                    <Switch\n                                        checked={isSmartPlaylist}\n                                        label=\"Is smart playlist?\"\n                                        onChange={(e) => {\n                                            const next = e.currentTarget.checked;\n                                            setIsSmartPlaylist(next);\n                                            if (!next) {\n                                                setStep(1);\n                                            }\n                                        }}\n                                    />\n                                )}\n                        </Group>\n                    </>\n                )}\n\n                {isSmartPlaylist && step === 2 && (\n                    <Stack pt=\"1rem\">\n                        <Text>Query Editor</Text>\n                        <PlaylistQueryBuilder\n                            limit={undefined}\n                            query={undefined}\n                            ref={queryBuilderRef}\n                            sortBy={[SongListSort.ALBUM]}\n                            sortOrder=\"asc\"\n                        />\n                    </Stack>\n                )}\n\n                <Group justify=\"flex-end\">\n                    {isSmartPlaylist && step === 2 && (\n                        <ModalButton onClick={() => setStep(1)} px=\"2xl\" uppercase variant=\"subtle\">\n                            Back\n                        </ModalButton>\n                    )}\n                    <ModalButton onClick={onCancel} px=\"2xl\" uppercase variant=\"subtle\">\n                        {t('common.cancel')}\n                    </ModalButton>\n                    <ModalButton\n                        disabled={isSubmitDisabled}\n                        loading={mutation.isPending}\n                        type=\"submit\"\n                        variant=\"filled\"\n                    >\n                        {isSmartPlaylist && step === 1\n                            ? t('common.confirm', { postProcess: 'sentenceCase' })\n                            : t('common.create')}\n                    </ModalButton>\n                </Group>\n            </Stack>\n        </form>\n    );\n};\n\nexport const openCreatePlaylistModal = (\n    server?: ServerListItem,\n    e?: MouseEvent<HTMLButtonElement>,\n) => {\n    e?.stopPropagation();\n\n    openModal({\n        children: <CreatePlaylistForm onCancel={() => closeAllModals()} />,\n        size: server?.type === ServerType?.NAVIDROME ? 'xl' : 'sm',\n        title: t('form.createPlaylist.title', { postProcess: 'titleCase' }),\n    });\n};\n"
  },
  {
    "path": "src/renderer/features/playlists/components/playlist-detail-album-view.tsx",
    "content": "import { useEffect, useMemo } from 'react';\n\nimport { useGridRows } from '/@/renderer/components/item-list/helpers/use-grid-rows';\nimport { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder';\nimport { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize';\nimport { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist';\nimport { ItemDetailList } from '/@/renderer/components/item-list/item-detail-list/item-detail-list';\nimport { ItemGridList } from '/@/renderer/components/item-list/item-grid-list/item-grid-list';\nimport { ItemListWithPagination } from '/@/renderer/components/item-list/item-list-pagination/item-list-pagination';\nimport { useItemListPagination } from '/@/renderer/components/item-list/item-list-pagination/use-item-list-pagination';\nimport { ItemTableList } from '/@/renderer/components/item-list/item-table-list/item-table-list';\nimport { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';\nimport { DefaultItemControlProps, ItemControls } from '/@/renderer/components/item-list/types';\nimport { useListContext } from '/@/renderer/context/list-context';\nimport { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';\nimport { usePlayer } from '/@/renderer/features/player/context/player-context';\nimport { usePlaylistSongListFilters } from '/@/renderer/features/playlists/hooks/use-playlist-song-list-filters';\nimport { applyClientSideSongFilters } from '/@/renderer/features/playlists/hooks/use-playlist-track-list';\nimport { type PlaylistAlbumRow, playlistSongsToAlbums } from '/@/renderer/features/playlists/utils';\nimport { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-search-term-filter';\nimport { searchLibraryItems } from '/@/renderer/features/shared/utils';\nimport { useGeneralSettings, useListSettings } from '/@/renderer/store';\nimport { sortSongList } from '/@/shared/api/utils';\nimport {\n    LibraryItem,\n    PlaylistSongListResponse,\n    Song,\n    SongListSort,\n    SortOrder,\n} from '/@/shared/types/domain-types';\nimport {\n    ItemListKey,\n    ListDisplayType,\n    ListPaginationType,\n    Play,\n    TableColumn,\n} from '/@/shared/types/types';\n\nexport const PlaylistDetailAlbumView = ({ data }: { data: PlaylistSongListResponse }) => {\n    const player = usePlayer();\n    const { setItemCount, setListData } = useListContext();\n    const { detail, display, grid, itemsPerPage, pagination, table } = useListSettings(\n        ItemListKey.PLAYLIST_ALBUM,\n    );\n    const { enableGridMultiSelect } = useGeneralSettings();\n    const { currentPage, onChange: onPageChange } = useItemListPagination();\n    const { searchTerm } = useSearchTermFilter();\n    const { query } = usePlaylistSongListFilters();\n\n    const filteredAndSortedSongs = useMemo(() => {\n        const raw = data?.items ?? [];\n        const filtered = applyClientSideSongFilters(raw, query as Record<string, unknown>);\n\n        const searched = searchTerm?.trim()\n            ? searchLibraryItems(filtered, searchTerm, LibraryItem.SONG)\n            : filtered;\n\n        return sortSongList(\n            searched,\n            (query.sortBy as SongListSort) ?? SongListSort.ID,\n            (query.sortOrder as SortOrder) ?? SortOrder.ASC,\n        );\n    }, [data?.items, query, searchTerm]);\n\n    const sortedAlbums = useMemo(\n        () => playlistSongsToAlbums(filteredAndSortedSongs),\n        [filteredAndSortedSongs],\n    );\n\n    const isPaginated = pagination === ListPaginationType.PAGINATED;\n    const totalAlbumCount = sortedAlbums.length;\n    const albumPageCount = Math.max(1, Math.ceil(totalAlbumCount / itemsPerPage));\n    const paginatedAlbums = useMemo(() => {\n        if (!isPaginated) return sortedAlbums;\n        const start = currentPage * itemsPerPage;\n        return sortedAlbums.slice(start, start + itemsPerPage);\n    }, [isPaginated, currentPage, itemsPerPage, sortedAlbums]);\n    const albumsToRender = isPaginated ? paginatedAlbums : sortedAlbums;\n\n    const playlistSongs = useMemo(() => data?.items ?? [], [data?.items]);\n\n    const albumControlOverrides = useMemo<Partial<ItemControls>>(() => {\n        return {\n            onFavorite: undefined,\n            onMore: ({ event, internalState, item }: DefaultItemControlProps) => {\n                if (!event) return;\n\n                const selected = internalState?.getSelected();\n\n                if (selected?.length === 0 && !item) {\n                    return;\n                }\n\n                let itemsToUse: (PlaylistAlbumRow | Song)[];\n                if ((selected?.length ?? 0) > 0) {\n                    itemsToUse = selected as (PlaylistAlbumRow | Song)[];\n                } else {\n                    itemsToUse = [item as PlaylistAlbumRow | Song];\n                }\n\n                const songs: Song[] = [];\n                for (const item of itemsToUse) {\n                    if (item._itemType === LibraryItem.ALBUM) {\n                        songs.push(...((item as PlaylistAlbumRow)._playlistSongs ?? []));\n                    } else if (item._itemType === LibraryItem.SONG) {\n                        songs.push(item as Song);\n                    }\n                }\n\n                ContextMenuController.call({\n                    cmd: { items: songs, type: LibraryItem.PLAYLIST_SONG },\n                    event,\n                });\n            },\n            onPlay: ({\n                item,\n                itemType,\n                playType,\n            }: DefaultItemControlProps & { playType: Play }) => {\n                if (!item) return;\n\n                const rowSongs = (item as PlaylistAlbumRow)._playlistSongs;\n                if (itemType === LibraryItem.ALBUM && rowSongs?.length) {\n                    player.addToQueueByData(rowSongs, playType);\n                    return;\n                }\n                player.addToQueueByFetch(item._serverId, [item.id], itemType, playType);\n            },\n            onRating: undefined,\n        };\n    }, [player]);\n\n    useEffect(() => {\n        setItemCount?.(totalAlbumCount);\n    }, [setItemCount, totalAlbumCount]);\n\n    useEffect(() => {\n        setListData?.(filteredAndSortedSongs);\n    }, [filteredAndSortedSongs, setListData]);\n\n    const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({ enabled: true });\n    const { handleColumnReordered } = useItemListColumnReorder({\n        itemListKey: ItemListKey.PLAYLIST_ALBUM,\n    });\n    const { handleColumnResized } = useItemListColumnResize({\n        itemListKey: ItemListKey.PLAYLIST_ALBUM,\n    });\n    const { handleColumnReordered: handleDetailColumnReordered } = useItemListColumnReorder({\n        itemListKey: ItemListKey.PLAYLIST_ALBUM,\n        tableKey: 'detail',\n    });\n    const { handleColumnResized: handleDetailColumnResized } = useItemListColumnResize({\n        itemListKey: ItemListKey.PLAYLIST_ALBUM,\n        tableKey: 'detail',\n    });\n    const rows = useGridRows(LibraryItem.ALBUM, ItemListKey.PLAYLIST_ALBUM, grid.size);\n\n    const tableColumns = useMemo(() => {\n        return table.columns.filter(\n            (column) =>\n                column.id !== TableColumn.USER_FAVORITE && column.id !== TableColumn.USER_RATING,\n        );\n    }, [table.columns]);\n\n    const renderAlbumList = () => {\n        switch (display) {\n            case ListDisplayType.DETAIL:\n                return (\n                    <ItemDetailList\n                        enableHeader={detail?.enableHeader}\n                        items={albumsToRender}\n                        listKey={ItemListKey.PLAYLIST_ALBUM}\n                        onColumnReordered={handleDetailColumnReordered}\n                        onColumnResized={handleDetailColumnResized}\n                        onScrollEnd={handleOnScrollEnd}\n                        onSongRowDoubleClick={({ internalState, item }) => {\n                            if (playlistSongs.length === 0) return;\n                            internalState?.setSelected([item]);\n                            player.addToQueueByData(playlistSongs, Play.NOW, item.id);\n                        }}\n                        overrideControls={albumControlOverrides}\n                        scrollOffset={scrollOffset ?? 0}\n                        songsByAlbumId={{}}\n                        tableId=\"album-detail\"\n                    />\n                );\n            case ListDisplayType.GRID:\n                return (\n                    <ItemGridList\n                        data={albumsToRender}\n                        enableExpansion\n                        enableMultiSelect={enableGridMultiSelect}\n                        gap={grid.itemGap}\n                        initialTop={{\n                            to: scrollOffset ?? 0,\n                            type: 'offset',\n                        }}\n                        itemsPerRow={grid.itemsPerRowEnabled ? grid.itemsPerRow : undefined}\n                        itemType={LibraryItem.ALBUM}\n                        onScrollEnd={handleOnScrollEnd}\n                        overrideControls={albumControlOverrides}\n                        rows={rows}\n                        size={grid.size}\n                    />\n                );\n            case ListDisplayType.TABLE:\n                return (\n                    <ItemTableList\n                        autoFitColumns={table.autoFitColumns}\n                        CellComponent={ItemTableListColumn}\n                        columns={tableColumns}\n                        data={albumsToRender}\n                        enableAlternateRowColors={table.enableAlternateRowColors}\n                        enableHeader={table.enableHeader}\n                        enableHorizontalBorders={table.enableHorizontalBorders}\n                        enableRowHoverHighlight={table.enableRowHoverHighlight}\n                        enableSelection\n                        enableVerticalBorders={table.enableVerticalBorders}\n                        initialTop={{\n                            to: scrollOffset ?? 0,\n                            type: 'offset',\n                        }}\n                        itemType={LibraryItem.ALBUM}\n                        onColumnReordered={handleColumnReordered}\n                        onColumnResized={handleColumnResized}\n                        onScrollEnd={handleOnScrollEnd}\n                        overrideControls={albumControlOverrides}\n                        size={table.size}\n                    />\n                );\n            default:\n                return null;\n        }\n    };\n\n    if (isPaginated) {\n        return (\n            <ItemListWithPagination\n                currentPage={currentPage}\n                itemsPerPage={itemsPerPage}\n                onChange={onPageChange}\n                pageCount={albumPageCount}\n                totalItemCount={totalAlbumCount}\n            >\n                {renderAlbumList()}\n            </ItemListWithPagination>\n        );\n    }\n\n    return renderAlbumList();\n};\n"
  },
  {
    "path": "src/renderer/features/playlists/components/playlist-detail-song-list-content.tsx",
    "content": "import { useQueryClient, useSuspenseQuery } from '@tanstack/react-query';\nimport { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react';\nimport { useParams } from 'react-router';\n\nimport { useItemListPagination } from '/@/renderer/components/item-list/item-list-pagination/use-item-list-pagination';\nimport { ItemListHandle } from '/@/renderer/components/item-list/types';\nimport { useListContext } from '/@/renderer/context/list-context';\nimport { eventEmitter } from '/@/renderer/events/event-emitter';\nimport { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';\nimport { PlaylistDetailAlbumView } from '/@/renderer/features/playlists/components/playlist-detail-album-view';\nimport { usePlaylistTrackList } from '/@/renderer/features/playlists/hooks/use-playlist-track-list';\nimport { useCurrentServer, useListSettings } from '/@/renderer/store';\nimport { Spinner } from '/@/shared/components/spinner/spinner';\nimport {\n    LibraryItem,\n    PlaylistSongListQuery,\n    PlaylistSongListResponse,\n    Song,\n} from '/@/shared/types/domain-types';\nimport {\n    ItemListKey,\n    ListDisplayType,\n    ListPaginationType,\n    TableColumn,\n} from '/@/shared/types/types';\n\nconst PlaylistDetailSongListTable = lazy(() =>\n    import('/@/renderer/features/playlists/components/playlist-detail-song-list-table').then(\n        (module) => ({\n            default: module.PlaylistDetailSongListTable,\n        }),\n    ),\n);\n\nconst PlaylistDetailSongListEditTable = lazy(() =>\n    import('/@/renderer/features/playlists/components/playlist-detail-song-list-table').then(\n        (module) => ({\n            default: module.PlaylistDetailSongListEditTable,\n        }),\n    ),\n);\n\nconst PlaylistDetailSongListGrid = lazy(() =>\n    import('/@/renderer/features/playlists/components/playlist-detail-song-list-grid').then(\n        (module) => ({\n            default: module.PlaylistDetailSongListGrid,\n        }),\n    ),\n);\n\nexport const PlaylistDetailSongListContent = () => {\n    const { playlistId } = useParams() as { playlistId: string };\n    const server = useCurrentServer();\n    const queryClient = useQueryClient();\n\n    const playlistSongsQuery = useSuspenseQuery(\n        playlistsQueries.songList({\n            query: {\n                id: playlistId,\n            },\n            serverId: server?.id,\n        }),\n    );\n\n    useEffect(() => {\n        const handleRefresh = async (payload: { key: string }) => {\n            if (\n                payload.key !== ItemListKey.PLAYLIST_SONG &&\n                payload.key !== ItemListKey.PLAYLIST_ALBUM\n            ) {\n                return;\n            }\n\n            const queryKey = playlistsQueries.songList({\n                query: {\n                    id: playlistId,\n                },\n                serverId: server?.id,\n            }).queryKey;\n\n            await queryClient.invalidateQueries({ queryKey });\n            await queryClient.refetchQueries({ queryKey });\n        };\n\n        eventEmitter.on('ITEM_LIST_REFRESH', handleRefresh);\n\n        return () => {\n            eventEmitter.off('ITEM_LIST_REFRESH', handleRefresh);\n        };\n    }, [playlistId, queryClient, server?.id]);\n\n    return (\n        <Suspense fallback={<Spinner container />}>\n            <PlaylistDetailSongList data={playlistSongsQuery.data} />\n        </Suspense>\n    );\n};\n\nexport type OverridePlaylistSongListQuery = Omit<Partial<PlaylistSongListQuery>, 'id'>;\n\ninterface PlaylistDetailSongListViewProps {\n    data: PlaylistSongListResponse;\n    items?: Song[];\n}\n\nexport const PlaylistDetailSongListView = ({ data, items }: PlaylistDetailSongListViewProps) => {\n    const server = useCurrentServer();\n    const { display, itemsPerPage, pagination, table } = useListSettings(ItemListKey.PLAYLIST_SONG);\n    const { currentPage, onChange: onPageChange } = useItemListPagination();\n    const isPaginated = pagination === ListPaginationType.PAGINATED;\n\n    const paginationProps = isPaginated\n        ? {\n              currentPage,\n              itemsPerPage,\n              onPageChange,\n          }\n        : undefined;\n\n    switch (display) {\n        case ListDisplayType.GRID: {\n            return (\n                <PlaylistDetailSongListGrid\n                    data={data}\n                    items={items}\n                    serverId={server.id}\n                    {...paginationProps}\n                />\n            );\n        }\n        case ListDisplayType.TABLE: {\n            return (\n                <PlaylistDetailSongListTable\n                    autoFitColumns={table.autoFitColumns}\n                    columns={table.columns}\n                    data={data}\n                    enableAlternateRowColors={table.enableAlternateRowColors}\n                    enableHeader={table.enableHeader}\n                    enableHorizontalBorders={table.enableHorizontalBorders}\n                    enableRowHoverHighlight={table.enableRowHoverHighlight}\n                    enableVerticalBorders={table.enableVerticalBorders}\n                    items={items}\n                    serverId={server.id}\n                    size={table.size}\n                    {...paginationProps}\n                />\n            );\n        }\n        default:\n            return null;\n    }\n};\n\nexport const PlaylistDetailSongListEdit = ({ data }: { data: PlaylistSongListResponse }) => {\n    const { playlistId } = useParams() as { playlistId: string };\n    const server = useCurrentServer();\n    const { display, table } = useListSettings(ItemListKey.PLAYLIST_SONG);\n\n    const [localData, setLocalData] = useState<PlaylistSongListResponse>(data);\n\n    const tableRef = useRef<ItemListHandle | null>(null);\n\n    // Listen for playlist reorder events\n    useEffect(() => {\n        const handleReorder = (payload: {\n            edge: 'bottom' | 'top' | null;\n            playlistId: string;\n            sourceIds: string[];\n            targetId: string;\n        }) => {\n            // Only handle events for this playlist\n            if (payload.playlistId !== playlistId) {\n                return;\n            }\n\n            setLocalData((prev) => {\n                if (!prev?.items || !payload.edge) {\n                    return prev;\n                }\n\n                // Create a list of IDs in current order\n                const currentIds = prev.items.map((item) => item.id);\n\n                // Find the target index\n                const targetIndex = currentIds.indexOf(payload.targetId);\n                if (targetIndex === -1) {\n                    return prev;\n                }\n\n                // Remove all source IDs from their current positions\n                const idsWithoutSources = currentIds.filter(\n                    (id) => !payload.sourceIds.includes(id),\n                );\n\n                // Calculate the insertion index based on the original target position\n                const sourcesBeforeTarget = payload.sourceIds.filter((id) => {\n                    const sourceIndex = currentIds.indexOf(id);\n                    return sourceIndex !== -1 && sourceIndex < targetIndex;\n                }).length;\n\n                // Calculate the insert index in the filtered list\n                const insertIndexInFiltered =\n                    payload.edge === 'top'\n                        ? targetIndex - sourcesBeforeTarget\n                        : targetIndex - sourcesBeforeTarget + 1;\n\n                // Ensure insertIndex is within bounds\n                const insertIndex = Math.max(\n                    0,\n                    Math.min(insertIndexInFiltered, idsWithoutSources.length),\n                );\n\n                // Insert source IDs at the calculated position\n                const reorderedIds = [\n                    ...idsWithoutSources.slice(0, insertIndex),\n                    ...payload.sourceIds,\n                    ...idsWithoutSources.slice(insertIndex),\n                ];\n\n                // Create a map for quick lookup\n                const itemMap = new Map(prev.items.map((item) => [item.id, item]));\n\n                // Reorder items based on new ID order\n                const reorderedItems = reorderedIds\n                    .map((id) => itemMap.get(id))\n                    .filter((item): item is NonNullable<typeof item> => item !== undefined);\n\n                return {\n                    ...prev,\n                    items: reorderedItems,\n                };\n            });\n        };\n\n        eventEmitter.on('PLAYLIST_REORDER', handleReorder);\n\n        return () => {\n            eventEmitter.off('PLAYLIST_REORDER', handleReorder);\n        };\n    }, [playlistId]);\n\n    const columns = useMemo(() => {\n        return [\n            {\n                align: 'center' as 'center' | 'end' | 'start',\n                id: TableColumn.PLAYLIST_REORDER,\n                isEnabled: true,\n                pinned: 'left' as 'left' | 'right' | null,\n                width: 100,\n            },\n            ...table.columns,\n        ];\n    }, [table.columns]);\n\n    const { setListData } = useListContext();\n\n    useEffect(() => {\n        setListData?.(localData.items);\n    }, [localData, setListData]);\n\n    switch (display) {\n        case ListDisplayType.GRID:\n        case ListDisplayType.TABLE: {\n            return (\n                <PlaylistDetailSongListEditTable\n                    autoFitColumns={table.autoFitColumns}\n                    columns={columns}\n                    data={localData}\n                    enableAlternateRowColors={table.enableAlternateRowColors}\n                    enableHeader={table.enableHeader}\n                    enableHorizontalBorders={table.enableHorizontalBorders}\n                    enableRowHoverHighlight={table.enableRowHoverHighlight}\n                    enableVerticalBorders={table.enableVerticalBorders}\n                    ref={tableRef}\n                    serverId={server.id}\n                    size={table.size}\n                />\n            );\n        }\n        default:\n            return null;\n    }\n};\n\nconst PlaylistDetailTrackView = ({ data }: { data: PlaylistSongListResponse }) => {\n    const { isSmartPlaylist, mode } = useListContext();\n\n    if (isSmartPlaylist) {\n        return <PlaylistDetailTrackViewContent data={data} />;\n    }\n\n    if (mode === 'edit') {\n        return <PlaylistDetailSongListEdit data={data} />;\n    }\n\n    return <PlaylistDetailTrackViewContent data={data} />;\n};\n\nconst PlaylistDetailTrackViewContent = ({ data }: { data: PlaylistSongListResponse }) => {\n    const { sortedAndFilteredSongs } = usePlaylistTrackList(data);\n    return <PlaylistDetailSongListView data={data} items={sortedAndFilteredSongs} />;\n};\n\nconst PlaylistDetailSongList = ({ data }: { data: PlaylistSongListResponse }) => {\n    const { displayMode } = useListContext();\n\n    if (displayMode === LibraryItem.ALBUM) {\n        return <PlaylistDetailAlbumView data={data} />;\n    }\n\n    return <PlaylistDetailTrackView data={data} />;\n};\n"
  },
  {
    "path": "src/renderer/features/playlists/components/playlist-detail-song-list-grid.tsx",
    "content": "import { forwardRef, useMemo } from 'react';\nimport { useEffect } from 'react';\n\nimport { useGridRows } from '/@/renderer/components/item-list/helpers/use-grid-rows';\nimport { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist';\nimport { ItemGridList } from '/@/renderer/components/item-list/item-grid-list/item-grid-list';\nimport { ItemListWithPagination } from '/@/renderer/components/item-list/item-list-pagination/item-list-pagination';\nimport { ItemListGridComponentProps } from '/@/renderer/components/item-list/types';\nimport { useListContext } from '/@/renderer/context/list-context';\nimport { usePlaylistSongListFilters } from '/@/renderer/features/playlists/hooks/use-playlist-song-list-filters';\nimport { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-search-term-filter';\nimport { searchLibraryItems } from '/@/renderer/features/shared/utils';\nimport { useGeneralSettings, useListSettings } from '/@/renderer/store';\nimport { sortSongList } from '/@/shared/api/utils';\nimport {\n    LibraryItem,\n    PlaylistSongListQuery,\n    PlaylistSongListResponse,\n    Song,\n} from '/@/shared/types/domain-types';\nimport { ItemListKey } from '/@/shared/types/types';\n\ninterface PlaylistDetailSongListGridProps\n    extends Omit<ItemListGridComponentProps<PlaylistSongListQuery>, 'query'> {\n    currentPage?: number;\n    data: PlaylistSongListResponse;\n    items?: Song[];\n    itemsPerPage?: number;\n    onPageChange?: (page: number) => void;\n}\n\nexport const PlaylistDetailSongListGrid = forwardRef<any, PlaylistDetailSongListGridProps>(\n    ({\n        currentPage,\n        data,\n        items: itemsProp,\n        itemsPerPage,\n        onPageChange,\n        saveScrollOffset = true,\n    }) => {\n        const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({\n            enabled: saveScrollOffset,\n        });\n\n        const { searchTerm } = useSearchTermFilter();\n        const { query } = usePlaylistSongListFilters();\n\n        const songDataFromData = useMemo(() => {\n            let list = data?.items || [];\n            if (searchTerm) {\n                list = searchLibraryItems(list, searchTerm, LibraryItem.SONG);\n                return list;\n            }\n            return sortSongList(list, query.sortBy, query.sortOrder);\n        }, [data?.items, searchTerm, query.sortBy, query.sortOrder]);\n\n        const { setListData } = useListContext();\n        const songData = itemsProp ?? songDataFromData;\n\n        useEffect(() => {\n            if (itemsProp == null && setListData) {\n                setListData(songDataFromData);\n            }\n        }, [itemsProp, songDataFromData, setListData]);\n\n        const gridProps = useListSettings(ItemListKey.PLAYLIST_SONG).grid;\n\n        const rows = useGridRows(\n            LibraryItem.PLAYLIST_SONG,\n            ItemListKey.PLAYLIST_SONG,\n            gridProps.size,\n        );\n        const { enableGridMultiSelect } = useGeneralSettings();\n\n        const isPaginated =\n            typeof currentPage === 'number' &&\n            typeof itemsPerPage === 'number' &&\n            typeof onPageChange === 'function';\n        const totalCount = songData.length;\n        const pageCount = Math.max(1, Math.ceil(totalCount / (itemsPerPage ?? 1)));\n        const paginatedData = useMemo(() => {\n            if (!isPaginated || currentPage == null || itemsPerPage == null) return songData;\n            const start = currentPage * itemsPerPage;\n            return songData.slice(start, start + itemsPerPage);\n        }, [currentPage, isPaginated, itemsPerPage, songData]);\n        const dataToRender = isPaginated ? paginatedData : songData;\n\n        const grid = (\n            <ItemGridList\n                data={dataToRender}\n                enableMultiSelect={enableGridMultiSelect}\n                gap={gridProps.itemGap}\n                initialTop={{\n                    to: scrollOffset ?? 0,\n                    type: 'offset',\n                }}\n                itemsPerRow={gridProps.itemsPerRowEnabled ? gridProps.itemsPerRow : undefined}\n                itemType={LibraryItem.PLAYLIST_SONG}\n                onScrollEnd={handleOnScrollEnd}\n                rows={rows}\n                size={gridProps.size}\n            />\n        );\n\n        if (isPaginated && itemsPerPage != null) {\n            return (\n                <ItemListWithPagination\n                    currentPage={currentPage!}\n                    itemsPerPage={itemsPerPage}\n                    onChange={onPageChange!}\n                    pageCount={pageCount}\n                    totalItemCount={totalCount}\n                >\n                    {grid}\n                </ItemListWithPagination>\n            );\n        }\n\n        return grid;\n    },\n);\n"
  },
  {
    "path": "src/renderer/features/playlists/components/playlist-detail-song-list-header-filters.tsx",
    "content": "import { openContextModal } from '@mantine/modals';\nimport { useQuery } from '@tanstack/react-query';\nimport { useCallback, useMemo } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { useParams } from 'react-router';\n\nimport i18n from '/@/i18n/i18n';\nimport {\n    ALBUM_TABLE_COLUMNS,\n    PLAYLIST_SONG_TABLE_COLUMNS,\n    SONG_TABLE_COLUMNS,\n} from '/@/renderer/components/item-list/item-table-list/default-columns';\nimport { useListContext } from '/@/renderer/context/list-context';\nimport { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';\nimport { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';\nimport { ClientSideSongFilters } from '/@/renderer/features/playlists/components/client-side-song-filters';\nimport { usePlaylistSongListFilters } from '/@/renderer/features/playlists/hooks/use-playlist-song-list-filters';\nimport { FilterButton } from '/@/renderer/features/shared/components/filter-button';\nimport {\n    ListConfigMenu,\n    SONG_DISPLAY_TYPES,\n} from '/@/renderer/features/shared/components/list-config-menu';\nimport { ListDisplayTypeToggleButton } from '/@/renderer/features/shared/components/list-display-type-toggle-button';\nimport { isFilterValueSet } from '/@/renderer/features/shared/components/list-filters';\nimport { ListRefreshButton } from '/@/renderer/features/shared/components/list-refresh-button';\nimport { ListSortByDropdown } from '/@/renderer/features/shared/components/list-sort-by-dropdown';\nimport { ListSortOrderToggleButton } from '/@/renderer/features/shared/components/list-sort-order-toggle-button';\nimport { MoreButton } from '/@/renderer/features/shared/components/more-button';\nimport { FILTER_KEYS } from '/@/renderer/features/shared/utils';\nimport { useContainerQuery } from '/@/renderer/hooks';\nimport {\n    PlaylistTarget,\n    useCurrentServerId,\n    usePlaylistTarget,\n    useSettingsStoreActions,\n} from '/@/renderer/store';\nimport { ActionIcon } from '/@/shared/components/action-icon/action-icon';\nimport { Button } from '/@/shared/components/button/button';\nimport { Divider } from '/@/shared/components/divider/divider';\nimport { Flex } from '/@/shared/components/flex/flex';\nimport { Group } from '/@/shared/components/group/group';\nimport { Icon } from '/@/shared/components/icon/icon';\nimport { Modal } from '/@/shared/components/modal/modal';\nimport { Tooltip } from '/@/shared/components/tooltip/tooltip';\nimport { useDisclosure } from '/@/shared/hooks/use-disclosure';\nimport { useLocalStorage } from '/@/shared/hooks/use-local-storage';\nimport {\n    LibraryItem,\n    Playlist,\n    SongListSort,\n    SortOrder,\n    UpdatePlaylistBody,\n} from '/@/shared/types/domain-types';\nimport { ItemListKey } from '/@/shared/types/types';\n\ninterface PlaylistDetailSongListHeaderFiltersProps {\n    isSmartPlaylist?: boolean;\n}\n\nconst PlaylistSongListFiltersModal = () => {\n    const { t } = useTranslation();\n    const { isSidebarOpen, setIsSidebarOpen } = useListContext();\n    const { clear, query } = usePlaylistSongListFilters();\n    const [isOpen, handlers] = useDisclosure(false);\n\n    const hasActiveFilters = useMemo(() => {\n        return Boolean(\n            isFilterValueSet(query[FILTER_KEYS.SONG.ALBUM_ARTIST_IDS]) ||\n                isFilterValueSet(query[FILTER_KEYS.SONG.ARTIST_IDS]) ||\n                query[FILTER_KEYS.SONG.FAVORITE] !== undefined ||\n                isFilterValueSet(query[FILTER_KEYS.SONG.GENRE_ID]) ||\n                query[FILTER_KEYS.SONG.HAS_RATING] !== undefined ||\n                query[FILTER_KEYS.SONG.MAX_YEAR] !== undefined ||\n                query[FILTER_KEYS.SONG.MIN_YEAR] !== undefined,\n        );\n    }, [query]);\n\n    const handlePin = () => {\n        setIsSidebarOpen?.(!isSidebarOpen);\n    };\n\n    const canPin = Boolean(setIsSidebarOpen);\n\n    return (\n        <>\n            <FilterButton isActive={hasActiveFilters} onClick={handlers.toggle} />\n            <Modal\n                handlers={handlers}\n                opened={isOpen}\n                size=\"lg\"\n                styles={{\n                    content: {\n                        height: '100%',\n                        maxHeight: '640px',\n                        maxWidth: 'var(--theme-content-max-width)',\n                        width: '100%',\n                    },\n                }}\n                title={\n                    <Group justify=\"space-between\" style={{ paddingRight: '3rem', width: '100%' }}>\n                        <Group>\n                            {canPin && (\n                                <ActionIcon\n                                    icon={isSidebarOpen ? 'unpin' : 'pin'}\n                                    onClick={handlePin}\n                                    variant=\"subtle\"\n                                />\n                            )}\n                            {t('common.filters', { postProcess: 'sentenceCase' })}\n                        </Group>\n                        <Button onClick={clear} size=\"compact-sm\" variant=\"subtle\">\n                            {t('common.reset', { postProcess: 'sentenceCase' })}\n                        </Button>\n                    </Group>\n                }\n            >\n                <ClientSideSongFilters />\n            </Modal>\n        </>\n    );\n};\n\nexport const PlaylistDetailSongListHeaderFilters = ({\n    isSmartPlaylist,\n}: PlaylistDetailSongListHeaderFiltersProps) => {\n    const { t } = useTranslation();\n    const { listKey: listKeyFromContext, mode, setMode } = useListContext();\n    const { playlistId } = useParams() as { playlistId: string };\n    const playlistTarget = usePlaylistTarget();\n    const { setPlaylistBehavior } = useSettingsStoreActions();\n    const serverId = useCurrentServerId();\n\n    const detailQuery = useQuery(playlistsQueries.detail({ query: { id: playlistId }, serverId }));\n\n    const handleMore = (event: React.MouseEvent<HTMLButtonElement>) => {\n        if (!detailQuery.data) return;\n\n        ContextMenuController.call({\n            cmd: {\n                items: [detailQuery.data],\n                type: LibraryItem.PLAYLIST,\n            },\n            event,\n        });\n    };\n\n    const listKey =\n        listKeyFromContext ??\n        (playlistTarget === PlaylistTarget.ALBUM\n            ? ItemListKey.PLAYLIST_ALBUM\n            : ItemListKey.PLAYLIST_SONG);\n    const isAlbumMode = listKey === ItemListKey.PLAYLIST_ALBUM;\n    const toggleChoice = isAlbumMode\n        ? t('entity.album', { count: 2, postProcess: 'titleCase' })\n        : t('entity.track', { count: 2, postProcess: 'titleCase' });\n\n    const handleToggleDisplayMode = useCallback(() => {\n        setPlaylistBehavior(\n            playlistTarget === PlaylistTarget.ALBUM ? PlaylistTarget.TRACK : PlaylistTarget.ALBUM,\n        );\n    }, [playlistTarget, setPlaylistBehavior]);\n\n    const { ref: containerRef, ...breakpoints } = useContainerQuery();\n\n    const isViewEditMode = !isSmartPlaylist && (breakpoints.isSm || isAlbumMode);\n    const isEditMode = mode === 'edit';\n\n    const [collapsed, setCollapsed] = useLocalStorage<boolean>({\n        defaultValue: false,\n        key: 'playlist-header-collapsed',\n    });\n\n    return (\n        <Flex justify=\"space-between\" ref={containerRef}>\n            <Group gap=\"sm\" w=\"100%\">\n                <Button\n                    leftSection={<Icon icon=\"arrowLeftRight\" />}\n                    onClick={handleToggleDisplayMode}\n                    variant=\"subtle\"\n                >\n                    {toggleChoice}\n                </Button>\n                <Divider orientation=\"vertical\" />\n                <ListSortByDropdown\n                    defaultSortByValue={SongListSort.ID}\n                    disabled={isEditMode}\n                    itemType={LibraryItem.PLAYLIST_SONG}\n                    listKey={ItemListKey.PLAYLIST_SONG}\n                />\n                <Divider orientation=\"vertical\" />\n                <ListSortOrderToggleButton\n                    defaultSortOrder={SortOrder.ASC}\n                    disabled={isEditMode}\n                    listKey={ItemListKey.PLAYLIST_SONG}\n                />\n                <Divider orientation=\"vertical\" />\n                <PlaylistSongListFiltersModal />\n                <ListRefreshButton disabled={isEditMode} listKey={listKey} />\n                <MoreButton onClick={handleMore} />\n            </Group>\n            <Group gap=\"sm\" wrap=\"nowrap\">\n                {isViewEditMode && <SaveAndReplaceButton mode={mode} playlist={detailQuery.data} />}\n                {isViewEditMode && (\n                    <Button\n                        onClick={() => setMode?.(mode === 'edit' ? 'view' : 'edit')}\n                        uppercase\n                        variant=\"subtle\"\n                    >\n                        {mode === 'edit'\n                            ? t('common.view', { postProcess: 'titleCase' })\n                            : t('common.edit', { postProcess: 'titleCase' })}\n                    </Button>\n                )}\n                <Tooltip\n                    label={t(`common.${collapsed ? 'expand' : 'collapse'}`, {\n                        postProcess: 'titleCase',\n                    })}\n                >\n                    <ActionIcon\n                        icon={collapsed ? 'arrowDownS' : 'arrowUpS'}\n                        iconProps={{ size: 'xl' }}\n                        onClick={() => setCollapsed((prev) => !prev)}\n                        variant=\"subtle\"\n                    />\n                </Tooltip>\n                <ListDisplayTypeToggleButton enableDetail={isAlbumMode} listKey={listKey} />\n                {isAlbumMode ? (\n                    <ListConfigMenu\n                        detailConfig={{\n                            optionsConfig: {\n                                autoFitColumns: { hidden: true },\n                            },\n                            tableColumnsData: SONG_TABLE_COLUMNS,\n                            tableKey: 'detail',\n                        }}\n                        listKey={listKey}\n                        tableColumnsData={ALBUM_TABLE_COLUMNS}\n                    />\n                ) : (\n                    <ListConfigMenu\n                        displayTypes={SONG_DISPLAY_TYPES}\n                        listKey={listKey}\n                        tableColumnsData={PLAYLIST_SONG_TABLE_COLUMNS}\n                    />\n                )}\n            </Group>\n        </Flex>\n    );\n};\n\nexport const openSaveAndReplaceModal = (playlistId: string, updateBody: UpdatePlaylistBody) => {\n    openContextModal({\n        innerProps: { playlistId, updateBody },\n        modalKey: 'saveAndReplace',\n        size: 'sm',\n        title: i18n.t('common.saveAndReplace', { postProcess: 'titleCase' }) as string,\n    });\n};\n\nconst SaveAndReplaceButton = ({\n    mode,\n    playlist,\n}: {\n    mode: 'edit' | 'view' | undefined;\n    playlist: Playlist | undefined;\n}) => {\n    const { t } = useTranslation();\n    const { playlistId } = useParams() as { playlistId: string };\n\n    const handleOpenModal = useCallback(() => {\n        if (!playlistId || !playlist) return;\n\n        const updateBody: UpdatePlaylistBody = {\n            comment: playlist.description ?? '',\n            name: playlist.name,\n            ownerId: playlist.ownerId ?? '',\n            public: playlist.public ?? false,\n            queryBuilderRules: playlist.rules ?? undefined,\n            sync: playlist.sync ?? false,\n        };\n\n        openSaveAndReplaceModal(playlistId, updateBody);\n    }, [playlistId, playlist]);\n\n    if (mode === 'view') {\n        return null;\n    }\n\n    return (\n        <Button\n            leftSection={<Icon color=\"error\" icon=\"save\" />}\n            onClick={handleOpenModal}\n            size=\"sm\"\n            variant=\"subtle\"\n        >\n            {t('common.saveAndReplace', { postProcess: 'titleCase' })}\n        </Button>\n    );\n};\n// const GenreFilterSelection = () => {\n//     const { t } = useTranslation();\n//     const { playlistId } = useParams() as { playlistId: string };\n//     const serverId = useCurrentServerId();\n\n//     const { data } = useQuery(playlistsQueries.songList({ query: { id: playlistId }, serverId }));\n\n//     const genres = useMemo(() => {\n//         const uniqueGenres = new Map<string, string>();\n\n//         data?.items.forEach((song) => {\n//             song.genres.forEach((genre) => {\n//                 if (genre.id) {\n//                     uniqueGenres.set(genre.id, genre.name);\n//                 }\n//             });\n//         });\n\n//         return Array.from(uniqueGenres.entries()).map(([id, name]) => ({\n//             label: name,\n//             value: id,\n//         }));\n//     }, [data?.items]);\n\n//     return (\n//         <Stack p=\"md\" style={{ background: 'var(--theme-colors-surface)', height: '12rem' }}>\n//             <Text>{t('filter.genre', { postProcess: 'titleCase' })}</Text>\n//             <ScrollArea>\n//                 <ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>\n//                     {genres.map((genre) => (\n//                         <li key={genre.value}>{genre.label}</li>\n//                     ))}\n//                 </ul>\n//             </ScrollArea>\n//         </Stack>\n//     );\n// };\n\n// const ArtistFilterSelection = () => {\n//     const { t } = useTranslation();\n//     const { playlistId } = useParams() as { playlistId: string };\n//     const serverId = useCurrentServerId();\n\n//     const { data } = useQuery(playlistsQueries.songList({ query: { id: playlistId }, serverId }));\n\n//     const artists = useMemo(() => {\n//         const uniqueArtists = new Map<string, string>();\n\n//         data?.items.forEach((song) => {\n//             song.artists.forEach((artist) => {\n//                 if (artist.id) {\n//                     uniqueArtists.set(artist.id, artist.name);\n//                 }\n//             });\n//         });\n\n//         return Array.from(uniqueArtists.entries()).map(([id, name]) => ({\n//             label: name,\n//             value: id,\n//         }));\n//     }, [data?.items]);\n\n//     return (\n//         <Stack style={{ height: '12rem' }}>\n//             <Text>{t('filter.artist', { postProcess: 'titleCase' })}</Text>\n//             <ScrollArea>\n//                 <ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>\n//                     {artists.map((artist) => (\n//                         <li key={artist.value}>{artist.label}</li>\n//                     ))}\n//                 </ul>\n//             </ScrollArea>\n//         </Stack>\n//     );\n// };\n"
  },
  {
    "path": "src/renderer/features/playlists/components/playlist-detail-song-list-header.tsx",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { useTranslation } from 'react-i18next';\nimport { useLocation, useParams } from 'react-router';\n\nimport { useItemImageUrl } from '/@/renderer/components/item-image/item-image';\nimport { PageHeader } from '/@/renderer/components/page-header/page-header';\nimport { useListContext } from '/@/renderer/context/list-context';\nimport { usePlayer } from '/@/renderer/features/player/context/player-context';\nimport { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';\nimport { PlaylistDetailSongListHeaderFilters } from '/@/renderer/features/playlists/components/playlist-detail-song-list-header-filters';\nimport { FilterBar } from '/@/renderer/features/shared/components/filter-bar';\nimport {\n    LibraryHeader,\n    LibraryHeaderMenu,\n} from '/@/renderer/features/shared/components/library-header';\nimport { LibraryHeaderBar } from '/@/renderer/features/shared/components/library-header-bar';\nimport { ListSearchInput } from '/@/renderer/features/shared/components/list-search-input';\nimport { AppRoute } from '/@/renderer/router/routes';\nimport { useCurrentServer } from '/@/renderer/store';\nimport { formatDurationString } from '/@/renderer/utils';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { useLocalStorage } from '/@/shared/hooks/use-local-storage';\nimport { LibraryItem, Song } from '/@/shared/types/domain-types';\nimport { Play } from '/@/shared/types/types';\n\ninterface PlaylistDetailSongListHeaderProps {\n    isSmartPlaylist?: boolean;\n    onConvertToSmart?: () => void;\n    onDelete?: () => void;\n    onToggleQueryBuilder?: () => void;\n}\n\nexport const PlaylistDetailSongListHeader = ({\n    isSmartPlaylist,\n}: PlaylistDetailSongListHeaderProps) => {\n    const { t } = useTranslation();\n    const { playlistId } = useParams() as { playlistId: string };\n    const { itemCount, listData } = useListContext();\n    const server = useCurrentServer();\n    const location = useLocation();\n\n    const detailQuery = useQuery({\n        ...playlistsQueries.detail({ query: { id: playlistId }, serverId: server?.id }),\n        placeholderData: location.state?.item,\n    });\n\n    const playlistDuration = detailQuery?.data?.duration;\n\n    const [collapsed] = useLocalStorage<boolean>({\n        defaultValue: false,\n        key: 'playlist-header-collapsed',\n    });\n\n    const player = usePlayer();\n\n    const handlePlay = (type?: Play) => {\n        player.addToQueueByData(listData as Song[], type || Play.NOW);\n    };\n\n    const imageUrl = useItemImageUrl({\n        id: detailQuery?.data?.imageId || undefined,\n        itemType: LibraryItem.PLAYLIST,\n        type: 'header',\n    });\n\n    return (\n        <Stack gap={0}>\n            {collapsed ? (\n                <PageHeader>\n                    <LibraryHeaderBar ignoreMaxWidth>\n                        <LibraryHeaderBar.PlayButton\n                            itemType={LibraryItem.PLAYLIST}\n                            songs={listData as Song[]}\n                        />\n                        <LibraryHeaderBar.Title>{detailQuery?.data?.name}</LibraryHeaderBar.Title>\n                        {isSmartPlaylist && (\n                            <LibraryHeaderBar.Badge>\n                                {t('entity.smartPlaylist')}\n                            </LibraryHeaderBar.Badge>\n                        )}\n                        {!!playlistDuration && (\n                            <LibraryHeaderBar.Badge>\n                                {formatDurationString(playlistDuration)}\n                            </LibraryHeaderBar.Badge>\n                        )}\n                        <LibraryHeaderBar.Badge\n                            isLoading={itemCount === null || itemCount === undefined}\n                        >\n                            {itemCount}\n                        </LibraryHeaderBar.Badge>\n                    </LibraryHeaderBar>\n                    <ListSearchInput />\n                </PageHeader>\n            ) : (\n                <LibraryHeader\n                    compact\n                    imageUrl={imageUrl}\n                    item={{\n                        imageId: detailQuery?.data?.imageId,\n                        imageUrl: detailQuery?.data?.imageUrl,\n                        route: AppRoute.PLAYLISTS,\n                        type: LibraryItem.PLAYLIST,\n                    }}\n                    title={detailQuery?.data?.name || ''}\n                    topRight={<ListSearchInput />}\n                >\n                    <LibraryHeaderMenu\n                        onPlay={(type) => handlePlay(type)}\n                        onShuffle={() => handlePlay(Play.SHUFFLE)}\n                    />\n                </LibraryHeader>\n            )}\n            <FilterBar>\n                <PlaylistDetailSongListHeaderFilters isSmartPlaylist={isSmartPlaylist} />\n            </FilterBar>\n        </Stack>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/playlists/components/playlist-detail-song-list-table.tsx",
    "content": "import { forwardRef, useMemo } from 'react';\nimport { useEffect } from 'react';\n\nimport { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder';\nimport { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize';\nimport { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist';\nimport { ItemListWithPagination } from '/@/renderer/components/item-list/item-list-pagination/item-list-pagination';\nimport { ItemTableList } from '/@/renderer/components/item-list/item-table-list/item-table-list';\nimport { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';\nimport { ItemControls, ItemListTableComponentProps } from '/@/renderer/components/item-list/types';\nimport { useListContext } from '/@/renderer/context/list-context';\nimport { usePlayer } from '/@/renderer/features/player/context/player-context';\nimport { usePlaylistSongListFilters } from '/@/renderer/features/playlists/hooks/use-playlist-song-list-filters';\nimport { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-search-term-filter';\nimport { searchLibraryItems } from '/@/renderer/features/shared/utils';\nimport { usePlayerSong } from '/@/renderer/store';\nimport { sortSongList } from '/@/shared/api/utils';\nimport {\n    LibraryItem,\n    PlaylistSongListQuery,\n    PlaylistSongListResponse,\n    Song,\n} from '/@/shared/types/domain-types';\nimport { ItemListKey, Play, TableColumn } from '/@/shared/types/types';\n\ninterface PlaylistDetailSongListTableProps\n    extends Omit<ItemListTableComponentProps<PlaylistSongListQuery>, 'query'> {\n    currentPage?: number;\n    data: PlaylistSongListResponse;\n    items?: Song[];\n    itemsPerPage?: number;\n    onPageChange?: (page: number) => void;\n}\n\nexport const PlaylistDetailSongListTable = forwardRef<any, PlaylistDetailSongListTableProps>(\n    (\n        {\n            autoFitColumns = false,\n            columns,\n            currentPage,\n            data,\n            enableAlternateRowColors = false,\n            enableHeader = true,\n            enableHorizontalBorders = false,\n            enableRowHoverHighlight = true,\n            enableSelection = true,\n            enableVerticalBorders = false,\n            items: itemsProp,\n            itemsPerPage,\n            onPageChange,\n            saveScrollOffset = true,\n            size = 'default',\n        },\n        ref,\n    ) => {\n        const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({\n            enabled: saveScrollOffset,\n        });\n\n        const { handleColumnReordered } = useItemListColumnReorder({\n            itemListKey: ItemListKey.PLAYLIST_SONG,\n        });\n\n        const { handleColumnResized } = useItemListColumnResize({\n            itemListKey: ItemListKey.PLAYLIST_SONG,\n        });\n\n        const { searchTerm } = useSearchTermFilter();\n        const { query } = usePlaylistSongListFilters();\n\n        const albumGroupingEnabled = columns.some(\n            (col) => col.id === TableColumn.ALBUM_GROUP && col.isEnabled,\n        );\n\n        const songDataFromData = useMemo(() => {\n            let list = data?.items || [];\n            if (searchTerm) {\n                list = searchLibraryItems(list, searchTerm, LibraryItem.SONG);\n                return list;\n            }\n            return sortSongList(list, query.sortBy, query.sortOrder);\n        }, [data?.items, searchTerm, query.sortBy, query.sortOrder]);\n\n        const { setListData } = useListContext();\n        const songData = itemsProp ?? songDataFromData;\n\n        useEffect(() => {\n            if (itemsProp == null && setListData) {\n                setListData(songDataFromData);\n            }\n        }, [itemsProp, songDataFromData, setListData]);\n\n        const player = usePlayer();\n\n        const currentSong = usePlayerSong();\n\n        const overrideControls: Partial<ItemControls> = useMemo(() => {\n            return {\n                onDoubleClick: ({ index, internalState, item, meta }) => {\n                    if (!item) {\n                        return;\n                    }\n\n                    const playType = (meta?.playType as Play) || Play.NOW;\n                    const items = internalState?.getData() as Song[];\n\n                    if (index !== undefined) {\n                        player.addToQueueByData(items, playType, item.id);\n                    }\n                },\n            };\n        }, [player]);\n\n        const getRowId = useMemo(() => {\n            return (item: unknown) => {\n                if (!item || typeof item !== 'object') {\n                    return 'id';\n                }\n                const song = item as Song;\n                return song.playlistItemId || song.id;\n            };\n        }, []);\n\n        const effectiveColumns = useMemo(() => {\n            if (albumGroupingEnabled) return columns;\n            return columns.filter((col) => col.id !== TableColumn.ALBUM_GROUP);\n        }, [columns, albumGroupingEnabled]);\n\n        const isPaginated =\n            typeof currentPage === 'number' &&\n            typeof itemsPerPage === 'number' &&\n            typeof onPageChange === 'function';\n        const totalCount = songData.length;\n        const pageCount = Math.max(1, Math.ceil(totalCount / (itemsPerPage ?? 1)));\n        const paginatedData = useMemo(() => {\n            if (!isPaginated || currentPage == null || itemsPerPage == null) return songData;\n            const start = currentPage * itemsPerPage;\n            return songData.slice(start, start + itemsPerPage);\n        }, [isPaginated, currentPage, itemsPerPage, songData]);\n        const dataToRender = isPaginated ? paginatedData : songData;\n\n        const table = (\n            <ItemTableList\n                activeRowId={currentSong?.id}\n                autoFitColumns={autoFitColumns}\n                CellComponent={ItemTableListColumn}\n                columns={effectiveColumns}\n                data={dataToRender}\n                enableAlternateRowColors={enableAlternateRowColors}\n                enableExpansion={false}\n                enableHeader={enableHeader}\n                enableHorizontalBorders={enableHorizontalBorders}\n                enableRowHoverHighlight={enableRowHoverHighlight}\n                enableSelection={enableSelection}\n                enableVerticalBorders={enableVerticalBorders}\n                getRowId={getRowId}\n                initialTop={{\n                    to: scrollOffset ?? 0,\n                    type: 'offset',\n                }}\n                itemType={LibraryItem.PLAYLIST_SONG}\n                onColumnReordered={handleColumnReordered}\n                onColumnResized={handleColumnResized}\n                onScrollEnd={handleOnScrollEnd}\n                overrideControls={overrideControls}\n                ref={ref}\n                size={size}\n            />\n        );\n\n        if (isPaginated && itemsPerPage != null) {\n            return (\n                <ItemListWithPagination\n                    currentPage={currentPage!}\n                    itemsPerPage={itemsPerPage}\n                    onChange={onPageChange!}\n                    pageCount={pageCount}\n                    totalItemCount={totalCount}\n                >\n                    {table}\n                </ItemListWithPagination>\n            );\n        }\n\n        return table;\n    },\n);\n\nexport const PlaylistDetailSongListEditTable = forwardRef<any, PlaylistDetailSongListTableProps>(\n    (\n        {\n            autoFitColumns = false,\n            columns,\n            data,\n            enableAlternateRowColors = false,\n            enableHeader = true,\n            enableHorizontalBorders = false,\n            enableRowHoverHighlight = true,\n            enableSelection = true,\n            enableVerticalBorders = false,\n            saveScrollOffset = true,\n            size = 'default',\n        },\n        ref,\n    ) => {\n        const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({\n            enabled: saveScrollOffset,\n        });\n\n        const { handleColumnReordered } = useItemListColumnReorder({\n            itemListKey: ItemListKey.PLAYLIST_SONG,\n        });\n\n        const { handleColumnResized } = useItemListColumnResize({\n            itemListKey: ItemListKey.PLAYLIST_SONG,\n        });\n\n        const player = usePlayer();\n\n        const currentSong = usePlayerSong();\n\n        const overrideControls: Partial<ItemControls> = useMemo(() => {\n            return {\n                onDoubleClick: ({ index, internalState, item, meta }) => {\n                    if (!item) {\n                        return;\n                    }\n\n                    const playType = (meta?.playType as Play) || Play.NOW;\n                    const items = internalState?.getData() as Song[];\n\n                    if (index !== undefined) {\n                        player.addToQueueByData(items, playType, item.id);\n                    }\n                },\n            };\n        }, [player]);\n\n        const getRowId = useMemo(() => {\n            return (item: unknown) => {\n                if (!item || typeof item !== 'object') {\n                    return 'id';\n                }\n                const song = item as Song;\n                return song.playlistItemId || song.id;\n            };\n        }, []);\n\n        return (\n            <ItemTableList\n                activeRowId={currentSong?.id}\n                autoFitColumns={autoFitColumns}\n                CellComponent={ItemTableListColumn}\n                columns={columns}\n                data={data.items}\n                enableAlternateRowColors={enableAlternateRowColors}\n                enableDrag\n                enableExpansion={false}\n                enableHeader={enableHeader}\n                enableHorizontalBorders={enableHorizontalBorders}\n                enableRowHoverHighlight={enableRowHoverHighlight}\n                enableSelection={enableSelection}\n                enableVerticalBorders={enableVerticalBorders}\n                getRowId={getRowId}\n                initialTop={{\n                    to: scrollOffset ?? 0,\n                    type: 'offset',\n                }}\n                itemType={LibraryItem.PLAYLIST_SONG}\n                onColumnReordered={handleColumnReordered}\n                onColumnResized={handleColumnResized}\n                onScrollEnd={handleOnScrollEnd}\n                overrideControls={overrideControls}\n                ref={ref}\n                size={size}\n            />\n        );\n    },\n);\n"
  },
  {
    "path": "src/renderer/features/playlists/components/playlist-list-content.tsx",
    "content": "import { lazy, Suspense, useMemo } from 'react';\n\nimport { usePlaylistListFilters } from '/@/renderer/features/playlists/hooks/use-playlist-list-filters';\nimport { ItemListSettings, useCurrentServer, useListSettings } from '/@/renderer/store';\nimport { Spinner } from '/@/shared/components/spinner/spinner';\nimport { PlaylistListQuery } from '/@/shared/types/domain-types';\nimport { ItemListKey, ListDisplayType, ListPaginationType } from '/@/shared/types/types';\n\nconst PlaylistListInfiniteGrid = lazy(() =>\n    import('/@/renderer/features/playlists/components/playlist-list-infinite-grid').then(\n        (module) => ({\n            default: module.PlaylistListInfiniteGrid,\n        }),\n    ),\n);\n\nconst PlaylistListPaginatedGrid = lazy(() =>\n    import('/@/renderer/features/playlists/components/playlist-list-paginated-grid').then(\n        (module) => ({\n            default: module.PlaylistListPaginatedGrid,\n        }),\n    ),\n);\n\nconst PlaylistListInfiniteTable = lazy(() =>\n    import('/@/renderer/features/playlists/components/playlist-list-infinite-table').then(\n        (module) => ({\n            default: module.PlaylistListInfiniteTable,\n        }),\n    ),\n);\n\nconst PlaylistListPaginatedTable = lazy(() =>\n    import('/@/renderer/features/playlists/components/playlist-list-paginated-table').then(\n        (module) => ({\n            default: module.PlaylistListPaginatedTable,\n        }),\n    ),\n);\n\nexport const PlaylistListContent = () => {\n    const { display, grid, itemsPerPage, pagination, table } = useListSettings(\n        ItemListKey.PLAYLIST,\n    );\n\n    return (\n        <Suspense fallback={<Spinner container />}>\n            <PlaylistListView\n                display={display}\n                grid={grid}\n                itemsPerPage={itemsPerPage}\n                pagination={pagination}\n                table={table}\n            />\n        </Suspense>\n    );\n};\n\nexport const PlaylistListView = ({\n    display,\n    grid,\n    itemsPerPage,\n    overrideQuery,\n    pagination,\n    table,\n}: ItemListSettings & { overrideQuery?: Omit<PlaylistListQuery, 'limit' | 'startIndex'> }) => {\n    const server = useCurrentServer();\n\n    const { query } = usePlaylistListFilters();\n\n    const mergedQuery = useMemo(() => {\n        if (!overrideQuery) {\n            return query;\n        }\n\n        return {\n            ...query,\n            ...overrideQuery,\n            sortBy: overrideQuery.sortBy || query.sortBy,\n            sortOrder: overrideQuery.sortOrder || query.sortOrder,\n        };\n    }, [query, overrideQuery]);\n\n    switch (display) {\n        case ListDisplayType.GRID: {\n            switch (pagination) {\n                case ListPaginationType.INFINITE: {\n                    return (\n                        <PlaylistListInfiniteGrid\n                            gap={grid.itemGap}\n                            itemsPerPage={itemsPerPage}\n                            itemsPerRow={grid.itemsPerRowEnabled ? grid.itemsPerRow : undefined}\n                            query={mergedQuery}\n                            serverId={server.id}\n                            size={grid.size}\n                        />\n                    );\n                }\n                case ListPaginationType.PAGINATED: {\n                    return (\n                        <PlaylistListPaginatedGrid\n                            gap={grid.itemGap}\n                            itemsPerPage={itemsPerPage}\n                            itemsPerRow={grid.itemsPerRowEnabled ? grid.itemsPerRow : undefined}\n                            query={mergedQuery}\n                            serverId={server.id}\n                            size={grid.size}\n                        />\n                    );\n                }\n                default:\n                    return null;\n            }\n        }\n        case ListDisplayType.TABLE: {\n            switch (pagination) {\n                case ListPaginationType.INFINITE: {\n                    return (\n                        <PlaylistListInfiniteTable\n                            autoFitColumns={table.autoFitColumns}\n                            columns={table.columns}\n                            enableAlternateRowColors={table.enableAlternateRowColors}\n                            enableHeader={table.enableHeader}\n                            enableHorizontalBorders={table.enableHorizontalBorders}\n                            enableRowHoverHighlight={table.enableRowHoverHighlight}\n                            enableVerticalBorders={table.enableVerticalBorders}\n                            itemsPerPage={itemsPerPage}\n                            query={mergedQuery}\n                            serverId={server.id}\n                            size={table.size}\n                        />\n                    );\n                }\n                case ListPaginationType.PAGINATED: {\n                    return (\n                        <PlaylistListPaginatedTable\n                            autoFitColumns={table.autoFitColumns}\n                            columns={table.columns}\n                            enableAlternateRowColors={table.enableAlternateRowColors}\n                            enableHeader={table.enableHeader}\n                            enableHorizontalBorders={table.enableHorizontalBorders}\n                            enableRowHoverHighlight={table.enableRowHoverHighlight}\n                            enableVerticalBorders={table.enableVerticalBorders}\n                            itemsPerPage={itemsPerPage}\n                            query={mergedQuery}\n                            serverId={server.id}\n                            size={table.size}\n                        />\n                    );\n                }\n                default:\n                    return null;\n            }\n        }\n    }\n\n    return null;\n};\n"
  },
  {
    "path": "src/renderer/features/playlists/components/playlist-list-header-filters.tsx",
    "content": "import { MouseEvent } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport { PLAYLIST_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns';\nimport { openCreatePlaylistModal } from '/@/renderer/features/playlists/components/create-playlist-form';\nimport { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';\nimport { ListDisplayTypeToggleButton } from '/@/renderer/features/shared/components/list-display-type-toggle-button';\nimport { ListRefreshButton } from '/@/renderer/features/shared/components/list-refresh-button';\nimport { ListSortByDropdown } from '/@/renderer/features/shared/components/list-sort-by-dropdown';\nimport { ListSortOrderToggleButton } from '/@/renderer/features/shared/components/list-sort-order-toggle-button';\nimport { useCurrentServer } from '/@/renderer/store';\nimport { Button } from '/@/shared/components/button/button';\nimport { Divider } from '/@/shared/components/divider/divider';\nimport { Flex } from '/@/shared/components/flex/flex';\nimport { Group } from '/@/shared/components/group/group';\nimport { LibraryItem, PlaylistListSort, SortOrder } from '/@/shared/types/domain-types';\nimport { ItemListKey } from '/@/shared/types/types';\n\nexport const PlaylistListHeaderFilters = () => {\n    const { t } = useTranslation();\n\n    const server = useCurrentServer();\n\n    const handleCreatePlaylistModal = (e: MouseEvent<HTMLButtonElement>) => {\n        openCreatePlaylistModal(server, e);\n    };\n\n    return (\n        <Flex justify=\"space-between\">\n            <Group gap=\"sm\" w=\"100%\">\n                <ListSortByDropdown\n                    defaultSortByValue={PlaylistListSort.NAME}\n                    itemType={LibraryItem.PLAYLIST}\n                    listKey={ItemListKey.PLAYLIST}\n                />\n                <Divider orientation=\"vertical\" />\n                <ListSortOrderToggleButton\n                    defaultSortOrder={SortOrder.ASC}\n                    listKey={ItemListKey.PLAYLIST}\n                />\n                <ListRefreshButton listKey={ItemListKey.PLAYLIST} />\n            </Group>\n            <Group gap=\"sm\" wrap=\"nowrap\">\n                <Button onClick={handleCreatePlaylistModal} variant=\"subtle\">\n                    {t('action.createPlaylist', { postProcess: 'sentenceCase' })}\n                </Button>\n                <ListDisplayTypeToggleButton listKey={ItemListKey.PLAYLIST} />\n                <ListConfigMenu\n                    listKey={ItemListKey.PLAYLIST}\n                    tableColumnsData={PLAYLIST_TABLE_COLUMNS}\n                />\n            </Group>\n        </Flex>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/playlists/components/playlist-list-header.tsx",
    "content": "import { useTranslation } from 'react-i18next';\n\nimport { useIsFetchingItemListCount } from '/@/renderer/components/item-list/helpers/use-is-fetching-item-list';\nimport { PageHeader } from '/@/renderer/components/page-header/page-header';\nimport { useListContext } from '/@/renderer/context/list-context';\nimport { PlaylistListHeaderFilters } from '/@/renderer/features/playlists/components/playlist-list-header-filters';\nimport { usePlaylistListFilters } from '/@/renderer/features/playlists/hooks/use-playlist-list-filters';\nimport { FilterBar } from '/@/renderer/features/shared/components/filter-bar';\nimport { LibraryHeaderBar } from '/@/renderer/features/shared/components/library-header-bar';\nimport { ListSearchInput } from '/@/renderer/features/shared/components/list-search-input';\nimport { Group } from '/@/shared/components/group/group';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { LibraryItem } from '/@/shared/types/domain-types';\n\ninterface PlaylistListHeaderProps {\n    title?: string;\n}\n\nexport const PlaylistListHeader = ({ title }: PlaylistListHeaderProps) => {\n    const { t } = useTranslation();\n\n    const pageTitle = title || t('page.playlistList.title', { postProcess: 'titleCase' });\n\n    return (\n        <Stack gap={0}>\n            <PageHeader>\n                <LibraryHeaderBar ignoreMaxWidth>\n                    <PlayButton />\n                    <LibraryHeaderBar.Title>{pageTitle}</LibraryHeaderBar.Title>\n                    <PlaylistListHeaderBadge />\n                </LibraryHeaderBar>\n                <Group>\n                    <ListSearchInput />\n                </Group>\n            </PageHeader>\n            <FilterBar>\n                <PlaylistListHeaderFilters />\n            </FilterBar>\n        </Stack>\n    );\n};\n\nconst PlaylistListHeaderBadge = () => {\n    const { itemCount } = useListContext();\n\n    const isFetching = useIsFetchingItemListCount({\n        itemType: LibraryItem.PLAYLIST,\n    });\n\n    return <LibraryHeaderBar.Badge isLoading={isFetching}>{itemCount}</LibraryHeaderBar.Badge>;\n};\n\nconst PlayButton = () => {\n    const { query } = usePlaylistListFilters();\n\n    return <LibraryHeaderBar.PlayButton itemType={LibraryItem.PLAYLIST} listQuery={query} />;\n};\n"
  },
  {
    "path": "src/renderer/features/playlists/components/playlist-list-infinite-grid.tsx",
    "content": "import { UseSuspenseQueryOptions } from '@tanstack/react-query';\n\nimport { api } from '/@/renderer/api';\nimport { useItemListInfiniteLoader } from '/@/renderer/components/item-list/helpers/item-list-infinite-loader';\nimport { useGridRows } from '/@/renderer/components/item-list/helpers/use-grid-rows';\nimport { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist';\nimport { ItemGridList } from '/@/renderer/components/item-list/item-grid-list/item-grid-list';\nimport { ItemListGridComponentProps } from '/@/renderer/components/item-list/types';\nimport { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';\nimport { useGeneralSettings } from '/@/renderer/store';\nimport {\n    LibraryItem,\n    PlaylistListQuery,\n    PlaylistListSort,\n    SortOrder,\n} from '/@/shared/types/domain-types';\nimport { ItemListKey } from '/@/shared/types/types';\n\ninterface PlaylistListInfiniteGridProps extends ItemListGridComponentProps<PlaylistListQuery> {}\n\nexport const PlaylistListInfiniteGrid = ({\n    gap = 'md',\n    itemsPerPage = 100,\n    itemsPerRow,\n    query = {\n        sortBy: PlaylistListSort.NAME,\n        sortOrder: SortOrder.ASC,\n    },\n    saveScrollOffset = true,\n    serverId,\n    size,\n}: PlaylistListInfiniteGridProps) => {\n    const listCountQuery = playlistsQueries.listCount({\n        query: { ...query },\n        serverId: serverId,\n    }) as UseSuspenseQueryOptions<number, Error, number, readonly unknown[]>;\n\n    const listQueryFn = api.controller.getPlaylistList;\n\n    const { dataVersion, getItem, getItemIndex, itemCount, loadedItems, onRangeChanged } =\n        useItemListInfiniteLoader({\n            eventKey: ItemListKey.PLAYLIST,\n            itemsPerPage,\n            itemType: LibraryItem.PLAYLIST,\n            listCountQuery,\n            listQueryFn,\n            query,\n            serverId,\n        });\n\n    const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({\n        enabled: saveScrollOffset,\n    });\n\n    const rows = useGridRows(LibraryItem.PLAYLIST, ItemListKey.PLAYLIST, size);\n    const { enableGridMultiSelect } = useGeneralSettings();\n\n    return (\n        <ItemGridList\n            data={loadedItems}\n            dataVersion={dataVersion}\n            enableMultiSelect={enableGridMultiSelect}\n            gap={gap}\n            getItem={getItem}\n            getItemIndex={getItemIndex}\n            initialTop={{\n                to: scrollOffset ?? 0,\n                type: 'offset',\n            }}\n            itemCount={itemCount}\n            itemsPerRow={itemsPerRow}\n            itemType={LibraryItem.PLAYLIST}\n            onRangeChanged={onRangeChanged}\n            onScrollEnd={handleOnScrollEnd}\n            rows={rows}\n            size={size}\n        />\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/playlists/components/playlist-list-infinite-table.tsx",
    "content": "import { UseSuspenseQueryOptions } from '@tanstack/react-query';\n\nimport { api } from '/@/renderer/api';\nimport { useItemListInfiniteLoader } from '/@/renderer/components/item-list/helpers/item-list-infinite-loader';\nimport { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder';\nimport { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize';\nimport { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist';\nimport { ItemTableList } from '/@/renderer/components/item-list/item-table-list/item-table-list';\nimport { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';\nimport { ItemListTableComponentProps } from '/@/renderer/components/item-list/types';\nimport { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';\nimport {\n    LibraryItem,\n    PlaylistListQuery,\n    PlaylistListSort,\n    SortOrder,\n} from '/@/shared/types/domain-types';\nimport { ItemListKey } from '/@/shared/types/types';\n\ninterface PlaylistListInfiniteTableProps extends ItemListTableComponentProps<PlaylistListQuery> {}\n\nexport const PlaylistListInfiniteTable = ({\n    autoFitColumns = false,\n    columns,\n    enableAlternateRowColors = false,\n    enableHeader = true,\n    enableHorizontalBorders = false,\n    enableRowHoverHighlight = true,\n    enableSelection = true,\n    enableVerticalBorders = false,\n    itemsPerPage = 100,\n    query = {\n        sortBy: PlaylistListSort.NAME,\n        sortOrder: SortOrder.ASC,\n    },\n    saveScrollOffset = true,\n    serverId,\n    size = 'default',\n}: PlaylistListInfiniteTableProps) => {\n    const listCountQuery = playlistsQueries.listCount({\n        query: { ...query },\n        serverId: serverId,\n    }) as UseSuspenseQueryOptions<number, Error, number, readonly unknown[]>;\n\n    const listQueryFn = api.controller.getPlaylistList;\n\n    const { getItem, getItemIndex, itemCount, loadedItems, onRangeChanged } =\n        useItemListInfiniteLoader({\n            eventKey: ItemListKey.PLAYLIST,\n            itemsPerPage,\n            itemType: LibraryItem.PLAYLIST,\n            listCountQuery,\n            listQueryFn,\n            query,\n            serverId,\n        });\n\n    const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({\n        enabled: saveScrollOffset,\n    });\n\n    const { handleColumnReordered } = useItemListColumnReorder({\n        itemListKey: ItemListKey.PLAYLIST,\n    });\n\n    const { handleColumnResized } = useItemListColumnResize({\n        itemListKey: ItemListKey.PLAYLIST,\n    });\n\n    return (\n        <ItemTableList\n            autoFitColumns={autoFitColumns}\n            CellComponent={ItemTableListColumn}\n            columns={columns}\n            data={loadedItems}\n            enableAlternateRowColors={enableAlternateRowColors}\n            enableHeader={enableHeader}\n            enableHorizontalBorders={enableHorizontalBorders}\n            enableRowHoverHighlight={enableRowHoverHighlight}\n            enableSelection={enableSelection}\n            enableVerticalBorders={enableVerticalBorders}\n            getItem={getItem}\n            getItemIndex={getItemIndex}\n            initialTop={{\n                to: scrollOffset ?? 0,\n                type: 'offset',\n            }}\n            itemCount={itemCount}\n            itemType={LibraryItem.PLAYLIST}\n            onColumnReordered={handleColumnReordered}\n            onColumnResized={handleColumnResized}\n            onRangeChanged={onRangeChanged}\n            onScrollEnd={handleOnScrollEnd}\n            size={size}\n        />\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/playlists/components/playlist-list-paginated-grid.tsx",
    "content": "import { UseSuspenseQueryOptions } from '@tanstack/react-query';\n\nimport { api } from '/@/renderer/api';\nimport { useItemListPaginatedLoader } from '/@/renderer/components/item-list/helpers/item-list-paginated-loader';\nimport { useGridRows } from '/@/renderer/components/item-list/helpers/use-grid-rows';\nimport { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist';\nimport { ItemGridList } from '/@/renderer/components/item-list/item-grid-list/item-grid-list';\nimport { ItemListWithPagination } from '/@/renderer/components/item-list/item-list-pagination/item-list-pagination';\nimport { useItemListPagination } from '/@/renderer/components/item-list/item-list-pagination/use-item-list-pagination';\nimport { ItemListGridComponentProps } from '/@/renderer/components/item-list/types';\nimport { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';\nimport { useGeneralSettings } from '/@/renderer/store';\nimport {\n    LibraryItem,\n    PlaylistListQuery,\n    PlaylistListSort,\n    SortOrder,\n} from '/@/shared/types/domain-types';\nimport { ItemListKey } from '/@/shared/types/types';\n\ninterface PlaylistListPaginatedGridProps extends ItemListGridComponentProps<PlaylistListQuery> {}\n\nexport const PlaylistListPaginatedGrid = ({\n    gap = 'md',\n    itemsPerPage = 100,\n    itemsPerRow,\n    query = {\n        sortBy: PlaylistListSort.NAME,\n        sortOrder: SortOrder.ASC,\n    },\n    saveScrollOffset = true,\n    serverId,\n    size,\n}: PlaylistListPaginatedGridProps) => {\n    const listCountQuery = playlistsQueries.listCount({\n        query: { ...query },\n        serverId: serverId,\n    }) as UseSuspenseQueryOptions<number, Error, number, readonly unknown[]>;\n\n    const listQueryFn = api.controller.getPlaylistList;\n\n    const { currentPage, onChange } = useItemListPagination();\n\n    const { data, pageCount, totalItemCount } = useItemListPaginatedLoader({\n        currentPage,\n        eventKey: ItemListKey.PLAYLIST,\n        itemsPerPage,\n        itemType: LibraryItem.PLAYLIST,\n        listCountQuery,\n        listQueryFn,\n        query,\n        serverId,\n    });\n\n    const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({\n        enabled: saveScrollOffset,\n    });\n\n    const rows = useGridRows(LibraryItem.PLAYLIST, ItemListKey.PLAYLIST, size);\n    const { enableGridMultiSelect } = useGeneralSettings();\n\n    return (\n        <ItemListWithPagination\n            currentPage={currentPage}\n            itemsPerPage={itemsPerPage}\n            onChange={onChange}\n            pageCount={pageCount}\n            totalItemCount={totalItemCount}\n        >\n            <ItemGridList\n                currentPage={currentPage}\n                data={data || []}\n                enableMultiSelect={enableGridMultiSelect}\n                gap={gap}\n                initialTop={{\n                    to: scrollOffset ?? 0,\n                    type: 'offset',\n                }}\n                itemsPerRow={itemsPerRow}\n                itemType={LibraryItem.PLAYLIST}\n                onScrollEnd={handleOnScrollEnd}\n                rows={rows}\n                size={size}\n            />\n        </ItemListWithPagination>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/playlists/components/playlist-list-paginated-table.tsx",
    "content": "import { UseSuspenseQueryOptions } from '@tanstack/react-query';\n\nimport { api } from '/@/renderer/api';\nimport { useItemListPaginatedLoader } from '/@/renderer/components/item-list/helpers/item-list-paginated-loader';\nimport { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder';\nimport { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize';\nimport { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist';\nimport { ItemListWithPagination } from '/@/renderer/components/item-list/item-list-pagination/item-list-pagination';\nimport { useItemListPagination } from '/@/renderer/components/item-list/item-list-pagination/use-item-list-pagination';\nimport { ItemTableList } from '/@/renderer/components/item-list/item-table-list/item-table-list';\nimport { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';\nimport { ItemListTableComponentProps } from '/@/renderer/components/item-list/types';\nimport { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';\nimport {\n    LibraryItem,\n    PlaylistListQuery,\n    PlaylistListSort,\n    SortOrder,\n} from '/@/shared/types/domain-types';\nimport { ItemListKey } from '/@/shared/types/types';\n\ninterface PlaylistListPaginatedTableProps extends ItemListTableComponentProps<PlaylistListQuery> {}\n\nexport const PlaylistListPaginatedTable = ({\n    autoFitColumns = false,\n    columns,\n    enableAlternateRowColors = false,\n    enableHeader = true,\n    enableHorizontalBorders = false,\n    enableRowHoverHighlight = true,\n    enableSelection = true,\n    enableVerticalBorders = false,\n    itemsPerPage = 100,\n    query = {\n        sortBy: PlaylistListSort.NAME,\n        sortOrder: SortOrder.ASC,\n    },\n    saveScrollOffset = true,\n    serverId,\n    size = 'default',\n}: PlaylistListPaginatedTableProps) => {\n    const listCountQuery = playlistsQueries.listCount({\n        query: { ...query },\n        serverId: serverId,\n    }) as UseSuspenseQueryOptions<number, Error, number, readonly unknown[]>;\n\n    const listQueryFn = api.controller.getPlaylistList;\n\n    const { currentPage, onChange } = useItemListPagination();\n\n    const { data, pageCount, totalItemCount } = useItemListPaginatedLoader({\n        currentPage,\n        eventKey: ItemListKey.PLAYLIST,\n        itemsPerPage,\n        itemType: LibraryItem.PLAYLIST,\n        listCountQuery,\n        listQueryFn,\n        query,\n        serverId,\n    });\n\n    const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({\n        enabled: saveScrollOffset,\n    });\n\n    const { handleColumnReordered } = useItemListColumnReorder({\n        itemListKey: ItemListKey.PLAYLIST,\n    });\n\n    const { handleColumnResized } = useItemListColumnResize({\n        itemListKey: ItemListKey.PLAYLIST,\n    });\n\n    const startRowIndex = currentPage * itemsPerPage;\n\n    return (\n        <ItemListWithPagination\n            currentPage={currentPage}\n            itemsPerPage={itemsPerPage}\n            onChange={onChange}\n            pageCount={pageCount}\n            totalItemCount={totalItemCount}\n        >\n            <ItemTableList\n                autoFitColumns={autoFitColumns}\n                CellComponent={ItemTableListColumn}\n                columns={columns}\n                data={data || []}\n                enableAlternateRowColors={enableAlternateRowColors}\n                enableHeader={enableHeader}\n                enableHorizontalBorders={enableHorizontalBorders}\n                enableRowHoverHighlight={enableRowHoverHighlight}\n                enableSelection={enableSelection}\n                enableVerticalBorders={enableVerticalBorders}\n                initialTop={{\n                    to: scrollOffset ?? 0,\n                    type: 'offset',\n                }}\n                itemType={LibraryItem.PLAYLIST}\n                onColumnReordered={handleColumnReordered}\n                onColumnResized={handleColumnResized}\n                onScrollEnd={handleOnScrollEnd}\n                size={size}\n                startRowIndex={startRowIndex}\n            />\n        </ItemListWithPagination>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/playlists/components/playlist-query-builder.tsx",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport clone from 'lodash/clone';\nimport get from 'lodash/get';\nimport setWith from 'lodash/setWith';\nimport { nanoid } from 'nanoid';\nimport {\n    forwardRef,\n    Ref,\n    useCallback,\n    useEffect,\n    useImperativeHandle,\n    useMemo,\n    useState,\n} from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport { QueryBuilder } from '/@/renderer/components/query-builder';\nimport { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';\nimport { convertNDQueryToQueryGroup } from '/@/renderer/features/playlists/utils';\nimport { useCurrentServer } from '/@/renderer/store';\nimport { useQueryBuilderSettings } from '/@/renderer/store/settings.store';\nimport {\n    NDSongQueryBooleanOperators,\n    NDSongQueryDateOperators,\n    NDSongQueryFields,\n    NDSongQueryNumberOperators,\n    NDSongQueryPlaylistOperators,\n    NDSongQueryStringOperators,\n} from '/@/shared/api/navidrome/navidrome-types';\nimport { ActionIcon } from '/@/shared/components/action-icon/action-icon';\nimport { Flex } from '/@/shared/components/flex/flex';\nimport { Group } from '/@/shared/components/group/group';\nimport { NumberInput } from '/@/shared/components/number-input/number-input';\nimport { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';\nimport { Select } from '/@/shared/components/select/select';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { useForm } from '/@/shared/hooks/use-form';\nimport { PlaylistListSort, SongListSort, SortOrder } from '/@/shared/types/domain-types';\nimport { QueryBuilderGroup, QueryBuilderRule } from '/@/shared/types/types';\n\ntype AddArgs = {\n    groupIndex: number[];\n    level: number;\n};\n\ntype DeleteArgs = {\n    groupIndex: number[];\n    level: number;\n    uniqueId: string;\n};\n\ninterface PlaylistQueryBuilderProps {\n    limit?: number;\n    playlistId?: string;\n    query: any;\n    sortBy: SongListSort | SongListSort[];\n    sortOrder: 'asc' | 'desc';\n}\n\ntype SortEntry = {\n    field: string;\n    order: 'asc' | 'desc';\n};\n\nconst DEFAULT_QUERY: QueryBuilderGroup = {\n    group: [],\n    rules: [\n        {\n            field: '',\n            operator: '',\n            uniqueId: nanoid(),\n            value: '',\n        },\n    ],\n    type: 'all',\n    uniqueId: nanoid(),\n};\n\n// Utility functions for path building\nconst getGroupPath = (level: number, groupIndex: number[]): string => {\n    if (level === 0) return 'group';\n    return `${groupIndex.map((idx) => `group[${idx}]`).join('.')}.group`;\n};\n\nconst getTypePath = (groupIndex: number[]): string => {\n    return groupIndex.map((idx) => `group[${idx}]`).join('.');\n};\n\nconst getRulePath = (level: number, groupIndex: number[]): string => {\n    if (level === 0) return 'rules';\n    return `${groupIndex.map((idx) => `group[${idx}]`).join('.')}.rules`;\n};\n\n// Parse sortBy and sortOrder into array of sort entries\nconst parseSortEntries = (\n    sortBy: SongListSort | SongListSort[],\n    sortOrder: 'asc' | 'desc',\n): SortEntry[] => {\n    if (Array.isArray(sortBy) && sortBy.length > 0) {\n        const firstSort = sortBy[0];\n        // Check if first entry is a string with commas (new syntax as single string)\n        if (typeof firstSort === 'string' && firstSort.includes(',')) {\n            return firstSort.split(',').map((s) => {\n                const trimmed = s.trim();\n                const field =\n                    trimmed.startsWith('+') || trimmed.startsWith('-') ? trimmed.slice(1) : trimmed;\n                const order = trimmed.startsWith('-') ? 'desc' : 'asc';\n                return { field, order };\n            });\n        }\n        // Check if first entry has +/- prefix (new syntax as array of prefixed strings)\n        if (\n            typeof firstSort === 'string' &&\n            (firstSort.startsWith('+') || firstSort.startsWith('-'))\n        ) {\n            return sortBy.map((s) => {\n                const field = s.startsWith('+') || s.startsWith('-') ? s.slice(1) : s;\n                const order = s.startsWith('-') ? 'desc' : 'asc';\n                return { field, order };\n            });\n        }\n        // Old syntax: array of fields with single order\n        return sortBy.map((field) => ({ field, order: sortOrder }));\n    }\n    if (sortBy && typeof sortBy === 'string') {\n        // Check if it's new syntax with +/- prefix\n        if (sortBy.includes(',') || sortBy.startsWith('+') || sortBy.startsWith('-')) {\n            return sortBy.split(',').map((s) => {\n                const trimmed = s.trim();\n                const field =\n                    trimmed.startsWith('+') || trimmed.startsWith('-') ? trimmed.slice(1) : trimmed;\n                const order = trimmed.startsWith('-') ? 'desc' : 'asc';\n                return { field, order };\n            });\n        }\n        // Single field, use provided sortOrder\n        return [{ field: sortBy, order: sortOrder }];\n    }\n    // Default\n    return [{ field: 'dateAdded', order: 'asc' }];\n};\n\n// Convert sort entries to new syntax: comma-separated with +/- prefix\nconst convertSortEntriesToSortString = (entries: SortEntry[]): string => {\n    return entries\n        .filter((entry) => entry.field)\n        .map((entry) => {\n            const prefix = entry.order === 'desc' ? '-' : '+';\n            return `${prefix}${entry.field}`;\n        })\n        .join(',');\n};\n\nexport type PlaylistQueryBuilderRef = {\n    getFilters: () => {\n        extraFilters: {\n            limit?: number;\n            sortBy?: string[];\n            sortOrder?: string;\n        };\n        filters: QueryBuilderGroup;\n    };\n};\n\nexport const PlaylistQueryBuilder = forwardRef(\n    (\n        { limit, playlistId, query, sortBy, sortOrder }: PlaylistQueryBuilderProps,\n        ref: Ref<PlaylistQueryBuilderRef>,\n    ) => {\n        const { t } = useTranslation();\n        const server = useCurrentServer();\n        const queryBuilderSettings = useQueryBuilderSettings();\n\n        // Memoize initial filters to avoid recalculation\n        const initialFilters = useMemo(\n            () => (query ? convertNDQueryToQueryGroup(query) : DEFAULT_QUERY),\n            [query],\n        );\n\n        const [filters, setFilters] = useState<QueryBuilderGroup>(initialFilters);\n\n        // Update filters when query changes\n        useEffect(() => {\n            if (query) {\n                setFilters(convertNDQueryToQueryGroup(query));\n            }\n        }, [query]);\n\n        const { data: playlists } = useQuery(\n            playlistsQueries.list({\n                query: { sortBy: PlaylistListSort.NAME, sortOrder: SortOrder.ASC, startIndex: 0 },\n                serverId: server?.id,\n            }),\n        );\n\n        const playlistData = useMemo(() => {\n            if (!playlists) return [];\n\n            return playlists.items\n                .filter((p) => !playlistId || p.id !== playlistId)\n                .map((p) => ({\n                    label: p.name,\n                    value: p.id,\n                }));\n        }, [playlistId, playlists]);\n\n        // Memoize parsed sort entries\n        const initialSortEntries = useMemo(\n            () => parseSortEntries(sortBy, sortOrder),\n            [sortBy, sortOrder],\n        );\n\n        const extraFiltersForm = useForm({\n            initialValues: {\n                limit,\n                sortEntries: initialSortEntries,\n            },\n        });\n\n        useImperativeHandle(\n            ref,\n            () => ({\n                getFilters: () => {\n                    const sortString = convertSortEntriesToSortString(\n                        extraFiltersForm.values.sortEntries,\n                    );\n                    return {\n                        extraFilters: {\n                            limit: extraFiltersForm.values.limit,\n                            sortBy: sortString ? [sortString] : undefined,\n                        },\n                        filters,\n                    };\n                },\n            }),\n            [extraFiltersForm.values.sortEntries, extraFiltersForm.values.limit, filters],\n        );\n\n        const handleResetFilters = useCallback(() => {\n            setFilters(query ? convertNDQueryToQueryGroup(query) : DEFAULT_QUERY);\n        }, [query]);\n\n        const handleClearFilters = useCallback(() => {\n            setFilters(DEFAULT_QUERY);\n        }, []);\n\n        const handleAddRuleGroup = useCallback((args: AddArgs) => {\n            const { groupIndex, level } = args;\n            const path = getGroupPath(level, groupIndex);\n\n            setFilters((prev) => {\n                const currentGroups = get(prev, path) || [];\n                return setWith(\n                    clone(prev),\n                    path,\n                    [\n                        ...currentGroups,\n                        {\n                            group: [],\n                            rules: [\n                                {\n                                    field: '',\n                                    operator: '',\n                                    uniqueId: nanoid(),\n                                    value: '',\n                                },\n                            ],\n                            type: 'any',\n                            uniqueId: nanoid(),\n                        },\n                    ],\n                    clone,\n                );\n            });\n        }, []);\n\n        const handleDeleteRuleGroup = useCallback((args: DeleteArgs) => {\n            const { groupIndex, level, uniqueId } = args;\n            const path = level === 0 ? 'group' : getGroupPath(level - 1, groupIndex.slice(0, -1));\n\n            setFilters((prev) => {\n                const currentGroups = get(prev, path);\n                if (!Array.isArray(currentGroups)) {\n                    return prev;\n                }\n                return setWith(\n                    clone(prev),\n                    path,\n                    currentGroups.filter((group: QueryBuilderGroup) => group.uniqueId !== uniqueId),\n                    clone,\n                );\n            });\n        }, []);\n\n        const handleAddRule = useCallback((args: AddArgs) => {\n            const { groupIndex, level } = args;\n            const path = getRulePath(level, groupIndex);\n\n            setFilters((prev) => {\n                const currentRules = get(prev, path) || [];\n                return setWith(\n                    clone(prev),\n                    path,\n                    [\n                        ...currentRules,\n                        {\n                            field: '',\n                            operator: '',\n                            uniqueId: nanoid(),\n                            value: null,\n                        },\n                    ],\n                    clone,\n                );\n            });\n        }, []);\n\n        const handleDeleteRule = useCallback((args: DeleteArgs) => {\n            const { groupIndex, level, uniqueId } = args;\n            const path = getRulePath(level, groupIndex);\n\n            setFilters((prev) => {\n                const currentRules = get(prev, path) || [];\n                return setWith(\n                    clone(prev),\n                    path,\n                    currentRules.filter((rule: QueryBuilderRule) => rule.uniqueId !== uniqueId),\n                    clone,\n                );\n            });\n        }, []);\n\n        const handleChangeField = useCallback((args: any) => {\n            const { groupIndex, level, uniqueId, value } = args;\n            const path = getRulePath(level, groupIndex);\n\n            setFilters((prev) => {\n                const currentRules = get(prev, path) || [];\n                return setWith(\n                    clone(prev),\n                    path,\n                    currentRules.map((rule: QueryBuilderRule) => {\n                        if (rule.uniqueId !== uniqueId) return rule;\n                        return {\n                            ...rule,\n                            field: value,\n                            operator: '',\n                            value: '',\n                        };\n                    }),\n                    clone,\n                );\n            });\n        }, []);\n\n        const handleChangeType = useCallback((args: any) => {\n            const { groupIndex, level, value } = args;\n\n            if (level === 0) {\n                setFilters((prev) => ({ ...prev, type: value }));\n                return;\n            }\n\n            const path = getTypePath(groupIndex);\n            setFilters((prev) =>\n                setWith(\n                    clone(prev),\n                    path,\n                    {\n                        ...get(prev, path),\n                        type: value,\n                    },\n                    clone,\n                ),\n            );\n        }, []);\n\n        const handleChangeOperator = useCallback((args: any) => {\n            const { groupIndex, level, uniqueId, value } = args;\n            const path = getRulePath(level, groupIndex);\n\n            setFilters((prev) => {\n                const currentRules = get(prev, path) || [];\n                return setWith(\n                    clone(prev),\n                    path,\n                    currentRules.map((rule: QueryBuilderRule) => {\n                        if (rule.uniqueId !== uniqueId) return rule;\n                        return {\n                            ...rule,\n                            operator: value,\n                        };\n                    }),\n                    clone,\n                );\n            });\n        }, []);\n\n        const handleChangeValue = useCallback((args: any) => {\n            const { groupIndex, level, uniqueId, value } = args;\n            const path = getRulePath(level, groupIndex);\n\n            setFilters((prev) => {\n                const currentRules = get(prev, path) || [];\n                return setWith(\n                    clone(prev),\n                    path,\n                    currentRules.map((rule: QueryBuilderRule) => {\n                        if (rule.uniqueId !== uniqueId) return rule;\n                        return {\n                            ...rule,\n                            value,\n                        };\n                    }),\n                    clone,\n                );\n            });\n        }, []);\n\n        const customFields = useMemo(() => {\n            return queryBuilderSettings.tag\n                .filter((field) => field.value && field.value.trim() !== '')\n                .map((field) => ({\n                    label: field.label,\n                    type: field.type,\n                    value: field.value,\n                }));\n        }, [queryBuilderSettings.tag]);\n\n        const groupedFilters = useMemo(() => {\n            type FilterGroup = {\n                group: string;\n                items: Array<{ label: string; type: string; value: string }>;\n            };\n            const groups: FilterGroup[] = [];\n\n            // Custom Fields group\n            if (customFields.length > 0) {\n                groups.push({\n                    group: t('queryBuilder.customTags', {\n                        postProcess: 'titleCase',\n                    }),\n                    items: customFields,\n                });\n            }\n\n            // Standard Fields group\n            if (NDSongQueryFields.length > 0) {\n                groups.push({\n                    group: t('queryBuilder.standardTags', {\n                        postProcess: 'titleCase',\n                    }),\n                    items: NDSongQueryFields,\n                });\n            }\n\n            if (groups.length === 0) {\n                return NDSongQueryFields;\n            }\n\n            if (groups.length === 1) {\n                return groups[0].items;\n            }\n\n            return groups;\n        }, [customFields, t]);\n\n        // Memoize sort options\n        const sortOptions = useMemo(\n            () => [\n                {\n                    label: t('filter.random', { postProcess: 'titleCase' }),\n                    type: 'string',\n                    value: 'random',\n                },\n                ...NDSongQueryFields,\n            ],\n            [t],\n        );\n\n        // Memoize order select data\n        const orderSelectData = useMemo(\n            () => [\n                {\n                    label: t('common.ascending', { postProcess: 'sentenceCase' }),\n                    value: 'asc',\n                },\n                {\n                    label: t('common.descending', { postProcess: 'sentenceCase' }),\n                    value: 'desc',\n                },\n            ],\n            [t],\n        );\n\n        // Memoize operators object\n        const operators = useMemo(\n            () => ({\n                boolean: NDSongQueryBooleanOperators,\n                date: NDSongQueryDateOperators,\n                number: NDSongQueryNumberOperators,\n                playlist: NDSongQueryPlaylistOperators,\n                string: NDSongQueryStringOperators,\n            }),\n            [],\n        );\n\n        const handleAddSortEntry = useCallback(() => {\n            extraFiltersForm.insertListItem('sortEntries', { field: '', order: 'asc' });\n        }, [extraFiltersForm]);\n\n        const handleRemoveSortEntry = useCallback(\n            (index: number) => {\n                extraFiltersForm.removeListItem('sortEntries', index);\n            },\n            [extraFiltersForm],\n        );\n\n        const handleSortFieldChange = useCallback(\n            (index: number, value: string) => {\n                extraFiltersForm.setFieldValue(`sortEntries.${index}.field`, value);\n            },\n            [extraFiltersForm],\n        );\n\n        const handleSortOrderChange = useCallback(\n            (index: number, value: 'asc' | 'desc') => {\n                extraFiltersForm.setFieldValue(`sortEntries.${index}.order`, value);\n            },\n            [extraFiltersForm],\n        );\n\n        return (\n            <Flex direction=\"column\" h=\"100%\" w=\"100%\">\n                <ScrollArea style={{ height: '100%' }}>\n                    <Stack gap=\"md\" h=\"100%\" p=\"1rem\">\n                        <QueryBuilder\n                            data={filters}\n                            filters={groupedFilters}\n                            groupIndex={[]}\n                            level={0}\n                            onAddRule={handleAddRule}\n                            onAddRuleGroup={handleAddRuleGroup}\n                            onChangeField={handleChangeField}\n                            onChangeOperator={handleChangeOperator}\n                            onChangeType={handleChangeType}\n                            onChangeValue={handleChangeValue}\n                            onClearFilters={handleClearFilters}\n                            onDeleteRule={handleDeleteRule}\n                            onDeleteRuleGroup={handleDeleteRuleGroup}\n                            onResetFilters={handleResetFilters}\n                            operators={operators}\n                            playlists={playlistData}\n                            uniqueId={filters.uniqueId}\n                        />\n                        <Group align=\"flex-end\" gap=\"sm\" w=\"100%\" wrap=\"nowrap\">\n                            <Stack gap=\"xs\" w=\"100%\">\n                                {extraFiltersForm.values.sortEntries.map((entry, index) => (\n                                    <Group align=\"flex-end\" gap=\"sm\" key={index} wrap=\"nowrap\">\n                                        <Select\n                                            data={sortOptions}\n                                            label={\n                                                index === 0\n                                                    ? t('common.sort', { postProcess: 'titleCase' })\n                                                    : ''\n                                            }\n                                            onChange={(value) =>\n                                                handleSortFieldChange(index, value || '')\n                                            }\n                                            searchable\n                                            value={entry.field}\n                                            width={200}\n                                        />\n                                        <Select\n                                            data={orderSelectData}\n                                            label={\n                                                index === 0\n                                                    ? t('common.sortOrder', {\n                                                          postProcess: 'titleCase',\n                                                      })\n                                                    : ''\n                                            }\n                                            onChange={(value) =>\n                                                handleSortOrderChange(\n                                                    index,\n                                                    (value as 'asc' | 'desc') || 'asc',\n                                                )\n                                            }\n                                            value={entry.order}\n                                            width={125}\n                                        />\n                                        {extraFiltersForm.values.sortEntries.length > 1 && (\n                                            <ActionIcon\n                                                icon=\"minus\"\n                                                onClick={() => handleRemoveSortEntry(index)}\n                                                variant=\"subtle\"\n                                            />\n                                        )}\n                                        {index ===\n                                            extraFiltersForm.values.sortEntries.length - 1 && (\n                                            <ActionIcon\n                                                icon=\"plus\"\n                                                onClick={handleAddSortEntry}\n                                                variant=\"subtle\"\n                                            />\n                                        )}\n                                    </Group>\n                                ))}\n                            </Stack>\n                            <NumberInput\n                                label={t('common.limit', { postProcess: 'titleCase' })}\n                                maxWidth=\"20%\"\n                                width={75}\n                                {...extraFiltersForm.getInputProps('limit')}\n                            />\n                        </Group>\n                    </Stack>\n                </ScrollArea>\n            </Flex>\n        );\n    },\n);\n"
  },
  {
    "path": "src/renderer/features/playlists/components/playlist-query-editor.tsx",
    "content": "import { closeAllModals, openModal } from '@mantine/modals';\nimport { useQuery } from '@tanstack/react-query';\nimport { useCallback, useMemo, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport {\n    PlaylistQueryBuilder,\n    PlaylistQueryBuilderRef,\n} from '/@/renderer/features/playlists/components/playlist-query-builder';\nimport { useUpdatePlaylist } from '/@/renderer/features/playlists/mutations/update-playlist-mutation';\nimport { convertQueryGroupToNDQuery } from '/@/renderer/features/playlists/utils';\nimport { JsonPreview } from '/@/renderer/features/shared/components/json-preview';\nimport { Box } from '/@/shared/components/box/box';\nimport { Button } from '/@/shared/components/button/button';\nimport { Flex } from '/@/shared/components/flex/flex';\nimport { Group } from '/@/shared/components/group/group';\nimport { Icon } from '/@/shared/components/icon/icon';\nimport { JsonInput } from '/@/shared/components/json-input/json-input';\nimport { ConfirmModal } from '/@/shared/components/modal/modal';\nimport { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';\nimport { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { Text } from '/@/shared/components/text/text';\nimport { toast } from '/@/shared/components/toast/toast';\nimport { SongListSort } from '/@/shared/types/domain-types';\n\nexport interface PlaylistQueryEditorProps {\n    detailQuery: ReturnType<typeof useQuery<any>>;\n    handleSave: (\n        filter: Record<string, any>,\n        extraFilters: { limit?: number; sortBy?: string[]; sortOrder?: string },\n    ) => void;\n    handleSaveAs: (\n        filter: Record<string, any>,\n        extraFilters: { limit?: number; sortBy?: string[]; sortOrder?: string },\n    ) => void;\n    isQueryBuilderExpanded: boolean;\n    onToggleExpand: () => void;\n    playlistId: string;\n    queryBuilderRef: React.RefObject<null | PlaylistQueryBuilderRef>;\n    updatePlaylistMutation: ReturnType<typeof useUpdatePlaylist>;\n}\n\ntype AppliedJsonState = {\n    limit?: number;\n    query: Record<string, any>;\n    sort?: string;\n};\n\ntype EditorMode = 'builder' | 'json';\n\nconst serializeFiltersToRulesJson = (filters: {\n    extraFilters: { limit?: number; sortBy?: string[] };\n    filters: any;\n}): Record<string, any> => {\n    const queryValue = convertQueryGroupToNDQuery(filters.filters);\n    const sortString = filters.extraFilters.sortBy?.[0];\n    return {\n        ...queryValue,\n        ...(filters.extraFilters.limit != null && { limit: filters.extraFilters.limit }),\n        ...(sortString && { sort: sortString }),\n    };\n};\n\nconst parseRulesJsonToSaveArgs = (\n    parsed: Record<string, any>,\n): { extraFilters: { limit?: number; sortBy?: string[] }; filter: Record<string, any> } => {\n    const rootKey = parsed.all ? 'all' : 'any';\n    const filter = rootKey in parsed ? { [rootKey]: parsed[rootKey] } : { all: [] };\n    return {\n        extraFilters: {\n            ...(parsed.limit != null && { limit: parsed.limit }),\n            ...(parsed.sort != null && { sortBy: [parsed.sort] }),\n        },\n        filter,\n    };\n};\n\nexport const PlaylistQueryEditor = ({\n    detailQuery,\n    handleSave,\n    handleSaveAs,\n    isQueryBuilderExpanded,\n    onToggleExpand,\n    playlistId,\n    queryBuilderRef,\n    updatePlaylistMutation,\n}: PlaylistQueryEditorProps) => {\n    const { t } = useTranslation();\n\n    const [editorMode, setEditorMode] = useState<EditorMode>('builder');\n    const [jsonText, setJsonText] = useState('');\n    const [appliedJsonState, setAppliedJsonState] = useState<AppliedJsonState | null>(null);\n\n    const getFiltersForSave = useCallback((): null | {\n        extraFilters: { limit?: number; sortBy?: string[]; sortOrder?: string };\n        filter: Record<string, any>;\n    } => {\n        if (editorMode === 'json') {\n            try {\n                const parsed = JSON.parse(jsonText) as Record<string, any>;\n                const { extraFilters, filter } = parseRulesJsonToSaveArgs(parsed);\n                return { extraFilters, filter };\n            } catch {\n                return null;\n            }\n        }\n        const filters = queryBuilderRef.current?.getFilters();\n        if (!filters) return null;\n        return {\n            extraFilters: filters.extraFilters,\n            filter: convertQueryGroupToNDQuery(filters.filters),\n        };\n    }, [editorMode, jsonText, queryBuilderRef]);\n\n    const openPreviewModal = useCallback(() => {\n        const payload = getFiltersForSave();\n        if (!payload) {\n            if (editorMode === 'json') {\n                toast.error({ message: t('error.invalidJson', { postProcess: 'sentenceCase' }) });\n            }\n            return;\n        }\n        const previewValue = {\n            ...payload.filter,\n            ...(payload.extraFilters.limit != null && { limit: payload.extraFilters.limit }),\n            ...(payload.extraFilters.sortBy?.[0] && { sort: payload.extraFilters.sortBy[0] }),\n        };\n        openModal({\n            children: <JsonPreview value={previewValue} />,\n            size: 'xl',\n            title: t('common.preview', { postProcess: 'titleCase' }),\n        });\n    }, [editorMode, getFiltersForSave, t]);\n\n    const openSaveAndReplaceModal = useCallback(() => {\n        if (!isQueryBuilderExpanded) return;\n        const payload = getFiltersForSave();\n        if (!payload) {\n            if (editorMode === 'json') {\n                toast.error({ message: t('error.invalidJson', { postProcess: 'sentenceCase' }) });\n            }\n            return;\n        }\n        openModal({\n            children: (\n                <ConfirmModal\n                    onConfirm={() => {\n                        handleSave(payload.filter, payload.extraFilters);\n                        closeAllModals();\n                    }}\n                >\n                    <Text>{t('common.areYouSure', { postProcess: 'sentenceCase' })}</Text>\n                </ConfirmModal>\n            ),\n            title: t('common.saveAndReplace', { postProcess: 'titleCase' }),\n        });\n    }, [editorMode, getFiltersForSave, handleSave, isQueryBuilderExpanded, t]);\n\n    const parseSortBy = useCallback((): string[] => {\n        const sort = detailQuery?.data?.rules?.sort;\n        // Handle new syntax: comma-separated with +/- prefix\n        // e.g., \"+album,-year\" -> return as single string in array\n        if (typeof sort === 'string') {\n            // Check if it's new syntax (has +/- prefix or commas)\n            if (sort.includes(',') || sort.startsWith('+') || sort.startsWith('-')) {\n                return [sort];\n            }\n            // Old syntax: single field, convert to new format with default order\n            const order = detailQuery?.data?.rules?.order || 'asc';\n            const prefix = order === 'desc' ? '-' : '+';\n            return [`${prefix}${sort}`];\n        }\n        if (Array.isArray(sort)) {\n            // If array, check if first item has +/- prefix\n            if (\n                sort.length > 0 &&\n                typeof sort[0] === 'string' &&\n                (sort[0].startsWith('+') || sort[0].startsWith('-'))\n            ) {\n                return sort;\n            }\n            // Old array format, convert to new format\n            const order = detailQuery?.data?.rules?.order || 'asc';\n            const prefix = order === 'desc' ? '-' : '+';\n            return sort.map((s) => `${prefix}${s}`);\n        }\n        return ['+dateAdded'];\n    }, [detailQuery?.data?.rules?.order, detailQuery?.data?.rules?.sort]);\n\n    const parseSortOrder = useCallback((): 'asc' | 'desc' => {\n        const sort = detailQuery?.data?.rules?.sort;\n        if (typeof sort === 'string' && sort.startsWith('-')) {\n            return 'desc';\n        }\n        // Fall back to old order field or default\n        return detailQuery?.data?.rules?.order || 'asc';\n    }, [detailQuery?.data?.rules?.order, detailQuery?.data?.rules?.sort]);\n\n    const effectiveQuery = useMemo(\n        () =>\n            appliedJsonState?.query ??\n            (detailQuery?.data?.rules?.all\n                ? { all: detailQuery.data.rules.all }\n                : detailQuery?.data?.rules?.any\n                  ? { any: detailQuery.data.rules.any }\n                  : detailQuery?.data?.rules),\n        [appliedJsonState?.query, detailQuery?.data?.rules],\n    );\n    const effectiveLimit = appliedJsonState?.limit ?? detailQuery?.data?.rules?.limit;\n    const effectiveSortBy = useMemo(\n        () =>\n            (appliedJsonState?.sort ? [appliedJsonState.sort] : parseSortBy()) as\n                | SongListSort\n                | SongListSort[],\n        [appliedJsonState?.sort, parseSortBy],\n    );\n    const effectiveSortOrder = appliedJsonState?.sort\n        ? appliedJsonState.sort.startsWith('-')\n            ? 'desc'\n            : 'asc'\n        : parseSortOrder();\n\n    const handleEditorModeChange = useCallback(\n        (value: string) => {\n            const nextMode = value as EditorMode;\n            if (nextMode === 'json') {\n                const filters = queryBuilderRef.current?.getFilters();\n                if (filters) {\n                    setJsonText(JSON.stringify(serializeFiltersToRulesJson(filters), null, 2));\n                } else {\n                    const fallback: Record<string, any> = effectiveQuery\n                        ? { ...effectiveQuery }\n                        : { all: [] };\n                    if (effectiveLimit != null) fallback.limit = effectiveLimit;\n                    if (effectiveSortBy?.[0]) fallback.sort = effectiveSortBy[0];\n                    if (!fallback.sort) fallback.sort = '+dateAdded';\n                    setJsonText(JSON.stringify(fallback, null, 2));\n                }\n                setEditorMode('json');\n            } else {\n                if (editorMode === 'json') {\n                    try {\n                        const parsed = JSON.parse(jsonText) as Record<string, any>;\n                        const rootKey = parsed.all ? 'all' : 'any';\n                        if (!parsed[rootKey] || !Array.isArray(parsed[rootKey])) {\n                            throw new Error('Invalid rules structure');\n                        }\n                        setAppliedJsonState({\n                            limit: parsed.limit,\n                            query: { [rootKey]: parsed[rootKey] },\n                            sort: parsed.sort,\n                        });\n                    } catch {\n                        toast.error({\n                            message: t('error.invalidJson', {\n                                postProcess: 'sentenceCase',\n                            }),\n                        });\n                        return;\n                    }\n                }\n                setEditorMode('builder');\n            }\n        },\n        [editorMode, effectiveLimit, effectiveQuery, effectiveSortBy, jsonText, queryBuilderRef, t],\n    );\n\n    return (\n        <div\n            className=\"query-editor-container\"\n            style={{ borderTop: '1px solid var(--theme-colors-border)' }}\n        >\n            <Stack gap={0} h=\"100%\" mah=\"30dvh\" p=\"sm\" w=\"100%\">\n                <Group justify=\"space-between\" wrap=\"nowrap\">\n                    <Group gap=\"sm\" wrap=\"nowrap\">\n                        <Button\n                            leftSection={\n                                <Icon\n                                    icon={isQueryBuilderExpanded ? 'arrowDownS' : 'arrowUpS'}\n                                    size=\"lg\"\n                                />\n                            }\n                            onClick={onToggleExpand}\n                            size=\"sm\"\n                            variant=\"subtle\"\n                        >\n                            {t('form.queryEditor.title', {\n                                postProcess: 'titleCase',\n                            })}\n                        </Button>\n                        {isQueryBuilderExpanded && (\n                            <SegmentedControl\n                                data={[\n                                    {\n                                        label: (\n                                            <Flex>\n                                                <Icon icon=\"queryBuilder\" />\n                                            </Flex>\n                                        ),\n                                        value: 'builder',\n                                    },\n                                    {\n                                        label: (\n                                            <Flex>\n                                                <Icon icon=\"json\" />\n                                            </Flex>\n                                        ),\n                                        value: 'json',\n                                    },\n                                ]}\n                                onChange={handleEditorModeChange}\n                                size=\"xs\"\n                                value={editorMode}\n                            />\n                        )}\n                    </Group>\n                    <Group gap=\"xs\">\n                        <Button onClick={openPreviewModal} size=\"sm\" variant=\"subtle\">\n                            {t('common.preview', { postProcess: 'titleCase' })}\n                        </Button>\n                        <Button\n                            disabled={!isQueryBuilderExpanded}\n                            leftSection={<Icon icon=\"save\" />}\n                            loading={updatePlaylistMutation?.isPending}\n                            onClick={() => {\n                                if (!isQueryBuilderExpanded) return;\n                                const payload = getFiltersForSave();\n                                if (payload) {\n                                    handleSaveAs(payload.filter, payload.extraFilters);\n                                } else if (editorMode === 'json') {\n                                    toast.error({\n                                        message: t('error.invalidJson', {\n                                            postProcess: 'sentenceCase',\n                                        }),\n                                    });\n                                }\n                            }}\n                            size=\"sm\"\n                            variant=\"subtle\"\n                        >\n                            {t('common.saveAs', { postProcess: 'titleCase' })}\n                        </Button>\n                        <Button\n                            disabled={!isQueryBuilderExpanded}\n                            leftSection={<Icon color=\"error\" icon=\"save\" />}\n                            onClick={openSaveAndReplaceModal}\n                            size=\"sm\"\n                            variant=\"subtle\"\n                        >\n                            {t('common.saveAndReplace', {\n                                postProcess: 'titleCase',\n                            })}\n                        </Button>\n                    </Group>\n                </Group>\n                <Box\n                    py=\"md\"\n                    style={{\n                        display: isQueryBuilderExpanded ? 'flex' : 'none',\n                        flex: 1,\n                        minHeight: 0,\n                        overflow: 'hidden',\n                    }}\n                >\n                    {editorMode === 'builder' ? (\n                        <PlaylistQueryBuilder\n                            key={JSON.stringify(appliedJsonState ?? detailQuery?.data?.rules)}\n                            limit={effectiveLimit}\n                            playlistId={playlistId}\n                            query={effectiveQuery}\n                            ref={queryBuilderRef}\n                            sortBy={effectiveSortBy}\n                            sortOrder={effectiveSortOrder}\n                        />\n                    ) : (\n                        <ScrollArea style={{ flex: 1, minHeight: 0 }}>\n                            <JsonInput\n                                autosize\n                                minRows={8}\n                                onChange={(value) => setJsonText(value)}\n                                placeholder='{ \"all\": [], \"limit\": 100, \"sort\": \"+dateAdded\" }'\n                                spellCheck={false}\n                                style={{ flex: 1, minHeight: 0 }}\n                                value={jsonText}\n                            />\n                        </ScrollArea>\n                    )}\n                </Box>\n            </Stack>\n        </div>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/playlists/components/save-and-replace-context-modal.tsx",
    "content": "import { closeAllModals, ContextModalProps } from '@mantine/modals';\nimport { useCallback } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport { useUpdatePlaylist } from '/@/renderer/features/playlists/mutations/update-playlist-mutation';\nimport { useCurrentServerId } from '/@/renderer/store';\nimport { ConfirmModal } from '/@/shared/components/modal/modal';\nimport { Text } from '/@/shared/components/text/text';\nimport { toast } from '/@/shared/components/toast/toast';\nimport { UpdatePlaylistBody } from '/@/shared/types/domain-types';\n\nexport const SaveAndReplaceContextModal = ({\n    innerProps,\n}: ContextModalProps<{ playlistId: string; updateBody: UpdatePlaylistBody }>) => {\n    const { t } = useTranslation();\n    const { playlistId, updateBody } = innerProps;\n    const serverId = useCurrentServerId();\n\n    const updatePlaylistMutation = useUpdatePlaylist({});\n\n    const handleConfirm = useCallback(() => {\n        if (!serverId || !playlistId) {\n            console.error('serverId or playlistId is not defined');\n            return;\n        }\n\n        updatePlaylistMutation.mutate(\n            {\n                apiClientProps: { serverId },\n                body: updateBody,\n                query: { id: playlistId },\n            },\n            {\n                onError: (err) => {\n                    console.error(err);\n                    toast.error({\n                        message: err.message,\n                        title: t('error.genericError', {\n                            postProcess: 'sentenceCase',\n                        }),\n                    });\n                },\n                onSuccess: () => {\n                    closeAllModals();\n                    toast.success({\n                        message: t('form.editPlaylist.success', {\n                            postProcess: 'sentenceCase',\n                        }),\n                    });\n                },\n            },\n        );\n    }, [t, serverId, playlistId, updateBody, updatePlaylistMutation]);\n\n    return (\n        <ConfirmModal loading={updatePlaylistMutation.isPending} onConfirm={handleConfirm}>\n            <Text>{t('form.editPlaylist.editNote', { postProcess: 'sentenceCase' })}</Text>\n        </ConfirmModal>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/playlists/components/save-as-playlist-form.tsx",
    "content": "import { useTranslation } from 'react-i18next';\n\nimport { useCreatePlaylist } from '/@/renderer/features/playlists/mutations/create-playlist-mutation';\nimport { useCurrentServer } from '/@/renderer/store';\nimport { hasFeature } from '/@/shared/api/utils';\nimport { Group } from '/@/shared/components/group/group';\nimport { ModalButton } from '/@/shared/components/modal/model-shared';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { Switch } from '/@/shared/components/switch/switch';\nimport { TextInput } from '/@/shared/components/text-input/text-input';\nimport { toast } from '/@/shared/components/toast/toast';\nimport { useForm } from '/@/shared/hooks/use-form';\nimport {\n    CreatePlaylistBody,\n    CreatePlaylistResponse,\n    ServerType,\n} from '/@/shared/types/domain-types';\nimport { ServerFeature } from '/@/shared/types/features-types';\n\ninterface SaveAsPlaylistFormProps {\n    body: Partial<CreatePlaylistBody>;\n    onCancel: () => void;\n    onSuccess: (data: CreatePlaylistResponse) => void;\n    serverId?: string;\n}\n\nexport const SaveAsPlaylistForm = ({\n    body,\n    onCancel,\n    onSuccess,\n    serverId,\n}: SaveAsPlaylistFormProps) => {\n    const { t } = useTranslation();\n    const mutation = useCreatePlaylist({});\n    const server = useCurrentServer();\n\n    const form = useForm<CreatePlaylistBody>({\n        initialValues: {\n            comment: body.comment || '',\n            name: body.name || '',\n            public: body.public,\n            queryBuilderRules: body.queryBuilderRules,\n        },\n    });\n\n    const handleSubmit = form.onSubmit((values) => {\n        mutation.mutate(\n            { apiClientProps: { serverId: serverId || '' }, body: values },\n            {\n                onError: (err) => {\n                    toast.error({\n                        message: err.message,\n                        title: t('error.genericError', { postProcess: 'sentenceCase' }),\n                    });\n                },\n                onSuccess: (data) => {\n                    toast.success({\n                        message: t('form.createPlaylist.success', { postProcess: 'sentenceCase' }),\n                    });\n                    onSuccess(data);\n                    onCancel();\n                },\n            },\n        );\n    });\n\n    const isPublicDisplayed = hasFeature(server, ServerFeature.PUBLIC_PLAYLIST);\n    const isSubmitDisabled = !form.values.name || mutation.isPending;\n\n    return (\n        <form onSubmit={handleSubmit}>\n            <Stack>\n                <TextInput\n                    data-autofocus\n                    label={t('form.createPlaylist.input', {\n                        context: 'name',\n                        postProcess: 'titleCase',\n                    })}\n                    required\n                    {...form.getInputProps('name')}\n                />\n                {server?.type === ServerType.NAVIDROME && (\n                    <TextInput\n                        label={t('form.createPlaylist.input', {\n                            context: 'description',\n                            postProcess: 'titleCase',\n                        })}\n                        {...form.getInputProps('comment')}\n                    />\n                )}\n                {isPublicDisplayed && (\n                    <Switch\n                        label={t('form.createPlaylist.input', {\n                            context: 'public',\n                            postProcess: 'titleCase',\n                        })}\n                        {...form.getInputProps('public', { type: 'checkbox' })}\n                    />\n                )}\n                <Group justify=\"flex-end\">\n                    <ModalButton onClick={onCancel}>{t('common.cancel')}</ModalButton>\n                    <ModalButton\n                        disabled={isSubmitDisabled}\n                        loading={mutation.isPending}\n                        type=\"submit\"\n                        variant=\"filled\"\n                    >\n                        {t('common.save')}\n                    </ModalButton>\n                </Group>\n            </Stack>\n        </form>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/playlists/components/update-playlist-form.tsx",
    "content": "import { closeModal, ContextModalProps } from '@mantine/modals';\nimport { useQuery } from '@tanstack/react-query';\nimport { t } from 'i18next';\nimport { useTranslation } from 'react-i18next';\n\nimport { useUpdatePlaylist } from '/@/renderer/features/playlists/mutations/update-playlist-mutation';\nimport { sharedQueries } from '/@/renderer/features/shared/api/shared-api';\nimport { useCurrentServer, useCurrentServerId, usePermissions } from '/@/renderer/store';\nimport { hasFeature } from '/@/shared/api/utils';\nimport { Group } from '/@/shared/components/group/group';\nimport { ModalButton } from '/@/shared/components/modal/model-shared';\nimport { Select } from '/@/shared/components/select/select';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { Switch } from '/@/shared/components/switch/switch';\nimport { TextInput } from '/@/shared/components/text-input/text-input';\nimport { toast } from '/@/shared/components/toast/toast';\nimport { useForm } from '/@/shared/hooks/use-form';\nimport {\n    ServerType,\n    SortOrder,\n    UpdatePlaylistBody,\n    UpdatePlaylistQuery,\n    UserListSort,\n} from '/@/shared/types/domain-types';\nimport { ServerFeature } from '/@/shared/types/features-types';\n\nexport const UpdatePlaylistContextModal = ({\n    id,\n    innerProps,\n}: ContextModalProps<{\n    body: Partial<UpdatePlaylistBody>;\n    query: UpdatePlaylistQuery;\n}>) => {\n    const { t } = useTranslation();\n    const mutation = useUpdatePlaylist({});\n    const server = useCurrentServer();\n    const { body, query } = innerProps;\n\n    const form = useForm<UpdatePlaylistBody>({\n        initialValues: {\n            comment: body?.comment || '',\n            name: body?.name || '',\n            ownerId: body.ownerId,\n            public: body.public,\n            queryBuilderRules: body.queryBuilderRules,\n            sync: body.sync,\n        },\n    });\n\n    const handleSubmit = form.onSubmit((values) => {\n        mutation.mutate(\n            {\n                apiClientProps: { serverId: server?.id || '' },\n                body: values,\n                query,\n            },\n            {\n                onError: (err) => {\n                    toast.error({\n                        message: err.message,\n                        title: t('error.genericError', { postProcess: 'sentenceCase' }),\n                    });\n                },\n                onSuccess: () => {\n                    toast.success({\n                        message: t('form.editPlaylist.success', { postProcess: 'sentenceCase' }),\n                    });\n                    closeModal(id);\n                },\n            },\n        );\n    });\n\n    const isPublicDisplayed = hasFeature(server, ServerFeature.PUBLIC_PLAYLIST);\n    const isOwnerDisplayed = server?.type === ServerType.NAVIDROME;\n    const isCommentDisplayed = server?.type === ServerType.NAVIDROME;\n    const isSubmitDisabled = !form.values.name || mutation.isPending;\n\n    return (\n        <form onSubmit={handleSubmit}>\n            <Stack>\n                <TextInput\n                    data-autofocus\n                    label={t('form.createPlaylist.input', {\n                        context: 'name',\n                        postProcess: 'titleCase',\n                    })}\n                    required\n                    {...form.getInputProps('name')}\n                />\n                {isCommentDisplayed && (\n                    <TextInput\n                        label={t('form.createPlaylist.input', {\n                            context: 'description',\n                            postProcess: 'titleCase',\n                        })}\n                        {...form.getInputProps('comment')}\n                    />\n                )}\n                {isOwnerDisplayed && <OwnerSelect form={form} />}\n                {isPublicDisplayed && (\n                    <>\n                        {server?.type === ServerType.JELLYFIN && (\n                            <div>\n                                {t('form.editPlaylist.publicJellyfinNote', {\n                                    postProcess: 'sentenceCase',\n                                })}\n                            </div>\n                        )}\n                        <Switch\n                            label={t('form.createPlaylist.input', {\n                                context: 'public',\n                                postProcess: 'titleCase',\n                            })}\n                            {...form.getInputProps('public', { type: 'checkbox' })}\n                        />\n                    </>\n                )}\n                <Group justify=\"flex-end\">\n                    <ModalButton onClick={() => closeModal(id)}>{t('common.cancel')}</ModalButton>\n                    <ModalButton\n                        disabled={isSubmitDisabled}\n                        loading={mutation.isPending}\n                        type=\"submit\"\n                        variant=\"filled\"\n                    >\n                        {t('common.save')}\n                    </ModalButton>\n                </Group>\n            </Stack>\n        </form>\n    );\n};\n\nconst OwnerSelect = ({ form }: { form: ReturnType<typeof useForm<UpdatePlaylistBody>> }) => {\n    const serverId = useCurrentServerId();\n    const permissions = usePermissions();\n\n    const usersQuery = useQuery(\n        sharedQueries.users({\n            options: { enabled: permissions.playlists.editOwner },\n            query: { sortBy: UserListSort.NAME, sortOrder: SortOrder.ASC, startIndex: 0 },\n            serverId,\n        }),\n    );\n\n    const userList = usersQuery.data?.items?.map((user) => ({\n        label: user.name,\n        value: user.id,\n    }));\n\n    if (!permissions.playlists.editOwner) {\n        return null;\n    }\n\n    return (\n        <Select\n            data={usersQuery.isLoading ? [] : userList}\n            disabled={usersQuery.isLoading}\n            {...form.getInputProps('ownerId')}\n            label={t('form.createPlaylist.input', {\n                context: 'owner',\n                postProcess: 'titleCase',\n            })}\n        />\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/playlists/components/update-playlist-modal.ts",
    "content": "import { openContextModal } from '@mantine/modals';\n\nimport i18n from '/@/i18n/i18n';\nimport { Playlist } from '/@/shared/types/domain-types';\n\nexport const openUpdatePlaylistModal = async (args: { playlist: Playlist }) => {\n    const { playlist } = args;\n\n    openContextModal({\n        innerProps: {\n            body: {\n                comment: playlist?.description || undefined,\n                genres: playlist?.genres,\n                name: playlist?.name,\n                ownerId: playlist?.ownerId || undefined,\n                public: playlist?.public || false,\n                queryBuilderRules: playlist?.rules || undefined,\n                sync: playlist?.sync || undefined,\n            },\n            query: { id: playlist?.id },\n        },\n        modalKey: 'updatePlaylist',\n        title: i18n.t('form.editPlaylist.title', { postProcess: 'titleCase' }) as string,\n    });\n};\n"
  },
  {
    "path": "src/renderer/features/playlists/hooks/use-playlist-list-filters.ts",
    "content": "import { useCallback, useMemo } from 'react';\nimport { useSearchParams } from 'react-router';\n\nimport { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-search-term-filter';\nimport { useSortByFilter } from '/@/renderer/features/shared/hooks/use-sort-by-filter';\nimport { useSortOrderFilter } from '/@/renderer/features/shared/hooks/use-sort-order-filter';\nimport { FILTER_KEYS } from '/@/renderer/features/shared/utils';\nimport { parseCustomFiltersParam } from '/@/renderer/utils/query-params';\nimport { PlaylistListSort } from '/@/shared/types/domain-types';\nimport { ItemListKey } from '/@/shared/types/types';\n\nexport const usePlaylistListFilters = () => {\n    const sortByFilter = useSortByFilter<PlaylistListSort>(null, ItemListKey.PLAYLIST);\n    const sortOrderFilter = useSortOrderFilter(null, ItemListKey.PLAYLIST);\n\n    const { searchTerm, setSearchTerm } = useSearchTermFilter('');\n\n    const [searchParams, setSearchParams] = useSearchParams();\n\n    const custom = useMemo(\n        () => parseCustomFiltersParam(searchParams, FILTER_KEYS.PLAYLIST.CUSTOM),\n        [searchParams],\n    );\n\n    const setCustom = useCallback(\n        (value: null | Record<string, any>) => {\n            setSearchParams(\n                (prev) => {\n                    const previousValue = prev.get(FILTER_KEYS.ALBUM._CUSTOM);\n\n                    const newCustom = {\n                        ...(previousValue ? JSON.parse(previousValue) : {}),\n                        ...value,\n                    };\n\n                    const filteredNewCustom = Object.fromEntries(\n                        Object.entries(newCustom).filter(\n                            ([, value]) => value !== null && value !== undefined,\n                        ),\n                    );\n\n                    prev.set(FILTER_KEYS.ALBUM._CUSTOM, JSON.stringify(filteredNewCustom));\n                    return prev;\n                },\n                {\n                    replace: true,\n                },\n            );\n        },\n        [setSearchParams],\n    );\n\n    const query = useMemo(\n        () => ({\n            _custom: custom ?? undefined,\n            searchTerm: searchTerm ?? undefined,\n            sortBy: sortByFilter.sortBy ?? undefined,\n            sortOrder: sortOrderFilter.sortOrder ?? undefined,\n        }),\n        [custom, searchTerm, sortByFilter.sortBy, sortOrderFilter.sortOrder],\n    );\n\n    return {\n        query,\n        setCustom,\n        setSearchTerm,\n    };\n};\n"
  },
  {
    "path": "src/renderer/features/playlists/hooks/use-playlist-song-list-filters.ts",
    "content": "import { useCallback, useMemo } from 'react';\nimport { useSearchParams } from 'react-router';\n\nimport { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-search-term-filter';\nimport { useSortByFilter } from '/@/renderer/features/shared/hooks/use-sort-by-filter';\nimport { useSortOrderFilter } from '/@/renderer/features/shared/hooks/use-sort-order-filter';\nimport { FILTER_KEYS } from '/@/renderer/features/shared/utils';\nimport { useAppStore } from '/@/renderer/store/app.store';\nimport {\n    parseArrayParam,\n    parseBooleanParam,\n    parseCustomFiltersParam,\n    parseIntParam,\n    setMultipleSearchParams,\n    setSearchParam,\n} from '/@/renderer/utils/query-params';\nimport { SongListSort, SortOrder } from '/@/shared/types/domain-types';\nimport { ItemListKey } from '/@/shared/types/types';\n\nexport const usePlaylistSongListFilters = () => {\n    const albumArtistIdsMode = useAppStore((state) => state.albumArtistIdsMode);\n    const artistIdsMode = useAppStore((state) => state.artistIdsMode);\n    const genreIdsMode = useAppStore((state) => state.genreIdsMode);\n    const setAlbumArtistIdsModeStore = useAppStore((state) => state.actions.setAlbumArtistIdsMode);\n    const setArtistIdsModeStore = useAppStore((state) => state.actions.setArtistIdsMode);\n    const setGenreIdsModeStore = useAppStore((state) => state.actions.setGenreIdsMode);\n    const { sortBy } = useSortByFilter<SongListSort>(SongListSort.ID, ItemListKey.PLAYLIST_SONG);\n\n    const { sortOrder } = useSortOrderFilter(SortOrder.ASC, ItemListKey.PLAYLIST_SONG);\n\n    const { searchTerm, setSearchTerm } = useSearchTermFilter('');\n\n    const [searchParams, setSearchParams] = useSearchParams();\n\n    const albumArtistIds = useMemo(\n        () => parseArrayParam(searchParams, FILTER_KEYS.SONG.ALBUM_ARTIST_IDS),\n        [searchParams],\n    );\n\n    const genreId = useMemo(\n        () => parseArrayParam(searchParams, FILTER_KEYS.SONG.GENRE_ID),\n        [searchParams],\n    );\n\n    const artistIds = useMemo(\n        () => parseArrayParam(searchParams, FILTER_KEYS.SONG.ARTIST_IDS),\n        [searchParams],\n    );\n\n    const minYear = useMemo(\n        () => parseIntParam(searchParams, FILTER_KEYS.SONG.MIN_YEAR),\n        [searchParams],\n    );\n\n    const maxYear = useMemo(\n        () => parseIntParam(searchParams, FILTER_KEYS.SONG.MAX_YEAR),\n        [searchParams],\n    );\n\n    const favorite = useMemo(\n        () => parseBooleanParam(searchParams, FILTER_KEYS.SONG.FAVORITE),\n        [searchParams],\n    );\n\n    const hasRating = useMemo(\n        () => parseBooleanParam(searchParams, FILTER_KEYS.SONG.HAS_RATING),\n        [searchParams],\n    );\n\n    const custom = useMemo(\n        () => parseCustomFiltersParam(searchParams, FILTER_KEYS.SONG._CUSTOM),\n        [searchParams],\n    );\n\n    const setAlbumArtistIds = useCallback(\n        (value: null | string[]) => {\n            setSearchParams(\n                (prev) => setSearchParam(prev, FILTER_KEYS.SONG.ALBUM_ARTIST_IDS, value),\n                { replace: true },\n            );\n        },\n        [setSearchParams],\n    );\n\n    const setGenreId = useCallback(\n        (value: null | string[]) => {\n            setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.GENRE_ID, value), {\n                replace: true,\n            });\n        },\n        [setSearchParams],\n    );\n\n    const setArtistIds = useCallback(\n        (value: null | string[]) => {\n            setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.ARTIST_IDS, value), {\n                replace: true,\n            });\n        },\n        [setSearchParams],\n    );\n\n    const setMinYear = useCallback(\n        (value: null | number) => {\n            setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.MIN_YEAR, value), {\n                replace: true,\n            });\n        },\n        [setSearchParams],\n    );\n\n    const setMaxYear = useCallback(\n        (value: null | number) => {\n            setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.MAX_YEAR, value), {\n                replace: true,\n            });\n        },\n        [setSearchParams],\n    );\n\n    const setFavorite = useCallback(\n        (value: boolean | null) => {\n            setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.FAVORITE, value), {\n                replace: true,\n            });\n        },\n        [setSearchParams],\n    );\n\n    const setHasRating = useCallback(\n        (value: boolean | null) => {\n            setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.HAS_RATING, value), {\n                replace: true,\n            });\n        },\n        [setSearchParams],\n    );\n\n    const setAlbumArtistIdsMode = useCallback(\n        (value: 'and' | 'or') => setAlbumArtistIdsModeStore(value),\n        [setAlbumArtistIdsModeStore],\n    );\n\n    const setArtistIdsMode = useCallback(\n        (value: 'and' | 'or') => setArtistIdsModeStore(value),\n        [setArtistIdsModeStore],\n    );\n\n    const setGenreIdsMode = useCallback(\n        (value: 'and' | 'or') => setGenreIdsModeStore(value),\n        [setGenreIdsModeStore],\n    );\n\n    const setCustom = useCallback(\n        (value: null | Record<string, any>) => {\n            setSearchParams(\n                (prev) => {\n                    const previousValue = prev.get(FILTER_KEYS.ALBUM._CUSTOM);\n\n                    const newCustom = {\n                        ...(previousValue ? JSON.parse(previousValue) : {}),\n                        ...value,\n                    };\n\n                    const filteredNewCustom = Object.fromEntries(\n                        Object.entries(newCustom).filter(\n                            ([, value]) => value !== null && value !== undefined,\n                        ),\n                    );\n\n                    prev.set(FILTER_KEYS.ALBUM._CUSTOM, JSON.stringify(filteredNewCustom));\n                    return prev;\n                },\n                {\n                    replace: true,\n                },\n            );\n        },\n        [setSearchParams],\n    );\n\n    const clear = useCallback(() => {\n        setSearchParams(\n            (prev) =>\n                setMultipleSearchParams(\n                    prev,\n                    {\n                        [FILTER_KEYS.SONG._CUSTOM]: null,\n                        [FILTER_KEYS.SONG.ALBUM_ARTIST_IDS]: null,\n                        [FILTER_KEYS.SONG.ARTIST_IDS]: null,\n                        [FILTER_KEYS.SONG.FAVORITE]: null,\n                        [FILTER_KEYS.SONG.GENRE_ID]: null,\n                        [FILTER_KEYS.SONG.HAS_RATING]: null,\n                        [FILTER_KEYS.SONG.MAX_YEAR]: null,\n                        [FILTER_KEYS.SONG.MIN_YEAR]: null,\n                    },\n                    new Set([FILTER_KEYS.SONG._CUSTOM]),\n                ),\n            { replace: true },\n        );\n    }, [setSearchParams]);\n\n    const query = useMemo(\n        () => ({\n            [FILTER_KEYS.SHARED.SEARCH_TERM]: searchTerm ?? undefined,\n            [FILTER_KEYS.SHARED.SORT_BY]: sortBy ?? undefined,\n            [FILTER_KEYS.SHARED.SORT_ORDER]: sortOrder ?? undefined,\n            [FILTER_KEYS.SONG._CUSTOM]: custom ?? undefined,\n            [FILTER_KEYS.SONG.ALBUM_ARTIST_IDS]: albumArtistIds ?? undefined,\n            [FILTER_KEYS.SONG.ALBUM_ARTIST_IDS_MODE]: albumArtistIdsMode,\n            [FILTER_KEYS.SONG.ARTIST_IDS]: artistIds ?? undefined,\n            [FILTER_KEYS.SONG.ARTIST_IDS_MODE]: artistIdsMode,\n            [FILTER_KEYS.SONG.FAVORITE]: favorite ?? undefined,\n            [FILTER_KEYS.SONG.GENRE_ID]: genreId ?? undefined,\n            [FILTER_KEYS.SONG.GENRE_ID_MODE]: genreIdsMode,\n            [FILTER_KEYS.SONG.HAS_RATING]: hasRating ?? undefined,\n            [FILTER_KEYS.SONG.MAX_YEAR]: maxYear ?? undefined,\n            [FILTER_KEYS.SONG.MIN_YEAR]: minYear ?? undefined,\n        }),\n        [\n            searchTerm,\n            sortBy,\n            sortOrder,\n            custom,\n            albumArtistIds,\n            albumArtistIdsMode,\n            artistIds,\n            artistIdsMode,\n            favorite,\n            genreId,\n            genreIdsMode,\n            hasRating,\n            maxYear,\n            minYear,\n        ],\n    );\n\n    return {\n        clear,\n        query,\n        setAlbumArtistIds,\n        setAlbumArtistIdsMode,\n        setArtistIds,\n        setArtistIdsMode,\n        setCustom,\n        setFavorite,\n        setGenreId,\n        setGenreIdsMode,\n        setHasRating,\n        setMaxYear,\n        setMinYear,\n        setSearchTerm,\n    };\n};\n"
  },
  {
    "path": "src/renderer/features/playlists/hooks/use-playlist-track-list.ts",
    "content": "import { useEffect, useMemo } from 'react';\n\nimport { useListContext } from '/@/renderer/context/list-context';\nimport { usePlaylistSongListFilters } from '/@/renderer/features/playlists/hooks/use-playlist-song-list-filters';\nimport { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-search-term-filter';\nimport { FILTER_KEYS } from '/@/renderer/features/shared/utils';\nimport { searchLibraryItems } from '/@/renderer/features/shared/utils';\nimport { sortSongList } from '/@/shared/api/utils';\nimport {\n    LibraryItem,\n    PlaylistSongListResponse,\n    Song,\n    SongListSort,\n    SortOrder,\n} from '/@/shared/types/domain-types';\n\nexport function applyClientSideSongFilters(songs: Song[], query: Record<string, unknown>): Song[] {\n    let result = songs;\n\n    const favorite = query[FILTER_KEYS.SONG.FAVORITE] as boolean | undefined;\n    if (favorite === true) {\n        result = result.filter((s) => s.userFavorite === true);\n    } else if (favorite === false) {\n        result = result.filter((s) => s.userFavorite === false);\n    }\n\n    const hasRating = query[FILTER_KEYS.SONG.HAS_RATING] as boolean | undefined;\n    if (hasRating === true) {\n        result = result.filter((s) => s.userRating != null && s.userRating > 0);\n    } else if (hasRating === false) {\n        result = result.filter((s) => s.userRating == null || s.userRating === 0);\n    }\n\n    const albumArtistIdsMode =\n        (query[FILTER_KEYS.SONG.ALBUM_ARTIST_IDS_MODE] as 'and' | 'or' | undefined) ?? 'and';\n    const albumArtistIds = query[FILTER_KEYS.SONG.ALBUM_ARTIST_IDS] as string[] | undefined;\n    if (albumArtistIds?.length) {\n        if (albumArtistIdsMode === 'and') {\n            result = result.filter((s) =>\n                albumArtistIds!.every((id) => s.albumArtists?.some((a) => a.id === id)),\n            );\n        } else {\n            const set = new Set(albumArtistIds);\n            result = result.filter((s) => s.albumArtists?.some((a) => a.id && set.has(a.id)));\n        }\n    }\n\n    const artistIdsMode =\n        (query[FILTER_KEYS.SONG.ARTIST_IDS_MODE] as 'and' | 'or' | undefined) ?? 'and';\n    const artistIds = query[FILTER_KEYS.SONG.ARTIST_IDS] as string[] | undefined;\n    if (artistIds?.length) {\n        if (artistIdsMode === 'and') {\n            result = result.filter((s) =>\n                artistIds!.every((id) => s.artists?.some((a) => a.id === id)),\n            );\n        } else {\n            const set = new Set(artistIds);\n            result = result.filter((s) => s.artists?.some((a) => a.id && set.has(a.id)));\n        }\n    }\n\n    const genreIdsMode =\n        (query[FILTER_KEYS.SONG.GENRE_ID_MODE] as 'and' | 'or' | undefined) ?? 'and';\n    const genreIds = query[FILTER_KEYS.SONG.GENRE_ID] as string[] | undefined;\n    if (genreIds?.length) {\n        if (genreIdsMode === 'and') {\n            result = result.filter((s) =>\n                genreIds!.every((id) => s.genres?.some((g) => g.id === id)),\n            );\n        } else {\n            const set = new Set(genreIds);\n            result = result.filter((s) => s.genres?.some((g) => g.id && set.has(g.id)));\n        }\n    }\n\n    const minYear = query[FILTER_KEYS.SONG.MIN_YEAR] as number | undefined;\n    if (minYear != null) {\n        result = result.filter((s) => s.releaseYear != null && s.releaseYear >= minYear);\n    }\n\n    const maxYear = query[FILTER_KEYS.SONG.MAX_YEAR] as number | undefined;\n    if (maxYear != null) {\n        result = result.filter((s) => s.releaseYear != null && s.releaseYear <= maxYear);\n    }\n\n    return result;\n}\n\nexport function usePlaylistTrackList(data: PlaylistSongListResponse | undefined): {\n    sortedAndFilteredSongs: Song[];\n    totalCount: number;\n} {\n    const { setItemCount, setListData } = useListContext();\n    const { searchTerm } = useSearchTermFilter();\n    const { query } = usePlaylistSongListFilters();\n\n    const sortedAndFilteredSongs = useMemo(() => {\n        const raw = data?.items ?? [];\n        const filtered = applyClientSideSongFilters(raw, query as Record<string, unknown>);\n        const searched = searchTerm\n            ? searchLibraryItems(filtered, searchTerm, LibraryItem.SONG)\n            : filtered;\n        return sortSongList(\n            searched,\n            (query.sortBy as SongListSort) ?? SongListSort.ID,\n            (query.sortOrder as SortOrder) ?? SortOrder.ASC,\n        );\n    }, [data?.items, query, searchTerm]);\n\n    const totalCount = sortedAndFilteredSongs.length;\n\n    useEffect(() => {\n        setListData?.(sortedAndFilteredSongs);\n        setItemCount?.(totalCount);\n    }, [query, searchTerm, setListData, setItemCount, sortedAndFilteredSongs, totalCount]);\n\n    return { sortedAndFilteredSongs, totalCount };\n}\n"
  },
  {
    "path": "src/renderer/features/playlists/hooks/use-recent-playlists.ts",
    "content": "import { useCallback } from 'react';\n\nimport { useSessionStorage } from '/@/shared/hooks/use-session-storage';\n\ninterface RecentPlaylists {\n    [serverId: string]: string;\n}\n\nconst RECENT_PLAYLISTS_KEY = 'recent-playlists';\nconst DEFAULT_VALUE: RecentPlaylists = {};\n\nexport const useRecentPlaylists = (serverId: null | string) => {\n    const [recentPlaylists, setRecentPlaylists] = useSessionStorage<RecentPlaylists>({\n        defaultValue: DEFAULT_VALUE,\n        key: RECENT_PLAYLISTS_KEY,\n    });\n\n    const getRecentPlaylistId = useCallback((): null | string => {\n        if (!serverId) return null;\n        return recentPlaylists[serverId] || null;\n    }, [recentPlaylists, serverId]);\n\n    const addRecentPlaylist = useCallback(\n        (playlistId: string) => {\n            if (!serverId || !playlistId) return;\n\n            setRecentPlaylists({\n                ...recentPlaylists,\n                [serverId]: playlistId,\n            });\n        },\n        [recentPlaylists, serverId, setRecentPlaylists],\n    );\n\n    return {\n        addRecentPlaylist,\n        recentPlaylistId: getRecentPlaylistId(),\n    };\n};\n"
  },
  {
    "path": "src/renderer/features/playlists/mutations/add-to-playlist-mutation.ts",
    "content": "import { useMutation, useQueryClient } from '@tanstack/react-query';\nimport { AxiosError } from 'axios';\n\nimport { api } from '/@/renderer/api';\nimport { queryKeys } from '/@/renderer/api/query-keys';\nimport { useRecentPlaylists } from '/@/renderer/features/playlists/hooks/use-recent-playlists';\nimport { MutationHookArgs } from '/@/renderer/lib/react-query';\nimport { useCurrentServerId } from '/@/renderer/store';\nimport { AddToPlaylistArgs, AddToPlaylistResponse } from '/@/shared/types/domain-types';\n\nexport const useAddToPlaylist = (args: MutationHookArgs) => {\n    const { options } = args || {};\n    const queryClient = useQueryClient();\n    const serverId = useCurrentServerId();\n\n    const { addRecentPlaylist } = useRecentPlaylists(serverId);\n\n    return useMutation<AddToPlaylistResponse, AxiosError, AddToPlaylistArgs, null>({\n        mutationFn: (args) => {\n            return api.controller.addToPlaylist({\n                ...args,\n                apiClientProps: { serverId: args.apiClientProps.serverId },\n            });\n        },\n        onSuccess: (_data, variables, context) => {\n            const { apiClientProps } = variables;\n            const serverId = apiClientProps.serverId;\n\n            if (!serverId) return;\n\n            queryClient.invalidateQueries({\n                exact: false,\n                queryKey: queryKeys.playlists.list(serverId),\n            });\n            queryClient.invalidateQueries({\n                queryKey: queryKeys.playlists.detail(serverId, variables.query.id),\n            });\n            queryClient.invalidateQueries({\n                queryKey: queryKeys.playlists.songList(serverId, variables.query.id),\n            });\n\n            addRecentPlaylist(variables.query.id);\n\n            options?.onSuccess?.(_data, variables, context);\n        },\n        ...options,\n    });\n};\n"
  },
  {
    "path": "src/renderer/features/playlists/mutations/create-playlist-mutation.ts",
    "content": "import { useMutation, useQueryClient } from '@tanstack/react-query';\nimport { AxiosError } from 'axios';\n\nimport { api } from '/@/renderer/api';\nimport { queryKeys } from '/@/renderer/api/query-keys';\nimport { infiniteLoaderDataQueryKey } from '/@/renderer/components/item-list/helpers/item-list-infinite-loader';\nimport { MutationHookArgs } from '/@/renderer/lib/react-query';\nimport {\n    CreatePlaylistArgs,\n    CreatePlaylistResponse,\n    LibraryItem,\n} from '/@/shared/types/domain-types';\n\nexport const useCreatePlaylist = (args: MutationHookArgs) => {\n    const { options } = args || {};\n    const queryClient = useQueryClient();\n\n    return useMutation<CreatePlaylistResponse, AxiosError, CreatePlaylistArgs, null>({\n        mutationFn: (args) => {\n            return api.controller.createPlaylist({\n                ...args,\n                apiClientProps: { serverId: args.apiClientProps.serverId },\n            });\n        },\n        ...options,\n        onSuccess: (data, variables, context) => {\n            const { serverId } = variables.apiClientProps;\n            queryClient.invalidateQueries({\n                exact: false,\n                queryKey: queryKeys.playlists.root(serverId),\n            });\n\n            queryClient.invalidateQueries({\n                exact: false,\n                queryKey: infiniteLoaderDataQueryKey(serverId, LibraryItem.PLAYLIST),\n            });\n            options?.onSuccess?.(data, variables, context);\n        },\n    });\n};\n"
  },
  {
    "path": "src/renderer/features/playlists/mutations/delete-playlist-mutation.ts",
    "content": "import { useMutation, useQueryClient } from '@tanstack/react-query';\nimport { AxiosError } from 'axios';\n\nimport { api } from '/@/renderer/api';\nimport { queryKeys } from '/@/renderer/api/query-keys';\nimport { infiniteLoaderDataQueryKey } from '/@/renderer/components/item-list/helpers/item-list-infinite-loader';\nimport {\n    applyDeletePlaylistOptimisticUpdates,\n    PreviousQueryData,\n    restorePlaylistQueryData,\n} from '/@/renderer/features/playlists/mutations/playlist-optimistic-updates';\nimport { MutationHookArgs } from '/@/renderer/lib/react-query';\nimport {\n    DeletePlaylistArgs,\n    DeletePlaylistResponse,\n    LibraryItem,\n} from '/@/shared/types/domain-types';\n\nexport const useDeletePlaylist = (args: MutationHookArgs) => {\n    const { options } = args || {};\n    const queryClient = useQueryClient();\n\n    return useMutation<DeletePlaylistResponse, AxiosError, DeletePlaylistArgs, PreviousQueryData[]>(\n        {\n            mutationFn: (args) => {\n                return api.controller.deletePlaylist({\n                    ...args,\n                    apiClientProps: { serverId: args.apiClientProps.serverId },\n                });\n            },\n            onError: (_error, _variables, context) => {\n                if (context) {\n                    restorePlaylistQueryData(queryClient, context);\n                }\n            },\n            onMutate: (variables) => {\n                queryClient.cancelQueries({\n                    queryKey: queryKeys.playlists.list(variables.apiClientProps.serverId),\n                });\n                return applyDeletePlaylistOptimisticUpdates(queryClient, variables);\n            },\n            ...options,\n            onSuccess: (data, variables, context) => {\n                const { serverId } = variables.apiClientProps;\n                queryClient.invalidateQueries({\n                    exact: false,\n                    queryKey: queryKeys.playlists.root(serverId),\n                });\n\n                queryClient.invalidateQueries({\n                    exact: false,\n                    queryKey: infiniteLoaderDataQueryKey(serverId, LibraryItem.PLAYLIST),\n                });\n                options?.onSuccess?.(data, variables, context);\n            },\n        },\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/playlists/mutations/playlist-optimistic-updates.ts",
    "content": "import { QueryClient } from '@tanstack/react-query';\n\nimport { queryKeys } from '/@/renderer/api/query-keys';\nimport { infiniteLoaderDataQueryKey } from '/@/renderer/components/item-list/helpers/item-list-infinite-loader';\nimport {\n    DeletePlaylistArgs,\n    LibraryItem,\n    Playlist,\n    PlaylistListResponse,\n} from '/@/shared/types/domain-types';\n\nexport interface PreviousQueryData {\n    data: unknown;\n    queryKey: readonly unknown[];\n}\n\nexport const applyDeletePlaylistOptimisticUpdates = (\n    queryClient: QueryClient,\n    variables: DeletePlaylistArgs,\n): PreviousQueryData[] => {\n    const previousQueries: PreviousQueryData[] = [];\n    const playlistId = variables.query.id;\n\n    // Update detail query - remove it\n    const detailQueryKey = queryKeys.playlists.detail(\n        variables.apiClientProps.serverId,\n        playlistId,\n    );\n\n    const detailQueries = queryClient.getQueriesData({\n        exact: false,\n        queryKey: detailQueryKey,\n    });\n\n    if (detailQueries.length) {\n        detailQueries.forEach(([queryKey, data]) => {\n            if (data) {\n                previousQueries.push({ data, queryKey });\n                queryClient.setQueryData(queryKey, undefined);\n            }\n        });\n    }\n\n    // Update list queries - remove the playlist from items\n    const listQueryKey = queryKeys.playlists.list(variables.apiClientProps.serverId);\n\n    const listQueries = queryClient.getQueriesData({\n        exact: false,\n        queryKey: listQueryKey,\n    });\n\n    if (listQueries.length) {\n        listQueries.forEach(([queryKey, data]) => {\n            if (data) {\n                previousQueries.push({ data, queryKey });\n                queryClient.setQueryData(queryKey, (prev: PlaylistListResponse | undefined) => {\n                    if (prev) {\n                        return {\n                            ...prev,\n                            items: prev.items.filter((item: Playlist) => item.id !== playlistId),\n                            totalRecordCount: Math.max(\n                                0,\n                                (prev.totalRecordCount || prev.items.length) - 1,\n                            ),\n                        };\n                    }\n\n                    return prev;\n                });\n            }\n        });\n    }\n\n    // Update infinite loader queries - remove the playlist from data array\n    const infiniteLoaderQueryKey = infiniteLoaderDataQueryKey(\n        variables.apiClientProps.serverId,\n        LibraryItem.PLAYLIST,\n    );\n\n    const infiniteLoaderQueries = queryClient.getQueriesData({\n        exact: false,\n        queryKey: infiniteLoaderQueryKey,\n    });\n\n    if (infiniteLoaderQueries.length) {\n        infiniteLoaderQueries.forEach(([queryKey, data]) => {\n            if (data) {\n                previousQueries.push({ data, queryKey });\n                queryClient.setQueryData(\n                    queryKey,\n                    (\n                        prev:\n                            | undefined\n                            | {\n                                  data: unknown[];\n                                  pagesLoaded: Record<string, boolean>;\n                              },\n                    ) => {\n                        if (prev && prev.data) {\n                            return {\n                                ...prev,\n                                data: prev.data.filter((item: any) => {\n                                    if (!item || !item.id) {\n                                        return true;\n                                    }\n\n                                    return item.id !== playlistId;\n                                }),\n                            };\n                        }\n\n                        return prev;\n                    },\n                );\n            }\n        });\n    }\n\n    // Update songList query - remove it\n    const songListQueryKey = queryKeys.playlists.songList(\n        variables.apiClientProps.serverId,\n        playlistId,\n    );\n\n    const songListQueries = queryClient.getQueriesData({\n        exact: false,\n        queryKey: songListQueryKey,\n    });\n\n    if (songListQueries.length) {\n        songListQueries.forEach(([queryKey, data]) => {\n            if (data) {\n                previousQueries.push({ data, queryKey });\n                queryClient.setQueryData(queryKey, undefined);\n            }\n        });\n    }\n\n    return previousQueries;\n};\n\nexport const restorePlaylistQueryData = (\n    queryClient: QueryClient,\n    previousQueries: PreviousQueryData[],\n): void => {\n    previousQueries.forEach(({ data, queryKey }) => {\n        queryClient.setQueryData(queryKey, data);\n    });\n};\n"
  },
  {
    "path": "src/renderer/features/playlists/mutations/remove-from-playlist-mutation.ts",
    "content": "import { useMutation, useQueryClient } from '@tanstack/react-query';\nimport { AxiosError } from 'axios';\n\nimport { api } from '/@/renderer/api';\nimport { queryKeys } from '/@/renderer/api/query-keys';\nimport { MutationOptions } from '/@/renderer/lib/react-query';\nimport { RemoveFromPlaylistArgs, RemoveFromPlaylistResponse } from '/@/shared/types/domain-types';\n\nexport const useRemoveFromPlaylist = (options?: MutationOptions) => {\n    const queryClient = useQueryClient();\n\n    return useMutation<RemoveFromPlaylistResponse, AxiosError, RemoveFromPlaylistArgs, null>({\n        mutationFn: (args) => {\n            return api.controller.removeFromPlaylist({\n                ...args,\n                apiClientProps: { serverId: args.apiClientProps.serverId },\n            });\n        },\n        onSuccess: (_data, variables) => {\n            const { apiClientProps } = variables;\n            const serverId = apiClientProps.serverId;\n\n            if (!serverId) return;\n\n            queryClient.invalidateQueries({\n                queryKey: queryKeys.playlists.list(serverId),\n            });\n            queryClient.invalidateQueries({\n                queryKey: queryKeys.playlists.detail(serverId, variables.query.id),\n            });\n            queryClient.invalidateQueries({\n                queryKey: queryKeys.playlists.songList(serverId, variables.query.id),\n            });\n        },\n        ...options,\n    });\n};\n"
  },
  {
    "path": "src/renderer/features/playlists/mutations/update-playlist-mutation.ts",
    "content": "import { useMutation, useQueryClient } from '@tanstack/react-query';\nimport { AxiosError } from 'axios';\n\nimport { api } from '/@/renderer/api';\nimport { queryKeys } from '/@/renderer/api/query-keys';\nimport { MutationHookArgs } from '/@/renderer/lib/react-query';\nimport { UpdatePlaylistArgs, UpdatePlaylistResponse } from '/@/shared/types/domain-types';\n\nexport const useUpdatePlaylist = (args: MutationHookArgs) => {\n    const { options } = args || {};\n    const queryClient = useQueryClient();\n\n    return useMutation<UpdatePlaylistResponse, AxiosError, UpdatePlaylistArgs, null>({\n        mutationFn: (args) => {\n            return api.controller.updatePlaylist({\n                ...args,\n                apiClientProps: { serverId: args.apiClientProps.serverId },\n            });\n        },\n        onSuccess: (_data, variables) => {\n            const { apiClientProps, query } = variables;\n            const serverId = apiClientProps.serverId;\n\n            if (!serverId) return;\n\n            queryClient.invalidateQueries({\n                queryKey: queryKeys.playlists.list(serverId),\n            });\n\n            if (query?.id) {\n                queryClient.invalidateQueries({\n                    queryKey: queryKeys.playlists.detail(serverId, query.id),\n                });\n                queryClient.invalidateQueries({\n                    queryKey: queryKeys.playlists.songList(serverId, query.id),\n                });\n            }\n        },\n        ...options,\n    });\n};\n"
  },
  {
    "path": "src/renderer/features/playlists/routes/playlist-detail-song-list-route.tsx",
    "content": "import { closeAllModals, openModal } from '@mantine/modals';\nimport { useQuery } from '@tanstack/react-query';\nimport { Suspense, useMemo, useRef, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { generatePath, useLocation, useNavigate, useParams } from 'react-router';\n\nimport { ListContext, useListContext } from '/@/renderer/context/list-context';\nimport { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';\nimport { ClientSideSongFilters } from '/@/renderer/features/playlists/components/client-side-song-filters';\nimport { PlaylistDetailSongListContent } from '/@/renderer/features/playlists/components/playlist-detail-song-list-content';\nimport { PlaylistDetailSongListHeader } from '/@/renderer/features/playlists/components/playlist-detail-song-list-header';\nimport { PlaylistQueryBuilderRef } from '/@/renderer/features/playlists/components/playlist-query-builder';\nimport { PlaylistQueryEditor } from '/@/renderer/features/playlists/components/playlist-query-editor';\nimport { SaveAsPlaylistForm } from '/@/renderer/features/playlists/components/save-as-playlist-form';\nimport { usePlaylistSongListFilters } from '/@/renderer/features/playlists/hooks/use-playlist-song-list-filters';\nimport { useDeletePlaylist } from '/@/renderer/features/playlists/mutations/delete-playlist-mutation';\nimport { useUpdatePlaylist } from '/@/renderer/features/playlists/mutations/update-playlist-mutation';\nimport { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';\nimport { ListWithSidebarContainer } from '/@/renderer/features/shared/components/list-with-sidebar-container';\nimport { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';\nimport { AppRoute } from '/@/renderer/router/routes';\nimport {\n    PlaylistTarget,\n    useCurrentServer,\n    usePageSidebar,\n    usePlaylistTarget,\n} from '/@/renderer/store';\nimport { ActionIcon } from '/@/shared/components/action-icon/action-icon';\nimport { Button } from '/@/shared/components/button/button';\nimport { Group } from '/@/shared/components/group/group';\nimport { ConfirmModal } from '/@/shared/components/modal/modal';\nimport { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';\nimport { Spinner } from '/@/shared/components/spinner/spinner';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { Text } from '/@/shared/components/text/text';\nimport { toast } from '/@/shared/components/toast/toast';\nimport { LibraryItem, ServerType } from '/@/shared/types/domain-types';\nimport { ItemListKey } from '/@/shared/types/types';\n\nconst PlaylistSongListFiltersSidebar = () => {\n    const { t } = useTranslation();\n    const { setIsSidebarOpen } = useListContext();\n    const { clear } = usePlaylistSongListFilters();\n\n    return (\n        <Stack h=\"100%\" style={{ minHeight: 0 }}>\n            <Group justify=\"space-between\" pb={0} pl=\"md\" pr=\"md\" pt=\"md\">\n                <Text fw={500} size=\"xl\">\n                    {t('common.filters', { postProcess: 'sentenceCase' })}\n                </Text>\n                <Group gap=\"xs\">\n                    <Button onClick={clear} size=\"compact-sm\" variant=\"subtle\">\n                        {t('common.reset', { postProcess: 'sentenceCase' })}\n                    </Button>\n                    {setIsSidebarOpen && (\n                        <ActionIcon\n                            icon=\"unpin\"\n                            onClick={() => setIsSidebarOpen(false)}\n                            size=\"compact-sm\"\n                            variant=\"subtle\"\n                        />\n                    )}\n                </Group>\n            </Group>\n            <ScrollArea style={{ flex: 1, minHeight: 0 }}>\n                <ClientSideSongFilters />\n            </ScrollArea>\n        </Stack>\n    );\n};\n\nconst PlaylistDetailSongListRoute = () => {\n    const { t } = useTranslation();\n    const navigate = useNavigate();\n    const location = useLocation();\n    const { playlistId } = useParams() as { playlistId: string };\n    const server = useCurrentServer();\n\n    const detailQuery = useQuery({\n        ...playlistsQueries.detail({ query: { id: playlistId }, serverId: server?.id }),\n        placeholderData: location.state?.item,\n    });\n    const deletePlaylistMutation = useDeletePlaylist({});\n    const updatePlaylistMutation = useUpdatePlaylist({});\n\n    const handleSave = (\n        filter: Record<string, any>,\n        extraFilters: { limit?: number; sortBy?: string[]; sortOrder?: string },\n    ) => {\n        if (!detailQuery?.data) return;\n\n        const sortValue =\n            extraFilters.sortBy && extraFilters.sortBy.length > 0\n                ? extraFilters.sortBy[0]\n                : '+dateAdded';\n\n        const rules = {\n            ...filter,\n            limit: extraFilters.limit || undefined,\n            sort: sortValue,\n        };\n\n        updatePlaylistMutation.mutate(\n            {\n                apiClientProps: { serverId: detailQuery?.data?._serverId },\n                body: {\n                    comment: detailQuery?.data?.description || '',\n                    name: detailQuery?.data?.name,\n                    ownerId: detailQuery?.data?.ownerId || '',\n                    public: detailQuery?.data?.public || false,\n                    queryBuilderRules: rules,\n                    sync: detailQuery?.data?.sync || false,\n                },\n                query: { id: playlistId },\n            },\n            {\n                onSuccess: () => {\n                    toast.success({ message: 'Playlist has been saved' });\n                },\n            },\n        );\n    };\n\n    const handleSaveAs = (\n        filter: Record<string, any>,\n        extraFilters: { limit?: number; sortBy?: string[]; sortOrder?: string },\n    ) => {\n        if (!detailQuery?.data) return;\n\n        const sortValue =\n            extraFilters.sortBy && extraFilters.sortBy.length > 0\n                ? extraFilters.sortBy[0]\n                : '+dateAdded';\n\n        const rules = {\n            ...filter,\n            limit: extraFilters.limit || undefined,\n            sort: sortValue,\n        };\n\n        openModal({\n            children: (\n                <SaveAsPlaylistForm\n                    body={{\n                        comment: detailQuery?.data?.description || '',\n                        name: detailQuery?.data?.name,\n                        ownerId: detailQuery?.data?.ownerId || '',\n                        public: detailQuery?.data?.public || false,\n                        queryBuilderRules: rules,\n                        sync: detailQuery?.data?.sync || false,\n                    }}\n                    onCancel={closeAllModals}\n                    onSuccess={(data) =>\n                        navigate(\n                            generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, {\n                                playlistId: data?.id || '',\n                            }),\n                        )\n                    }\n                    serverId={detailQuery?.data?._serverId || ''}\n                />\n            ),\n            title: t('common.saveAs', { postProcess: 'sentenceCase' }),\n        });\n    };\n\n    const openDeletePlaylistModal = () => {\n        openModal({\n            children: (\n                <ConfirmModal\n                    onConfirm={() => {\n                        if (!detailQuery?.data) return;\n                        deletePlaylistMutation?.mutate(\n                            {\n                                apiClientProps: { serverId: detailQuery.data._serverId },\n                                query: { id: detailQuery.data.id },\n                            },\n                            {\n                                onError: (err) => {\n                                    toast.error({\n                                        message: err.message,\n                                        title: t('error.genericError', {\n                                            postProcess: 'sentenceCase',\n                                        }),\n                                    });\n                                },\n                                onSuccess: () => {\n                                    navigate(AppRoute.PLAYLISTS, { replace: true });\n                                },\n                            },\n                        );\n                        closeAllModals();\n                    }}\n                >\n                    <Text>Are you sure you want to delete this playlist?</Text>\n                </ConfirmModal>\n            ),\n            title: t('form.deletePlaylist.title', { postProcess: 'sentenceCase' }),\n        });\n    };\n\n    const isSmartPlaylist = Boolean(\n        !detailQuery?.isLoading &&\n            detailQuery?.data?.rules &&\n            server?.type === ServerType.NAVIDROME,\n    );\n\n    const [showQueryBuilder, setShowQueryBuilder] = useState(false);\n    const [isQueryBuilderExpanded, setIsQueryBuilderExpanded] = useState(false);\n    const queryBuilderRef = useRef<PlaylistQueryBuilderRef>(null);\n\n    const handleToggleExpand = () => {\n        setIsQueryBuilderExpanded((prev) => !prev);\n    };\n\n    const handleToggleShowQueryBuilder = () => {\n        setShowQueryBuilder((prev) => !prev);\n        setIsQueryBuilderExpanded(true);\n    };\n\n    const playlistTarget = usePlaylistTarget();\n    const displayMode: LibraryItem.ALBUM | LibraryItem.SONG =\n        playlistTarget === PlaylistTarget.ALBUM ? LibraryItem.ALBUM : LibraryItem.SONG;\n    const listKey =\n        displayMode === LibraryItem.ALBUM ? ItemListKey.PLAYLIST_ALBUM : ItemListKey.PLAYLIST_SONG;\n\n    const [itemCount, setItemCount] = useState<number | undefined>(undefined);\n    const [listData, setListData] = useState<unknown[]>([]);\n    const [mode, setMode] = useState<'edit' | 'view'>('view');\n    const [isSidebarOpen, setIsSidebarOpen] = usePageSidebar(listKey);\n\n    const providerValue = useMemo(() => {\n        return {\n            customFilters: undefined,\n            displayMode,\n            id: playlistId,\n            isSidebarOpen,\n            isSmartPlaylist,\n            itemCount,\n            listData,\n            listKey,\n            mode,\n            pageKey: listKey,\n            setIsSidebarOpen,\n            setItemCount,\n            setListData,\n            setMode,\n        };\n    }, [\n        playlistId,\n        isSmartPlaylist,\n        displayMode,\n        listKey,\n        isSidebarOpen,\n        itemCount,\n        listData,\n        mode,\n        setIsSidebarOpen,\n    ]);\n\n    return (\n        <AnimatedPage key={`playlist-detail-songList-${playlistId}`}>\n            <ListContext.Provider value={providerValue}>\n                <PlaylistDetailSongListHeader\n                    isSmartPlaylist={!!isSmartPlaylist}\n                    onConvertToSmart={() => {\n                        if (!isSmartPlaylist) {\n                            setShowQueryBuilder(true);\n                            setIsQueryBuilderExpanded(true);\n                        }\n                    }}\n                    onDelete={() => openDeletePlaylistModal()}\n                    onToggleQueryBuilder={handleToggleShowQueryBuilder}\n                />\n\n                <ListWithSidebarContainer>\n                    <ListWithSidebarContainer.SidebarPortal>\n                        <Suspense fallback={<Spinner container />}>\n                            <PlaylistSongListFiltersSidebar />\n                        </Suspense>\n                    </ListWithSidebarContainer.SidebarPortal>\n                    <Suspense fallback={<Spinner container />}>\n                        <PlaylistDetailSongListContent />\n                    </Suspense>\n                </ListWithSidebarContainer>\n                {(isSmartPlaylist || showQueryBuilder) && (\n                    <PlaylistQueryEditor\n                        detailQuery={detailQuery}\n                        handleSave={handleSave}\n                        handleSaveAs={handleSaveAs}\n                        isQueryBuilderExpanded={isQueryBuilderExpanded}\n                        onToggleExpand={handleToggleExpand}\n                        playlistId={playlistId}\n                        queryBuilderRef={queryBuilderRef}\n                        updatePlaylistMutation={updatePlaylistMutation}\n                    />\n                )}\n            </ListContext.Provider>\n        </AnimatedPage>\n    );\n};\n\nconst PlaylistDetailSongListRouteWithBoundary = () => {\n    return (\n        <PageErrorBoundary>\n            <PlaylistDetailSongListRoute />\n        </PageErrorBoundary>\n    );\n};\n\nexport default PlaylistDetailSongListRouteWithBoundary;\n"
  },
  {
    "path": "src/renderer/features/playlists/routes/playlist-list-route.tsx",
    "content": "import { useMemo, useState } from 'react';\nimport { useParams } from 'react-router';\n\nimport { ListContext } from '/@/renderer/context/list-context';\nimport { PlaylistListContent } from '/@/renderer/features/playlists/components/playlist-list-content';\nimport { PlaylistListHeader } from '/@/renderer/features/playlists/components/playlist-list-header';\nimport { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';\nimport { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';\nimport { ItemListKey } from '/@/shared/types/types';\n\nconst PlaylistListRoute = () => {\n    const { playlistId } = useParams();\n    const pageKey = ItemListKey.PLAYLIST;\n\n    const [itemCount, setItemCount] = useState<number | undefined>(undefined);\n\n    const providerValue = useMemo(() => {\n        return {\n            id: playlistId,\n            itemCount,\n            pageKey,\n            setItemCount,\n        };\n    }, [playlistId, itemCount, pageKey, setItemCount]);\n\n    return (\n        <AnimatedPage>\n            <ListContext.Provider value={providerValue}>\n                <PlaylistListHeader />\n                <PlaylistListContent />\n            </ListContext.Provider>\n        </AnimatedPage>\n    );\n};\n\nconst PlaylistListRouteWithBoundary = () => {\n    return (\n        <PageErrorBoundary>\n            <PlaylistListRoute />\n        </PageErrorBoundary>\n    );\n};\n\nexport default PlaylistListRouteWithBoundary;\n"
  },
  {
    "path": "src/renderer/features/playlists/utils.ts",
    "content": "import { nanoid } from 'nanoid/non-secure';\n\nimport { NDSongQueryFields } from '/@/shared/api/navidrome/navidrome-types';\nimport { Album, LibraryItem, Song } from '/@/shared/types/domain-types';\nimport { QueryBuilderGroup } from '/@/shared/types/types';\n\nexport type PlaylistAlbumRow = Album & { _playlistSongs?: Song[] };\n\nexport function playlistSongsToAlbums(songs: Song[]): PlaylistAlbumRow[] {\n    if (songs.length === 0) return [];\n\n    const rows: PlaylistAlbumRow[] = [];\n    let group: Song[] = [songs[0]];\n    let prevAlbumId = songs[0].albumId;\n\n    const pushRow = (song: Song, groupSongs: Song[]) => {\n        rows.push({\n            _itemType: LibraryItem.ALBUM,\n            _playlistSongs: groupSongs,\n            _serverId: song._serverId,\n            _serverType: song._serverType,\n            albumArtistName: song.albumArtistName,\n            albumArtists: song.albumArtists,\n            artists: song.artists,\n            comment: song.comment,\n            createdAt: song.createdAt,\n            duration: null,\n            explicitStatus: song.explicitStatus,\n            genres: song.genres,\n            id: song.albumId,\n            imageId: song.imageId,\n            imageUrl: song.imageUrl,\n            isCompilation: song.compilation,\n            lastPlayedAt: song.lastPlayedAt,\n            mbzId: null,\n            mbzReleaseGroupId: null,\n            name: song.album ?? '',\n            originalDate: null,\n            originalYear: null,\n            participants: song.participants,\n            playCount: null,\n            recordLabels: [],\n            releaseDate: song.releaseDate,\n            releaseType: null,\n            releaseTypes: [],\n            releaseYear: song.releaseYear,\n            size: null,\n            songCount: null,\n            sortName: song.album ?? '',\n            tags: song.tags,\n            updatedAt: song.updatedAt,\n            userFavorite: false,\n            userRating: null,\n            version: null,\n        });\n    };\n\n    for (let i = 1; i < songs.length; i++) {\n        const song = songs[i];\n        if (song.albumId === prevAlbumId) {\n            group.push(song);\n        } else {\n            pushRow(group[0], group);\n            group = [song];\n            prevAlbumId = song.albumId;\n        }\n    }\n    pushRow(group[0], group);\n\n    return rows;\n}\n\nexport const parseQueryBuilderChildren = (groups: QueryBuilderGroup[], data: any[]) => {\n    if (groups.length === 0) {\n        return data;\n    }\n\n    const filterGroups: any[] = [];\n\n    for (const group of groups) {\n        const rootType = group.type;\n        const query: any = {\n            [rootType]: [],\n        };\n\n        for (const rule of group.rules) {\n            if (rule.field && rule.operator) {\n                const [table, field] = rule.field.split('.');\n                const operator = mapDatePickerOperatorToApi(rule.operator);\n                const value = field !== 'releaseDate' ? rule.value : new Date(rule.value);\n\n                switch (table) {\n                    default:\n                        query[rootType].push({\n                            [operator]: {\n                                [table]: value,\n                            },\n                        });\n                        break;\n                }\n            }\n        }\n\n        if (group.group.length > 0) {\n            const b = parseQueryBuilderChildren(group.group, data);\n            b.forEach((c) => query[rootType].push(c));\n        }\n\n        data.push(query);\n        filterGroups.push(query);\n    }\n\n    return filterGroups;\n};\n\n// Convert QueryBuilderGroup to default query\nexport const convertQueryGroupToNDQuery = (filter: QueryBuilderGroup) => {\n    const rootQueryType = filter.type;\n    const rootQuery = {\n        [rootQueryType]: [] as any[],\n    };\n\n    for (const rule of filter.rules) {\n        if (rule.field && rule.operator) {\n            const [field] = rule.field.split('.');\n            const operator = mapDatePickerOperatorToApi(rule.operator);\n            let value = rule.value;\n\n            const booleanFields = NDSongQueryFields.filter(\n                (queryField) => queryField.type === 'boolean',\n            ).map((field) => field.value);\n\n            // Convert string values to boolean\n            if (booleanFields.includes(field)) {\n                value = value === 'true';\n            }\n\n            switch (field) {\n                default:\n                    rootQuery[rootQueryType].push({\n                        [operator]: {\n                            [field]: value,\n                        },\n                    });\n                    break;\n            }\n        }\n    }\n\n    const groups = parseQueryBuilderChildren(filter.group, []);\n    for (const group of groups) {\n        rootQuery[rootQueryType].push(group);\n    }\n\n    return rootQuery;\n};\n\n// Convert default query to QueryBuilderGroup\nexport const convertNDQueryToQueryGroup = (query: Record<string, any>) => {\n    const rootType = Object.keys(query)[0];\n    const rootGroup: QueryBuilderGroup = {\n        group: [],\n        rules: [],\n        type: rootType as 'all' | 'any',\n        uniqueId: nanoid(),\n    };\n\n    for (const rule of query[rootType]) {\n        if (rule.any || rule.all) {\n            const group = convertNDQueryToQueryGroup(rule);\n            rootGroup.group.push(group);\n        } else {\n            let operator = Object.keys(rule)[0];\n            const field = Object.keys(rule[operator])[0];\n            let value = rule[operator][field];\n\n            const booleanFields = NDSongQueryFields.filter(\n                (queryField) => queryField.type === 'boolean',\n            ).map((field) => field.value);\n\n            // Convert boolean values to string\n            if (booleanFields.includes(field)) {\n                value = value.toString();\n            }\n\n            // Use date-picker operator in UI when value is date-like (e.g. YYYY-MM-DD); otherwise keep API operator\n            operator = mapApiOperatorToDatePicker(operator, value);\n\n            rootGroup.rules.push({\n                field,\n                operator,\n                uniqueId: nanoid(),\n                value,\n            });\n        }\n    }\n\n    return rootGroup;\n};\n\nconst DATE_STRING_REGEX = /^\\d{4}-\\d{2}-\\d{2}$/;\n\nfunction isDateLikeValue(value: unknown): boolean {\n    if (value instanceof Date) return true;\n    if (typeof value === 'string') return DATE_STRING_REGEX.test(value.trim());\n    return false;\n}\n\nfunction isDateRangeValue(value: unknown): value is [null | string, null | string] {\n    if (!Array.isArray(value) || value.length !== 2) return false;\n    const [a, b] = value;\n    return (a == null || isDateLikeValue(a)) && (b == null || isDateLikeValue(b));\n}\n\nfunction mapApiOperatorToDatePicker(operator: string, value: unknown): string {\n    if (operator === 'before' && isDateLikeValue(value)) return 'beforeDate';\n    if (operator === 'after' && isDateLikeValue(value)) return 'afterDate';\n    if (operator === 'inTheRange' && isDateRangeValue(value)) return 'inTheRangeDate';\n    return operator;\n}\n\nfunction mapDatePickerOperatorToApi(operator: string): string {\n    if (operator === 'beforeDate') return 'before';\n    if (operator === 'afterDate') return 'after';\n    if (operator === 'inTheRangeDate') return 'inTheRange';\n    return operator;\n}\n"
  },
  {
    "path": "src/renderer/features/radio/api/radio-api.ts",
    "content": "import { queryOptions } from '@tanstack/react-query';\n\nimport { api } from '/@/renderer/api';\nimport { queryKeys } from '/@/renderer/api/query-keys';\nimport { QueryHookArgs } from '/@/renderer/lib/react-query';\n\nexport const radioQueries = {\n    list: (args: QueryHookArgs<void>) => {\n        return queryOptions({\n            gcTime: 1000 * 60 * 60,\n            queryFn: ({ signal }) => {\n                return api.controller.getInternetRadioStations({\n                    apiClientProps: { serverId: args.serverId, signal },\n                });\n            },\n            queryKey: queryKeys.radio.list(args.serverId || ''),\n            ...args.options,\n        });\n    },\n};\n"
  },
  {
    "path": "src/renderer/features/radio/components/create-radio-station-form.tsx",
    "content": "import { t } from 'i18next';\nimport { MouseEvent } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport { useCreateRadioStation } from '/@/renderer/features/radio/mutations/create-radio-station-mutation';\nimport { useCurrentServer } from '/@/renderer/store';\nimport { Group } from '/@/shared/components/group/group';\nimport { closeAllModals, openModal } from '/@/shared/components/modal/modal';\nimport { ModalButton } from '/@/shared/components/modal/model-shared';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { TextInput } from '/@/shared/components/text-input/text-input';\nimport { toast } from '/@/shared/components/toast/toast';\nimport { useForm } from '/@/shared/hooks/use-form';\nimport { CreateInternetRadioStationBody, ServerListItem } from '/@/shared/types/domain-types';\n\ninterface CreateRadioStationFormProps {\n    onCancel: () => void;\n}\n\nexport const CreateRadioStationForm = ({ onCancel }: CreateRadioStationFormProps) => {\n    const { t } = useTranslation();\n    const mutation = useCreateRadioStation({});\n    const server = useCurrentServer();\n\n    const form = useForm<CreateInternetRadioStationBody>({\n        initialValues: {\n            homepageUrl: '',\n            name: '',\n            streamUrl: '',\n        },\n    });\n\n    const handleSubmit = form.onSubmit((values) => {\n        if (!server) return;\n\n        mutation.mutate(\n            {\n                apiClientProps: { serverId: server.id },\n                body: values,\n            },\n            {\n                onError: (error) => {\n                    toast.error({\n                        message: (error as Error).message,\n                        title: t('error.genericError', {\n                            postProcess: 'sentenceCase',\n                        }) as string,\n                    });\n                },\n                onSuccess: () => {\n                    closeAllModals();\n                },\n            },\n        );\n    });\n\n    return (\n        <form onSubmit={handleSubmit}>\n            <Stack gap=\"md\">\n                <TextInput\n                    label={t('form.createRadioStation.input', {\n                        context: 'name',\n                        postProcess: 'titleCase',\n                    })}\n                    required\n                    {...form.getInputProps('name')}\n                />\n                <TextInput\n                    label={t('form.createRadioStation.input', {\n                        context: 'streamUrl',\n                        postProcess: 'titleCase',\n                    })}\n                    required\n                    {...form.getInputProps('streamUrl')}\n                />\n                <TextInput\n                    label={t('form.createRadioStation.input', {\n                        context: 'homepageUrl',\n                        postProcess: 'titleCase',\n                    })}\n                    {...form.getInputProps('homepageUrl')}\n                />\n                <Group justify=\"flex-end\">\n                    <ModalButton onClick={onCancel} variant=\"subtle\">\n                        {t('common.cancel', { postProcess: 'sentenceCase' })}\n                    </ModalButton>\n                    <ModalButton loading={mutation.isPending} type=\"submit\" variant=\"filled\">\n                        {t('common.create', { postProcess: 'sentenceCase' })}\n                    </ModalButton>\n                </Group>\n            </Stack>\n        </form>\n    );\n};\n\nexport const openCreateRadioStationModal = (\n    server: null | ServerListItem,\n    e?: MouseEvent<HTMLButtonElement>,\n) => {\n    e?.stopPropagation();\n\n    if (!server) {\n        toast.error({\n            message: t('common.error.noServer', { postProcess: 'sentenceCase' }) as string,\n        });\n        return;\n    }\n\n    openModal({\n        children: <CreateRadioStationForm onCancel={closeAllModals} />,\n        title: t('action.createRadioStation', { postProcess: 'titleCase' }) as string,\n    });\n};\n"
  },
  {
    "path": "src/renderer/features/radio/components/edit-radio-station-form.tsx",
    "content": "import { t } from 'i18next';\nimport { MouseEvent } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport { useUpdateRadioStation } from '/@/renderer/features/radio/mutations/update-radio-station-mutation';\nimport { useCurrentServer } from '/@/renderer/store';\nimport { logFn } from '/@/renderer/utils/logger';\nimport { logMsg } from '/@/renderer/utils/logger-message';\nimport { Group } from '/@/shared/components/group/group';\nimport { closeAllModals, openModal } from '/@/shared/components/modal/modal';\nimport { ModalButton } from '/@/shared/components/modal/model-shared';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { TextInput } from '/@/shared/components/text-input/text-input';\nimport { toast } from '/@/shared/components/toast/toast';\nimport { useForm } from '/@/shared/hooks/use-form';\nimport {\n    InternetRadioStation,\n    ServerListItem,\n    UpdateInternetRadioStationBody,\n} from '/@/shared/types/domain-types';\n\ninterface EditRadioStationFormProps {\n    onCancel: () => void;\n    station: InternetRadioStation;\n}\n\nexport const EditRadioStationForm = ({ onCancel, station }: EditRadioStationFormProps) => {\n    const { t } = useTranslation();\n    const mutation = useUpdateRadioStation({});\n    const server = useCurrentServer();\n\n    const form = useForm<UpdateInternetRadioStationBody>({\n        initialValues: {\n            homepageUrl: station.homepageUrl || '',\n            name: station.name,\n            streamUrl: station.streamUrl,\n        },\n    });\n\n    const handleSubmit = form.onSubmit((values) => {\n        if (!server) return;\n\n        mutation.mutate(\n            {\n                apiClientProps: { serverId: server.id },\n                body: values,\n                query: { id: station.id },\n            },\n            {\n                onError: (error) => {\n                    logFn.error(logMsg.other.error, {\n                        meta: { error: error as Error },\n                    });\n\n                    toast.error({\n                        message: (error as Error).message,\n                        title: t('error.genericError', {\n                            postProcess: 'sentenceCase',\n                        }) as string,\n                    });\n                },\n                onSuccess: () => {\n                    closeAllModals();\n                },\n            },\n        );\n    });\n\n    return (\n        <form onSubmit={handleSubmit}>\n            <Stack gap=\"md\">\n                <TextInput\n                    label={t('form.createRadioStation.input', {\n                        context: 'name',\n                        postProcess: 'titleCase',\n                    })}\n                    required\n                    {...form.getInputProps('name')}\n                />\n                <TextInput\n                    label={t('form.createRadioStation.input', {\n                        context: 'streamUrl',\n                        postProcess: 'titleCase',\n                    })}\n                    required\n                    {...form.getInputProps('streamUrl')}\n                />\n                <TextInput\n                    label={t('form.createRadioStation.input', {\n                        context: 'homepageUrl',\n                        postProcess: 'titleCase',\n                    })}\n                    {...form.getInputProps('homepageUrl')}\n                />\n                <Group justify=\"flex-end\">\n                    <ModalButton onClick={onCancel} variant=\"subtle\">\n                        {t('common.cancel', { postProcess: 'sentenceCase' })}\n                    </ModalButton>\n                    <ModalButton loading={mutation.isPending} type=\"submit\" variant=\"filled\">\n                        {t('common.save', { postProcess: 'sentenceCase' })}\n                    </ModalButton>\n                </Group>\n            </Stack>\n        </form>\n    );\n};\n\nexport const openEditRadioStationModal = (\n    station: InternetRadioStation,\n    server: null | ServerListItem,\n    e?: MouseEvent<HTMLButtonElement>,\n) => {\n    e?.stopPropagation();\n\n    if (!server) {\n        toast.error({\n            message: t('common.error.noServer', { postProcess: 'sentenceCase' }) as string,\n        });\n        return;\n    }\n\n    openModal({\n        children: <EditRadioStationForm onCancel={closeAllModals} station={station} />,\n        title: t('common.edit', { postProcess: 'titleCase' }) as string,\n    });\n};\n"
  },
  {
    "path": "src/renderer/features/radio/components/radio-list-content.tsx",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { Suspense, useEffect, useMemo } from 'react';\n\nimport { useListContext } from '/@/renderer/context/list-context';\nimport { radioQueries } from '/@/renderer/features/radio/api/radio-api';\nimport { RadioListItems } from '/@/renderer/features/radio/components/radio-list-items';\nimport { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-search-term-filter';\nimport { useSortByFilter } from '/@/renderer/features/shared/hooks/use-sort-by-filter';\nimport { useSortOrderFilter } from '/@/renderer/features/shared/hooks/use-sort-order-filter';\nimport { searchLibraryItems } from '/@/renderer/features/shared/utils';\nimport { useCurrentServer } from '/@/renderer/store';\nimport { sortRadioList } from '/@/shared/api/utils';\nimport { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';\nimport { Spinner } from '/@/shared/components/spinner/spinner';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { LibraryItem, RadioListSort, SortOrder } from '/@/shared/types/domain-types';\nimport { ItemListKey } from '/@/shared/types/types';\n\nexport const RadioListContent = () => {\n    const server = useCurrentServer();\n    const { setItemCount } = useListContext();\n    const { searchTerm } = useSearchTermFilter();\n    const { sortBy } = useSortByFilter<RadioListSort>(RadioListSort.NAME, ItemListKey.RADIO);\n    const { sortOrder } = useSortOrderFilter(SortOrder.ASC, ItemListKey.RADIO);\n\n    const radioListQuery = useQuery({\n        ...radioQueries.list({\n            query: undefined,\n            serverId: server?.id || '',\n        }),\n    });\n\n    const filteredAndSortedRadioStations = useMemo(() => {\n        let stations = radioListQuery.data || [];\n\n        if (searchTerm) {\n            stations = searchLibraryItems(stations, searchTerm, LibraryItem.RADIO_STATION);\n        }\n\n        if (sortBy && sortOrder) {\n            stations = sortRadioList(stations, sortBy, sortOrder);\n        }\n\n        return stations;\n    }, [radioListQuery.data, searchTerm, sortBy, sortOrder]);\n\n    useEffect(() => {\n        setItemCount?.(filteredAndSortedRadioStations.length || 0);\n    }, [filteredAndSortedRadioStations.length, setItemCount]);\n\n    if (radioListQuery.isLoading) {\n        return <Spinner container />;\n    }\n\n    return (\n        <Suspense fallback={<Spinner container />}>\n            <ScrollArea>\n                <Stack p=\"md\">\n                    <RadioListItems data={filteredAndSortedRadioStations} />\n                </Stack>\n            </ScrollArea>\n        </Suspense>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/radio/components/radio-list-header-filters.tsx",
    "content": "import { MouseEvent } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport { openCreateRadioStationModal } from '/@/renderer/features/radio/components/create-radio-station-form';\nimport { ListSortByDropdown } from '/@/renderer/features/shared/components/list-sort-by-dropdown';\nimport { ListSortOrderToggleButton } from '/@/renderer/features/shared/components/list-sort-order-toggle-button';\nimport { useCurrentServer, usePermissions } from '/@/renderer/store';\nimport { Button } from '/@/shared/components/button/button';\nimport { Divider } from '/@/shared/components/divider/divider';\nimport { Flex } from '/@/shared/components/flex/flex';\nimport { Group } from '/@/shared/components/group/group';\nimport { LibraryItem, RadioListSort, SortOrder } from '/@/shared/types/domain-types';\nimport { ItemListKey } from '/@/shared/types/types';\n\nexport const RadioListHeaderFilters = () => {\n    const { t } = useTranslation();\n    const server = useCurrentServer();\n    const permissions = usePermissions();\n\n    const handleCreateRadioStationModal = (e: MouseEvent<HTMLButtonElement>) => {\n        openCreateRadioStationModal(server, e);\n    };\n\n    return (\n        <Flex justify=\"space-between\">\n            <Group gap=\"sm\" w=\"100%\">\n                <ListSortByDropdown\n                    defaultSortByValue={RadioListSort.NAME}\n                    itemType={LibraryItem.RADIO_STATION}\n                    listKey={ItemListKey.RADIO}\n                />\n                <Divider orientation=\"vertical\" />\n                <ListSortOrderToggleButton\n                    defaultSortOrder={SortOrder.ASC}\n                    listKey={ItemListKey.RADIO}\n                />\n            </Group>\n            {permissions.radio.create && (\n                <Group gap=\"sm\" wrap=\"nowrap\">\n                    <Button onClick={handleCreateRadioStationModal} variant=\"subtle\">\n                        {t('action.createRadioStation', { postProcess: 'sentenceCase' })}\n                    </Button>\n                </Group>\n            )}\n        </Flex>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/radio/components/radio-list-header.tsx",
    "content": "import { useTranslation } from 'react-i18next';\n\nimport { PageHeader } from '/@/renderer/components/page-header/page-header';\nimport { useListContext } from '/@/renderer/context/list-context';\nimport { RadioListHeaderFilters } from '/@/renderer/features/radio/components/radio-list-header-filters';\nimport { FilterBar } from '/@/renderer/features/shared/components/filter-bar';\nimport { LibraryHeaderBar } from '/@/renderer/features/shared/components/library-header-bar';\nimport { ListSearchInput } from '/@/renderer/features/shared/components/list-search-input';\nimport { Group } from '/@/shared/components/group/group';\nimport { Stack } from '/@/shared/components/stack/stack';\n\ninterface RadioListHeaderProps {\n    title?: string;\n}\n\nexport const RadioListHeader = ({ title }: RadioListHeaderProps) => {\n    const { t } = useTranslation();\n\n    const { itemCount } = useListContext();\n    const pageTitle = title || t('page.radioList.title', { postProcess: 'titleCase' });\n\n    return (\n        <Stack gap={0}>\n            <PageHeader>\n                <LibraryHeaderBar ignoreMaxWidth>\n                    <LibraryHeaderBar.Title>{pageTitle}</LibraryHeaderBar.Title>\n                    <LibraryHeaderBar.Badge isLoading={itemCount === undefined}>\n                        {itemCount}\n                    </LibraryHeaderBar.Badge>\n                </LibraryHeaderBar>\n                <Group>\n                    <ListSearchInput />\n                </Group>\n            </PageHeader>\n            <FilterBar>\n                <RadioListHeaderFilters />\n            </FilterBar>\n        </Stack>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/radio/components/radio-list-items.module.css",
    "content": ".radio-item {\n    cursor: pointer;\n    border-left: 3px solid transparent;\n    transition: background-color 0.15s ease;\n}\n\n.radio-item:hover {\n    @mixin dark {\n        background-color: lighten(var(--theme-colors-surface), 1%);\n    }\n\n    @mixin light {\n        background-color: darken(var(--theme-colors-surface), 1%);\n    }\n}\n\n.radio-item-active {\n    border-left: 3px solid var(--theme-colors-primary);\n}\n\n.radio-item-button {\n    all: unset;\n    flex: 1;\n    width: 100%;\n}\n\n.radio-item-link {\n    color: inherit;\n    text-decoration: underline;\n}\n"
  },
  {
    "path": "src/renderer/features/radio/components/radio-list-items.tsx",
    "content": "import clsx from 'clsx';\nimport { useCallback, useMemo } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport styles from './radio-list-items.module.css';\n\nimport { openEditRadioStationModal } from '/@/renderer/features/radio/components/edit-radio-station-form';\nimport {\n    useRadioControls,\n    useRadioPlayer,\n} from '/@/renderer/features/radio/hooks/use-radio-player';\nimport { useDeleteRadioStation } from '/@/renderer/features/radio/mutations/delete-radio-station-mutation';\nimport { useCurrentServer, usePermissions } from '/@/renderer/store';\nimport { ActionIcon } from '/@/shared/components/action-icon/action-icon';\nimport { Flex } from '/@/shared/components/flex/flex';\nimport { Group } from '/@/shared/components/group/group';\nimport { Icon } from '/@/shared/components/icon/icon';\nimport { closeAllModals, ConfirmModal, openModal } from '/@/shared/components/modal/modal';\nimport { Paper } from '/@/shared/components/paper/paper';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { Text } from '/@/shared/components/text/text';\nimport { toast } from '/@/shared/components/toast/toast';\nimport { InternetRadioStation } from '/@/shared/types/domain-types';\n\ninterface RadioListItemProps {\n    station: InternetRadioStation;\n}\n\ninterface RadioListItemsProps {\n    data: InternetRadioStation[];\n}\n\nconst RadioListItem = ({ station }: RadioListItemProps) => {\n    const { t } = useTranslation();\n    const { currentStreamUrl, isPlaying } = useRadioPlayer();\n    const { play, stop } = useRadioControls();\n    const server = useCurrentServer();\n    const permissions = usePermissions();\n    const deleteRadioStationMutation = useDeleteRadioStation({});\n\n    const isCurrentStation = currentStreamUrl === station.streamUrl;\n    const stationIsPlaying = isCurrentStation && isPlaying;\n\n    const handleClick = () => {\n        if (stationIsPlaying) {\n            stop();\n        } else {\n            play(station.streamUrl, station.name);\n        }\n    };\n\n    const handleEditClick = (e: React.MouseEvent<HTMLButtonElement>) => {\n        e.stopPropagation();\n        openEditRadioStationModal(station, server, e);\n    };\n\n    const handleDeleteClick = useCallback(\n        async (e: React.MouseEvent<HTMLButtonElement>) => {\n            e.stopPropagation();\n\n            if (!server) return;\n\n            openModal({\n                children: (\n                    <ConfirmModal\n                        labels={{\n                            cancel: t('common.cancel', { postProcess: 'sentenceCase' }),\n                            confirm: t('common.delete', { postProcess: 'sentenceCase' }),\n                        }}\n                        loading={deleteRadioStationMutation.isPending}\n                        onConfirm={async () => {\n                            try {\n                                await deleteRadioStationMutation.mutateAsync({\n                                    apiClientProps: { serverId: server.id },\n                                    query: { id: station.id },\n                                });\n\n                                // Stop playback if this station is currently playing\n                                if (isCurrentStation) {\n                                    stop();\n                                }\n                            } catch (err: any) {\n                                toast.error({\n                                    message: err.message,\n                                    title: t('error.genericError', {\n                                        postProcess: 'sentenceCase',\n                                    }),\n                                });\n                            }\n\n                            closeAllModals();\n                        }}\n                    >\n                        <Text>{t('common.areYouSure', { postProcess: 'sentenceCase' })}</Text>\n                    </ConfirmModal>\n                ),\n                title: t('common.delete', { postProcess: 'titleCase' }),\n            });\n        },\n        [deleteRadioStationMutation, isCurrentStation, server, station.id, stop, t],\n    );\n\n    return (\n        <Paper\n            className={clsx(styles['radio-item'], {\n                [styles['radio-item-active']]: isCurrentStation,\n            })}\n            p=\"md\"\n        >\n            <Flex align=\"flex-start\" gap=\"md\" justify=\"space-between\">\n                <button className={styles['radio-item-button']} onClick={handleClick} role=\"button\">\n                    <Stack gap=\"xs\">\n                        <Group gap=\"xs\">\n                            <Icon color=\"muted\" icon=\"radio\" size=\"md\" />\n                            <Text fw={500} size=\"md\">\n                                {station.name}\n                            </Text>\n                        </Group>\n                        <Text isMuted size=\"sm\">\n                            {station.streamUrl}\n                        </Text>\n                        {station.homepageUrl && (\n                            <Text isMuted size=\"sm\">\n                                {station.homepageUrl}\n                            </Text>\n                        )}\n                    </Stack>\n                </button>\n                {(permissions.radio.edit || permissions.radio.delete) && (\n                    <Group gap=\"xs\">\n                        {permissions.radio.edit && (\n                            <ActionIcon\n                                icon=\"edit\"\n                                onClick={handleEditClick}\n                                size=\"sm\"\n                                tooltip={{\n                                    label: t('common.edit', { postProcess: 'sentenceCase' }),\n                                }}\n                                variant=\"subtle\"\n                            />\n                        )}\n                        {permissions.radio.delete && (\n                            <ActionIcon\n                                icon=\"delete\"\n                                iconProps={{ color: 'error' }}\n                                onClick={handleDeleteClick}\n                                size=\"sm\"\n                                tooltip={{\n                                    label: t('common.delete', { postProcess: 'sentenceCase' }),\n                                }}\n                                variant=\"subtle\"\n                            />\n                        )}\n                    </Group>\n                )}\n            </Flex>\n        </Paper>\n    );\n};\n\nexport const RadioListItems = ({ data }: RadioListItemsProps) => {\n    const items = useMemo(\n        () => data.map((station) => <RadioListItem key={station.id} station={station} />),\n        [data],\n    );\n\n    return <Stack gap=\"sm\">{items}</Stack>;\n};\n"
  },
  {
    "path": "src/renderer/features/radio/components/radio-web-player.tsx",
    "content": "import type ReactPlayer from 'react-player';\n\nimport { useCallback, useEffect, useRef, useState } from 'react';\n\nimport {\n    WebPlayerEngine,\n    WebPlayerEngineHandle,\n} from '/@/renderer/features/player/audio-player/engine/web-player-engine';\nimport { useWebAudio } from '/@/renderer/features/player/hooks/use-webaudio';\nimport {\n    useIsRadioActive,\n    useRadioPlayer,\n    useRadioStore,\n} from '/@/renderer/features/radio/hooks/use-radio-player';\nimport { usePlaybackSettings, usePlayerMuted, usePlayerVolume } from '/@/renderer/store';\nimport { toast } from '/@/shared/components/toast/toast';\nimport { PlayerStatus } from '/@/shared/types/types';\n\nexport function RadioWebPlayer() {\n    const playerRef = useRef<null | WebPlayerEngineHandle>(null);\n    const { currentStreamUrl, isPlaying } = useRadioPlayer();\n    const { actions } = useRadioStore();\n    const { setCurrentStreamUrl, setIsPlaying, setStationName } = actions;\n    const isRadioActive = useIsRadioActive();\n    const isMuted = usePlayerMuted();\n    const volume = usePlayerVolume();\n    const { preservePitch } = usePlaybackSettings();\n    const { webAudio } = useWebAudio();\n\n    const [playerStatus, setPlayerStatus] = useState<PlayerStatus>(\n        isPlaying ? PlayerStatus.PLAYING : PlayerStatus.PAUSED,\n    );\n    const [player1Source, setPlayer1Source] = useState<MediaElementAudioSourceNode | null>(null);\n    const processedMediaElementRef = useRef<HTMLMediaElement | null>(null);\n    const player1SourceRef = useRef<MediaElementAudioSourceNode | null>(null);\n\n    useEffect(() => {\n        player1SourceRef.current = player1Source;\n    }, [player1Source]);\n\n    useEffect(() => {\n        setPlayerStatus(isPlaying ? PlayerStatus.PLAYING : PlayerStatus.PAUSED);\n    }, [isPlaying]);\n\n    // Cleanup source only on unmount\n    useEffect(() => {\n        return () => {\n            if (player1SourceRef.current) {\n                try {\n                    player1SourceRef.current.disconnect();\n                } catch {\n                    // Ignore disconnect errors\n                }\n                setPlayer1Source(null);\n                processedMediaElementRef.current = null;\n            }\n        };\n    }, []);\n\n    useEffect(() => {\n        if (!webAudio || !player1Source) return;\n\n        const linearVolume = volume / 100;\n        const gainValue = isMuted ? 0 : linearVolume;\n\n        try {\n            webAudio.gains[0].gain.setValueAtTime(gainValue, 0);\n        } catch (error) {\n            console.error('Error setting radio volume gain', error);\n        }\n    }, [volume, isMuted, webAudio, player1Source]);\n\n    const handlePlayer1Start = useCallback(\n        async (player: ReactPlayer) => {\n            if (!webAudio) return;\n\n            const internal = player.getInternalPlayer() as HTMLMediaElement | undefined;\n            if (!internal) return;\n\n            // If we've already processed this exact media element, reuse the existing source\n            if (processedMediaElementRef.current === internal && player1Source) {\n                // Ensure it's still connected to the gain node\n                try {\n                    if (!player1Source.context) {\n                        const { gains } = webAudio;\n                        player1Source.connect(gains[0]);\n                    }\n                } catch {\n                    // Already connected, which is what we want\n                }\n                return;\n            }\n\n            if (currentStreamUrl) {\n                if (webAudio.context.state !== 'running') {\n                    await webAudio.context.resume();\n                }\n            }\n\n            try {\n                const { context, gains } = webAudio;\n                const source = context.createMediaElementSource(internal);\n                source.connect(gains[0]);\n                setPlayer1Source(source);\n                processedMediaElementRef.current = internal;\n            } catch {\n                processedMediaElementRef.current = internal;\n\n                if (webAudio && webAudio.gains[0]) {\n                    const linearVolume = volume / 100;\n                    const gainValue = isMuted ? 0 : linearVolume;\n                    webAudio.gains[0].gain.setValueAtTime(gainValue, 0);\n                }\n            }\n        },\n        [player1Source, currentStreamUrl, webAudio, volume, isMuted],\n    );\n\n    const onProgressPlayer1 = useCallback(() => {\n        // We don't need to handle progress for radio streams\n    }, []);\n\n    const onEndedPlayer1 = useCallback(() => {\n        console.error('Radio stream ended unexpectedly');\n        setIsPlaying(false);\n        setCurrentStreamUrl(null);\n        setStationName(null);\n        toast.error({ message: 'Radio stream ended unexpectedly' });\n    }, [setIsPlaying, setCurrentStreamUrl, setStationName]);\n\n    if (!isRadioActive) {\n        return null;\n    }\n\n    return (\n        <WebPlayerEngine\n            isMuted={isMuted}\n            isTransitioning={false}\n            onEndedPlayer1={onEndedPlayer1}\n            onEndedPlayer2={() => {}}\n            onErrorPause={() => {}}\n            onProgressPlayer1={onProgressPlayer1}\n            onProgressPlayer2={() => {}}\n            onStartedPlayer1={handlePlayer1Start}\n            onStartedPlayer2={() => {}}\n            playerNum={1}\n            playerRef={playerRef}\n            playerStatus={playerStatus}\n            preservesPitch={preservePitch}\n            speed={1}\n            src1={currentStreamUrl || undefined}\n            src2={undefined}\n            volume={volume}\n        />\n    );\n}\n"
  },
  {
    "path": "src/renderer/features/radio/hooks/use-radio-player.ts",
    "content": "import IcecastMetadataStats from 'icecast-metadata-stats';\nimport isElectron from 'is-electron';\nimport React, { useEffect } from 'react';\nimport { createWithEqualityFn } from 'zustand/traditional';\n\nimport { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';\nimport { usePlaybackType, usePlayerStoreBase, useSettingsStore } from '/@/renderer/store';\nimport { PlayerStatus, PlayerType } from '/@/shared/types/types';\n\nexport interface RadioMetadata {\n    artist: null | string;\n    title: null | string;\n}\n\ninterface RadioStore {\n    actions: {\n        pause: () => void;\n        play: (streamUrl?: string, stationName?: string) => void;\n        setCurrentStreamUrl: (currentStreamUrl: null | string) => void;\n        setIsPlaying: (isPlaying: boolean) => void;\n        setMetadata: (metadata: null | RadioMetadata) => void;\n        setStationName: (stationName: null | string) => void;\n        stop: () => void;\n    };\n    currentStreamUrl: null | string;\n    isPlaying: boolean;\n    metadata: null | RadioMetadata;\n    stationName: null | string;\n}\n\nexport const useRadioStore = createWithEqualityFn<RadioStore>((set) => ({\n    actions: {\n        pause: () => {\n            set({ isPlaying: false });\n            usePlayerStoreBase.getState().mediaPause();\n        },\n        play: (streamUrl?: string, stationName?: string) => {\n            set((state) => {\n                const newStreamUrl = streamUrl ?? state.currentStreamUrl;\n                const newStationName = stationName ?? state.stationName;\n\n                if (!newStreamUrl) {\n                    return state;\n                }\n\n                // Reset metadata when switching stations (streamUrl changes)\n                const isSwitchingStation = newStreamUrl !== state.currentStreamUrl;\n\n                usePlayerStoreBase.getState().mediaPlay();\n\n                return {\n                    currentStreamUrl: newStreamUrl,\n                    isPlaying: true,\n                    metadata: isSwitchingStation ? null : state.metadata,\n                    stationName: newStationName,\n                };\n            });\n        },\n        setCurrentStreamUrl: (currentStreamUrl) => set({ currentStreamUrl }),\n        setIsPlaying: (isPlaying) => set({ isPlaying }),\n        setMetadata: (metadata) => set({ metadata }),\n        setStationName: (stationName) => set({ stationName }),\n        stop: () => {\n            const playbackType = useSettingsStore.getState().playback.type;\n\n            set({\n                currentStreamUrl: null,\n                isPlaying: false,\n                metadata: null,\n                stationName: null,\n            });\n\n            // When stopping radio with mpv, just pause instead of calling mediaStop\n            // This prevents mpv from quitting\n            if (playbackType === PlayerType.LOCAL && mpvPlayer) {\n                mpvPlayer.pause();\n            } else {\n                usePlayerStoreBase.getState().mediaStop();\n            }\n        },\n    },\n    currentStreamUrl: null,\n    isPlaying: false,\n    metadata: null,\n    stationName: null,\n}));\n\nexport const useIsPlayingRadio = () => useRadioStore((state) => state.isPlaying);\n\nexport const useIsRadioActive = () => useRadioStore((state) => Boolean(state.currentStreamUrl));\n\nexport const useRadioPlayer = () => {\n    const currentStreamUrl = useRadioStore((state) => state.currentStreamUrl);\n    const isPlaying = useRadioStore((state) => state.isPlaying);\n    const metadata = useRadioStore((state) => state.metadata);\n    const stationName = useRadioStore((state) => state.stationName);\n\n    return {\n        currentStreamUrl,\n        isPlaying,\n        metadata,\n        stationName,\n    };\n};\n\nexport const useRadioControls = () => {\n    const { pause, play, stop } = useRadioStore((state) => state.actions);\n\n    return {\n        pause,\n        play,\n        stop,\n    };\n};\n\nconst mpvPlayer = isElectron() ? window.api.mpvPlayer : null;\nconst mpvPlayerListener = isElectron() ? window.api.mpvPlayerListener : null;\nconst ipc = isElectron() ? window.api.ipc : null;\n\nexport const useRadioAudioInstance = () => {\n    const { actions } = useRadioStore();\n    const { setCurrentStreamUrl, setIsPlaying, setStationName } = actions;\n    const currentStreamUrl = useRadioStore((state) => state.currentStreamUrl);\n    const isPlaying = useRadioStore((state) => state.isPlaying);\n    const isRadioActive = useIsRadioActive();\n    const playbackType = usePlaybackType();\n    const isUsingMpv = playbackType === PlayerType.LOCAL && mpvPlayer;\n\n    // Handle mpv playback\n    useEffect(() => {\n        if (!isUsingMpv || !mpvPlayer) {\n            return;\n        }\n\n        if (currentStreamUrl) {\n            mpvPlayer.setQueue(currentStreamUrl, undefined, !isPlaying);\n        } else {\n            mpvPlayer.pause();\n        }\n    }, [\n        currentStreamUrl,\n        isPlaying,\n        isUsingMpv,\n        setIsPlaying,\n        setCurrentStreamUrl,\n        setStationName,\n    ]);\n\n    useEffect(() => {\n        if (!isUsingMpv || !mpvPlayerListener || !ipc || !isRadioActive) {\n            return;\n        }\n\n        const handleMpvPlay = () => {\n            setIsPlaying(true);\n        };\n\n        const handleMpvPause = () => {\n            setIsPlaying(false);\n        };\n\n        const handleMpvStop = () => {\n            setIsPlaying(false);\n            setCurrentStreamUrl(null);\n            setStationName(null);\n        };\n\n        mpvPlayerListener.rendererPlay(handleMpvPlay);\n        mpvPlayerListener.rendererPause(handleMpvPause);\n        mpvPlayerListener.rendererStop(handleMpvStop);\n\n        return () => {\n            ipc.removeAllListeners('renderer-player-play');\n            ipc.removeAllListeners('renderer-player-pause');\n            ipc.removeAllListeners('renderer-player-stop');\n        };\n    }, [isUsingMpv, isRadioActive, setIsPlaying, setCurrentStreamUrl, setStationName]);\n\n    usePlayerEvents(\n        {\n            onPlayerStatus: (properties, prev) => {\n                const radioState = useRadioStore.getState();\n                if (!radioState.currentStreamUrl) {\n                    return;\n                }\n\n                const { status } = properties;\n                const { status: prevStatus } = prev;\n\n                if (status === prevStatus) {\n                    return;\n                }\n\n                if (status === PlayerStatus.PLAYING && prevStatus === PlayerStatus.PAUSED) {\n                    actions.play();\n                } else if (status === PlayerStatus.PAUSED && prevStatus === PlayerStatus.PLAYING) {\n                    actions.pause();\n                }\n            },\n        },\n        [actions],\n    );\n};\n\nexport const useRadioMetadata = () => {\n    const { actions, currentStreamUrl } = useRadioStore();\n    const { setMetadata } = actions;\n    const playbackType = usePlaybackType();\n    const isUsingMpv = playbackType === PlayerType.LOCAL && mpvPlayer;\n\n    useEffect(() => {\n        if (!currentStreamUrl) {\n            setMetadata(null);\n            return;\n        }\n\n        // If using mpv, fetch metadata from mpv periodically\n        if (isUsingMpv && mpvPlayer) {\n            let intervalId: NodeJS.Timeout | null = null;\n\n            const fetchMpvMetadata = async () => {\n                try {\n                    const metadata = await mpvPlayer.getStreamMetadata();\n                    setMetadata(metadata);\n                } catch {\n                    // Ignore error\n                }\n            };\n\n            intervalId = setInterval(fetchMpvMetadata, 5000);\n\n            return () => {\n                if (intervalId) {\n                    clearInterval(intervalId);\n                }\n                setMetadata(null);\n            };\n        }\n\n        // Otherwise, use IcecastMetadataStats for web player\n        let statsListener: IcecastMetadataStats | null = null;\n\n        try {\n            statsListener = new IcecastMetadataStats(currentStreamUrl, {\n                interval: 12,\n                onStats: (stats) => {\n                    // Parse ICY metadata - typically in format \"Artist - Title\" or just \"Title\"\n                    let streamTitle: null | string = null;\n\n                    if (stats.StreamTitle) {\n                        streamTitle = stats.StreamTitle;\n                    } else if (stats.icy?.StreamTitle) {\n                        streamTitle = stats.icy.StreamTitle;\n                    }\n\n                    // Parse the combined format into title and artist\n                    let artist: null | string = null;\n                    let title: null | string = null;\n\n                    if (streamTitle) {\n                        // Try to parse \"Artist - Title\" format\n                        const match = streamTitle.match(/^(.*?)\\s*[-–—]\\s*(.+)$/);\n                        if (match) {\n                            artist = match[1].trim() || null;\n                            title = match[2].trim() || null;\n                        } else {\n                            // If no separator found, treat the whole thing as title\n                            title = streamTitle;\n                        }\n                    }\n\n                    setMetadata(title || artist ? { artist, title } : null);\n                },\n                sources: ['icy'],\n            });\n\n            statsListener.start();\n        } catch {\n            setMetadata(null);\n        }\n\n        return () => {\n            if (statsListener) {\n                statsListener.stop();\n            }\n            setMetadata(null);\n        };\n    }, [currentStreamUrl, setMetadata, isUsingMpv]);\n};\n\nconst RadioAudioInstanceHookInner = () => {\n    useRadioAudioInstance();\n    return null;\n};\n\nexport const RadioAudioInstanceHook = () => {\n    const isRadioActive = useIsRadioActive();\n\n    if (!isRadioActive) {\n        return null;\n    }\n\n    return React.createElement(RadioAudioInstanceHookInner);\n};\n\nconst RadioMetadataHookInner = () => {\n    useRadioMetadata();\n    return null;\n};\n\nexport const RadioMetadataHook = () => {\n    const isRadioActive = useIsRadioActive();\n\n    if (!isRadioActive) {\n        return null;\n    }\n\n    return React.createElement(RadioMetadataHookInner);\n};\n"
  },
  {
    "path": "src/renderer/features/radio/mutations/create-radio-station-mutation.ts",
    "content": "import { useMutation, useQueryClient } from '@tanstack/react-query';\nimport { AxiosError } from 'axios';\n\nimport { api } from '/@/renderer/api';\nimport { queryKeys } from '/@/renderer/api/query-keys';\nimport { MutationHookArgs } from '/@/renderer/lib/react-query';\nimport {\n    CreateInternetRadioStationArgs,\n    CreateInternetRadioStationResponse,\n} from '/@/shared/types/domain-types';\n\nexport const useCreateRadioStation = (args: MutationHookArgs) => {\n    const { options } = args || {};\n    const queryClient = useQueryClient();\n\n    return useMutation<\n        CreateInternetRadioStationResponse,\n        AxiosError,\n        CreateInternetRadioStationArgs,\n        null\n    >({\n        mutationFn: (args) => {\n            return api.controller.createInternetRadioStation({\n                ...args,\n                apiClientProps: { serverId: args.apiClientProps.serverId },\n            });\n        },\n        onSuccess: (_args, variables) => {\n            queryClient.invalidateQueries({\n                exact: false,\n                queryKey: queryKeys.radio.list(variables.apiClientProps.serverId),\n            });\n        },\n        ...options,\n    });\n};\n"
  },
  {
    "path": "src/renderer/features/radio/mutations/delete-radio-station-mutation.ts",
    "content": "import { useMutation, useQueryClient } from '@tanstack/react-query';\nimport { AxiosError } from 'axios';\n\nimport { api } from '/@/renderer/api';\nimport { queryKeys } from '/@/renderer/api/query-keys';\nimport { MutationHookArgs } from '/@/renderer/lib/react-query';\nimport {\n    DeleteInternetRadioStationArgs,\n    DeleteInternetRadioStationResponse,\n} from '/@/shared/types/domain-types';\n\nexport const useDeleteRadioStation = (args: MutationHookArgs) => {\n    const { options } = args || {};\n    const queryClient = useQueryClient();\n\n    return useMutation<\n        DeleteInternetRadioStationResponse,\n        AxiosError,\n        DeleteInternetRadioStationArgs,\n        null\n    >({\n        mutationFn: (args) => {\n            return api.controller.deleteInternetRadioStation({\n                ...args,\n                apiClientProps: { serverId: args.apiClientProps.serverId },\n            });\n        },\n        onSuccess: (_args, variables) => {\n            queryClient.invalidateQueries({\n                exact: false,\n                queryKey: queryKeys.radio.list(variables.apiClientProps.serverId),\n            });\n        },\n        ...options,\n    });\n};\n"
  },
  {
    "path": "src/renderer/features/radio/mutations/update-radio-station-mutation.ts",
    "content": "import { useMutation, useQueryClient } from '@tanstack/react-query';\nimport { AxiosError } from 'axios';\n\nimport { api } from '/@/renderer/api';\nimport { queryKeys } from '/@/renderer/api/query-keys';\nimport { MutationHookArgs } from '/@/renderer/lib/react-query';\nimport {\n    UpdateInternetRadioStationArgs,\n    UpdateInternetRadioStationResponse,\n} from '/@/shared/types/domain-types';\n\nexport const useUpdateRadioStation = (args: MutationHookArgs) => {\n    const { options } = args || {};\n    const queryClient = useQueryClient();\n\n    return useMutation<\n        UpdateInternetRadioStationResponse,\n        AxiosError,\n        UpdateInternetRadioStationArgs,\n        null\n    >({\n        mutationFn: (args) => {\n            return api.controller.updateInternetRadioStation({\n                ...args,\n                apiClientProps: { serverId: args.apiClientProps.serverId },\n            });\n        },\n        onSuccess: (_args, variables) => {\n            queryClient.invalidateQueries({\n                exact: false,\n                queryKey: queryKeys.radio.list(variables.apiClientProps.serverId),\n            });\n        },\n        ...options,\n    });\n};\n"
  },
  {
    "path": "src/renderer/features/radio/routes/radio-list-route.tsx",
    "content": "import { useMemo, useState } from 'react';\n\nimport { ListContext } from '/@/renderer/context/list-context';\nimport { RadioListContent } from '/@/renderer/features/radio/components/radio-list-content';\nimport { RadioListHeader } from '/@/renderer/features/radio/components/radio-list-header';\nimport { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';\nimport { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';\nimport { ItemListKey } from '/@/shared/types/types';\n\nconst RadioListRoute = () => {\n    const pageKey = ItemListKey.RADIO;\n\n    const [itemCount, setItemCount] = useState<number | undefined>(undefined);\n\n    const providerValue = useMemo(() => {\n        return {\n            id: undefined,\n            itemCount,\n            pageKey,\n            setItemCount,\n        };\n    }, [itemCount, pageKey, setItemCount]);\n\n    return (\n        <AnimatedPage>\n            <ListContext.Provider value={providerValue}>\n                <RadioListHeader />\n                <RadioListContent />\n            </ListContext.Provider>\n        </AnimatedPage>\n    );\n};\n\nconst RadioListRouteWithBoundary = () => {\n    return (\n        <PageErrorBoundary>\n            <RadioListRoute />\n        </PageErrorBoundary>\n    );\n};\n\nexport default RadioListRouteWithBoundary;\n"
  },
  {
    "path": "src/renderer/features/radio/store/radio-store.ts",
    "content": "import merge from 'lodash/merge';\nimport { nanoid } from 'nanoid/non-secure';\nimport { devtools, persist } from 'zustand/middleware';\nimport { immer } from 'zustand/middleware/immer';\nimport { createWithEqualityFn } from 'zustand/traditional';\n\nimport { InternetRadioStation } from '/@/shared/types/domain-types';\n\nexport interface RadioStoreSlice extends RadioStoreState {\n    actions: {\n        createStation: (\n            serverId: string,\n            station: Omit<InternetRadioStation, 'id'>,\n        ) => InternetRadioStation;\n        deleteStation: (serverId: string, stationId: string) => void;\n        getStation: (serverId: string, stationId: string) => InternetRadioStation | null;\n        getStations: (serverId: string) => InternetRadioStation[];\n        updateStation: (\n            serverId: string,\n            stationId: string,\n            updates: Partial<InternetRadioStation>,\n        ) => void;\n    };\n}\n\nexport interface RadioStoreState {\n    stations: Record<string, Record<string, InternetRadioStation>>;\n}\n\nconst initialState: RadioStoreState = {\n    stations: {},\n};\n\nexport const useRadioStore = createWithEqualityFn<RadioStoreSlice>()(\n    persist(\n        devtools(\n            immer((set, get) => ({\n                ...initialState,\n                actions: {\n                    createStation: (serverId, station) => {\n                        const id = nanoid();\n                        const newStation: InternetRadioStation = {\n                            ...station,\n                            id,\n                        };\n\n                        set((state) => {\n                            if (!state.stations[serverId]) {\n                                state.stations[serverId] = {};\n                            }\n                            state.stations[serverId][id] = newStation;\n                        });\n\n                        return newStation;\n                    },\n                    deleteStation: (serverId, stationId) => {\n                        set((state) => {\n                            if (state.stations[serverId]) {\n                                delete state.stations[serverId][stationId];\n                                // Clean up empty server entries\n                                if (Object.keys(state.stations[serverId]).length === 0) {\n                                    delete state.stations[serverId];\n                                }\n                            }\n                        });\n                    },\n                    getStation: (serverId, stationId) => {\n                        const state = get();\n                        return state.stations[serverId]?.[stationId] || null;\n                    },\n                    getStations: (serverId) => {\n                        const state = get();\n                        const serverStations = state.stations[serverId];\n                        if (!serverStations) {\n                            return [];\n                        }\n                        return Object.values(serverStations);\n                    },\n                    updateStation: (serverId, stationId, updates) => {\n                        set((state) => {\n                            if (state.stations[serverId]?.[stationId]) {\n                                state.stations[serverId][stationId] = {\n                                    ...state.stations[serverId][stationId],\n                                    ...updates,\n                                };\n                            }\n                        });\n                    },\n                },\n            })),\n            { name: 'store_radio' },\n        ),\n        {\n            merge: (persistedState, currentState) => merge(currentState, persistedState),\n            name: 'store_radio',\n            version: 1,\n        },\n    ),\n);\n\nexport const useRadioStoreActions = () => useRadioStore((state) => state.actions);\n\nexport const useRadioStations = (serverId: string) => {\n    return useRadioStore((state) => {\n        const serverStations = state.stations[serverId];\n        if (!serverStations) {\n            return [];\n        }\n        return Object.values(serverStations);\n    });\n};\n\nexport const useRadioStation = (serverId: string, stationId: string) => {\n    return useRadioStore((state) => state.stations[serverId]?.[stationId] || null);\n};\n"
  },
  {
    "path": "src/renderer/features/remote/hooks/use-remote.tsx",
    "content": "import isElectron from 'is-electron';\nimport { useEffect, useRef } from 'react';\n\nimport { getItemImageUrl } from '/@/renderer/components/item-image/item-image';\nimport { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';\nimport { useSetRating } from '/@/renderer/features/shared/hooks/use-set-rating';\nimport { useCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation';\nimport { useDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation';\nimport { usePlayerActions, usePlayerStore, useRemoteSettings } from '/@/renderer/store';\nimport { LogCategory, logFn } from '/@/renderer/utils/logger';\nimport { logMsg } from '/@/renderer/utils/logger-message';\nimport { toast } from '/@/shared/components/toast/toast';\nimport { LibraryItem } from '/@/shared/types/domain-types';\nimport { PlayerShuffle } from '/@/shared/types/types';\n\nconst remote = isElectron() ? window.api.remote : null;\nconst ipc = isElectron() ? window.api.ipc : null;\n\nexport const useRemote = () => {\n    const { mediaSkipForward, setVolume } = usePlayerActions();\n    const player = usePlayerStore();\n\n    const remoteSettings = useRemoteSettings();\n    const setRating = useSetRating();\n    const addToFavoritesMutation = useCreateFavorite({});\n    const removeFromFavoritesMutation = useDeleteFavorite({});\n\n    const isRemoteEnabled = remoteSettings.enabled;\n\n    // Initialize the remote\n    useEffect(() => {\n        if (!isRemoteEnabled) {\n            return;\n        }\n\n        logFn.debug(logMsg[LogCategory.REMOTE].initializingRemoteSettings, {\n            category: LogCategory.REMOTE,\n            meta: {\n                enabled: remoteSettings.enabled,\n                port: remoteSettings.port,\n                username: remoteSettings.username,\n            },\n        });\n\n        remote\n            ?.updateSetting(\n                remoteSettings.enabled,\n                remoteSettings.port,\n                remoteSettings.username,\n                remoteSettings.password,\n            )\n            .catch((error) => {\n                logFn.error(logMsg[LogCategory.REMOTE].failedToEnableRemote, {\n                    category: LogCategory.REMOTE,\n                    meta: { error },\n                });\n                toast.warn({ message: error, title: 'Failed to enable remote' });\n            });\n        // We only want to fire this once\n        // eslint-disable-next-line react-hooks/exhaustive-deps\n    }, []);\n\n    useEffect(() => {\n        if (!isRemoteEnabled || !remote) {\n            return;\n        }\n\n        remote.requestPosition((_e: unknown, data: { position: number }) => {\n            logFn.debug(logMsg[LogCategory.REMOTE].requestPositionReceived, {\n                category: LogCategory.REMOTE,\n                meta: { position: data.position },\n            });\n            const newTime = data.position;\n            player.mediaSeekToTimestamp(newTime);\n        });\n\n        remote.requestSeek((_e: unknown, data: { offset: number }) => {\n            logFn.debug(logMsg[LogCategory.REMOTE].requestSeekReceived, {\n                category: LogCategory.REMOTE,\n                meta: { offset: data.offset },\n            });\n            mediaSkipForward(data.offset);\n        });\n\n        remote.requestRating(\n            (_e: unknown, data: { id: string; rating: number; serverId: string }) => {\n                logFn.debug(logMsg[LogCategory.REMOTE].requestRatingReceived, {\n                    category: LogCategory.REMOTE,\n                    meta: { id: data.id, rating: data.rating, serverId: data.serverId },\n                });\n                setRating(data.serverId, [data.id], LibraryItem.SONG, data.rating);\n            },\n        );\n\n        remote.requestVolume((_e: unknown, data: { volume: number }) => {\n            logFn.debug(logMsg[LogCategory.REMOTE].requestVolumeReceived, {\n                category: LogCategory.REMOTE,\n                meta: { volume: data.volume },\n            });\n            setVolume(data.volume);\n        });\n\n        remote.requestFavorite(\n            (_e: unknown, data: { favorite: boolean; id: string; serverId: string }) => {\n                logFn.debug(logMsg[LogCategory.REMOTE].requestFavoriteReceived, {\n                    category: LogCategory.REMOTE,\n                    meta: { favorite: data.favorite, id: data.id, serverId: data.serverId },\n                });\n                const mutator = data.favorite\n                    ? addToFavoritesMutation\n                    : removeFromFavoritesMutation;\n                mutator.mutate({\n                    apiClientProps: { serverId: data.serverId },\n                    query: {\n                        id: [data.id],\n                        type: LibraryItem.SONG,\n                    },\n                });\n            },\n        );\n\n        return () => {\n            ipc?.removeAllListeners('request-position');\n            ipc?.removeAllListeners('request-seek');\n            ipc?.removeAllListeners('request-volume');\n            ipc?.removeAllListeners('request-favorite');\n            ipc?.removeAllListeners('request-rating');\n        };\n    }, [\n        addToFavoritesMutation,\n        isRemoteEnabled,\n        mediaSkipForward,\n        player,\n        removeFromFavoritesMutation,\n        setVolume,\n        setRating,\n    ]);\n\n    // Send initial song if one is already playing\n    const isInitializedRef = useRef(false);\n    useEffect(() => {\n        if (isInitializedRef.current || !isRemoteEnabled || !remote) {\n            return;\n        }\n\n        isInitializedRef.current = true;\n\n        const currentSong = player.getCurrentSong();\n\n        if (currentSong) {\n            logFn.debug(logMsg[LogCategory.REMOTE].sendingInitialSong, {\n                category: LogCategory.REMOTE,\n                meta: {\n                    artistName: currentSong.artistName,\n                    id: currentSong.id,\n                    name: currentSong.name,\n                },\n            });\n\n            const imageUrl =\n                getItemImageUrl({\n                    id: currentSong.id,\n                    imageUrl: currentSong.imageUrl,\n                    itemType: LibraryItem.SONG,\n                    serverId: currentSong._serverId,\n                    type: 'itemCard',\n                    useRemoteUrl: true,\n                }) || null;\n\n            remote.updateSong(currentSong, imageUrl);\n        }\n    }, [isRemoteEnabled, player]);\n\n    usePlayerEvents(\n        {\n            onCurrentSongChange: (properties) => {\n                if (!isRemoteEnabled || !remote) {\n                    return;\n                }\n\n                logFn.debug(logMsg[LogCategory.REMOTE].updateSongSent, {\n                    category: LogCategory.REMOTE,\n                    meta: {\n                        artistName: properties.song?.artistName,\n                        id: properties.song?.id,\n                        index: properties.index,\n                        name: properties.song?.name,\n                    },\n                });\n                if (properties.song) {\n                    const song = properties.song;\n                    const imageUrl =\n                        getItemImageUrl({\n                            id: song.id,\n                            imageUrl: song.imageUrl,\n                            itemType: LibraryItem.SONG,\n                            serverId: song._serverId,\n                            type: 'itemCard',\n                            useRemoteUrl: true,\n                        }) || null;\n\n                    remote.updateSong(song, imageUrl);\n                } else {\n                    remote.updateSong(undefined);\n                }\n            },\n            onPlayerProgress: (properties) => {\n                if (!isRemoteEnabled || !remote) {\n                    return;\n                }\n\n                logFn.debug(logMsg[LogCategory.REMOTE].updatePositionSent, {\n                    category: LogCategory.REMOTE,\n                    meta: { timestamp: properties.timestamp },\n                });\n                remote.updatePosition(properties.timestamp);\n            },\n            onPlayerRepeat: (properties) => {\n                if (!isRemoteEnabled || !remote) {\n                    return;\n                }\n\n                logFn.debug(logMsg[LogCategory.REMOTE].updateRepeatSent, {\n                    category: LogCategory.REMOTE,\n                    meta: { repeat: properties.repeat },\n                });\n                remote.updateRepeat(properties.repeat);\n            },\n            onPlayerShuffle: (properties) => {\n                if (!isRemoteEnabled || !remote) {\n                    return;\n                }\n\n                const isShuffleEnabled = properties.shuffle !== PlayerShuffle.NONE;\n                logFn.debug(logMsg[LogCategory.REMOTE].updateShuffleSent, {\n                    category: LogCategory.REMOTE,\n                    meta: { isShuffleEnabled, shuffle: properties.shuffle },\n                });\n                remote.updateShuffle(isShuffleEnabled);\n            },\n            onPlayerStatus: (properties) => {\n                if (!isRemoteEnabled || !remote) {\n                    return;\n                }\n\n                logFn.debug(logMsg[LogCategory.REMOTE].updatePlaybackSent, {\n                    category: LogCategory.REMOTE,\n                    meta: { status: properties.status },\n                });\n                remote.updatePlayback(properties.status);\n            },\n            onPlayerVolume: (properties) => {\n                if (!isRemoteEnabled || !remote) {\n                    return;\n                }\n\n                logFn.debug(logMsg[LogCategory.REMOTE].updateVolumeSent, {\n                    category: LogCategory.REMOTE,\n                    meta: { volume: properties.volume },\n                });\n                remote.updateVolume(properties.volume);\n            },\n            onUserFavorite: (properties) => {\n                if (!isRemoteEnabled || !remote) {\n                    return;\n                }\n\n                logFn.debug(logMsg[LogCategory.REMOTE].updateFavoriteSent, {\n                    category: LogCategory.REMOTE,\n                    meta: {\n                        favorite: properties.favorite,\n                        id: properties.id,\n                        serverId: properties.serverId,\n                    },\n                });\n                remote.updateFavorite(properties.favorite, properties.serverId, properties.id);\n            },\n            onUserRating: (properties) => {\n                if (!isRemoteEnabled || !remote) {\n                    return;\n                }\n\n                logFn.debug(logMsg[LogCategory.REMOTE].updateRatingSent, {\n                    category: LogCategory.REMOTE,\n                    meta: {\n                        id: properties.id,\n                        rating: properties.rating || 0,\n                        serverId: properties.serverId,\n                    },\n                });\n                remote.updateRating(properties.rating || 0, properties.serverId, properties.id);\n            },\n        },\n        [],\n    );\n};\n\nexport const RemoteHook = () => {\n    useRemote();\n    return null;\n};\n"
  },
  {
    "path": "src/renderer/features/search/api/search-api.ts",
    "content": "import { infiniteQueryOptions, queryOptions } from '@tanstack/react-query';\n\nimport { api } from '/@/renderer/api';\nimport { queryKeys } from '/@/renderer/api/query-keys';\nimport { QueryHookArgs } from '/@/renderer/lib/react-query';\nimport { SearchQuery, SearchResponse } from '/@/shared/types/domain-types';\n\nconst SEARCH_PAGE_SIZE = 4;\n\nexport const searchQueries = {\n    search: (args: QueryHookArgs<SearchQuery>) => {\n        return queryOptions({\n            queryFn: ({ signal }) => {\n                return api.controller.search({\n                    apiClientProps: { serverId: args.serverId, signal },\n                    query: args.query,\n                });\n            },\n            queryKey: queryKeys.search.list(args.serverId, args.query),\n            ...args.options,\n        });\n    },\n    searchAlbumArtistsInfinite: (args: {\n        enabled?: boolean;\n        searchTerm: string;\n        serverId: string | undefined;\n    }) => {\n        const { enabled = true, searchTerm, serverId } = args;\n        return infiniteQueryOptions({\n            enabled: Boolean(serverId && searchTerm && enabled),\n            getNextPageParam: (lastPage: SearchResponse, allPages: SearchResponse[]) => {\n                const len = lastPage.albumArtists.length;\n                if (len < SEARCH_PAGE_SIZE) return undefined;\n                return allPages.length * SEARCH_PAGE_SIZE;\n            },\n            initialPageParam: 0,\n            queryFn: ({ pageParam, signal }) => {\n                if (!serverId) throw new Error('serverId required');\n                const startIndex = (pageParam ?? 0) as number;\n                return api.controller.search({\n                    apiClientProps: { serverId, signal },\n                    query: {\n                        albumArtistLimit: SEARCH_PAGE_SIZE,\n                        albumArtistStartIndex: startIndex,\n                        albumLimit: 0,\n                        albumStartIndex: 0,\n                        query: searchTerm,\n                        songLimit: 0,\n                        songStartIndex: 0,\n                    },\n                });\n            },\n            queryKey: queryKeys.search.infiniteList(serverId ?? '', 'albumArtists', searchTerm),\n        });\n    },\n    searchAlbumsInfinite: (args: {\n        enabled?: boolean;\n        searchTerm: string;\n        serverId: string | undefined;\n    }) => {\n        const { enabled = true, searchTerm, serverId } = args;\n        return infiniteQueryOptions({\n            enabled: Boolean(serverId && searchTerm && enabled),\n            getNextPageParam: (lastPage: SearchResponse, allPages: SearchResponse[]) => {\n                const len = lastPage.albums.length;\n                if (len < SEARCH_PAGE_SIZE) return undefined;\n                return allPages.length * SEARCH_PAGE_SIZE;\n            },\n            initialPageParam: 0,\n            queryFn: ({ pageParam, signal }) => {\n                if (!serverId) throw new Error('serverId required');\n                const startIndex = (pageParam ?? 0) as number;\n                return api.controller.search({\n                    apiClientProps: { serverId, signal },\n                    query: {\n                        albumArtistLimit: 0,\n                        albumArtistStartIndex: 0,\n                        albumLimit: SEARCH_PAGE_SIZE,\n                        albumStartIndex: startIndex,\n                        query: searchTerm,\n                        songLimit: 0,\n                        songStartIndex: 0,\n                    },\n                });\n            },\n            queryKey: queryKeys.search.infiniteList(serverId ?? '', 'albums', searchTerm),\n        });\n    },\n    searchSongsInfinite: (args: {\n        enabled?: boolean;\n        searchTerm: string;\n        serverId: string | undefined;\n    }) => {\n        const { enabled = true, searchTerm, serverId } = args;\n        return infiniteQueryOptions({\n            enabled: Boolean(serverId && searchTerm && enabled),\n            getNextPageParam: (lastPage: SearchResponse, allPages: SearchResponse[]) => {\n                const len = lastPage.songs.length;\n                if (len < SEARCH_PAGE_SIZE) return undefined;\n                return allPages.length * SEARCH_PAGE_SIZE;\n            },\n            initialPageParam: 0,\n            queryFn: ({ pageParam, signal }) => {\n                if (!serverId) throw new Error('serverId required');\n                const startIndex = (pageParam ?? 0) as number;\n                return api.controller.search({\n                    apiClientProps: { serverId, signal },\n                    query: {\n                        albumArtistLimit: 0,\n                        albumArtistStartIndex: 0,\n                        albumLimit: 0,\n                        albumStartIndex: 0,\n                        query: searchTerm,\n                        songLimit: SEARCH_PAGE_SIZE,\n                        songStartIndex: startIndex,\n                    },\n                });\n            },\n            queryKey: queryKeys.search.infiniteList(serverId ?? '', 'songs', searchTerm),\n        });\n    },\n};\n"
  },
  {
    "path": "src/renderer/features/search/components/collapsible-command-group.module.css",
    "content": ".root {\n    display: flex;\n    flex-direction: column;\n}\n\n\n.heading {\n    display: flex;\n    gap: var(--theme-spacing-xs);\n    align-items: center;\n    font-size: var(--theme-font-size-sm);\n    cursor: pointer;\n    user-select: none;\n    opacity: 0.8;\n}\n\n.heading:hover {\n    opacity: 1;\n}\n\n.heading:focus-visible {\n    outline: 2px solid var(--theme-colors-primary);\n    outline-offset: 2px;\n}\n\n.chevron {\n    flex-shrink: 0;\n    width: 1rem;\n    height: 1rem;\n    opacity: 0.9;\n}\n\n.subtitle {\n    display: flex;\n    align-items: center;\n}\n\n.items {\n    display: flex;\n    flex-direction: column;\n}\n"
  },
  {
    "path": "src/renderer/features/search/components/collapsible-command-group.tsx",
    "content": "import { ReactNode, useCallback, useState } from 'react';\n\nimport styles from './collapsible-command-group.module.css';\n\nimport { Group } from '/@/shared/components/group/group';\nimport { Icon } from '/@/shared/components/icon/icon';\nimport { Paper } from '/@/shared/components/paper/paper';\n\ninterface CollapsibleCommandGroupProps {\n    children: ReactNode;\n    defaultExpanded?: boolean;\n    expanded?: boolean;\n    heading: string;\n    onToggle?: () => void;\n    subtitle?: ReactNode;\n}\n\nexport function CollapsibleCommandGroup({\n    children,\n    defaultExpanded = true,\n    expanded: controlledExpanded,\n    heading,\n    onToggle,\n    subtitle,\n}: CollapsibleCommandGroupProps) {\n    const [internalExpanded, setInternalExpanded] = useState(defaultExpanded);\n\n    const isControlled = controlledExpanded !== undefined && onToggle !== undefined;\n    const expanded = isControlled ? controlledExpanded : internalExpanded;\n\n    const toggle = useCallback(() => {\n        if (isControlled) {\n            onToggle?.();\n        } else {\n            setInternalExpanded((prev) => !prev);\n        }\n    }, [isControlled, onToggle]);\n\n    const handleKeyDown = useCallback(\n        (e: React.KeyboardEvent) => {\n            if (e.key === 'Enter' || e.key === ' ') {\n                e.preventDefault();\n                toggle();\n            }\n        },\n        [toggle],\n    );\n\n    return (\n        <div className={styles.root}>\n            <Paper p=\"sm\" radius=\"sm\" withBorder>\n                <div\n                    className={styles.heading}\n                    onClick={toggle}\n                    onKeyDown={handleKeyDown}\n                    role=\"button\"\n                    tabIndex={0}\n                >\n                    <Icon className={styles.chevron} icon={expanded ? 'dropdown' : 'arrowRightS'} />\n                    <Group justify=\"space-between\" w=\"100%\">\n                        <span>{heading}</span>\n                        {subtitle && <span className={styles.subtitle}>{subtitle}</span>}\n                    </Group>\n                </div>\n            </Paper>\n            {expanded && <div className={styles.items}>{children}</div>}\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/features/search/components/command-item-selectable.tsx",
    "content": "import { Command } from 'cmdk';\nimport { ComponentPropsWithoutRef, ReactNode, useEffect, useRef, useState } from 'react';\n\ninterface CommandItemSelectableProps\n    extends Omit<ComponentPropsWithoutRef<typeof Command.Item>, 'children'> {\n    children: (args: { isHighlighted: boolean }) => ReactNode;\n}\n\nexport function CommandItemSelectable({ children, ...itemProps }: CommandItemSelectableProps) {\n    const ref = useRef<HTMLDivElement>(null);\n    const [isHighlighted, setIsHighlighted] = useState(false);\n\n    useEffect(() => {\n        const el = ref.current;\n        if (!el) return;\n\n        setIsHighlighted(el.getAttribute('aria-selected') === 'true');\n\n        const observer = new MutationObserver(() => {\n            const selected = el.getAttribute('aria-selected') === 'true';\n            setIsHighlighted(selected);\n        });\n\n        observer.observe(el, {\n            attributeFilter: ['aria-selected'],\n            attributes: true,\n        });\n\n        return () => observer.disconnect();\n    }, []);\n\n    return (\n        <Command.Item {...itemProps} ref={ref}>\n            {children({ isHighlighted })}\n        </Command.Item>\n    );\n}\n"
  },
  {
    "path": "src/renderer/features/search/components/command-palette.tsx",
    "content": "import { useCallback, useRef, useState } from 'react';\n\nimport { Command, CommandPalettePages } from '/@/renderer/features/search/components/command';\nimport { GoToCommands } from '/@/renderer/features/search/components/go-to-commands';\nimport { HomeCommands } from '/@/renderer/features/search/components/home-commands';\nimport { SearchAlbumArtistsSection } from '/@/renderer/features/search/components/search-album-artists-section';\nimport { SearchAlbumsSection } from '/@/renderer/features/search/components/search-albums-section';\nimport { SearchSongsSection } from '/@/renderer/features/search/components/search-songs-section';\nimport { ServerCommands } from '/@/renderer/features/search/components/server-commands';\nimport { useAppStore } from '/@/renderer/store';\nimport { ActionIcon } from '/@/shared/components/action-icon/action-icon';\nimport { Breadcrumb } from '/@/shared/components/breadcrumb/breadcrumb';\nimport { Button } from '/@/shared/components/button/button';\nimport { Divider } from '/@/shared/components/divider/divider';\nimport { Group } from '/@/shared/components/group/group';\nimport { Icon } from '/@/shared/components/icon/icon';\nimport { Kbd } from '/@/shared/components/kbd/kbd';\nimport { Modal } from '/@/shared/components/modal/modal';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { TextInput } from '/@/shared/components/text-input/text-input';\nimport { useDebouncedValue } from '/@/shared/hooks/use-debounced-value';\nimport { useDisclosure } from '/@/shared/hooks/use-disclosure';\n\ninterface CommandPaletteProps {\n    modalProps: (typeof useDisclosure)['arguments'];\n}\n\nconst SEARCH_SECTION_IDS = {\n    albums: 'albums',\n    artists: 'artists',\n    tracks: 'tracks',\n} as const;\n\ninterface CommandPaletteSearchProps {\n    children?: React.ReactNode;\n    isHome: boolean;\n    onSelectResult: () => void;\n    query: string;\n    searchInputRef: React.RefObject<HTMLInputElement | null>;\n    setQuery: (query: string) => void;\n}\n\nfunction CommandPaletteSearch({\n    children,\n    isHome,\n    onSelectResult,\n    query,\n    searchInputRef,\n    setQuery,\n}: CommandPaletteSearchProps) {\n    const [debouncedQuery] = useDebouncedValue(query, 400);\n    const searchSectionsExpanded = useAppStore(\n        (state) => state.commandPaletteSearchSectionsExpanded,\n    );\n    const setSearchSectionExpanded = useAppStore(\n        (state) => state.actions.setCommandPaletteSearchSectionExpanded,\n    );\n\n    return (\n        <>\n            <TextInput\n                data-autofocus\n                leftSection={<Icon icon=\"search\" />}\n                onChange={(e) => setQuery(e.currentTarget.value)}\n                ref={searchInputRef}\n                rightSection={\n                    query && (\n                        <ActionIcon\n                            onClick={() => {\n                                setQuery('');\n                                searchInputRef.current?.focus();\n                            }}\n                            variant=\"transparent\"\n                        >\n                            <Icon icon=\"x\" />\n                        </ActionIcon>\n                    )\n                }\n                size=\"sm\"\n                value={query}\n            />\n            <Divider my=\"sm\" />\n            <Command.List>\n                <Stack gap=\"xs\">\n                    <SearchAlbumsSection\n                        debouncedQuery={debouncedQuery ?? ''}\n                        expanded={searchSectionsExpanded[SEARCH_SECTION_IDS.albums] ?? true}\n                        isHome={isHome}\n                        onSelectResult={onSelectResult}\n                        onToggle={() =>\n                            setSearchSectionExpanded(\n                                SEARCH_SECTION_IDS.albums,\n                                !(searchSectionsExpanded[SEARCH_SECTION_IDS.albums] ?? true),\n                            )\n                        }\n                        query={query}\n                    />\n                    <SearchAlbumArtistsSection\n                        debouncedQuery={debouncedQuery ?? ''}\n                        expanded={searchSectionsExpanded[SEARCH_SECTION_IDS.artists] ?? true}\n                        isHome={isHome}\n                        onSelectResult={onSelectResult}\n                        onToggle={() =>\n                            setSearchSectionExpanded(\n                                SEARCH_SECTION_IDS.artists,\n                                !(searchSectionsExpanded[SEARCH_SECTION_IDS.artists] ?? true),\n                            )\n                        }\n                        query={query}\n                    />\n                    <SearchSongsSection\n                        debouncedQuery={debouncedQuery ?? ''}\n                        expanded={searchSectionsExpanded[SEARCH_SECTION_IDS.tracks] ?? true}\n                        isHome={isHome}\n                        onSelectResult={onSelectResult}\n                        onToggle={() =>\n                            setSearchSectionExpanded(\n                                SEARCH_SECTION_IDS.tracks,\n                                !(searchSectionsExpanded[SEARCH_SECTION_IDS.tracks] ?? true),\n                            )\n                        }\n                        query={query}\n                    />\n                </Stack>\n                {children}\n            </Command.List>\n        </>\n    );\n}\n\nexport const CommandPalette = ({ modalProps }: CommandPaletteProps) => {\n    const [value, setValue] = useState('');\n    const [query, setQuery] = useState('');\n    const [pages, setPages] = useState<CommandPalettePages[]>([CommandPalettePages.HOME]);\n    const activePage = pages[pages.length - 1];\n    const isHome = activePage === CommandPalettePages.HOME;\n    const commandRootRef = useRef<HTMLDivElement>(null);\n    const searchInputRef = useRef<HTMLInputElement>(null);\n\n    const popPage = useCallback(() => {\n        setPages((pages) => {\n            const x = [...pages];\n            x.splice(-1, 1);\n            return x;\n        });\n    }, []);\n\n    const handleSelectResult = useCallback(() => {\n        modalProps.handlers.close();\n        setQuery('');\n    }, [modalProps.handlers]);\n\n    return (\n        <Modal\n            {...modalProps}\n            centered\n            handlers={{\n                ...modalProps.handlers,\n                close: () => {\n                    if (isHome) {\n                        modalProps.handlers.close();\n                        setQuery('');\n                    } else {\n                        popPage();\n                    }\n                },\n                toggle: () => {\n                    if (isHome) {\n                        modalProps.handlers.toggle();\n                        setQuery('');\n                    } else {\n                        popPage();\n                    }\n                },\n            }}\n            size=\"lg\"\n            styles={{\n                body: { padding: '0' },\n                header: { display: 'none' },\n            }}\n        >\n            <Command\n                filter={(value, search) => {\n                    if (value.includes(search)) return 1;\n                    if (value.includes('search')) return 1;\n                    return 0;\n                }}\n                label=\"Global Command Menu\"\n                onKeyDown={(e) => {\n                    if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {\n                        searchInputRef.current?.focus();\n                    }\n\n                    if (e.key === 'Tab' && !e.shiftKey) {\n                        const root = commandRootRef.current;\n                        if (!root) return;\n\n                        const selectedItem = root.querySelector(\n                            '[cmdk-item][aria-selected=\"true\"]',\n                        ) as HTMLElement | null;\n\n                        if (!selectedItem) return;\n\n                        const focusTarget = selectedItem.querySelector(\n                            'button:not([disabled]), [tabindex]:not([tabindex=\"-1\"])',\n                        ) as HTMLElement | null;\n\n                        if (!focusTarget) return;\n\n                        e.preventDefault();\n                        e.stopPropagation();\n\n                        requestAnimationFrame(() => {\n                            focusTarget.focus();\n                        });\n                    }\n                }}\n                onValueChange={setValue}\n                ref={commandRootRef}\n                value={value}\n            >\n                <CommandPaletteSearch\n                    isHome={isHome}\n                    onSelectResult={handleSelectResult}\n                    query={query}\n                    searchInputRef={searchInputRef}\n                    setQuery={setQuery}\n                >\n                    {activePage === CommandPalettePages.HOME && (\n                        <HomeCommands\n                            handleClose={modalProps.handlers.close}\n                            pages={pages}\n                            query={query}\n                            setPages={setPages}\n                            setQuery={setQuery}\n                        />\n                    )}\n                    {activePage === CommandPalettePages.GO_TO && (\n                        <GoToCommands\n                            handleClose={modalProps.handlers.close}\n                            setPages={setPages}\n                            setQuery={setQuery}\n                        />\n                    )}\n                    {activePage === CommandPalettePages.MANAGE_SERVERS && (\n                        <ServerCommands\n                            handleClose={modalProps.handlers.close}\n                            setPages={setPages}\n                            setQuery={setQuery}\n                        />\n                    )}\n                </CommandPaletteSearch>\n            </Command>\n            <Divider my=\"sm\" />\n            <Group justify=\"space-between\">\n                <Breadcrumb separator={<Icon icon=\"arrowRight\" />}>\n                    {pages.map((page, index) => (\n                        <Button\n                            key={page}\n                            onClick={() => setPages((prev) => prev.slice(0, index + 1))}\n                            size=\"compact-xs\"\n                            variant=\"subtle\"\n                        >\n                            {page?.toLocaleUpperCase()}\n                        </Button>\n                    ))}\n                </Breadcrumb>\n\n                <Group gap=\"sm\">\n                    <Kbd size=\"md\">ESC</Kbd>\n                    <Kbd size=\"md\">↑</Kbd>\n                    <Kbd size=\"md\">↓</Kbd>\n                    <Kbd size=\"md\">⏎</Kbd>\n                </Group>\n            </Group>\n        </Modal>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/search/components/command.css",
    "content": "input[cmdk-input] {\n    width: 100%;\n    font-size: var(--theme-font-size-md);\n    border: none;\n    border-radius: var(--theme-radius-sm);\n}\n\n[cmdk-group-heading] {\n    margin: var(--theme-spacing-md) 0;\n    font-size: var(--theme-font-size-sm);\n    opacity: 0.8;\n}\n\n[cmdk-group-items] {\n    display: flex;\n    flex-direction: column;\n    gap: 0;\n}\n\n[cmdk-item] {\n    display: flex;\n    gap: var(--theme-spacing-sm);\n    align-items: center;\n    padding: var(--theme-spacing-sm);\n    font-size: var(--theme-font-size-md);\n    color: var(--theme-colors-foreground);\n    cursor: pointer;\n    border-radius: var(--theme-radius-sm);\n\n    svg {\n        width: 1.2rem;\n        height: 1.2rem;\n    }\n\n    &[data-selected='true'] {\n        background: var(--theme-colors-surface);\n    }\n}\n\n[cmdk-separator] {\n    height: 1px;\n    margin: 0 0 var(--theme-spacing-sm);\n    background: var(--theme-colors-border);\n}\n"
  },
  {
    "path": "src/renderer/features/search/components/command.tsx",
    "content": "import { Command as Cmdk } from 'cmdk';\n\nimport './command.css';\n\nexport enum CommandPalettePages {\n    GO_TO = 'go',\n    HOME = 'home',\n    MANAGE_SERVERS = 'servers',\n}\n\nexport const Command = Cmdk as typeof Cmdk;\n"
  },
  {
    "path": "src/renderer/features/search/components/go-to-commands.tsx",
    "content": "import { Dispatch, useCallback } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { useNavigate } from 'react-router';\n\nimport { Command, CommandPalettePages } from '/@/renderer/features/search/components/command';\nimport { openSettingsModal } from '/@/renderer/features/settings/utils/open-settings-modal';\nimport { AppRoute } from '/@/renderer/router/routes';\n\ninterface GoToCommandsProps {\n    handleClose: () => void;\n    setPages: (pages: CommandPalettePages[]) => void;\n    setQuery: Dispatch<string>;\n}\n\nexport const GoToCommands = ({ handleClose, setPages, setQuery }: GoToCommandsProps) => {\n    const { t } = useTranslation();\n    const navigate = useNavigate();\n\n    const goTo = useCallback(\n        (route: string) => {\n            navigate(route);\n            handleClose();\n            setPages([CommandPalettePages.HOME]);\n            setQuery('');\n        },\n        [handleClose, navigate, setPages, setQuery],\n    );\n\n    return (\n        <>\n            <Command.Group>\n                <Command.Item onSelect={() => goTo(AppRoute.HOME)}>\n                    {t('page.sidebar.home', { postProcess: 'titleCase' })}\n                </Command.Item>\n                <Command.Item onSelect={() => goTo(AppRoute.SEARCH)}>\n                    {t('page.sidebar.search', { postProcess: 'titleCase' })}\n                </Command.Item>\n                <Command.Item\n                    onSelect={() => {\n                        openSettingsModal();\n                    }}\n                >\n                    {t('page.sidebar.settings', { postProcess: 'titleCase' })}\n                </Command.Item>\n            </Command.Group>\n            <Command.Group heading=\"Library\">\n                <Command.Item onSelect={() => goTo(AppRoute.LIBRARY_ALBUMS)}>\n                    {t('page.sidebar.albums', { postProcess: 'titleCase' })}\n                </Command.Item>\n                <Command.Item onSelect={() => goTo(AppRoute.LIBRARY_SONGS)}>\n                    {t('page.sidebar.tracks', { postProcess: 'titleCase' })}\n                </Command.Item>\n                <Command.Item onSelect={() => goTo(AppRoute.LIBRARY_ALBUM_ARTISTS)}>\n                    {t('page.sidebar.albumArtists', { postProcess: 'titleCase' })}\n                </Command.Item>\n                <Command.Item onSelect={() => goTo(AppRoute.LIBRARY_GENRES)}>\n                    {t('page.sidebar.genres', { postProcess: 'titleCase' })}\n                </Command.Item>\n                <Command.Item onSelect={() => goTo(AppRoute.LIBRARY_FOLDERS)}>\n                    {t('page.sidebar.folders', { postProcess: 'titleCase' })}\n                </Command.Item>\n                <Command.Item onSelect={() => goTo(AppRoute.PLAYLISTS)}>\n                    {t('page.sidebar.playlists', { postProcess: 'titleCase' })}\n                </Command.Item>\n            </Command.Group>\n            <Command.Separator />\n        </>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/search/components/home-commands.tsx",
    "content": "import { nanoid } from 'nanoid/non-secure';\nimport { Dispatch, useCallback } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { createSearchParams, generatePath, useNavigate } from 'react-router';\n\nimport { openCreatePlaylistModal } from '/@/renderer/features/playlists/components/create-playlist-form';\nimport { Command, CommandPalettePages } from '/@/renderer/features/search/components/command';\nimport { AppRoute } from '/@/renderer/router/routes';\nimport { useCurrentServer } from '/@/renderer/store';\nimport { LibraryItem } from '/@/shared/types/domain-types';\n\ninterface HomeCommandsProps {\n    handleClose: () => void;\n    pages: CommandPalettePages[];\n    query: string;\n    setPages: Dispatch<CommandPalettePages[]>;\n    setQuery: Dispatch<string>;\n}\n\nexport const HomeCommands = ({\n    handleClose,\n    pages,\n    query,\n    setPages,\n    setQuery,\n}: HomeCommandsProps) => {\n    const { t } = useTranslation();\n    const navigate = useNavigate();\n    const server = useCurrentServer();\n\n    const handleCreatePlaylistModal = useCallback(() => {\n        handleClose();\n        openCreatePlaylistModal(server);\n    }, [handleClose, server]);\n\n    const handleSearch = () => {\n        navigate(\n            {\n                pathname: generatePath(AppRoute.SEARCH, { itemType: LibraryItem.SONG }),\n                search: createSearchParams({\n                    query,\n                }).toString(),\n            },\n            {\n                state: {\n                    navigationId: nanoid(),\n                },\n            },\n        );\n        handleClose();\n        setQuery('');\n    };\n\n    return (\n        <>\n            <Command.Group heading={t('page.globalSearch.title', { postProcess: 'titleCase' })}>\n                <Command.Item\n                    onSelect={handleSearch}\n                    value={t('common.search', { postProcess: 'sentenceCase' })}\n                >\n                    {query\n                        ? t('page.globalSearch.commands.searchFor', {\n                              postProcess: 'sentenceCase',\n                              query,\n                          })\n                        : `${t('common.search', { postProcess: 'sentenceCase' })}...`}\n                </Command.Item>\n                <Command.Item onSelect={handleCreatePlaylistModal}>\n                    {t('action.createPlaylist', { postProcess: 'sentenceCase' })}...\n                </Command.Item>\n                <Command.Item onSelect={() => setPages([...pages, CommandPalettePages.GO_TO])}>\n                    {t('page.globalSearch.commands.goToPage', { postProcess: 'sentenceCase' })}...\n                </Command.Item>\n                <Command.Item\n                    onSelect={() => setPages([...pages, CommandPalettePages.MANAGE_SERVERS])}\n                >\n                    {t('page.globalSearch.commands.serverCommands', {\n                        postProcess: 'sentenceCase',\n                    })}\n                    ...\n                </Command.Item>\n            </Command.Group>\n        </>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/search/components/library-command-item.module.css",
    "content": ".item-grid {\n    display: grid;\n    grid-template-areas: 'image info';\n    grid-template-rows: 1fr;\n    grid-template-columns: var(--item-height) minmax(0, 1fr);\n    grid-auto-columns: 1fr;\n    gap: 0.5rem;\n    width: 100%;\n    max-width: 100%;\n    height: 100%;\n    letter-spacing: 0.5px;\n}\n\n.image-wrapper {\n    display: flex;\n    grid-area: image;\n    align-items: center;\n    justify-content: center;\n    height: 100%;\n}\n\n.metadata-wrapper {\n    display: flex;\n    flex-direction: column;\n    grid-area: info;\n    justify-content: center;\n    width: 100%;\n}\n\n.image {\n    object-fit: var(--theme-image-fit);\n    background: alpha(var(--theme-colors-foreground-muted), 0.3);\n    border-radius: 4px;\n}\n\n.controls {\n    display: flex;\n    align-items: center;\n    justify-content: center;\n\n    svg {\n        width: var(--theme-font-size-sm);\n        height: var(--theme-font-size-sm);\n    }\n}\n"
  },
  {
    "path": "src/renderer/features/search/components/library-command-item.tsx",
    "content": "import { CSSProperties, useCallback, useState } from 'react';\n\nimport styles from './library-command-item.module.css';\n\nimport { ItemImage } from '/@/renderer/components/item-image/item-image';\nimport { usePlayer } from '/@/renderer/features/player/context/player-context';\nimport {\n    LONG_PRESS_PLAY_BEHAVIOR,\n    PlayTooltip,\n} from '/@/renderer/features/shared/components/play-button-group';\nimport { usePlayButtonClick } from '/@/renderer/features/shared/hooks/use-play-button-click';\nimport { useCurrentServer } from '/@/renderer/store';\nimport { ActionIcon, ActionIconGroup } from '/@/shared/components/action-icon/action-icon';\nimport { Flex } from '/@/shared/components/flex/flex';\nimport { Text } from '/@/shared/components/text/text';\nimport { ExplicitStatus, LibraryItem, Song } from '/@/shared/types/domain-types';\nimport { Play } from '/@/shared/types/types';\n\nconst createPlayKeyDownHandler = (\n    playType: Play,\n    disabled: boolean,\n    onPlay: (type: Play) => void,\n) => {\n    return (e: React.KeyboardEvent) => {\n        if (e.key === ' ' || e.key === 'Enter') {\n            e.preventDefault();\n            e.stopPropagation();\n            if (!disabled) {\n                onPlay(playType);\n            }\n        } else if (e.key === 'Tab') {\n            e.stopPropagation();\n        }\n    };\n};\n\ninterface LibraryCommandItemProps {\n    disabled?: boolean;\n    explicitStatus?: ExplicitStatus | null;\n    id: string;\n    imageId: null | string;\n    imageUrl: null | string;\n    isHighlighted?: boolean;\n    itemType: LibraryItem;\n    song?: Song;\n    subtitle?: string;\n    title?: string;\n}\n\nexport const LibraryCommandItem = ({\n    disabled,\n    explicitStatus,\n    id,\n    imageId,\n    imageUrl,\n    isHighlighted,\n    itemType,\n    song,\n    subtitle,\n    title,\n}: LibraryCommandItemProps) => {\n    const { addToQueueByData, addToQueueByFetch } = usePlayer();\n    const server = useCurrentServer();\n\n    const handlePlay = useCallback(\n        (playType: Play) => {\n            if (!server.id) return;\n\n            // Use addToQueueByData for songs when we have the song data\n            if (itemType === LibraryItem.SONG && song) {\n                addToQueueByData([song], playType);\n            } else {\n                addToQueueByFetch(server.id, [id], itemType, playType);\n            }\n        },\n        [addToQueueByData, addToQueueByFetch, id, itemType, server.id, song],\n    );\n\n    const handlePlayNext = usePlayButtonClick({\n        onClick: () => {\n            handlePlay(Play.NEXT);\n        },\n        onLongPress: () => {\n            handlePlay(LONG_PRESS_PLAY_BEHAVIOR[Play.NEXT]);\n        },\n    });\n\n    const handlePlayNow = usePlayButtonClick({\n        onClick: () => {\n            handlePlay(Play.NOW);\n        },\n        onLongPress: () => {\n            handlePlay(LONG_PRESS_PLAY_BEHAVIOR[Play.NOW]);\n        },\n    });\n\n    const handlePlayLast = usePlayButtonClick({\n        onClick: () => {\n            handlePlay(Play.LAST);\n        },\n        onLongPress: () => {\n            handlePlay(LONG_PRESS_PLAY_BEHAVIOR[Play.LAST]);\n        },\n    });\n\n    const [isHovered, setIsHovered] = useState(false);\n\n    const showControls = isHighlighted || isHovered;\n\n    return (\n        <Flex\n            gap=\"xl\"\n            justify=\"space-between\"\n            onMouseEnter={() => setIsHovered(true)}\n            onMouseLeave={() => setIsHovered(false)}\n            style={{ height: '40px', width: '100%' }}\n        >\n            <div className={styles.itemGrid} style={{ '--item-height': '40px' } as CSSProperties}>\n                <div className={styles.imageWrapper}>\n                    <ItemImage\n                        alt=\"cover\"\n                        className={styles.image}\n                        explicitStatus={explicitStatus ?? song?.explicitStatus ?? null}\n                        height={40}\n                        id={imageId}\n                        itemType={itemType}\n                        src={imageUrl}\n                        type=\"table\"\n                        width={40}\n                    />\n                </div>\n                <div className={styles.metadataWrapper}>\n                    <Text overflow=\"hidden\">{title}</Text>\n                    <Text isMuted overflow=\"hidden\" size=\"sm\">\n                        {subtitle}\n                    </Text>\n                </div>\n            </div>\n            {showControls && (\n                <ActionIconGroup className={styles.controls}>\n                    <PlayTooltip disabled={disabled} type={Play.NOW}>\n                        <ActionIcon\n                            icon=\"mediaPlay\"\n                            size=\"xs\"\n                            variant=\"default\"\n                            {...handlePlayNow.handlers}\n                            {...handlePlayNow.props}\n                            onKeyDown={createPlayKeyDownHandler(\n                                Play.NOW,\n                                Boolean(disabled ?? handlePlayNow.props.disabled),\n                                handlePlay,\n                            )}\n                        />\n                    </PlayTooltip>\n                    <PlayTooltip disabled={disabled} type={Play.NEXT}>\n                        <ActionIcon\n                            icon=\"mediaPlayNext\"\n                            size=\"xs\"\n                            variant=\"default\"\n                            {...handlePlayNext.handlers}\n                            {...handlePlayNext.props}\n                            onKeyDown={createPlayKeyDownHandler(\n                                Play.NEXT,\n                                Boolean(disabled ?? handlePlayNext.props.disabled),\n                                handlePlay,\n                            )}\n                        />\n                    </PlayTooltip>\n                    <PlayTooltip disabled={disabled} type={Play.LAST}>\n                        <ActionIcon\n                            icon=\"mediaPlayLast\"\n                            size=\"xs\"\n                            variant=\"default\"\n                            {...handlePlayLast.handlers}\n                            {...handlePlayLast.props}\n                            onKeyDown={createPlayKeyDownHandler(\n                                Play.LAST,\n                                Boolean(disabled ?? handlePlayLast.props.disabled),\n                                handlePlay,\n                            )}\n                        />\n                    </PlayTooltip>\n                </ActionIconGroup>\n            )}\n        </Flex>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/search/components/search-album-artists-section.tsx",
    "content": "import { useInfiniteQuery } from '@tanstack/react-query';\nimport { nanoid } from 'nanoid/non-secure';\nimport { useCallback } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { createSearchParams, generatePath, useNavigate } from 'react-router';\n\nimport { searchQueries } from '/@/renderer/features/search/api/search-api';\nimport { CollapsibleCommandGroup } from '/@/renderer/features/search/components/collapsible-command-group';\nimport { CommandItemSelectable } from '/@/renderer/features/search/components/command-item-selectable';\nimport { LibraryCommandItem } from '/@/renderer/features/search/components/library-command-item';\nimport { FILTER_KEYS } from '/@/renderer/features/shared/utils';\nimport { AppRoute } from '/@/renderer/router/routes';\nimport { useCurrentServer } from '/@/renderer/store';\nimport { Box } from '/@/shared/components/box/box';\nimport { Button } from '/@/shared/components/button/button';\nimport { Spinner } from '/@/shared/components/spinner/spinner';\nimport { Text } from '/@/shared/components/text/text';\nimport { LibraryItem } from '/@/shared/types/domain-types';\n\ninterface SearchAlbumArtistsSectionProps {\n    debouncedQuery: string;\n    expanded: boolean;\n    isHome: boolean;\n    onSelectResult: () => void;\n    onToggle: () => void;\n    query: string;\n}\n\nexport function SearchAlbumArtistsSection({\n    debouncedQuery,\n    expanded,\n    isHome,\n    onSelectResult,\n    onToggle,\n    query,\n}: SearchAlbumArtistsSectionProps) {\n    const navigate = useNavigate();\n    const server = useCurrentServer();\n    const { t } = useTranslation();\n\n    const { data, fetchNextPage, hasNextPage, isFetched, isFetchingNextPage, isLoading } =\n        useInfiniteQuery(\n            searchQueries.searchAlbumArtistsInfinite({\n                enabled: isHome && debouncedQuery !== '' && query !== '',\n                searchTerm: debouncedQuery,\n                serverId: server?.id,\n            }),\n        );\n\n    const artists = data?.pages.flatMap((p) => p.albumArtists) ?? [];\n    const showSection = isHome;\n    const numberOfResults = hasNextPage ? `${artists.length}+` : artists.length;\n\n    const handleGoToPage = useCallback(() => {\n        navigate(\n            {\n                pathname: AppRoute.LIBRARY_ALBUM_ARTISTS,\n                search: createSearchParams({\n                    [FILTER_KEYS.SHARED.SEARCH_TERM]: debouncedQuery || query,\n                }).toString(),\n            },\n            { state: { navigationId: nanoid() } },\n        );\n        onSelectResult();\n    }, [debouncedQuery, navigate, onSelectResult, query]);\n\n    if (!showSection) return null;\n\n    return (\n        <CollapsibleCommandGroup\n            expanded={expanded}\n            heading={t('entity.albumArtist', { count: 2, postProcess: 'titleCase' })}\n            onToggle={onToggle}\n            subtitle={\n                isFetched ? (\n                    <>\n                        {query ? (\n                            <Button\n                                onClick={(e) => {\n                                    e.preventDefault();\n                                    e.stopPropagation();\n                                    handleGoToPage();\n                                }}\n                                onKeyDown={(e) => {\n                                    e.preventDefault();\n                                    e.stopPropagation();\n                                }}\n                                size=\"compact-xs\"\n                                variant=\"filled\"\n                                w=\"8rem\"\n                            >\n                                {t('common.numberOfResults', { numberOfResults })}\n                            </Button>\n                        ) : null}\n                    </>\n                ) : undefined\n            }\n        >\n            {isLoading ? (\n                <Box p=\"md\">\n                    <Spinner container />\n                </Box>\n            ) : (\n                <>\n                    {artists.map((artist) => (\n                        <CommandItemSelectable\n                            key={`search-artist-${artist.id}`}\n                            onSelect={() => {\n                                navigate(\n                                    generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {\n                                        albumArtistId: artist.id,\n                                    }),\n                                );\n                                onSelectResult();\n                            }}\n                            value={`search-artist-${artist.id}`}\n                        >\n                            {({ isHighlighted }) => (\n                                <LibraryCommandItem\n                                    disabled={artist?.albumCount === 0}\n                                    id={artist.id}\n                                    imageId={artist.imageId}\n                                    imageUrl={artist.imageUrl}\n                                    isHighlighted={isHighlighted}\n                                    itemType={LibraryItem.ALBUM_ARTIST}\n                                    subtitle={\n                                        artist?.albumCount !== undefined &&\n                                        artist?.albumCount !== null\n                                            ? t('entity.albumWithCount', {\n                                                  count: artist.albumCount,\n                                              })\n                                            : undefined\n                                    }\n                                    title={artist.name}\n                                />\n                            )}\n                        </CommandItemSelectable>\n                    ))}\n                    {hasNextPage && (\n                        <CommandItemSelectable\n                            disabled={isFetchingNextPage}\n                            onSelect={() => fetchNextPage()}\n                            value=\"search-artists-load-more\"\n                        >\n                            {() => (\n                                <Text>\n                                    {isFetchingNextPage ? (\n                                        <Spinner />\n                                    ) : (\n                                        <Text size=\"sm\">\n                                            {t('action.viewMore', { postProcess: 'titleCase' })}\n                                        </Text>\n                                    )}\n                                </Text>\n                            )}\n                        </CommandItemSelectable>\n                    )}\n                </>\n            )}\n        </CollapsibleCommandGroup>\n    );\n}\n"
  },
  {
    "path": "src/renderer/features/search/components/search-albums-section.tsx",
    "content": "import { useInfiniteQuery } from '@tanstack/react-query';\nimport { nanoid } from 'nanoid/non-secure';\nimport { useCallback } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { createSearchParams, generatePath, useNavigate } from 'react-router';\n\nimport { searchQueries } from '/@/renderer/features/search/api/search-api';\nimport { CollapsibleCommandGroup } from '/@/renderer/features/search/components/collapsible-command-group';\nimport { CommandItemSelectable } from '/@/renderer/features/search/components/command-item-selectable';\nimport { LibraryCommandItem } from '/@/renderer/features/search/components/library-command-item';\nimport { FILTER_KEYS } from '/@/renderer/features/shared/utils';\nimport { AppRoute } from '/@/renderer/router/routes';\nimport { useCurrentServer } from '/@/renderer/store';\nimport { Box } from '/@/shared/components/box/box';\nimport { Button } from '/@/shared/components/button/button';\nimport { Spinner } from '/@/shared/components/spinner/spinner';\nimport { Text } from '/@/shared/components/text/text';\nimport { LibraryItem } from '/@/shared/types/domain-types';\n\ninterface SearchAlbumsSectionProps {\n    debouncedQuery: string;\n    expanded: boolean;\n    isHome: boolean;\n    onSelectResult: () => void;\n    onToggle: () => void;\n    query: string;\n}\n\nexport function SearchAlbumsSection({\n    debouncedQuery,\n    expanded,\n    isHome,\n    onSelectResult,\n    onToggle,\n    query,\n}: SearchAlbumsSectionProps) {\n    const navigate = useNavigate();\n    const server = useCurrentServer();\n    const { t } = useTranslation();\n\n    const { data, fetchNextPage, hasNextPage, isFetched, isFetchingNextPage, isLoading } =\n        useInfiniteQuery(\n            searchQueries.searchAlbumsInfinite({\n                enabled: isHome && debouncedQuery !== '' && query !== '',\n                searchTerm: debouncedQuery,\n                serverId: server?.id,\n            }),\n        );\n\n    const albums = data?.pages.flatMap((p) => p.albums) ?? [];\n    const showSection = isHome;\n    const numberOfResults = hasNextPage ? `${albums.length}+` : albums.length;\n\n    const handleGoToPage = useCallback(() => {\n        navigate(\n            {\n                pathname: AppRoute.LIBRARY_ALBUMS,\n                search: createSearchParams({\n                    [FILTER_KEYS.SHARED.SEARCH_TERM]: debouncedQuery || query,\n                }).toString(),\n            },\n            { state: { navigationId: nanoid() } },\n        );\n        onSelectResult();\n    }, [debouncedQuery, navigate, onSelectResult, query]);\n\n    if (!showSection) return null;\n\n    return (\n        <CollapsibleCommandGroup\n            expanded={expanded}\n            heading={t('entity.album', { count: 2, postProcess: 'titleCase' })}\n            onToggle={onToggle}\n            subtitle={\n                isFetched ? (\n                    <>\n                        {query ? (\n                            <Button\n                                onClick={(e) => {\n                                    e.preventDefault();\n                                    e.stopPropagation();\n                                    handleGoToPage();\n                                }}\n                                onKeyDown={(e) => {\n                                    e.preventDefault();\n                                    e.stopPropagation();\n                                }}\n                                size=\"compact-xs\"\n                                variant=\"filled\"\n                                w=\"8rem\"\n                            >\n                                {t('common.numberOfResults', { numberOfResults })}\n                            </Button>\n                        ) : null}\n                    </>\n                ) : undefined\n            }\n        >\n            {isLoading ? (\n                <Box p=\"md\">\n                    <Spinner container />\n                </Box>\n            ) : (\n                <>\n                    {albums.map((album) => (\n                        <CommandItemSelectable\n                            key={`search-album-${album.id}`}\n                            onSelect={() => {\n                                navigate(\n                                    generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {\n                                        albumId: album.id,\n                                    }),\n                                );\n                                onSelectResult();\n                            }}\n                            value={`search-album-${album.id}`}\n                        >\n                            {({ isHighlighted }) => (\n                                <LibraryCommandItem\n                                    explicitStatus={album.explicitStatus}\n                                    id={album.id}\n                                    imageId={album.imageId}\n                                    imageUrl={album.imageUrl}\n                                    isHighlighted={isHighlighted}\n                                    itemType={LibraryItem.ALBUM}\n                                    subtitle={album.albumArtists\n                                        .map((artist) => artist.name)\n                                        .join(', ')}\n                                    title={album.name}\n                                />\n                            )}\n                        </CommandItemSelectable>\n                    ))}\n                    {hasNextPage && (\n                        <CommandItemSelectable\n                            disabled={isFetchingNextPage}\n                            onSelect={() => fetchNextPage()}\n                            value=\"search-albums-load-more\"\n                        >\n                            {() => (\n                                <div\n                                    style={{\n                                        display: 'flex',\n                                        justifyContent: 'center',\n                                        width: '100%',\n                                    }}\n                                >\n                                    {isFetchingNextPage ? (\n                                        <Spinner />\n                                    ) : (\n                                        <Text size=\"sm\">\n                                            {t('action.viewMore', { postProcess: 'titleCase' })}\n                                        </Text>\n                                    )}\n                                </div>\n                            )}\n                        </CommandItemSelectable>\n                    )}\n                </>\n            )}\n        </CollapsibleCommandGroup>\n    );\n}\n"
  },
  {
    "path": "src/renderer/features/search/components/search-content.tsx",
    "content": "import { Suspense } from 'react';\nimport { useParams, useSearchParams } from 'react-router';\n\nimport {\n    AlbumListView,\n    OverrideAlbumListQuery,\n} from '/@/renderer/features/albums/components/album-list-content';\nimport {\n    AlbumArtistListView,\n    OverrideAlbumArtistListQuery,\n} from '/@/renderer/features/artists/components/album-artist-list-content';\nimport { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';\nimport {\n    OverrideSongListQuery,\n    SongListView,\n} from '/@/renderer/features/songs/components/song-list-content';\nimport { useListSettings } from '/@/renderer/store';\nimport { Spinner } from '/@/shared/components/spinner/spinner';\nimport {\n    AlbumArtistListSort,\n    AlbumListSort,\n    LibraryItem,\n    SongListSort,\n    SortOrder,\n} from '/@/shared/types/domain-types';\nimport { ItemListKey } from '/@/shared/types/types';\n\nexport const SearchContent = () => {\n    const { itemType } = useParams() as { itemType: LibraryItem };\n\n    return (\n        <AnimatedPage>\n            <Suspense fallback={<Spinner container />}>\n                {itemType === LibraryItem.ALBUM && <AlbumSearch />}\n                {itemType === LibraryItem.SONG && <SongSearch />}\n                {itemType === LibraryItem.ALBUM_ARTIST && <ArtistSearch />}\n            </Suspense>\n        </AnimatedPage>\n    );\n};\n\nconst AlbumSearch = () => {\n    const { display, grid, itemsPerPage, pagination, table } = useListSettings(ItemListKey.ALBUM);\n    const [searchParams] = useSearchParams();\n\n    const albumQuery: OverrideAlbumListQuery = {\n        searchTerm: searchParams.get('query') || '',\n        sortBy: AlbumListSort.NAME,\n        sortOrder: SortOrder.ASC,\n    };\n\n    return (\n        <AlbumListView\n            display={display}\n            grid={grid}\n            itemsPerPage={itemsPerPage}\n            overrideQuery={albumQuery}\n            pagination={pagination}\n            table={table}\n        />\n    );\n};\n\nconst SongSearch = () => {\n    const { display, grid, itemsPerPage, pagination, table } = useListSettings(ItemListKey.SONG);\n    const [searchParams] = useSearchParams();\n\n    const songQuery: OverrideSongListQuery = {\n        searchTerm: searchParams.get('query') || '',\n        sortBy: SongListSort.NAME,\n        sortOrder: SortOrder.ASC,\n    };\n\n    return (\n        <SongListView\n            display={display}\n            grid={grid}\n            itemsPerPage={itemsPerPage}\n            overrideQuery={songQuery}\n            pagination={pagination}\n            table={table}\n        />\n    );\n};\n\nconst ArtistSearch = () => {\n    const { display, grid, itemsPerPage, pagination, table } = useListSettings(ItemListKey.ARTIST);\n    const [searchParams] = useSearchParams();\n\n    const albumArtistQuery: OverrideAlbumArtistListQuery = {\n        searchTerm: searchParams.get('query') || '',\n        sortBy: AlbumArtistListSort.NAME,\n        sortOrder: SortOrder.ASC,\n    };\n\n    return (\n        <AlbumArtistListView\n            display={display}\n            grid={grid}\n            itemsPerPage={itemsPerPage}\n            overrideQuery={albumArtistQuery}\n            pagination={pagination}\n            table={table}\n        />\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/search/components/search-header.tsx",
    "content": "import debounce from 'lodash/debounce';\nimport { ChangeEvent } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { generatePath, Link, useParams, useSearchParams } from 'react-router';\n\nimport {\n    ALBUM_ARTIST_TABLE_COLUMNS,\n    ALBUM_TABLE_COLUMNS,\n    SONG_TABLE_COLUMNS,\n} from '/@/renderer/components/item-list/item-table-list/default-columns';\nimport { PageHeader } from '/@/renderer/components/page-header/page-header';\nimport { FilterBar } from '/@/renderer/features/shared/components/filter-bar';\nimport { LibraryHeaderBar } from '/@/renderer/features/shared/components/library-header-bar';\nimport {\n    ListConfigMenu,\n    SONG_DISPLAY_TYPES,\n} from '/@/renderer/features/shared/components/list-config-menu';\nimport { SearchInput } from '/@/renderer/features/shared/components/search-input';\nimport { AppRoute } from '/@/renderer/router/routes';\nimport { Button, ButtonGroup } from '/@/shared/components/button/button';\nimport { Flex } from '/@/shared/components/flex/flex';\nimport { Group } from '/@/shared/components/group/group';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { LibraryItem } from '/@/shared/types/domain-types';\nimport { ItemListKey } from '/@/shared/types/types';\n\ninterface SearchHeaderProps {\n    navigationId: string;\n}\n\nexport const SearchHeader = ({ navigationId }: SearchHeaderProps) => {\n    const { t } = useTranslation();\n    const { itemType } = useParams() as { itemType: LibraryItem };\n    const [searchParams, setSearchParams] = useSearchParams();\n\n    const handleSearch = debounce((e: ChangeEvent<HTMLInputElement>) => {\n        setSearchParams({ query: e.target.value }, { replace: true, state: { navigationId } });\n    }, 200);\n\n    const listConfigMenuProps = {\n        [LibraryItem.ALBUM]: {\n            listKey: ItemListKey.ALBUM,\n            tableColumnsData: ALBUM_TABLE_COLUMNS,\n        },\n        [LibraryItem.ALBUM_ARTIST]: {\n            listKey: ItemListKey.ALBUM_ARTIST,\n            tableColumnsData: ALBUM_ARTIST_TABLE_COLUMNS,\n        },\n        [LibraryItem.SONG]: {\n            displayTypes: SONG_DISPLAY_TYPES,\n            listKey: ItemListKey.SONG,\n            tableColumnsData: SONG_TABLE_COLUMNS,\n        },\n    };\n\n    return (\n        <Stack gap={0}>\n            <PageHeader>\n                <Flex justify=\"space-between\" w=\"100%\">\n                    <LibraryHeaderBar ignoreMaxWidth>\n                        <LibraryHeaderBar.Title>Search</LibraryHeaderBar.Title>\n                    </LibraryHeaderBar>\n                    <Group>\n                        <SearchInput\n                            defaultValue={searchParams.get('query') || ''}\n                            onChange={handleSearch}\n                        />\n                    </Group>\n                </Flex>\n            </PageHeader>\n            <FilterBar>\n                <Flex justify=\"space-between\" w=\"100%\">\n                    <ButtonGroup>\n                        <Button\n                            component={Link}\n                            fw={600}\n                            replace\n                            size=\"compact-md\"\n                            state={{ navigationId }}\n                            to={{\n                                pathname: generatePath(AppRoute.SEARCH, {\n                                    itemType: LibraryItem.SONG,\n                                }),\n                                search: searchParams.toString(),\n                            }}\n                            variant={itemType === LibraryItem.SONG ? 'filled' : 'default'}\n                        >\n                            {t('entity.track', { count: 2, postProcess: 'sentenceCase' })}\n                        </Button>\n                        <Button\n                            component={Link}\n                            fw={600}\n                            replace\n                            size=\"compact-md\"\n                            state={{ navigationId }}\n                            to={{\n                                pathname: generatePath(AppRoute.SEARCH, {\n                                    itemType: LibraryItem.ALBUM,\n                                }),\n                                search: searchParams.toString(),\n                            }}\n                            variant={itemType === LibraryItem.ALBUM ? 'filled' : 'default'}\n                        >\n                            {t('entity.album', { count: 2, postProcess: 'sentenceCase' })}\n                        </Button>\n                        <Button\n                            component={Link}\n                            fw={600}\n                            replace\n                            size=\"compact-md\"\n                            state={{ navigationId }}\n                            to={{\n                                pathname: generatePath(AppRoute.SEARCH, {\n                                    itemType: LibraryItem.ALBUM_ARTIST,\n                                }),\n                                search: searchParams.toString(),\n                            }}\n                            variant={itemType === LibraryItem.ALBUM_ARTIST ? 'filled' : 'default'}\n                        >\n                            {t('entity.artist', { count: 2, postProcess: 'sentenceCase' })}\n                        </Button>\n                    </ButtonGroup>\n                    <ListConfigMenu {...listConfigMenuProps[itemType]} />\n                </Flex>\n            </FilterBar>\n        </Stack>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/search/components/search-songs-section.tsx",
    "content": "import { useInfiniteQuery } from '@tanstack/react-query';\nimport { nanoid } from 'nanoid/non-secure';\nimport { useCallback } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { createSearchParams, generatePath, useNavigate } from 'react-router';\n\nimport { searchQueries } from '/@/renderer/features/search/api/search-api';\nimport { CollapsibleCommandGroup } from '/@/renderer/features/search/components/collapsible-command-group';\nimport { CommandItemSelectable } from '/@/renderer/features/search/components/command-item-selectable';\nimport { LibraryCommandItem } from '/@/renderer/features/search/components/library-command-item';\nimport { FILTER_KEYS } from '/@/renderer/features/shared/utils';\nimport { AppRoute } from '/@/renderer/router/routes';\nimport { useCurrentServer } from '/@/renderer/store';\nimport { Box } from '/@/shared/components/box/box';\nimport { Button } from '/@/shared/components/button/button';\nimport { Spinner } from '/@/shared/components/spinner/spinner';\nimport { Text } from '/@/shared/components/text/text';\nimport { LibraryItem } from '/@/shared/types/domain-types';\n\ninterface SearchSongsSectionProps {\n    debouncedQuery: string;\n    expanded: boolean;\n    isHome: boolean;\n    onSelectResult: () => void;\n    onToggle: () => void;\n    query: string;\n}\n\nexport function SearchSongsSection({\n    debouncedQuery,\n    expanded,\n    isHome,\n    onSelectResult,\n    onToggle,\n    query,\n}: SearchSongsSectionProps) {\n    const navigate = useNavigate();\n    const server = useCurrentServer();\n    const { t } = useTranslation();\n\n    const { data, fetchNextPage, hasNextPage, isFetched, isFetchingNextPage, isLoading } =\n        useInfiniteQuery(\n            searchQueries.searchSongsInfinite({\n                enabled: isHome && debouncedQuery !== '' && query !== '',\n                searchTerm: debouncedQuery,\n                serverId: server?.id,\n            }),\n        );\n\n    const songs = data?.pages.flatMap((p) => p.songs) ?? [];\n    const showSection = isHome;\n    const numberOfResults = hasNextPage ? `${songs.length}+` : songs.length;\n\n    const handleGoToPage = useCallback(() => {\n        navigate(\n            {\n                pathname: AppRoute.LIBRARY_SONGS,\n                search: createSearchParams({\n                    [FILTER_KEYS.SHARED.SEARCH_TERM]: debouncedQuery || query,\n                }).toString(),\n            },\n            { state: { navigationId: nanoid() } },\n        );\n        onSelectResult();\n    }, [debouncedQuery, navigate, onSelectResult, query]);\n\n    if (!showSection) return null;\n\n    return (\n        <CollapsibleCommandGroup\n            expanded={expanded}\n            heading={t('entity.track', { count: 2, postProcess: 'titleCase' })}\n            onToggle={onToggle}\n            subtitle={\n                isFetched ? (\n                    <>\n                        {query ? (\n                            <Button\n                                onClick={(e) => {\n                                    e.preventDefault();\n                                    e.stopPropagation();\n                                    handleGoToPage();\n                                }}\n                                onKeyDown={(e) => {\n                                    e.preventDefault();\n                                    e.stopPropagation();\n                                }}\n                                size=\"compact-xs\"\n                                variant=\"filled\"\n                                w=\"8rem\"\n                            >\n                                {t('common.numberOfResults', { numberOfResults })}\n                            </Button>\n                        ) : null}\n                    </>\n                ) : undefined\n            }\n        >\n            {isLoading ? (\n                <Box p=\"md\">\n                    <Spinner container />\n                </Box>\n            ) : (\n                <>\n                    {songs.map((song) => (\n                        <CommandItemSelectable\n                            key={`search-song-${song.id}`}\n                            onSelect={() => {\n                                navigate(\n                                    generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {\n                                        albumId: song.albumId,\n                                    }),\n                                );\n                                onSelectResult();\n                            }}\n                            value={`search-song-${song.id}`}\n                        >\n                            {({ isHighlighted }) => (\n                                <LibraryCommandItem\n                                    explicitStatus={song.explicitStatus}\n                                    id={song.id}\n                                    imageId={song.imageId}\n                                    imageUrl={song.imageUrl}\n                                    isHighlighted={isHighlighted}\n                                    itemType={LibraryItem.SONG}\n                                    song={song}\n                                    subtitle={song.artists.map((artist) => artist.name).join(', ')}\n                                    title={song.name}\n                                />\n                            )}\n                        </CommandItemSelectable>\n                    ))}\n                    {hasNextPage && (\n                        <CommandItemSelectable\n                            disabled={isFetchingNextPage}\n                            onSelect={() => fetchNextPage()}\n                            value=\"search-songs-load-more\"\n                        >\n                            {() => (\n                                <div\n                                    style={{\n                                        display: 'flex',\n                                        justifyContent: 'center',\n                                        width: '100%',\n                                    }}\n                                >\n                                    {isFetchingNextPage ? (\n                                        <Spinner />\n                                    ) : (\n                                        <Text size=\"sm\">\n                                            {t('action.viewMore', { postProcess: 'titleCase' })}\n                                        </Text>\n                                    )}\n                                </div>\n                            )}\n                        </CommandItemSelectable>\n                    )}\n                </>\n            )}\n        </CollapsibleCommandGroup>\n    );\n}\n"
  },
  {
    "path": "src/renderer/features/search/components/server-commands.tsx",
    "content": "import { openModal } from '@mantine/modals';\nimport { Dispatch, useCallback } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { useNavigate } from 'react-router';\n\nimport { isServerLock } from '/@/renderer/features/action-required/utils/window-properties';\nimport { Command, CommandPalettePages } from '/@/renderer/features/search/components/command';\nimport { ServerList } from '/@/renderer/features/servers/components/server-list';\nimport { AppRoute } from '/@/renderer/router/routes';\nimport { useAuthStoreActions, useServerList } from '/@/renderer/store';\nimport { ServerListItemWithCredential } from '/@/shared/types/domain-types';\n\ninterface ServerCommandsProps {\n    handleClose: () => void;\n    setPages: (pages: CommandPalettePages[]) => void;\n    setQuery: Dispatch<string>;\n}\n\nexport const ServerCommands = ({ handleClose, setPages, setQuery }: ServerCommandsProps) => {\n    const { t } = useTranslation();\n    const serverList = useServerList();\n    const navigate = useNavigate();\n    const { setCurrentServer } = useAuthStoreActions();\n\n    const handleManageServersModal = useCallback(() => {\n        openModal({\n            children: <ServerList />,\n            title: t('page.appMenu.manageServers', { postProcess: 'sentenceCase' }),\n        });\n        handleClose();\n        setQuery('');\n        setPages([CommandPalettePages.HOME]);\n    }, [handleClose, setPages, setQuery, t]);\n\n    const handleSelectServer = useCallback(\n        (server: ServerListItemWithCredential) => {\n            navigate(AppRoute.HOME);\n            setCurrentServer(server);\n            handleClose();\n            setQuery('');\n            setPages([CommandPalettePages.HOME]);\n        },\n        [handleClose, navigate, setCurrentServer, setPages, setQuery],\n    );\n\n    return (\n        <>\n            <Command.Group\n                heading={t('page.appMenu.selectServer', { postProcess: 'sentenceCase' })}\n            >\n                {Object.keys(serverList).map((key) => (\n                    <Command.Item\n                        key={key}\n                        onSelect={() => handleSelectServer(serverList[key])}\n                    >{`${serverList[key].name}...`}</Command.Item>\n                ))}\n            </Command.Group>\n            {!isServerLock() && (\n                <Command.Group heading={t('common.manage', { postProcess: 'sentenceCase' })}>\n                    <Command.Item onSelect={handleManageServersModal}>\n                        {t('page.appMenu.manageServers', { postProcess: 'sentenceCase' })}...\n                    </Command.Item>\n                </Command.Group>\n            )}\n            <Command.Separator />\n        </>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/search/routes/search-route.tsx",
    "content": "import { useId } from 'react';\nimport { useLocation, useParams } from 'react-router';\n\nimport { SearchContent } from '/@/renderer/features/search/components/search-content';\nimport { SearchHeader } from '/@/renderer/features/search/components/search-header';\nimport { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';\nimport { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';\n\nconst SearchRoute = () => {\n    const { state: locationState } = useLocation();\n    const localNavigationId = useId();\n    const navigationId = locationState?.navigationId || localNavigationId;\n    const { itemType } = useParams() as { itemType: string };\n\n    return (\n        <AnimatedPage key={`search-${navigationId}`}>\n            <SearchHeader navigationId={navigationId} />\n            <SearchContent key={`page-${itemType}`} />\n        </AnimatedPage>\n    );\n};\n\nconst SearchRouteWithBoundary = () => {\n    return (\n        <PageErrorBoundary>\n            <SearchRoute />\n        </PageErrorBoundary>\n    );\n};\n\nexport default SearchRouteWithBoundary;\n"
  },
  {
    "path": "src/renderer/features/servers/components/add-server-form.tsx",
    "content": "import { closeAllModals } from '@mantine/modals';\nimport isElectron from 'is-electron';\nimport { nanoid } from 'nanoid/non-secure';\nimport { useEffect, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport { api } from '/@/renderer/api';\nimport {\n    isLegacyAuth,\n    isServerLock,\n} from '/@/renderer/features/action-required/utils/window-properties';\nimport JellyfinIcon from '/@/renderer/features/servers/assets/jellyfin.png';\nimport NavidromeIcon from '/@/renderer/features/servers/assets/navidrome.png';\nimport SubsonicIcon from '/@/renderer/features/servers/assets/opensubsonic.png';\nimport { IgnoreCorsSslSwitches } from '/@/renderer/features/servers/components/ignore-cors-ssl-switches';\nimport { useAuthStoreActions, useServerList } from '/@/renderer/store';\nimport { Checkbox } from '/@/shared/components/checkbox/checkbox';\nimport { Divider } from '/@/shared/components/divider/divider';\nimport { Group } from '/@/shared/components/group/group';\nimport { ModalButton } from '/@/shared/components/modal/model-shared';\nimport { Paper } from '/@/shared/components/paper/paper';\nimport { PasswordInput } from '/@/shared/components/password-input/password-input';\nimport { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { TextInput } from '/@/shared/components/text-input/text-input';\nimport { Text } from '/@/shared/components/text/text';\nimport { toast } from '/@/shared/components/toast/toast';\nimport { useFocusTrap } from '/@/shared/hooks/use-focus-trap';\nimport { useForm } from '/@/shared/hooks/use-form';\nimport { AuthenticationResponse, ServerListItemWithCredential } from '/@/shared/types/domain-types';\nimport { DiscoveredServerItem, ServerType, toServerType } from '/@/shared/types/types';\n\nconst autodiscover = isElectron() ? window.api.autodiscover : null;\nconst localSettings = isElectron() ? window.api.localSettings : null;\n\ninterface AddServerFormProps {\n    onCancel: (() => void) | null;\n}\n\ninterface ServerDetails {\n    icon: string;\n    name: string;\n}\n\nfunction ServerIconWithLabel({ icon, label }: { icon: string; label: string }) {\n    return (\n        <Stack align=\"center\" justify=\"center\">\n            <img height=\"50\" src={icon} width=\"50\" />\n            <Text>{label}</Text>\n        </Stack>\n    );\n}\n\nfunction useAutodiscovery() {\n    const [isDone, setDone] = useState(false);\n    const [servers, setServers] = useState<DiscoveredServerItem[]>([]);\n\n    useEffect(() => {\n        setServers([]);\n\n        autodiscover\n            ?.discover((newServer) => {\n                setServers((tail) => [...tail, newServer]);\n            })\n            .then(() => {\n                setDone(true);\n            });\n    }, []);\n\n    return { isDone, servers };\n}\n\nconst SERVER_TYPES: Record<ServerType, ServerDetails> = {\n    [ServerType.JELLYFIN]: {\n        icon: JellyfinIcon,\n        name: 'Jellyfin',\n    },\n    [ServerType.NAVIDROME]: {\n        icon: NavidromeIcon,\n        name: 'Navidrome',\n    },\n    [ServerType.SUBSONIC]: {\n        icon: SubsonicIcon,\n        name: 'OpenSubsonic',\n    },\n};\n\nconst ALL_SERVERS = Object.keys(SERVER_TYPES).map((serverType) => {\n    const info = SERVER_TYPES[serverType];\n    return {\n        label: <ServerIconWithLabel icon={info.icon} label={info.name} />,\n        value: serverType,\n    };\n});\n\nexport const AddServerForm = ({ onCancel }: AddServerFormProps) => {\n    const { t } = useTranslation();\n    const focusTrapRef = useFocusTrap(true);\n    const [isLoading, setIsLoading] = useState(false);\n    const { addServer, setCurrentServer } = useAuthStoreActions();\n    const serverList = useServerList();\n    const { servers: discovered } = useAutodiscovery();\n\n    const serverLock = isServerLock();\n\n    const form = useForm({\n        initialValues: {\n            legacyAuth: isLegacyAuth(),\n            name:\n                (localSettings ? localSettings.env.SERVER_NAME : window.SERVER_NAME) || 'My Server',\n            password: '',\n            preferInstantMix: undefined,\n            preferRemoteUrl: false,\n            remoteUrl: '',\n            savePassword: undefined,\n            type:\n                (localSettings\n                    ? localSettings.env.SERVER_TYPE\n                    : toServerType(window.SERVER_TYPE)) ?? ServerType.NAVIDROME,\n            url: (localSettings ? localSettings.env.SERVER_URL : window.SERVER_URL) ?? 'https://',\n            username: '',\n        },\n    });\n\n    const isSubmitDisabled = !form.values.name || !form.values.url || !form.values.username;\n\n    const fillServerDetails = (server: DiscoveredServerItem) => {\n        form.setValues({ ...server });\n    };\n\n    const handleSubmit = form.onSubmit(async (values) => {\n        if (serverLock && Object.keys(serverList).length >= 1) {\n            toast.error({\n                message: t('error.serverLockSingleServer', { postProcess: 'sentenceCase' }),\n            });\n            return;\n        }\n\n        const authFunction = api.controller.authenticate;\n\n        if (!authFunction) {\n            return toast.error({\n                message: t('error.invalidServer', { postProcess: 'sentenceCase' }),\n            });\n        }\n\n        try {\n            setIsLoading(true);\n            const data: AuthenticationResponse | undefined = await authFunction(\n                values.url,\n                {\n                    legacy: values.legacyAuth,\n                    password: values.password,\n                    username: values.username,\n                },\n                values.type as ServerType,\n            );\n\n            if (!data) {\n                return toast.error({\n                    message: t('error.authenticationFailed', { postProcess: 'sentenceCase' }),\n                });\n            }\n\n            const serverItem: ServerListItemWithCredential = {\n                credential: data.credential,\n                id: nanoid(),\n                isAdmin: data.isAdmin,\n                name: values.name,\n                type: values.type as ServerType,\n                url: values.url.replace(/\\/$/, ''),\n                userId: data.userId,\n                username: data.username,\n            };\n\n            if (values.preferInstantMix !== undefined) {\n                serverItem.preferInstantMix = values.preferInstantMix;\n            }\n\n            if (values.savePassword !== undefined) {\n                serverItem.savePassword = values.savePassword;\n            }\n\n            if (values.remoteUrl?.trim()) {\n                serverItem.remoteUrl = values.remoteUrl.trim().replace(/\\/$/, '');\n            }\n\n            if (values.preferRemoteUrl !== undefined) {\n                serverItem.preferRemoteUrl = values.preferRemoteUrl;\n            }\n\n            if (data.ndCredential !== undefined) {\n                serverItem.ndCredential = data.ndCredential;\n            }\n\n            addServer(serverItem);\n            setCurrentServer(serverItem);\n            closeAllModals();\n\n            toast.success({\n                message: t('form.addServer.success', { postProcess: 'sentenceCase' }),\n            });\n\n            if (localSettings && values.savePassword) {\n                const saved = await localSettings.passwordSet(values.password, serverItem.id);\n                if (!saved) {\n                    toast.error({\n                        message: t('form.addServer.error', {\n                            context: 'savePassword',\n                            postProcess: 'sentenceCase',\n                        }),\n                    });\n                }\n            }\n        } catch (err: any) {\n            setIsLoading(false);\n            return toast.error({ message: err?.message });\n        }\n\n        return setIsLoading(false);\n    });\n\n    return (\n        <>\n            <Stack>\n                {discovered.map((server) => (\n                    <Paper key={server.url} p=\"10px\">\n                        <Group>\n                            <img height=\"32\" src={SERVER_TYPES[server.type].icon} width=\"32\" />\n                            <div\n                                onClick={() => fillServerDetails(server)}\n                                style={{ cursor: 'pointer' }}\n                            >\n                                <Text fw={700}>{server.name}</Text>\n                                <Text>\n                                    {SERVER_TYPES[server.type].name} server at {server.url}\n                                </Text>\n                            </div>\n                        </Group>\n                    </Paper>\n                ))}\n            </Stack>\n            <form onSubmit={handleSubmit}>\n                <Stack m={5} ref={focusTrapRef}>\n                    <SegmentedControl\n                        data={ALL_SERVERS}\n                        disabled={serverLock}\n                        p=\"md\"\n                        withItemsBorders={false}\n                        {...form.getInputProps('type')}\n                    />\n                    <Group grow>\n                        <TextInput\n                            data-autofocus\n                            disabled={serverLock}\n                            label={t('form.addServer.input', {\n                                context: 'name',\n                                postProcess: 'titleCase',\n                            })}\n                            required\n                            {...form.getInputProps('name')}\n                        />\n                        <TextInput\n                            disabled={serverLock}\n                            label={t('form.addServer.input', {\n                                context: 'url',\n                                postProcess: 'titleCase',\n                            })}\n                            required\n                            {...form.getInputProps('url')}\n                        />\n                    </Group>\n                    <TextInput\n                        disabled={serverLock}\n                        label={t('form.addServer.input', {\n                            context: 'remoteUrl',\n                            postProcess: 'titleCase',\n                        })}\n                        placeholder={t('form.addServer.input', {\n                            context: 'remoteUrlPlaceholder',\n                            postProcess: 'sentenceCase',\n                        })}\n                        {...form.getInputProps('remoteUrl')}\n                    />\n                    {form.values.remoteUrl && (\n                        <Checkbox\n                            label={t('form.addServer.input', {\n                                context: 'preferRemoteUrl',\n                                postProcess: 'titleCase',\n                            })}\n                            {...form.getInputProps('preferRemoteUrl', {\n                                type: 'checkbox',\n                            })}\n                        />\n                    )}\n                    <TextInput\n                        label={t('form.addServer.input', {\n                            context: 'username',\n                            postProcess: 'titleCase',\n                        })}\n                        required\n                        {...form.getInputProps('username')}\n                    />\n                    <PasswordInput\n                        label={t('form.addServer.input', {\n                            context: 'password',\n                            postProcess: 'titleCase',\n                        })}\n                        {...form.getInputProps('password')}\n                    />\n                    {localSettings && form.values.type === ServerType.NAVIDROME && (\n                        <Checkbox\n                            label={t('form.addServer.input', {\n                                context: 'savePassword',\n                                postProcess: 'titleCase',\n                            })}\n                            {...form.getInputProps('savePassword', {\n                                type: 'checkbox',\n                            })}\n                        />\n                    )}\n                    {form.values.type === ServerType.SUBSONIC && (\n                        <Checkbox\n                            disabled={serverLock}\n                            label={t('form.addServer.input', {\n                                context: 'legacyAuthentication',\n                                postProcess: 'titleCase',\n                            })}\n                            {...form.getInputProps('legacyAuth', { type: 'checkbox' })}\n                        />\n                    )}\n                    {form.values.type === ServerType.JELLYFIN && (\n                        <Checkbox\n                            description={t('form.addServer.input', {\n                                context: 'preferInstantMixDescription',\n                                postProcess: 'sentenceCase',\n                            })}\n                            label={t('form.addServer.input', {\n                                context: 'preferInstantMix',\n                                postProcess: 'titleCase',\n                            })}\n                            {...form.getInputProps('preferInstantMix', {\n                                type: 'checkbox',\n                            })}\n                        />\n                    )}\n                    {isElectron() && (\n                        <>\n                            <Divider />\n                            <IgnoreCorsSslSwitches />\n                            <Divider />\n                        </>\n                    )}\n                    <Group grow justify=\"flex-end\">\n                        {onCancel && (\n                            <ModalButton onClick={onCancel}>{t('common.cancel')}</ModalButton>\n                        )}\n                        <ModalButton\n                            disabled={isSubmitDisabled}\n                            loading={isLoading}\n                            type=\"submit\"\n                            variant=\"filled\"\n                        >\n                            {t('common.add')}\n                        </ModalButton>\n                    </Group>\n                </Stack>\n            </form>\n        </>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/servers/components/edit-server-form.tsx",
    "content": "import { closeAllModals } from '@mantine/modals';\nimport isElectron from 'is-electron';\nimport { useState } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport i18n from '/@/i18n/i18n';\nimport { api } from '/@/renderer/api';\nimport { queryClient } from '/@/renderer/lib/react-query';\nimport { getServerById, useAuthStoreActions } from '/@/renderer/store';\nimport { Checkbox } from '/@/shared/components/checkbox/checkbox';\nimport { Group } from '/@/shared/components/group/group';\nimport { Icon } from '/@/shared/components/icon/icon';\nimport { ModalButton } from '/@/shared/components/modal/model-shared';\nimport { PasswordInput } from '/@/shared/components/password-input/password-input';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { TextInput } from '/@/shared/components/text-input/text-input';\nimport { toast } from '/@/shared/components/toast/toast';\nimport { Tooltip } from '/@/shared/components/tooltip/tooltip';\nimport { useFocusTrap } from '/@/shared/hooks/use-focus-trap';\nimport { useForm } from '/@/shared/hooks/use-form';\nimport {\n    AuthenticationResponse,\n    ServerListItem,\n    ServerListItemWithCredential,\n    ServerType,\n} from '/@/shared/types/domain-types';\n\nconst localSettings = isElectron() ? window.api.localSettings : null;\n\ninterface EditServerFormProps {\n    isUpdate?: boolean;\n    onCancel: () => void;\n    password: null | string;\n    server: ServerListItem;\n}\n\nconst ModifiedFieldIndicator = () => {\n    return (\n        <Tooltip label={i18n.t('common.modified', { postProcess: 'titleCase' }) as string}>\n            <Icon color=\"warn\" icon=\"info\" />\n        </Tooltip>\n    );\n};\n\nexport const EditServerForm = ({ isUpdate, onCancel, password, server }: EditServerFormProps) => {\n    const { t } = useTranslation();\n    const { updateServer } = useAuthStoreActions();\n    const focusTrapRef = useFocusTrap();\n    const [isLoading, setIsLoading] = useState(false);\n\n    const form = useForm({\n        initialValues: {\n            isAdmin: server?.isAdmin,\n            legacyAuth: false,\n            name: server?.name,\n            password: password || '',\n            preferInstantMix: server.preferInstantMix,\n            preferRemoteUrl: server?.preferRemoteUrl || false,\n            remoteUrl: server?.remoteUrl || '',\n            savePassword: server.savePassword,\n            type: server?.type,\n            url: server?.url,\n            username: server?.username,\n        },\n    });\n\n    const isSubsonic = form.values.type === ServerType.SUBSONIC;\n    const isNavidrome = form.values.type === ServerType.NAVIDROME;\n\n    const handleSubmit = form.onSubmit(async (values) => {\n        try {\n            setIsLoading(true);\n\n            // Check if we can skip authentication\n            const usernameChanged = values.username !== server.username;\n            const passwordProvided = values.password && values.password.trim() !== '';\n            const urlChanged = values.url !== server.url;\n            const typeChanged = values.type !== server.type;\n\n            // Skip authentication if username hasn't changed, password is empty, and URL/type haven't changed\n            const canSkipAuth =\n                !usernameChanged && !passwordProvided && !urlChanged && !typeChanged;\n\n            let data: AuthenticationResponse | undefined;\n            let serverItem: ServerListItemWithCredential;\n\n            if (canSkipAuth) {\n                // Use existing server credentials\n                const existingServer = getServerById(server.id);\n                if (!existingServer) {\n                    return toast.error({\n                        message: t('error.invalidServer', { postProcess: 'sentenceCase' }),\n                    });\n                }\n\n                serverItem = {\n                    ...existingServer,\n                    id: server.id,\n                    name: values.name,\n                    type: values.type,\n                    url: values.url,\n                };\n            } else {\n                // Need to authenticate\n                const authFunction = api.controller.authenticate;\n\n                if (!authFunction) {\n                    return toast.error({\n                        message: t('error.invalidServer', { postProcess: 'sentenceCase' }),\n                    });\n                }\n\n                data = await authFunction(\n                    values.url,\n                    {\n                        legacy: values.legacyAuth,\n                        password: values.password,\n                        username: values.username,\n                    },\n                    values.type,\n                );\n\n                if (!data) {\n                    return toast.error({\n                        message: t('error.authenticationFailed', { postProcess: 'sentenceCase' }),\n                    });\n                }\n\n                serverItem = {\n                    credential: data.credential,\n                    id: server.id,\n                    isAdmin: data.isAdmin,\n                    name: values.name,\n                    type: values.type,\n                    url: values.url,\n                    userId: data.userId,\n                    username: data.username,\n                };\n\n                if (data.ndCredential !== undefined) {\n                    serverItem.ndCredential = data.ndCredential;\n                }\n            }\n\n            // Update optional fields\n            if (values.preferInstantMix !== undefined) {\n                serverItem.preferInstantMix = values.preferInstantMix;\n            }\n\n            if (values.savePassword !== undefined) {\n                serverItem.savePassword = values.savePassword;\n            }\n\n            if (values.remoteUrl?.trim()) {\n                serverItem.remoteUrl = values.remoteUrl.trim().replace(/\\/$/, '');\n            } else {\n                serverItem.remoteUrl = undefined;\n            }\n\n            if (values.preferRemoteUrl !== undefined) {\n                serverItem.preferRemoteUrl = values.preferRemoteUrl;\n            }\n\n            updateServer(server.id, serverItem);\n            toast.success({\n                message: t('form.updateServer.title', { postProcess: 'sentenceCase' }),\n            });\n\n            // Handle password saving in local settings\n            if (localSettings) {\n                if (canSkipAuth) {\n                    // If we skipped auth, only update savePassword preference\n                    // Don't change the actual saved password\n                    if (!values.savePassword) {\n                        localSettings.passwordRemove(server.id);\n                    }\n                } else {\n                    // If we authenticated, update password if savePassword is enabled\n                    if (values.savePassword && passwordProvided) {\n                        const saved = await localSettings.passwordSet(values.password, server.id);\n                        if (!saved) {\n                            toast.error({\n                                message: t('form.addServer.error', {\n                                    context: 'savePassword',\n                                    postProcess: 'sentenceCase',\n                                }),\n                            });\n                        }\n                    } else if (!values.savePassword) {\n                        localSettings.passwordRemove(server.id);\n                    }\n                }\n            }\n\n            queryClient.removeQueries();\n        } catch (err: any) {\n            setIsLoading(false);\n            return toast.error({ message: err?.message });\n        }\n\n        if (isUpdate) closeAllModals();\n        return setIsLoading(false);\n    });\n\n    return (\n        <form onSubmit={handleSubmit}>\n            <Stack ref={focusTrapRef}>\n                <TextInput\n                    label={t('form.addServer.input', {\n                        context: 'name',\n                        postProcess: 'titleCase',\n                    })}\n                    required\n                    rightSection={form.isDirty('name') && <ModifiedFieldIndicator />}\n                    {...form.getInputProps('name')}\n                />\n                <TextInput\n                    label={t('form.addServer.input', {\n                        context: 'url',\n                        postProcess: 'titleCase',\n                    })}\n                    required\n                    rightSection={form.isDirty('url') && <ModifiedFieldIndicator />}\n                    {...form.getInputProps('url')}\n                />\n                <TextInput\n                    label={t('form.addServer.input', {\n                        context: 'remoteUrl',\n                        postProcess: 'titleCase',\n                    })}\n                    placeholder={t('form.addServer.input', {\n                        context: 'remoteUrlPlaceholder',\n                        postProcess: 'sentenceCase',\n                    })}\n                    rightSection={form.isDirty('remoteUrl') && <ModifiedFieldIndicator />}\n                    {...form.getInputProps('remoteUrl')}\n                />\n                {form.values.remoteUrl && (\n                    <Group gap=\"xs\">\n                        <Checkbox\n                            label={t('form.addServer.input', {\n                                context: 'preferRemoteUrl',\n                                postProcess: 'titleCase',\n                            })}\n                            {...form.getInputProps('preferRemoteUrl', {\n                                type: 'checkbox',\n                            })}\n                        />\n                        {form.isDirty('preferRemoteUrl') && <ModifiedFieldIndicator />}\n                    </Group>\n                )}\n                <TextInput\n                    label={t('form.addServer.input', {\n                        context: 'username',\n                        postProcess: 'titleCase',\n                    })}\n                    required\n                    rightSection={form.isDirty('username') && <ModifiedFieldIndicator />}\n                    {...form.getInputProps('username')}\n                />\n                <PasswordInput\n                    data-autofocus\n                    label={t('form.addServer.input', {\n                        context: 'password',\n                        postProcess: 'titleCase',\n                    })}\n                    {...form.getInputProps('password')}\n                />\n                {localSettings && isNavidrome && (\n                    <Checkbox\n                        label={t('form.addServer.input', {\n                            context: 'savePassword',\n                            postProcess: 'titleCase',\n                        })}\n                        {...form.getInputProps('savePassword', {\n                            type: 'checkbox',\n                        })}\n                    />\n                )}\n                {isSubsonic && (\n                    <Checkbox\n                        label={t('form.addServer.input', {\n                            context: 'legacyAuthentication',\n                            postProcess: 'titleCase',\n                        })}\n                        {...form.getInputProps('legacyAuth', {\n                            type: 'checkbox',\n                        })}\n                    />\n                )}\n                {form.values.type === ServerType.JELLYFIN && (\n                    <Checkbox\n                        description={t('form.addServer.input', {\n                            context: 'preferInstantMixDescription',\n                            postProcess: 'sentenceCase',\n                        })}\n                        label={t('form.addServer.input', {\n                            context: 'preferInstantMix',\n                            postProcess: 'titleCase',\n                        })}\n                        {...form.getInputProps('preferInstantMix', {\n                            type: 'checkbox',\n                        })}\n                    />\n                )}\n                <Group justify=\"flex-end\">\n                    <ModalButton onClick={onCancel}>{t('common.cancel')}</ModalButton>\n                    <ModalButton loading={isLoading} type=\"submit\" variant=\"filled\">\n                        {t('common.save')}\n                    </ModalButton>\n                </Group>\n            </Stack>\n        </form>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/servers/components/ignore-cors-ssl-switches.tsx",
    "content": "import isElectron from 'is-electron';\nimport { ChangeEvent } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport { Group } from '/@/shared/components/group/group';\nimport { Switch } from '/@/shared/components/switch/switch';\nimport { useLocalStorage } from '/@/shared/hooks/use-local-storage';\n\nconst localSettings = isElectron() ? window.api.localSettings : null;\n\nexport function IgnoreCorsSslSwitches() {\n    const { t } = useTranslation();\n\n    const [ignoreCORS, setIgnoreCORS] = useLocalStorage({\n        defaultValue: 'false',\n        key: 'ignore_cors',\n    });\n    const [ignoreSSL, setIgnoreSSL] = useLocalStorage({\n        defaultValue: 'false',\n        key: 'ignore_ssl',\n    });\n\n    const handleUpdateIgnoreCORS = (e: ChangeEvent<HTMLInputElement>) => {\n        setIgnoreCORS(String(e.currentTarget.checked));\n        localSettings?.set('ignore_cors', e.currentTarget.checked);\n    };\n\n    const handleUpdateIgnoreSSL = (e: ChangeEvent<HTMLInputElement>) => {\n        setIgnoreSSL(String(e.currentTarget.checked));\n        localSettings?.set('ignore_ssl', e.currentTarget.checked);\n    };\n\n    if (!isElectron()) {\n        return null;\n    }\n\n    return (\n        <>\n            <Group>\n                <Switch\n                    checked={ignoreCORS === 'true'}\n                    label={t('form.addServer.ignoreCors', {\n                        postProcess: 'sentenceCase',\n                    })}\n                    onChange={handleUpdateIgnoreCORS}\n                />\n            </Group>\n            <Group>\n                <Switch\n                    checked={ignoreSSL === 'true'}\n                    label={t('form.addServer.ignoreSsl', {\n                        postProcess: 'sentenceCase',\n                    })}\n                    onChange={handleUpdateIgnoreSSL}\n                />\n            </Group>\n        </>\n    );\n}\n"
  },
  {
    "path": "src/renderer/features/servers/components/server-list-item.tsx",
    "content": "import isElectron from 'is-electron';\nimport { useCallback, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport { EditServerForm } from '/@/renderer/features/servers/components/edit-server-form';\nimport { ServerSection } from '/@/renderer/features/servers/components/server-section';\nimport { useAuthStoreActions } from '/@/renderer/store';\nimport { Button, TimeoutButton } from '/@/shared/components/button/button';\nimport { Divider } from '/@/shared/components/divider/divider';\nimport { Group } from '/@/shared/components/group/group';\nimport { Icon } from '/@/shared/components/icon/icon';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { Table } from '/@/shared/components/table/table';\nimport { useDisclosure } from '/@/shared/hooks/use-disclosure';\nimport { ServerListItem as ServerItem } from '/@/shared/types/domain-types';\n\nconst localSettings = isElectron() ? window.api.localSettings : null;\n\ninterface ServerListItemProps {\n    server: ServerItem;\n}\n\nexport const ServerListItem = ({ server }: ServerListItemProps) => {\n    const { t } = useTranslation();\n    const [edit, editHandlers] = useDisclosure(false);\n    const [savedPassword, setSavedPassword] = useState('');\n    const { deleteServer } = useAuthStoreActions();\n\n    const handleDeleteServer = () => {\n        deleteServer(server.id);\n        localSettings?.passwordRemove(server.name);\n    };\n\n    const handleEdit = useCallback(() => {\n        if (!edit && localSettings && server.savePassword) {\n            localSettings\n                .passwordGet(server.id)\n                .then((password: null | string) => {\n                    if (password) {\n                        setSavedPassword(password);\n                    } else {\n                        setSavedPassword('');\n                    }\n                    editHandlers.open();\n                    return null;\n                })\n                .catch((error: any) => {\n                    console.error(error);\n                    setSavedPassword('');\n                    editHandlers.open();\n                });\n        } else {\n            setSavedPassword('');\n            editHandlers.open();\n        }\n    }, [edit, editHandlers, server.id, server.savePassword]);\n\n    return (\n        <Stack>\n            <ServerSection title={null}>\n                {edit ? (\n                    <EditServerForm\n                        onCancel={() => editHandlers.toggle()}\n                        password={savedPassword}\n                        server={server}\n                    />\n                ) : (\n                    <Stack>\n                        <Table layout=\"fixed\" variant=\"vertical\" withTableBorder>\n                            <Table.Tbody>\n                                <Table.Tr>\n                                    <Table.Th>\n                                        {t('page.manageServers.url', {\n                                            postProcess: 'sentenceCase',\n                                        })}\n                                    </Table.Th>\n                                    <Table.Td>{server.url}</Table.Td>\n                                </Table.Tr>\n                                <Table.Tr>\n                                    <Table.Th>\n                                        {t('page.manageServers.username', {\n                                            postProcess: 'sentenceCase',\n                                        })}\n                                    </Table.Th>\n                                    <Table.Td>{server.username}</Table.Td>\n                                </Table.Tr>\n                            </Table.Tbody>\n                        </Table>\n                        <Group grow>\n                            <Button\n                                leftSection={<Icon icon=\"edit\" />}\n                                onClick={() => handleEdit()}\n                                tooltip={{\n                                    label: t('page.manageServers.editServerDetailsTooltip', {\n                                        postProcess: 'sentenceCase',\n                                    }),\n                                }}\n                            >\n                                {t('common.edit', { postProcess: 'titleCase' })}\n                            </Button>\n                        </Group>\n                    </Stack>\n                )}\n            </ServerSection>\n            <Divider my=\"sm\" />\n            <TimeoutButton\n                leftSection={<Icon icon=\"delete\" />}\n                timeoutProps={{ callback: handleDeleteServer, duration: 1000 }}\n                variant=\"state-error\"\n            >\n                {t('page.manageServers.removeServer', { postProcess: 'sentenceCase' })}\n            </TimeoutButton>\n        </Stack>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/servers/components/server-list.tsx",
    "content": "import { openContextModal } from '@mantine/modals';\nimport isElectron from 'is-electron';\nimport { useTranslation } from 'react-i18next';\n\nimport { isServerLock } from '/@/renderer/features/action-required/utils/window-properties';\nimport JellyfinLogo from '/@/renderer/features/servers/assets/jellyfin.png';\nimport NavidromeLogo from '/@/renderer/features/servers/assets/navidrome.png';\nimport OpenSubsonicLogo from '/@/renderer/features/servers/assets/opensubsonic.png';\nimport { AddServerForm } from '/@/renderer/features/servers/components/add-server-form';\nimport { IgnoreCorsSslSwitches } from '/@/renderer/features/servers/components/ignore-cors-ssl-switches';\nimport { ServerListItem } from '/@/renderer/features/servers/components/server-list-item';\nimport { useCurrentServer, useServerList } from '/@/renderer/store';\nimport { Accordion } from '/@/shared/components/accordion/accordion';\nimport { Button } from '/@/shared/components/button/button';\nimport { Divider } from '/@/shared/components/divider/divider';\nimport { Group } from '/@/shared/components/group/group';\nimport { Icon } from '/@/shared/components/icon/icon';\nimport { ContextModalVars } from '/@/shared/components/modal/modal';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { Text } from '/@/shared/components/text/text';\nimport { ServerType } from '/@/shared/types/domain-types';\n\nexport const ServerList = () => {\n    const { t } = useTranslation();\n    const currentServer = useCurrentServer();\n    const serverListQuery = useServerList();\n    const serverLock = isServerLock();\n\n    const handleAddServerModal = () => {\n        openContextModal({\n            innerProps: {\n                modalBody: (vars: ContextModalVars) => (\n                    <AddServerForm onCancel={() => vars.context.closeModal(vars.id)} />\n                ),\n            },\n            modalKey: 'base',\n            title: t('form.addServer.title', { postProcess: 'titleCase' }),\n        });\n    };\n\n    return (\n        <>\n            <Stack>\n                <Accordion variant=\"separated\">\n                    {Object.keys(serverListQuery)?.map((serverId) => {\n                        const server = serverListQuery[serverId];\n                        return (\n                            <Accordion.Item key={server.id} value={server.name}>\n                                <Accordion.Control>\n                                    <Group>\n                                        <img\n                                            src={\n                                                server.type === ServerType.NAVIDROME\n                                                    ? NavidromeLogo\n                                                    : server.type === ServerType.JELLYFIN\n                                                      ? JellyfinLogo\n                                                      : OpenSubsonicLogo\n                                            }\n                                            style={{\n                                                height: 'var(--theme-font-size-lg)',\n                                                width: 'var(--theme-font-size-lg)',\n                                            }}\n                                        />\n                                        <Text fw={server.id === currentServer?.id ? 600 : 400}>\n                                            {server?.name}\n                                        </Text>\n                                    </Group>\n                                </Accordion.Control>\n                                <Accordion.Panel>\n                                    <ServerListItem server={server} />\n                                </Accordion.Panel>\n                            </Accordion.Item>\n                        );\n                    })}\n                    {!serverLock && (\n                        <Group grow pt=\"md\">\n                            <Button\n                                autoFocus\n                                leftSection={<Icon icon=\"add\" />}\n                                onClick={handleAddServerModal}\n                            >\n                                {t('form.addServer.title', { postProcess: 'titleCase' })}\n                            </Button>\n                        </Group>\n                    )}\n                </Accordion>\n                {isElectron() && (\n                    <>\n                        <Divider />\n                        <IgnoreCorsSslSwitches />\n                    </>\n                )}\n            </Stack>\n        </>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/servers/components/server-section.tsx",
    "content": "import React, { Fragment } from 'react';\n\nimport { Text } from '/@/shared/components/text/text';\n\ninterface ServerSectionProps {\n    children: React.ReactNode;\n    title: React.ReactNode | string;\n}\n\nexport const ServerSection = ({ children, title }: ServerSectionProps) => {\n    return (\n        <Fragment>\n            {React.isValidElement(title) ? title : <Text>{title}</Text>}\n            <div style={{ padding: '1rem' }}>{children}</div>\n        </Fragment>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/settings/components/advanced/advanced-tab.tsx",
    "content": "import { memo } from 'react';\nimport { Fragment } from 'react/jsx-runtime';\n\nimport { AnalyticsSettings } from '/@/renderer/features/settings/components/advanced/analytics-settings';\nimport { ExportImportSettings } from '/@/renderer/features/settings/components/advanced/export-import-settings';\nimport { LoggerSettings } from '/@/renderer/features/settings/components/advanced/logger-settings';\nimport { CacheSettings } from '/@/renderer/features/settings/components/window/cache-settngs';\nimport { UpdateSettings } from '/@/renderer/features/settings/components/window/update-settings';\nimport { Divider } from '/@/shared/components/divider/divider';\nimport { Stack } from '/@/shared/components/stack/stack';\n\nconst sections = [\n    { component: UpdateSettings, key: 'update' },\n    { component: AnalyticsSettings, key: 'analytics' },\n    { component: ExportImportSettings, key: 'export-import' },\n    { component: LoggerSettings, key: 'logger' },\n    { component: CacheSettings, key: 'cache' },\n];\n\nexport const AdvancedTab = memo(() => {\n    return (\n        <Stack gap=\"md\">\n            {sections.map(({ component: Section, key }, index) => (\n                <Fragment key={key}>\n                    <Section />\n                    {index < sections.length - 1 && <Divider />}\n                </Fragment>\n            ))}\n        </Stack>\n    );\n});\n"
  },
  {
    "path": "src/renderer/features/settings/components/advanced/analytics-settings.tsx",
    "content": "import { memo } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport {\n    SettingOption,\n    SettingsSection,\n} from '/@/renderer/features/settings/components/settings-section';\nimport { Switch } from '/@/shared/components/switch/switch';\n\nexport const AnalyticsSettings = memo(() => {\n    const { t } = useTranslation();\n\n    const handleSetSendAnalytics = (send: boolean) => {\n        if (send) {\n            localStorage.removeItem('umami.disabled');\n        } else {\n            localStorage.setItem('umami.disabled', '1');\n        }\n    };\n\n    const analyticsOptions: SettingOption[] = [\n        {\n            control: (\n                <Switch\n                    aria-label={t('setting.analyticsEnable', { postProcess: 'sentenceCase' })}\n                    defaultChecked={localStorage.getItem('umami.disabled') !== '1'}\n                    onChange={(e) => handleSetSendAnalytics(e.currentTarget.checked)}\n                />\n            ),\n            description: t('setting.analyticsEnable_description', { postProcess: 'sentenceCase' }),\n            title: t('setting.analyticsEnable', { postProcess: 'sentenceCase' }),\n        },\n    ];\n\n    return (\n        <SettingsSection\n            options={analyticsOptions}\n            title={t('page.setting.analytics', { postProcess: 'sentenceCase' })}\n        />\n    );\n});\n"
  },
  {
    "path": "src/renderer/features/settings/components/advanced/export-import-settings.tsx",
    "content": "import { openModal } from '@mantine/modals';\nimport { t } from 'i18next';\nimport { memo, useCallback } from 'react';\n\nimport { ExportImportSettingsModal } from '/@/renderer/components/export-import-settings-modal/export-import-settings-modal';\nimport {\n    SettingOption,\n    SettingsSection,\n} from '/@/renderer/features/settings/components/settings-section';\nimport { useSettingsForExport } from '/@/renderer/store';\nimport { Button } from '/@/shared/components/button/button';\n\nexport const ExportImportSettings = memo(() => {\n    const settingForExport = useSettingsForExport();\n\n    const onExportSettings = useCallback(() => {\n        const settingsFile = new File([JSON.stringify(settingForExport)], 'feishin-settings.json', {\n            type: 'application/json',\n        });\n\n        const settingsFileLink = document.createElement('a');\n        const settingsFilesUrl = URL.createObjectURL(settingsFile);\n        settingsFileLink.href = settingsFilesUrl;\n        settingsFileLink.download = settingsFile.name;\n        settingsFileLink.click();\n\n        URL.revokeObjectURL(settingsFilesUrl);\n    }, [settingForExport]);\n\n    const openImportModal = () => {\n        openModal({\n            children: <ExportImportSettingsModal />,\n            size: 'lg',\n            title: t('setting.exportImportSettings_importModalTitle', {\n                postProcess: 'sentenceCase',\n            }),\n        });\n    };\n\n    const options: SettingOption[] = [\n        {\n            control: (\n                <>\n                    <Button onClick={onExportSettings} size=\"compact-sm\">\n                        {t('setting.exportImportSettings_control_exportText', {\n                            postProcess: 'sentenceCase',\n                        })}\n                    </Button>\n                    <Button onClick={openImportModal} size=\"compact-sm\">\n                        {t('setting.exportImportSettings_control_importText', {\n                            postProcess: 'sentenceCase',\n                        })}\n                    </Button>\n                </>\n            ),\n            description: t('setting.exportImportSettings_control_description', {\n                postProcess: 'sentenceCase',\n            }),\n            title: t('setting.exportImportSettings_control_title', {\n                postProcess: 'sentenceCase',\n            }),\n        },\n    ];\n\n    return (\n        <SettingsSection\n            options={options}\n            title={t('page.setting.exportImport', { postProcess: 'sentenceCase' })}\n        />\n    );\n});\n"
  },
  {
    "path": "src/renderer/features/settings/components/advanced/logger-settings.tsx",
    "content": "import { memo } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport {\n    SettingOption,\n    SettingsSection,\n} from '/@/renderer/features/settings/components/settings-section';\nimport { logFn, LogLevel } from '/@/renderer/utils/logger';\nimport { Select } from '/@/shared/components/select/select';\n\nconst DEFAULT_LOG_LEVEL: LogLevel = process.env.NODE_ENV === 'production' ? 'info' : 'debug';\n\nexport const LoggerSettings = memo(() => {\n    const { t } = useTranslation();\n\n    const getCurrentLogLevel = (): LogLevel => {\n        const stored = localStorage.getItem('log_level');\n        if (stored && ['debug', 'error', 'info', 'warn'].includes(stored)) {\n            return stored as LogLevel;\n        }\n        return DEFAULT_LOG_LEVEL;\n    };\n\n    const handleLogLevelChange = (value: null | string) => {\n        if (!value) return;\n\n        const logLevel = value as LogLevel;\n        localStorage.setItem('log_level', logLevel);\n\n        // Update the logger dynamically\n        if (logFn.updateLogLevel) {\n            logFn.updateLogLevel(logLevel);\n        }\n    };\n\n    const loggerOptions: SettingOption[] = [\n        {\n            control: (\n                <Select\n                    data={[\n                        {\n                            label: t('setting.logLevel', {\n                                context: 'optionDebug',\n                                postProcess: 'titleCase',\n                            }),\n                            value: 'debug',\n                        },\n                        {\n                            label: t('setting.logLevel', {\n                                context: 'optionInfo',\n                                postProcess: 'titleCase',\n                            }),\n                            value: 'info',\n                        },\n                        {\n                            label: t('setting.logLevel', {\n                                context: 'optionWarn',\n                                postProcess: 'titleCase',\n                            }),\n                            value: 'warn',\n                        },\n                        {\n                            label: t('setting.logLevel', {\n                                context: 'optionError',\n                                postProcess: 'titleCase',\n                            }),\n                            value: 'error',\n                        },\n                    ]}\n                    defaultValue={getCurrentLogLevel()}\n                    onChange={handleLogLevelChange}\n                />\n            ),\n            description: t('setting.logLevel', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            title: t('setting.logLevel', { postProcess: 'sentenceCase' }),\n        },\n    ];\n\n    return (\n        <SettingsSection\n            options={loggerOptions}\n            title={t('page.setting.logger', { postProcess: 'sentenceCase' })}\n        />\n    );\n});\n"
  },
  {
    "path": "src/renderer/features/settings/components/advanced/styles-settings.tsx",
    "content": "import { memo, useEffect, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport { SettingsOptions } from '/@/renderer/features/settings/components/settings-option';\nimport { useCssSettings, useSettingsStoreActions } from '/@/renderer/store';\nimport { sanitizeCss } from '/@/renderer/utils/sanitize';\nimport { Button } from '/@/shared/components/button/button';\nimport { Code } from '/@/shared/components/code/code';\nimport { Switch } from '/@/shared/components/switch/switch';\nimport { Text } from '/@/shared/components/text/text';\nimport { Textarea } from '/@/shared/components/textarea/textarea';\n\nexport const StylesSettings = memo(() => {\n    const [open, setOpen] = useState(false);\n    const { t } = useTranslation();\n\n    const { content, enabled } = useCssSettings();\n    const [css, setCss] = useState(content);\n\n    const { setSettings } = useSettingsStoreActions();\n\n    const handleSave = () => {\n        setSettings({\n            css: {\n                content: css,\n                enabled,\n            },\n        });\n    };\n\n    useEffect(() => {\n        if (content !== css) {\n            setCss(content);\n        }\n        // eslint-disable-next-line react-hooks/exhaustive-deps -- Reason: This is to only fire if an external source updates the stores css.content\n    }, [content]);\n\n    return (\n        <>\n            <SettingsOptions\n                control={\n                    <Switch\n                        checked={enabled}\n                        onChange={(e) => {\n                            setSettings({\n                                css: {\n                                    content,\n                                    enabled: e.currentTarget.checked,\n                                },\n                            });\n                        }}\n                    />\n                }\n                description={t('setting.customCssEnable', {\n                    context: 'description',\n                    postProcess: 'sentenceCase',\n                })}\n                note={t('setting.customCssNotice', { postProcess: 'sentenceCase' })}\n                title={t('setting.customCssEnable', { postProcess: 'sentenceCase' })}\n            />\n            {enabled && (\n                <>\n                    <SettingsOptions\n                        control={\n                            <>\n                                {open && (\n                                    <Button\n                                        onClick={handleSave}\n                                        size=\"compact-md\"\n                                        // disabled={isSaveButtonDisabled}\n                                        variant=\"filled\"\n                                    >\n                                        {t('common.save', { postProcess: 'titleCase' })}\n                                    </Button>\n                                )}\n                                <Button\n                                    onClick={() => setOpen(!open)}\n                                    size=\"compact-md\"\n                                    variant=\"filled\"\n                                >\n                                    {t(open ? 'common.close' : 'common.edit', {\n                                        postProcess: 'titleCase',\n                                    })}\n                                </Button>\n                            </>\n                        }\n                        description={t('setting.customCss', {\n                            context: 'description',\n                            postProcess: 'sentenceCase',\n                        })}\n                        title={t('setting.customCss', { postProcess: 'sentenceCase' })}\n                    />\n                    {open && (\n                        <>\n                            <Textarea\n                                autosize\n                                defaultValue={css}\n                                minRows={8}\n                                onBlur={(e) =>\n                                    setCss(sanitizeCss(`<style>${e.currentTarget.value}`))\n                                }\n                            />\n                            <Text>{t('common.preview', { postProcess: 'sentenceCase' })}: </Text>\n                            <Code block>{css}</Code>\n                        </>\n                    )}\n                </>\n            )}\n        </>\n    );\n});\n"
  },
  {
    "path": "src/renderer/features/settings/components/general/application-settings.tsx",
    "content": "import type { IpcRendererEvent } from 'electron';\n\nimport { t } from 'i18next';\nimport isElectron from 'is-electron';\nimport { memo, useCallback, useEffect, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport i18n, { languages } from '/@/i18n/i18n';\nimport { ImageResolutionSettings } from '/@/renderer/features/settings/components/general/art-resolution-settings';\nimport {\n    ArtistReleaseTypeSettings,\n    ArtistSettings,\n} from '/@/renderer/features/settings/components/general/artist-settings';\nimport { FullscreenPlayerSettings } from '/@/renderer/features/settings/components/general/fullscreen-player-settings';\nimport { HomeSettings } from '/@/renderer/features/settings/components/general/home-settings';\nimport { PathSettings } from '/@/renderer/features/settings/components/general/path-settings';\nimport {\n    SettingOption,\n    SettingsSection,\n} from '/@/renderer/features/settings/components/settings-section';\nimport {\n    HomeFeatureStyle,\n    SideQueueLayout,\n    SideQueueType,\n    useFontSettings,\n    useGeneralSettings,\n    useSettingsStoreActions,\n} from '/@/renderer/store/settings.store';\nimport { type Font, FONT_OPTIONS } from '/@/renderer/types/fonts';\nimport { FileInput } from '/@/shared/components/file-input/file-input';\nimport { NumberInput } from '/@/shared/components/number-input/number-input';\nimport { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control';\nimport { Select } from '/@/shared/components/select/select';\nimport { Slider } from '/@/shared/components/slider/slider';\nimport { Switch } from '/@/shared/components/switch/switch';\nimport { toast } from '/@/shared/components/toast/toast';\nimport { FontType } from '/@/shared/types/types';\n\nconst localSettings = isElectron() ? window.api.localSettings : null;\nconst ipc = isElectron() ? window.api.ipc : null;\n// Electron 32+ removed file.path, use this which is exposed in preload to get real path\nconst webUtils = isElectron() ? window.electron.webUtils : null;\n\nconst HOME_FEATURE_STYLE_OPTIONS = [\n    {\n        label: t('setting.homeFeatureStyle', {\n            context: 'optionSingle',\n            postProcess: 'sentenceCase',\n        }),\n        value: HomeFeatureStyle.SINGLE,\n    },\n    {\n        label: t('setting.homeFeatureStyle', {\n            context: 'optionMultiple',\n            postProcess: 'sentenceCase',\n        }),\n        value: HomeFeatureStyle.MULTIPLE,\n    },\n];\n\nconst SIDE_QUEUE_OPTIONS = [\n    {\n        label: t('setting.sidePlayQueueStyle', {\n            context: 'optionAttached',\n            postProcess: 'sentenceCase',\n        }),\n        value: 'sideQueue',\n    },\n    {\n        label: t('setting.sidePlayQueueStyle', {\n            context: 'optionDetached',\n            postProcess: 'sentenceCase',\n        }),\n        value: 'sideDrawerQueue',\n    },\n];\n\nconst SIDE_QUEUE_LAYOUT_OPTIONS = [\n    {\n        label: t('setting.sidePlayQueueLayout', {\n            context: 'optionHorizontal',\n            postProcess: 'sentenceCase',\n        }),\n        value: 'horizontal',\n    },\n    {\n        label: t('setting.sidePlayQueueLayout', {\n            context: 'optionVertical',\n            postProcess: 'sentenceCase',\n        }),\n        value: 'vertical',\n    },\n];\n\nconst FONT_TYPES: Font[] = [\n    {\n        label: i18n.t('setting.fontType', {\n            context: 'optionBuiltIn',\n            postProcess: 'sentenceCase',\n        }),\n        value: FontType.BUILT_IN,\n    },\n];\n\nif (window.queryLocalFonts) {\n    FONT_TYPES.push({\n        label: i18n.t('setting.fontType', { context: 'optionSystem', postProcess: 'sentenceCase' }),\n        value: FontType.SYSTEM,\n    });\n}\n\nif (isElectron()) {\n    FONT_TYPES.push({\n        label: i18n.t('setting.fontType', { context: 'optionCustom', postProcess: 'sentenceCase' }),\n        value: FontType.CUSTOM,\n    });\n}\n\nexport const ApplicationSettings = memo(() => {\n    const { t } = useTranslation();\n    const settings = useGeneralSettings();\n    const fontSettings = useFontSettings();\n    const { setSettings } = useSettingsStoreActions();\n    const [localFonts, setLocalFonts] = useState<Font[]>([]);\n\n    // const fontList = useMemo(() => {\n    //     if (fontSettings.custom) {\n    //         return fontSettings.custom.split(/(\\\\|\\/)/g).pop()!;\n    //     }\n    //     return '';\n    // }, [fontSettings.custom]);\n\n    const onFontError = useCallback(\n        (_: IpcRendererEvent, file: string) => {\n            toast.error({\n                message: `${file} is not a valid font file`,\n            });\n\n            setSettings({\n                font: {\n                    ...fontSettings,\n                    custom: null,\n                },\n            });\n        },\n        [fontSettings, setSettings],\n    );\n\n    useEffect(() => {\n        if (localSettings) {\n            localSettings.fontError(onFontError);\n\n            return () => {\n                ipc?.removeAllListeners('custom-font-error');\n            };\n        }\n\n        return () => {};\n    }, [onFontError]);\n\n    useEffect(() => {\n        const getFonts = async () => {\n            if (\n                fontSettings.type === FontType.SYSTEM &&\n                localFonts.length === 0 &&\n                window.queryLocalFonts\n            ) {\n                try {\n                    // WARNING (Oct 17 2023): while this query is valid for chromium-based\n                    // browsers, it is still experimental, and so Typescript will complain\n                    const status = await navigator.permissions.query({\n                        name: 'local-fonts' as any,\n                    });\n\n                    if (status.state === 'denied') {\n                        throw new Error(\n                            t('error.localFontAccessDenied', { postProcess: 'sentenceCase' }),\n                        );\n                    }\n\n                    const data = await window.queryLocalFonts();\n                    setLocalFonts(\n                        data.map((font) => ({\n                            label: font.fullName,\n                            value: font.postscriptName,\n                        })),\n                    );\n                } catch (error) {\n                    console.error('Failed to get local fonts', error);\n                    toast.error({\n                        message: t('error.systemFontError', { postProcess: 'sentenceCase' }),\n                    });\n\n                    setSettings({\n                        font: {\n                            ...fontSettings,\n                            type: FontType.BUILT_IN,\n                        },\n                    });\n                }\n            }\n        };\n        getFonts();\n    }, [fontSettings, localFonts, setSettings, t]);\n\n    const handleChangeLanguage = (e: null | string) => {\n        if (!e) return;\n        setSettings({\n            general: {\n                ...settings,\n                language: e,\n            },\n        });\n    };\n\n    const options: SettingOption[] = [\n        {\n            control: (\n                <Select\n                    data={languages.map((language) => ({\n                        label: `${language.label} (${language.value})`,\n                        value: language.value,\n                    }))}\n                    onChange={handleChangeLanguage}\n                    value={settings.language}\n                />\n            ),\n            description: t('setting.language', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: false,\n            title: t('setting.language', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <Select\n                    data={FONT_TYPES}\n                    onChange={(e) => {\n                        if (!e) return;\n                        setSettings({\n                            font: {\n                                ...fontSettings,\n                                type: e as FontType,\n                            },\n                        });\n                    }}\n                    value={fontSettings.type}\n                />\n            ),\n            description: t('setting.fontType', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: FONT_TYPES.length === 1,\n            title: t('setting.fontType', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <Select\n                    data={FONT_OPTIONS}\n                    onChange={(e) => {\n                        if (!e) return;\n                        setSettings({\n                            font: {\n                                ...fontSettings,\n                                builtIn: e,\n                            },\n                        });\n                    }}\n                    searchable\n                    value={fontSettings.builtIn}\n                />\n            ),\n            description: t('setting.font', { context: 'description', postProcess: 'sentenceCase' }),\n            isHidden: localFonts && fontSettings.type !== FontType.BUILT_IN,\n            title: t('setting.font', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <Select\n                    data={localFonts}\n                    onChange={(e) => {\n                        if (!e) return;\n                        setSettings({\n                            font: {\n                                ...fontSettings,\n                                system: e,\n                            },\n                        });\n                    }}\n                    searchable\n                    value={fontSettings.system}\n                    w={300}\n                />\n            ),\n            description: t('setting.font', { context: 'description', postProcess: 'sentenceCase' }),\n            isHidden: !localFonts || fontSettings.type !== FontType.SYSTEM,\n            title: t('setting.font', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <FileInput\n                    accept=\".ttc,.ttf,.otf,.woff,.woff2\"\n                    onChange={(e) =>\n                        setSettings({\n                            font: {\n                                ...fontSettings,\n                                custom: e ? webUtils?.getPathForFile(e) || null : null,\n                            },\n                        })\n                    }\n                    w={300}\n                />\n            ),\n            description: t('setting.customFontPath', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: fontSettings.type !== FontType.CUSTOM,\n            title: t('setting.customFontPath', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <NumberInput\n                    max={300}\n                    min={50}\n                    onBlur={(e) => {\n                        if (!e) return;\n                        const newVal = e.currentTarget.value\n                            ? Math.min(Math.max(Number(e.currentTarget.value), 50), 300)\n                            : settings.zoomFactor;\n                        setSettings({\n                            general: {\n                                ...settings,\n                                zoomFactor: newVal,\n                            },\n                        });\n                        localSettings!.setZoomFactor(newVal);\n                    }}\n                    value={settings.zoomFactor}\n                />\n            ),\n            description: t('setting.zoom', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: !isElectron(),\n            title: t('setting.zoom', {\n                postProcess: 'sentenceCase',\n            }),\n        },\n        {\n            control: (\n                <Switch\n                    defaultChecked={settings.resume}\n                    onChange={(e) => {\n                        localSettings?.set('resume', e.target.checked);\n                        setSettings({\n                            general: {\n                                ...settings,\n                                resume: e.currentTarget.checked,\n                            },\n                        });\n                    }}\n                />\n            ),\n            description: t('setting.savePlayQueue', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: !isElectron(),\n            title: t('setting.savePlayQueue', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <Switch\n                    aria-label={t('setting.homeFeature', { postProcess: 'sentenceCase' })}\n                    defaultChecked={settings.homeFeature}\n                    onChange={(e) =>\n                        setSettings({\n                            general: {\n                                ...settings,\n                                homeFeature: e.currentTarget.checked,\n                            },\n                        })\n                    }\n                />\n            ),\n            description: t('setting.homeFeature', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: false,\n            title: t('setting.homeFeature', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <SegmentedControl\n                    aria-label={t('setting.homeFeatureStyle', { postProcess: 'sentenceCase' })}\n                    data={HOME_FEATURE_STYLE_OPTIONS}\n                    defaultValue={settings.homeFeatureStyle}\n                    onChange={(e) =>\n                        setSettings({\n                            general: {\n                                ...settings,\n                                homeFeatureStyle: e as HomeFeatureStyle,\n                            },\n                        })\n                    }\n                />\n            ),\n            description: t('setting.homeFeatureStyle', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: false,\n            title: t('setting.homeFeatureStyle', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <Switch\n                    aria-label={t('setting.albumBackground', { postProcess: 'sentenceCase' })}\n                    defaultChecked={settings.albumBackground}\n                    onChange={(e) =>\n                        setSettings({\n                            general: {\n                                ...settings,\n                                albumBackground: e.currentTarget.checked,\n                            },\n                        })\n                    }\n                />\n            ),\n            description: t('setting.albumBackground', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: false,\n            title: t('setting.albumBackground', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <Slider\n                    defaultValue={settings.albumBackgroundBlur}\n                    label={(e) => `${e} rem`}\n                    max={6}\n                    min={0}\n                    onChangeEnd={(e) => {\n                        setSettings({\n                            general: {\n                                ...settings,\n                                albumBackgroundBlur: e,\n                            },\n                        });\n                    }}\n                    step={0.5}\n                    w={100}\n                />\n            ),\n            description: t('setting.albumBackgroundBlur', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: !settings.albumBackground,\n            title: t('setting.albumBackgroundBlur', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <Switch\n                    aria-label={t('setting.artistBackground', { postProcess: 'sentenceCase' })}\n                    defaultChecked={settings.artistBackground}\n                    onChange={(e) =>\n                        setSettings({\n                            general: {\n                                ...settings,\n                                artistBackground: e.currentTarget.checked,\n                            },\n                        })\n                    }\n                />\n            ),\n            description: t('setting.artistBackground', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: false,\n            title: t('setting.artistBackground', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <Slider\n                    defaultValue={settings.artistBackgroundBlur}\n                    label={(e) => `${e} rem`}\n                    max={6}\n                    min={0}\n                    onChangeEnd={(e) => {\n                        setSettings({\n                            general: {\n                                ...settings,\n                                artistBackgroundBlur: e,\n                            },\n                        });\n                    }}\n                    step={0.5}\n                    w={100}\n                />\n            ),\n            description: t('setting.artistBackgroundBlur', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: !settings.artistBackground,\n            title: t('setting.artistBackgroundBlur', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <Switch\n                    aria-label=\"Toggle using native aspect ratio\"\n                    defaultChecked={settings.nativeAspectRatio}\n                    onChange={(e) =>\n                        setSettings({\n                            general: {\n                                ...settings,\n                                nativeAspectRatio: e.currentTarget.checked,\n                            },\n                        })\n                    }\n                />\n            ),\n            description: t('setting.imageAspectRatio', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: false,\n            title: t('setting.imageAspectRatio', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <Select\n                    data={SIDE_QUEUE_OPTIONS}\n                    defaultValue={settings.sideQueueType}\n                    onChange={(e) => {\n                        setSettings({\n                            general: {\n                                ...settings,\n                                sideQueueType: e as SideQueueType,\n                            },\n                        });\n                    }}\n                />\n            ),\n            description: t('setting.sidePlayQueueStyle', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: false,\n            title: t('setting.sidePlayQueueStyle', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <SegmentedControl\n                    aria-label={t('setting.sidePlayQueueLayout', { postProcess: 'sentenceCase' })}\n                    data={SIDE_QUEUE_LAYOUT_OPTIONS}\n                    defaultValue={settings.sideQueueLayout}\n                    onChange={(e) =>\n                        setSettings({\n                            general: {\n                                ...settings,\n                                sideQueueLayout: e as SideQueueLayout,\n                            },\n                        })\n                    }\n                />\n            ),\n            description: t('setting.sidePlayQueueLayout', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: settings.sideQueueType !== 'sideQueue',\n            title: t('setting.sidePlayQueueLayout', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <Switch\n                    defaultChecked={settings.showRatings}\n                    onChange={(e) => {\n                        setSettings({\n                            general: {\n                                ...settings,\n                                showRatings: e.currentTarget.checked,\n                            },\n                        });\n                    }}\n                />\n            ),\n            description: t('setting.showRatings', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: false,\n            title: t('setting.showRatings', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <Switch\n                    aria-label={t('setting.blurExplicitImages', { postProcess: 'sentenceCase' })}\n                    defaultChecked={settings.blurExplicitImages}\n                    onChange={(e) =>\n                        setSettings({\n                            general: {\n                                ...settings,\n                                blurExplicitImages: e.currentTarget.checked,\n                            },\n                        })\n                    }\n                />\n            ),\n            description: t('setting.blurExplicitImages', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: false,\n            title: t('setting.blurExplicitImages', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <Switch\n                    aria-label={t('setting.enableGridMultiSelect', { postProcess: 'sentenceCase' })}\n                    defaultChecked={settings.enableGridMultiSelect}\n                    onChange={(e) =>\n                        setSettings({\n                            general: {\n                                ...settings,\n                                enableGridMultiSelect: e.currentTarget.checked,\n                            },\n                        })\n                    }\n                />\n            ),\n            description: t('setting.enableGridMultiSelect', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: false,\n            title: t('setting.enableGridMultiSelect', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <Switch\n                    aria-label={t('setting.playerbarOpenDrawer', { postProcess: 'sentenceCase' })}\n                    defaultChecked={settings.playerbarOpenDrawer}\n                    onChange={(e) =>\n                        setSettings({\n                            general: {\n                                ...settings,\n                                playerbarOpenDrawer: e.currentTarget.checked,\n                            },\n                        })\n                    }\n                />\n            ),\n            description: t('setting.playerbarOpenDrawer', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: false,\n            title: t('setting.playerbarOpenDrawer', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <Switch\n                    aria-label={t('setting.autosave', { postProcess: 'sentenceCase' })}\n                    defaultChecked={settings.autoSave.enabled}\n                    onChange={(e) => {\n                        setSettings({\n                            general: {\n                                ...settings,\n                                autoSave: {\n                                    ...settings.autoSave,\n                                    enabled: e.currentTarget.checked,\n                                },\n                            },\n                        });\n                    }}\n                />\n            ),\n            description: t('setting.autosave', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            title: t('setting.autosave', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <NumberInput\n                    min={1}\n                    onBlur={(e) => {\n                        if (!e) return;\n                        const newVal = e.currentTarget.value\n                            ? Math.max(Number(e.currentTarget.value), 1)\n                            : settings.autoSave.count;\n                        setSettings({\n                            general: {\n                                ...settings,\n                                autoSave: {\n                                    ...settings.autoSave,\n                                    count: newVal,\n                                },\n                            },\n                        });\n                    }}\n                    value={settings.autoSave.count}\n                />\n            ),\n            description: t('setting.autosaveCount', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: !settings.autoSave.enabled,\n            title: t('setting.autosaveCount', { postProcess: 'sentenceCase' }),\n        },\n    ];\n\n    return (\n        <SettingsSection\n            extra={\n                <>\n                    <ImageResolutionSettings />\n                    <HomeSettings />\n                    <ArtistSettings />\n                    <ArtistReleaseTypeSettings />\n                    <FullscreenPlayerSettings />\n                    <PathSettings />\n                </>\n            }\n            options={options}\n            title={t('page.setting.application', { postProcess: 'sentenceCase' })}\n        />\n    );\n});\n"
  },
  {
    "path": "src/renderer/features/settings/components/general/art-resolution-settings.tsx",
    "content": "import { memo, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport i18n from '/@/i18n/i18n';\nimport { SettingsOptions } from '/@/renderer/features/settings/components/settings-option';\nimport { useGeneralSettings, useSettingsStoreActions } from '/@/renderer/store';\nimport { Button } from '/@/shared/components/button/button';\nimport { NumberInput } from '/@/shared/components/number-input/number-input';\nimport { Table } from '/@/shared/components/table/table';\nimport { Text } from '/@/shared/components/text/text';\n\nconst options = [\n    {\n        label: i18n.t('setting.imageResolution_optionTable', { postProcess: 'sentenceCase' }),\n        value: 'table',\n    },\n    {\n        label: i18n.t('setting.imageResolution_optionItemCard', { postProcess: 'sentenceCase' }),\n        value: 'itemCard',\n    },\n    {\n        label: i18n.t('setting.imageResolution_optionSidebar', { postProcess: 'sentenceCase' }),\n        value: 'sidebar',\n    },\n    {\n        label: i18n.t('setting.imageResolution_optionHeader', { postProcess: 'sentenceCase' }),\n        value: 'header',\n    },\n    {\n        label: i18n.t('setting.imageResolution_optionFullScreenPlayer', {\n            postProcess: 'sentenceCase',\n        }),\n        value: 'fullScreenPlayer',\n    },\n];\n\nexport const ImageResolutionSettings = memo(() => {\n    const { t } = useTranslation();\n    const { setSettings } = useSettingsStoreActions();\n    const settings = useGeneralSettings();\n\n    const [open, setOpen] = useState(false);\n\n    const descriptionText = t('setting.imageResolution', {\n        context: 'description',\n        postProcess: 'sentenceCase',\n    });\n\n    const titleText = t('setting.imageResolution', { postProcess: 'sentenceCase' });\n\n    return (\n        <>\n            <SettingsOptions\n                control={\n                    <>\n                        <Button\n                            onClick={() => setOpen(!open)}\n                            size=\"compact-md\"\n                            variant={open ? 'subtle' : 'filled'}\n                        >\n                            {t(open ? 'common.close' : 'common.edit', { postProcess: 'titleCase' })}\n                        </Button>\n                    </>\n                }\n                description={descriptionText}\n                title={titleText}\n            />\n            {open && (\n                <Table withRowBorders={false}>\n                    <Table.Tbody>\n                        {options.map((option) => (\n                            <Table.Tr key={option.value}>\n                                <Table.Th key={option.value}>\n                                    <Text>{option.label}</Text>\n                                </Table.Th>\n                                <Table.Td align=\"right\" key={option.value}>\n                                    <NumberInput\n                                        max={2000}\n                                        min={0}\n                                        onChange={(e) => {\n                                            if (typeof e !== 'number') return;\n\n                                            setSettings({\n                                                general: {\n                                                    ...settings,\n                                                    imageRes: {\n                                                        ...settings.imageRes,\n                                                        [option.value]: e,\n                                                    },\n                                                },\n                                            });\n                                        }}\n                                        rightSection={\n                                            <Text isMuted isNoSelect pr=\"lg\" size=\"sm\">\n                                                px\n                                            </Text>\n                                        }\n                                        value={settings.imageRes[option.value]}\n                                        width={90}\n                                    />\n                                </Table.Td>\n                            </Table.Tr>\n                        ))}\n                    </Table.Tbody>\n                </Table>\n            )}\n        </>\n    );\n});\n"
  },
  {
    "path": "src/renderer/features/settings/components/general/artist-settings.tsx",
    "content": "import { memo } from 'react';\n\nimport { DraggableItems } from '/@/renderer/features/settings/components/general/draggable-items';\nimport {\n    ArtistItem,\n    ArtistReleaseTypeItem,\n    SortableItem,\n    useGeneralSettings,\n    useSettingsStoreActions,\n} from '/@/renderer/store';\n\nconst ARTIST_ITEMS: Array<[ArtistItem, string]> = [\n    [ArtistItem.BIOGRAPHY, 'table.column.biography'],\n    [ArtistItem.FAVORITE_SONGS, 'page.albumArtistDetail.favoriteSongs'],\n    [ArtistItem.TOP_SONGS, 'page.albumArtistDetail.topSongs'],\n    [ArtistItem.RECENT_ALBUMS, 'page.albumArtistDetail.recentReleases'],\n    [ArtistItem.SIMILAR_ARTISTS, 'page.albumArtistDetail.relatedArtists'],\n];\n\nexport const ArtistSettings = memo(() => {\n    const { artistItems } = useGeneralSettings();\n    const { setArtistItems } = useSettingsStoreActions();\n\n    return (\n        <DraggableItems\n            description=\"setting.artistConfiguration\"\n            itemLabels={ARTIST_ITEMS}\n            items={artistItems as SortableItem<ArtistItem>[]}\n            setItems={setArtistItems}\n            title=\"setting.artistConfiguration\"\n        />\n    );\n});\n\nconst ARTIST_RELEASE_TYPE_ITEMS: Array<[ArtistReleaseTypeItem, string]> = [\n    [ArtistReleaseTypeItem.APPEARS_ON, 'page.albumArtistDetail.appearsOn'],\n    [ArtistReleaseTypeItem.RELEASE_TYPE_ALBUM, 'releaseType.primary.album'],\n    [ArtistReleaseTypeItem.RELEASE_TYPE_EP, 'releaseType.primary.ep'],\n    [ArtistReleaseTypeItem.RELEASE_TYPE_SINGLE, 'releaseType.primary.single'],\n    [ArtistReleaseTypeItem.RELEASE_TYPE_BROADCAST, 'releaseType.primary.broadcast'],\n    [ArtistReleaseTypeItem.RELEASE_TYPE_COMPILATION, 'releaseType.secondary.compilation'],\n    [ArtistReleaseTypeItem.RELEASE_TYPE_AUDIO_DRAMA, 'releaseType.secondary.audioDrama'],\n    [ArtistReleaseTypeItem.RELEASE_TYPE_AUDIOBOOK, 'releaseType.secondary.audiobook'],\n    [ArtistReleaseTypeItem.RELEASE_TYPE_INTERVIEW, 'releaseType.secondary.interview'],\n    [ArtistReleaseTypeItem.RELEASE_TYPE_LIVE, 'releaseType.secondary.live'],\n    [ArtistReleaseTypeItem.RELEASE_TYPE_MIXTAPE_STREET, 'releaseType.secondary.mixtape'],\n    [ArtistReleaseTypeItem.RELEASE_TYPE_OTHER, 'releaseType.primary.other'],\n    [ArtistReleaseTypeItem.RELEASE_TYPE_REMIX, 'releaseType.secondary.remix'],\n    [ArtistReleaseTypeItem.RELEASE_TYPE_DJ_MIX, 'releaseType.secondary.djMix'],\n    [ArtistReleaseTypeItem.RELEASE_TYPE_DEMO, 'releaseType.secondary.demo'],\n    [ArtistReleaseTypeItem.RELEASE_TYPE_FIELD_RECORDING, 'releaseType.secondary.fieldRecording'],\n    [ArtistReleaseTypeItem.RELEASE_TYPE_SOUNDTRACK, 'releaseType.secondary.soundtrack'],\n    [ArtistReleaseTypeItem.RELEASE_TYPE_SPOKENWORD, 'releaseType.secondary.spokenWord'],\n];\n\nexport const ArtistReleaseTypeSettings = memo(() => {\n    const { artistReleaseTypeItems } = useGeneralSettings();\n    const { setArtistReleaseTypeItems } = useSettingsStoreActions();\n\n    return (\n        <DraggableItems\n            description=\"setting.artistReleaseTypeConfiguration\"\n            itemLabels={ARTIST_RELEASE_TYPE_ITEMS}\n            items={artistReleaseTypeItems as SortableItem<ArtistReleaseTypeItem>[]}\n            setItems={setArtistReleaseTypeItems}\n            title=\"setting.artistReleaseTypeConfiguration\"\n        />\n    );\n});\n"
  },
  {
    "path": "src/renderer/features/settings/components/general/control-settings.tsx",
    "content": "import { memo } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport {\n    SettingOption,\n    SettingsSection,\n} from '/@/renderer/features/settings/components/settings-section';\nimport {\n    BarAlign,\n    PlayerbarSliderType,\n    useGeneralSettings,\n    usePlayerbarSlider,\n    useSettingsStoreActions,\n} from '/@/renderer/store/settings.store';\nimport { Group } from '/@/shared/components/group/group';\nimport { NumberInput } from '/@/shared/components/number-input/number-input';\nimport { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control';\nimport { Select } from '/@/shared/components/select/select';\nimport { Slider } from '/@/shared/components/slider/slider';\nimport { Switch } from '/@/shared/components/switch/switch';\nimport { Text } from '/@/shared/components/text/text';\nimport { Tooltip } from '/@/shared/components/tooltip/tooltip';\nimport { Play } from '/@/shared/types/types';\n\nexport const ControlSettings = memo(() => {\n    const { t } = useTranslation();\n    const settings = useGeneralSettings();\n    const playerbarSlider = usePlayerbarSlider();\n    const { setSettings } = useSettingsStoreActions();\n\n    const controlOptions: SettingOption[] = [\n        {\n            control: (\n                <NumberInput\n                    defaultValue={settings.buttonSize}\n                    hideControls={false}\n                    max={30}\n                    min={15}\n                    onBlur={(e) => {\n                        if (!e) return;\n                        const newVal = e.currentTarget.value\n                            ? Math.min(Math.max(Number(e.currentTarget.value), 15), 30)\n                            : settings.buttonSize;\n                        setSettings({\n                            general: {\n                                ...settings,\n                                buttonSize: newVal,\n                            },\n                        });\n                    }}\n                    width={75}\n                />\n            ),\n            description: t('setting.buttonSize', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: false,\n            title: t('setting.buttonSize', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <Switch\n                    aria-label=\"Toggle skip buttons\"\n                    defaultChecked={settings.skipButtons?.enabled}\n                    onChange={(e) =>\n                        setSettings({\n                            general: {\n                                ...settings,\n                                skipButtons: {\n                                    ...settings.skipButtons,\n                                    enabled: e.currentTarget.checked,\n                                },\n                            },\n                        })\n                    }\n                />\n            ),\n            description: t('setting.showSkipButtons', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: false,\n            title: t('setting.showSkipButtons', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <Group>\n                    <Tooltip label={t('common.backward', { postProcess: 'titleCase' })}>\n                        <NumberInput\n                            defaultValue={settings.skipButtons.skipBackwardSeconds}\n                            min={0}\n                            onBlur={(e) =>\n                                setSettings({\n                                    general: {\n                                        ...settings,\n                                        skipButtons: {\n                                            ...settings.skipButtons,\n                                            skipBackwardSeconds: e.currentTarget.value\n                                                ? Number(e.currentTarget.value)\n                                                : 0,\n                                        },\n                                    },\n                                })\n                            }\n                            width={75}\n                        />\n                    </Tooltip>\n                    <Tooltip label={t('common.forward', { postProcess: 'titleCase' })}>\n                        <NumberInput\n                            defaultValue={settings.skipButtons.skipForwardSeconds}\n                            min={0}\n                            onBlur={(e) =>\n                                setSettings({\n                                    general: {\n                                        ...settings,\n                                        skipButtons: {\n                                            ...settings.skipButtons,\n                                            skipForwardSeconds: e.currentTarget.value\n                                                ? Number(e.currentTarget.value)\n                                                : 0,\n                                        },\n                                    },\n                                })\n                            }\n                            width={75}\n                        />\n                    </Tooltip>\n                </Group>\n            ),\n            description: t('setting.skipDuration', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: false,\n            title: t('setting.skipDuration', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <Select\n                    data={[\n                        {\n                            label: t('setting.playButtonBehavior', {\n                                context: 'optionPlay',\n                                postProcess: 'titleCase',\n                            }),\n                            value: Play.NOW,\n                        },\n                        {\n                            label: t('setting.playButtonBehavior', {\n                                context: 'optionAddNext',\n                                postProcess: 'titleCase',\n                            }),\n                            value: Play.NEXT,\n                        },\n                        {\n                            label: t('setting.playButtonBehavior', {\n                                context: 'optionAddLast',\n                                postProcess: 'titleCase',\n                            }),\n                            value: Play.LAST,\n                        },\n                        {\n                            label: t('setting.playButtonBehavior', {\n                                context: 'optionPlayShuffled',\n                                postProcess: 'titleCase',\n                            }),\n                            value: Play.SHUFFLE,\n                        },\n                    ]}\n                    defaultValue={settings.playButtonBehavior}\n                    onChange={(e) =>\n                        setSettings({\n                            general: {\n                                ...settings,\n                                playButtonBehavior: e as Play,\n                            },\n                        })\n                    }\n                />\n            ),\n            description: t('setting.playButtonBehavior', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: false,\n            title: t('setting.playButtonBehavior', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <Switch\n                    aria-label=\"Follow current song\"\n                    defaultChecked={settings.followCurrentSong}\n                    onChange={(e) =>\n                        setSettings({\n                            general: {\n                                ...settings,\n                                followCurrentSong: e.currentTarget.checked,\n                            },\n                        })\n                    }\n                />\n            ),\n            description: t('setting.followCurrentSong', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: false,\n            title: t('setting.followCurrentSong', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <NumberInput\n                    defaultValue={settings.artistRadioCount}\n                    max={200}\n                    min={10}\n                    onBlur={(e) => {\n                        if (!e) return;\n                        const newVal = e.currentTarget.value\n                            ? Math.min(Math.max(Number(e.currentTarget.value), 10), 100)\n                            : settings.artistRadioCount;\n                        setSettings({\n                            general: {\n                                ...settings,\n                                artistRadioCount: newVal,\n                            },\n                        });\n                    }}\n                    width={75}\n                />\n            ),\n            description: t('setting.artistRadioCount', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: false,\n            title: t('setting.artistRadioCount', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <Slider\n                    defaultValue={settings.volumeWheelStep}\n                    max={20}\n                    min={1}\n                    onChangeEnd={(e) => {\n                        setSettings({\n                            general: {\n                                ...settings,\n                                volumeWheelStep: e,\n                            },\n                        });\n                    }}\n                    w={100}\n                />\n            ),\n            description: t('setting.volumeWheelStep', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: false,\n            title: t('setting.volumeWheelStep', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <NumberInput\n                    defaultValue={settings.volumeWidth}\n                    max={180}\n                    min={30}\n                    onBlur={(e) => {\n                        setSettings({\n                            general: { ...settings, volumeWidth: Number(e.currentTarget.value) },\n                        });\n                    }}\n                    placeholder=\"0\"\n                    rightSection={<Text size=\"sm\">px</Text>}\n                    width={75}\n                />\n            ),\n            description: t('setting.volumeWidth', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: false,\n            title: t('setting.volumeWidth', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <SegmentedControl\n                    data={[\n                        {\n                            label: t('setting.playerbarSliderType', {\n                                context: 'optionSlider',\n                                postProcess: 'titleCase',\n                            }),\n                            value: PlayerbarSliderType.SLIDER,\n                        },\n                        {\n                            label: t('setting.playerbarSliderType', {\n                                context: 'optionWaveform',\n                                postProcess: 'titleCase',\n                            }),\n                            value: PlayerbarSliderType.WAVEFORM,\n                        },\n                    ]}\n                    onChange={(value) => {\n                        setSettings({\n                            general: {\n                                ...settings,\n                                playerbarSlider: {\n                                    ...playerbarSlider,\n                                    type: value as PlayerbarSliderType,\n                                },\n                            },\n                        });\n                    }}\n                    size=\"sm\"\n                    value={playerbarSlider?.type || PlayerbarSliderType.WAVEFORM}\n                    w=\"100%\"\n                />\n            ),\n            description: t('setting.playerbarSlider', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: false,\n            title: t('setting.playerbarSlider', { postProcess: 'sentenceCase' }),\n        },\n        ...(playerbarSlider?.type === PlayerbarSliderType.WAVEFORM\n            ? [\n                  {\n                      control: (\n                          <SegmentedControl\n                              data={[\n                                  {\n                                      label: t('setting.playerbarWaveformAlign', {\n                                          context: 'optionTop',\n                                          postProcess: 'titleCase',\n                                      }),\n                                      value: BarAlign.TOP,\n                                  },\n                                  {\n                                      label: t('setting.playerbarWaveformAlign', {\n                                          context: 'optionCenter',\n                                          postProcess: 'titleCase',\n                                      }),\n                                      value: BarAlign.CENTER,\n                                  },\n                                  {\n                                      label: t('setting.playerbarWaveformAlign', {\n                                          context: 'optionBottom',\n                                          postProcess: 'titleCase',\n                                      }),\n                                      value: BarAlign.BOTTOM,\n                                  },\n                              ]}\n                              onChange={(value) => {\n                                  setSettings({\n                                      general: {\n                                          ...settings,\n                                          playerbarSlider: {\n                                              ...playerbarSlider,\n                                              barAlign: (value as BarAlign) || BarAlign.CENTER,\n                                          },\n                                      },\n                                  });\n                              }}\n                              size=\"sm\"\n                              value={playerbarSlider?.barAlign || BarAlign.CENTER}\n                              w=\"100%\"\n                          />\n                      ),\n                      description: t('setting.playerbarWaveformAlign', {\n                          context: 'description',\n                          postProcess: 'sentenceCase',\n                      }),\n                      isHidden: false,\n                      title: t('setting.playerbarWaveformAlign', {\n                          postProcess: 'sentenceCase',\n                      }),\n                  },\n                  {\n                      control: (\n                          <Slider\n                              defaultValue={playerbarSlider?.barWidth ?? 2}\n                              max={10}\n                              min={0}\n                              onChangeEnd={(value) => {\n                                  setSettings({\n                                      general: {\n                                          ...settings,\n                                          playerbarSlider: {\n                                              ...playerbarSlider,\n                                              barWidth: value,\n                                          },\n                                      },\n                                  });\n                              }}\n                              step={1}\n                              styles={{\n                                  root: {},\n                              }}\n                              w=\"120px\"\n                          />\n                      ),\n                      description: t('setting.playerbarWaveformBarWidth', {\n                          context: 'description',\n                          postProcess: 'sentenceCase',\n                      }),\n                      isHidden: false,\n                      title: t('setting.playerbarWaveformBarWidth', {\n                          postProcess: 'sentenceCase',\n                      }),\n                  },\n                  {\n                      control: (\n                          <Slider\n                              defaultValue={playerbarSlider?.barGap || 0}\n                              max={10}\n                              min={0}\n                              onChangeEnd={(value) => {\n                                  setSettings({\n                                      general: {\n                                          ...settings,\n                                          playerbarSlider: {\n                                              ...playerbarSlider,\n                                              barGap: value,\n                                          },\n                                      },\n                                  });\n                              }}\n                              step={1}\n                              styles={{\n                                  root: {},\n                              }}\n                              w=\"120px\"\n                          />\n                      ),\n                      description: t('setting.playerbarWaveformGap', {\n                          context: 'description',\n                          postProcess: 'sentenceCase',\n                      }),\n                      isHidden: false,\n                      title: t('setting.playerbarWaveformGap', {\n                          postProcess: 'sentenceCase',\n                      }),\n                  },\n                  {\n                      control: (\n                          <Slider\n                              defaultValue={playerbarSlider?.barRadius ?? 4}\n                              max={20}\n                              min={0}\n                              onChangeEnd={(value) => {\n                                  setSettings({\n                                      general: {\n                                          ...settings,\n                                          playerbarSlider: {\n                                              ...playerbarSlider,\n                                              barRadius: value,\n                                          },\n                                      },\n                                  });\n                              }}\n                              step={1}\n                              styles={{\n                                  root: {},\n                              }}\n                              w=\"120px\"\n                          />\n                      ),\n                      description: t('setting.playerbarWaveformRadius', {\n                          context: 'description',\n                          postProcess: 'sentenceCase',\n                      }),\n                      isHidden: false,\n                      title: t('setting.playerbarWaveformRadius', {\n                          postProcess: 'sentenceCase',\n                      }),\n                  },\n              ]\n            : []),\n    ];\n\n    return (\n        <SettingsSection\n            options={controlOptions}\n            title={t('page.setting.controls', { postProcess: 'sentenceCase' })}\n        />\n    );\n});\n"
  },
  {
    "path": "src/renderer/features/settings/components/general/draggable-item.tsx",
    "content": "import { DragControls, Reorder, useDragControls } from 'motion/react';\n\nimport { ActionIcon } from '/@/shared/components/action-icon/action-icon';\nimport { Checkbox } from '/@/shared/components/checkbox/checkbox';\nimport { Group } from '/@/shared/components/group/group';\nimport { Text } from '/@/shared/components/text/text';\n\nconst DragHandle = ({ dragControls }: { dragControls: DragControls }) => {\n    return (\n        <ActionIcon\n            icon=\"dragVertical\"\n            iconProps={{\n                size: 'md',\n            }}\n            onPointerDown={(event) => dragControls.start(event)}\n            size=\"xs\"\n            style={{ cursor: 'grab' }}\n            variant=\"transparent\"\n        />\n    );\n};\n\nexport interface DraggableItemProps {\n    handleChangeDisabled: (id: string, e: boolean) => void;\n    item: SidebarItem;\n    value: string;\n}\n\ninterface SidebarItem {\n    disabled: boolean;\n    id: string;\n}\n\nexport const DraggableItem = ({ handleChangeDisabled, item, value }: DraggableItemProps) => {\n    const dragControls = useDragControls();\n\n    return (\n        <Reorder.Item as=\"div\" dragControls={dragControls} dragListener={false} value={item}>\n            <Group py=\"md\" style={{ boxShadow: '0 1px 3px rgba(0,0,0,.1)' }} wrap=\"nowrap\">\n                <Checkbox\n                    checked={!item.disabled}\n                    onChange={(e) => handleChangeDisabled(item.id, e.target.checked)}\n                    size=\"xs\"\n                />\n                <DragHandle dragControls={dragControls} />\n                <Text>{value}</Text>\n            </Group>\n        </Reorder.Item>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/settings/components/general/draggable-items.tsx",
    "content": "import isEqual from 'lodash/isEqual';\nimport { Reorder } from 'motion/react';\nimport { useCallback, useMemo, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport { DraggableItem } from '/@/renderer/features/settings/components/general/draggable-item';\nimport { SettingsOptions } from '/@/renderer/features/settings/components/settings-option';\nimport { useSettingSearchContext } from '/@/renderer/features/settings/context/search-context';\nimport { SortableItem } from '/@/renderer/store';\nimport { Button } from '/@/shared/components/button/button';\n\nexport type DraggableItemsProps<K, T> = {\n    description: string;\n    itemLabels: Array<[K, string]>;\n    items: T[];\n    setItems: (items: T[]) => void;\n    title: string;\n};\n\nconst mergeItems = <K extends string, T extends SortableItem<K>>(\n    items: T[],\n    itemLabels: Array<[string, string]>,\n): T[] => {\n    const allItemIds = itemLabels.map(([key]) => key);\n\n    const missingItemIds = allItemIds.filter((id) => !items.some((item) => item.id === id));\n\n    const merged = [\n        ...items,\n        ...(missingItemIds.map((id) => ({\n            disabled: true,\n            id,\n        })) as T[]),\n    ];\n\n    // Remove any duplicates\n    const uniqueMerged = merged.filter(\n        (item, index, self) => index === self.findIndex((t) => t.id === item.id),\n    );\n\n    // Remove any that don't match the itemLabels\n    return uniqueMerged.filter((item) => itemLabels.some(([key]) => key === item.id));\n};\n\nexport const DraggableItems = <K extends string, T extends SortableItem<K>>({\n    description,\n    itemLabels,\n    items,\n    setItems,\n    title,\n}: DraggableItemsProps<K, T>) => {\n    const { t } = useTranslation();\n    const keyword = useSettingSearchContext();\n    const [open, setOpen] = useState(false);\n\n    const translatedItemMap = useMemo(\n        () =>\n            Object.fromEntries(\n                itemLabels.map(([key, value]) => [key, t(value, { postProcess: 'sentenceCase' })]),\n            ) as Record<K, string>,\n        [itemLabels, t],\n    );\n\n    const [localItems, setLocalItems] = useState(mergeItems(items, itemLabels));\n\n    const handleChangeDisabled = useCallback((id: string, e: boolean) => {\n        setLocalItems((items) =>\n            items.map((item) => {\n                if (item.id === id) {\n                    return {\n                        ...item,\n                        disabled: !e,\n                    };\n                }\n\n                return item;\n            }),\n        );\n    }, []);\n\n    const titleText = t(title, { postProcess: 'sentenceCase' });\n    const descriptionText = t(description, {\n        context: 'description',\n        postProcess: 'sentenceCase',\n    });\n\n    const shouldShow = useMemo(() => {\n        return (\n            keyword === '' ||\n            title.toLocaleLowerCase().includes(keyword) ||\n            description.toLocaleLowerCase().includes(keyword)\n        );\n    }, [description, keyword, title]);\n\n    if (!shouldShow) {\n        return null;\n    }\n\n    const isSaveButtonDisabled = isEqual(items, localItems);\n\n    const handleSave = () => {\n        setItems(localItems);\n    };\n\n    return (\n        <>\n            <SettingsOptions\n                control={\n                    <>\n                        {open && (\n                            <Button\n                                disabled={isSaveButtonDisabled}\n                                onClick={handleSave}\n                                size=\"compact-md\"\n                                variant=\"filled\"\n                            >\n                                {t('common.save', { postProcess: 'titleCase' })}\n                            </Button>\n                        )}\n                        <Button\n                            onClick={() => setOpen(!open)}\n                            size=\"compact-md\"\n                            variant={open ? 'subtle' : 'filled'}\n                        >\n                            {t(open ? 'common.close' : 'common.edit', { postProcess: 'titleCase' })}\n                        </Button>\n                    </>\n                }\n                description={descriptionText}\n                title={titleText}\n            />\n            {open && (\n                <Reorder.Group\n                    axis=\"y\"\n                    onReorder={setLocalItems}\n                    style={{ userSelect: 'none' }}\n                    values={localItems}\n                >\n                    {localItems.map((item) => (\n                        <DraggableItem\n                            handleChangeDisabled={handleChangeDisabled}\n                            item={item}\n                            key={item.id}\n                            value={translatedItemMap[item.id]}\n                        />\n                    ))}\n                </Reorder.Group>\n            )}\n        </>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/settings/components/general/external-links-settings.tsx",
    "content": "import { memo } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport {\n    SettingOption,\n    SettingsSection,\n} from '/@/renderer/features/settings/components/settings-section';\nimport { useGeneralSettings, useSettingsStoreActions } from '/@/renderer/store/settings.store';\nimport { Switch } from '/@/shared/components/switch/switch';\n\nexport const ExternalLinksSettings = memo(() => {\n    const { t } = useTranslation();\n    const settings = useGeneralSettings();\n    const { setSettings } = useSettingsStoreActions();\n\n    const options: SettingOption[] = [\n        {\n            control: (\n                <Switch\n                    defaultChecked={settings.externalLinks}\n                    onChange={(e) => {\n                        setSettings({\n                            general: {\n                                ...settings,\n                                externalLinks: e.currentTarget.checked,\n                            },\n                        });\n                    }}\n                />\n            ),\n            description: t('setting.externalLinks', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            title: t('setting.externalLinks', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <Switch\n                    defaultChecked={settings.lastFM}\n                    onChange={(e) => {\n                        setSettings({\n                            general: {\n                                ...settings,\n                                lastFM: e.currentTarget.checked,\n                            },\n                        });\n                    }}\n                />\n            ),\n            description: t('setting.lastfm', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: !settings.externalLinks,\n            title: t('setting.lastfm', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <Switch\n                    defaultChecked={settings.listenBrainz}\n                    onChange={(e) => {\n                        setSettings({\n                            general: {\n                                ...settings,\n                                listenBrainz: e.currentTarget.checked,\n                            },\n                        });\n                    }}\n                />\n            ),\n            description: t('setting.listenbrainz', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: !settings.externalLinks,\n            title: t('setting.listenbrainz', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <Switch\n                    defaultChecked={settings.musicBrainz}\n                    onChange={(e) => {\n                        setSettings({\n                            general: {\n                                ...settings,\n                                musicBrainz: e.currentTarget.checked,\n                            },\n                        });\n                    }}\n                />\n            ),\n            description: t('setting.musicbrainz', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: !settings.externalLinks,\n            title: t('setting.musicbrainz', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <Switch\n                    defaultChecked={settings.qobuz}\n                    onChange={(e) => {\n                        setSettings({\n                            general: {\n                                ...settings,\n                                qobuz: e.currentTarget.checked,\n                            },\n                        });\n                    }}\n                />\n            ),\n            description: t('setting.qobuz', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: !settings.externalLinks,\n            title: t('setting.qobuz', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <Switch\n                    defaultChecked={settings.spotify}\n                    onChange={(e) => {\n                        setSettings({\n                            general: {\n                                ...settings,\n                                spotify: e.currentTarget.checked,\n                            },\n                        });\n                    }}\n                />\n            ),\n            description: t('setting.spotify', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: !settings.externalLinks,\n            title: t('setting.spotify', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <Switch\n                    defaultChecked={settings.nativeSpotify}\n                    onChange={(e) => {\n                        setSettings({\n                            general: {\n                                ...settings,\n                                nativeSpotify: e.currentTarget.checked,\n                            },\n                        });\n                    }}\n                />\n            ),\n            description: t('setting.nativeSpotify', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: !settings.externalLinks || !settings.spotify,\n            title: t('setting.nativeSpotify', { postProcess: 'sentenceCase' }),\n        },\n    ];\n\n    return (\n        <SettingsSection\n            options={options}\n            title={t('common.externalLinks', { postProcess: 'sentenceCase' })}\n        />\n    );\n});\n"
  },
  {
    "path": "src/renderer/features/settings/components/general/fullscreen-player-settings.tsx",
    "content": "import { memo } from 'react';\n\nimport { DraggableItems } from '/@/renderer/features/settings/components/general/draggable-items';\nimport {\n    PlayerItem,\n    SortableItem,\n    useGeneralSettings,\n    useSettingsStoreActions,\n} from '/@/renderer/store';\n\nconst PLAYER_ITEMS: Array<[PlayerItem, string]> = [\n    [PlayerItem.BIT_DEPTH, 'common.bitDepth'],\n    [PlayerItem.BIT_RATE, 'common.bitrate'],\n    [PlayerItem.BPM, 'common.bpm'],\n    [PlayerItem.CODEC, 'common.codec'],\n    [PlayerItem.DISC_NUMBER, 'table.config.label.discNumber'],\n    [PlayerItem.GENRES, 'entity.genre_other'],\n    [PlayerItem.RELEASE_DATE, 'filter.releaseDate'],\n    [PlayerItem.RELEASE_TYPE, 'common.releaseType'],\n    [PlayerItem.RELEASE_YEAR, 'filter.releaseYear'],\n    [PlayerItem.SAMPLE_RATE, 'common.sampleRate'],\n    [PlayerItem.TRACK_NUMBER, 'table.config.label.trackNumber'],\n];\n\nexport const FullscreenPlayerSettings = memo(() => {\n    const { playerItems } = useGeneralSettings();\n    const { setPlayerItems } = useSettingsStoreActions();\n\n    return (\n        <DraggableItems\n            description=\"setting.playerItemConfiguration\"\n            itemLabels={PLAYER_ITEMS}\n            items={playerItems as SortableItem<PlayerItem>[]}\n            setItems={setPlayerItems}\n            title=\"setting.playerItemConfiguration\"\n        />\n    );\n});\n"
  },
  {
    "path": "src/renderer/features/settings/components/general/general-tab.tsx",
    "content": "import { memo, useMemo } from 'react';\nimport { Fragment } from 'react/jsx-runtime';\n\nimport { ApplicationSettings } from '/@/renderer/features/settings/components/general/application-settings';\nimport { ControlSettings } from '/@/renderer/features/settings/components/general/control-settings';\nimport { ExternalLinksSettings } from '/@/renderer/features/settings/components/general/external-links-settings';\nimport { LyricSettings } from '/@/renderer/features/settings/components/general/lyric-settings';\nimport { QueryBuilderSettings } from '/@/renderer/features/settings/components/general/query-builder-settings';\nimport { ScrobbleSettings } from '/@/renderer/features/settings/components/general/scrobble-settings';\nimport { SidebarSettings } from '/@/renderer/features/settings/components/general/sidebar-settings';\nimport { ThemeSettings } from '/@/renderer/features/settings/components/general/theme-settings';\nimport { useCurrentServer } from '/@/renderer/store';\nimport { hasFeature } from '/@/shared/api/utils';\nimport { Divider } from '/@/shared/components/divider/divider';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { ServerFeature } from '/@/shared/types/features-types';\n\nexport const GeneralTab = memo(() => {\n    const server = useCurrentServer();\n    const supportsSmartPlaylists = hasFeature(server, ServerFeature.PLAYLISTS_SMART);\n\n    const sections = useMemo(() => {\n        const baseSections = [\n            { component: ThemeSettings, key: 'theme' },\n            { component: ApplicationSettings, key: 'application' },\n            { component: ExternalLinksSettings, key: 'externalLinks' },\n            { component: ControlSettings, key: 'control' },\n            { component: SidebarSettings, key: 'sidebar' },\n            { component: ScrobbleSettings, key: 'scrobble' },\n            { component: LyricSettings, key: 'lyrics' },\n        ];\n\n        if (supportsSmartPlaylists) {\n            baseSections.push({ component: QueryBuilderSettings, key: 'queryBuilder' });\n        }\n\n        return baseSections;\n    }, [supportsSmartPlaylists]);\n\n    return (\n        <Stack gap=\"md\">\n            {sections.map(({ component: Section, key }, index) => (\n                <Fragment key={key}>\n                    <Section />\n                    {index < sections.length - 1 && <Divider />}\n                </Fragment>\n            ))}\n        </Stack>\n    );\n});\n"
  },
  {
    "path": "src/renderer/features/settings/components/general/home-settings.tsx",
    "content": "import { memo } from 'react';\n\nimport { DraggableItems } from '/@/renderer/features/settings/components/general/draggable-items';\nimport {\n    HomeItem,\n    SortableItem,\n    useGeneralSettings,\n    useSettingsStoreActions,\n} from '/@/renderer/store';\n\nconst HOME_ITEMS: Array<[string, string]> = [\n    [HomeItem.GENRES, 'page.home.genres'],\n    [HomeItem.RANDOM, 'page.home.explore'],\n    [HomeItem.RECENTLY_PLAYED, 'page.home.recentlyPlayed'],\n    [HomeItem.RECENTLY_ADDED, 'page.home.newlyAdded'],\n    [HomeItem.RECENTLY_RELEASED, 'page.home.recentlyReleased'],\n    [HomeItem.MOST_PLAYED, 'page.home.mostPlayed'],\n];\n\nexport const HomeSettings = memo(() => {\n    const { homeItems } = useGeneralSettings();\n    const { setHomeItems } = useSettingsStoreActions();\n\n    return (\n        <DraggableItems\n            description=\"setting.homeConfiguration\"\n            itemLabels={HOME_ITEMS}\n            items={homeItems as SortableItem<HomeItem>[]}\n            setItems={setHomeItems}\n            title=\"setting.homeConfiguration\"\n        />\n    );\n});\n"
  },
  {
    "path": "src/renderer/features/settings/components/general/lyric-settings.tsx",
    "content": "import isElectron from 'is-electron';\nimport { memo } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport { languages } from '/@/i18n/i18n';\nimport {\n    SettingOption,\n    SettingsSection,\n} from '/@/renderer/features/settings/components/settings-section';\nimport { useLyricsSettings, useSettingsStoreActions } from '/@/renderer/store';\nimport { MultiSelect } from '/@/shared/components/multi-select/multi-select';\nimport { NumberInput } from '/@/shared/components/number-input/number-input';\nimport { Select } from '/@/shared/components/select/select';\nimport { Switch } from '/@/shared/components/switch/switch';\nimport { TextInput } from '/@/shared/components/text-input/text-input';\nimport { LyricSource } from '/@/shared/types/domain-types';\n\nconst localSettings = isElectron() ? window.api.localSettings : null;\n\nexport const LyricSettings = memo(() => {\n    const { t } = useTranslation();\n    const settings = useLyricsSettings();\n    const { setSettings } = useSettingsStoreActions();\n\n    const updateSetting = (updates: Partial<typeof settings>) => {\n        setSettings({\n            lyrics: {\n                ...settings,\n                ...updates,\n            },\n        });\n    };\n\n    const lyricOptions: SettingOption[] = [\n        {\n            control: (\n                <Switch\n                    aria-label=\"Follow lyrics\"\n                    defaultChecked={settings.follow}\n                    onChange={(e) => updateSetting({ follow: e.currentTarget.checked })}\n                />\n            ),\n            description: t('setting.followLyric', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            title: t('setting.followLyric', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <Switch\n                    aria-label=\"Prefer local lyrics\"\n                    defaultChecked={settings.preferLocalLyrics}\n                    onChange={(e) => updateSetting({ preferLocalLyrics: e.currentTarget.checked })}\n                />\n            ),\n            description: t('setting.preferLocalLyrics', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: !isElectron(),\n            title: t('setting.preferLocalLyrics', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <Switch\n                    aria-label=\"Enable fetching lyrics\"\n                    defaultChecked={settings.fetch}\n                    onChange={(e) => updateSetting({ fetch: e.currentTarget.checked })}\n                />\n            ),\n            description: t('setting.lyricFetch', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: !isElectron(),\n            title: t('setting.lyricFetch', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <MultiSelect\n                    aria-label=\"Lyric providers\"\n                    clearable\n                    data={Object.values(LyricSource)}\n                    defaultValue={settings.sources}\n                    onChange={(e: string[]) => {\n                        localSettings?.set('lyrics', e);\n                        updateSetting({ sources: e.map((source) => source as LyricSource) });\n                    }}\n                    width={300}\n                />\n            ),\n            description: t('setting.lyricFetchProvider', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: !isElectron(),\n            title: t('setting.lyricFetchProvider', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <Switch\n                    aria-label=\"Enable NetEase translations\"\n                    defaultChecked={settings.enableNeteaseTranslation}\n                    onChange={(e) => {\n                        const isChecked = e.currentTarget.checked;\n                        updateSetting({ enableNeteaseTranslation: isChecked });\n                        localSettings?.set('enableNeteaseTranslation', isChecked);\n                    }}\n                />\n            ),\n            description: t('setting.neteaseTranslation', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: !isElectron(),\n            title: t('setting.neteaseTranslation', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <NumberInput\n                    defaultValue={settings.delayMs}\n                    onBlur={(e) => {\n                        const value = Number(e.currentTarget.value);\n                        updateSetting({ delayMs: value });\n                    }}\n                    step={10}\n                    width={100}\n                />\n            ),\n            description: t('setting.lyricOffset', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: !isElectron(),\n            title: t('setting.lyricOffset', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <Select\n                    data={languages}\n                    onChange={(value) => {\n                        updateSetting({ translationTargetLanguage: value });\n                    }}\n                    value={settings.translationTargetLanguage}\n                />\n            ),\n            description: t('setting.translationTargetLanguage', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: !isElectron(),\n            title: t('setting.translationTargetLanguage', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <Select\n                    clearable\n                    data={['Microsoft Azure', 'Google Cloud']}\n                    onChange={(value) => {\n                        updateSetting({ translationApiProvider: value });\n                    }}\n                    value={settings.translationApiProvider}\n                />\n            ),\n            description: t('setting.translationApiProvider', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: !isElectron(),\n            title: t('setting.translationApiProvider', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <TextInput\n                    onChange={(e) => {\n                        updateSetting({ translationApiKey: e.currentTarget.value });\n                    }}\n                    value={settings.translationApiKey}\n                />\n            ),\n            description: t('setting.translationApiKey', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: !isElectron(),\n            title: t('setting.translationApiKey', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <Switch\n                    aria-label=\"Enable auto translation\"\n                    defaultChecked={settings.enableAutoTranslation}\n                    onChange={(e) =>\n                        updateSetting({ enableAutoTranslation: e.currentTarget.checked })\n                    }\n                />\n            ),\n            description: t('setting.enableAutoTranslation', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: !isElectron(),\n            title: t('setting.enableAutoTranslation', { postProcess: 'sentenceCase' }),\n        },\n    ];\n\n    return (\n        <SettingsSection\n            options={lyricOptions}\n            title={t('page.setting.lyrics', { postProcess: 'sentenceCase' })}\n        />\n    );\n});\n"
  },
  {
    "path": "src/renderer/features/settings/components/general/path-settings.tsx",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { memo, useEffect, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport { songsQueries } from '/@/renderer/features/songs/api/songs-api';\nimport { useCurrentServerId, useGeneralSettings, useSettingsStoreActions } from '/@/renderer/store';\nimport { ActionIcon } from '/@/shared/components/action-icon/action-icon';\nimport { Code } from '/@/shared/components/code/code';\nimport { Group } from '/@/shared/components/group/group';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { TextInput } from '/@/shared/components/text-input/text-input';\nimport { Text } from '/@/shared/components/text/text';\nimport { useDebouncedCallback } from '/@/shared/hooks/use-debounced-callback';\nimport { Played } from '/@/shared/types/domain-types';\n\nexport const PathSettings = memo(() => {\n    const { t } = useTranslation();\n    const serverId = useCurrentServerId();\n    const randomSong = useQuery({\n        ...songsQueries.random({\n            query: { limit: 1, played: Played.All },\n            serverId,\n        }),\n        gcTime: Infinity,\n        staleTime: Infinity,\n    });\n\n    const { pathReplace, pathReplaceWith } = useGeneralSettings();\n    const { setSettings } = useSettingsStoreActions();\n\n    const [localPathReplace, setLocalPathReplace] = useState(pathReplace);\n    const [localPathReplaceWith, setLocalPathReplaceWith] = useState(pathReplaceWith);\n\n    useEffect(() => {\n        setLocalPathReplace(pathReplace);\n    }, [pathReplace]);\n\n    useEffect(() => {\n        setLocalPathReplaceWith(pathReplaceWith);\n    }, [pathReplaceWith]);\n\n    const debouncedSetPathReplace = useDebouncedCallback((value: string) => {\n        setSettings({\n            general: {\n                pathReplace: value,\n            },\n        });\n\n        randomSong.refetch();\n    }, 500);\n\n    const debouncedSetPathReplaceWith = useDebouncedCallback((value: string) => {\n        setSettings({\n            general: {\n                pathReplaceWith: value,\n            },\n        });\n\n        randomSong.refetch();\n    }, 500);\n\n    return (\n        <Stack>\n            <Group>\n                <Text>{t('setting.pathReplace', { postProcess: 'sentenceCase' })}</Text>\n                <ActionIcon\n                    icon=\"refresh\"\n                    loading={randomSong.isFetching}\n                    onClick={() => randomSong.refetch()}\n                    size=\"xs\"\n                    variant=\"transparent\"\n                />\n            </Group>\n            <Code>\n                <Text isMuted size=\"md\">\n                    {randomSong.data?.items[0]?.path || ''}\n                </Text>\n            </Code>\n            <Group grow>\n                <TextInput\n                    onChange={(e) => {\n                        const value = e.currentTarget.value;\n                        setLocalPathReplace(value);\n                        debouncedSetPathReplace(value);\n                    }}\n                    placeholder={t('setting.pathReplace_optionRemovePrefix', {\n                        postProcess: 'sentenceCase',\n                    })}\n                    value={localPathReplace}\n                />\n                <TextInput\n                    onChange={(e) => {\n                        const value = e.currentTarget.value;\n                        setLocalPathReplaceWith(value);\n                        debouncedSetPathReplaceWith(value);\n                    }}\n                    placeholder={t('setting.pathReplace_optionAddPrefix', {\n                        postProcess: 'sentenceCase',\n                    })}\n                    value={localPathReplaceWith}\n                />\n            </Group>\n        </Stack>\n    );\n});\n"
  },
  {
    "path": "src/renderer/features/settings/components/general/query-builder-settings.tsx",
    "content": "import { memo } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport {\n    SettingOption,\n    SettingsSection,\n} from '/@/renderer/features/settings/components/settings-section';\nimport { useQueryBuilderSettings, useSettingsStoreActions } from '/@/renderer/store/settings.store';\nimport { ActionIcon } from '/@/shared/components/action-icon/action-icon';\nimport { Button } from '/@/shared/components/button/button';\nimport { Group } from '/@/shared/components/group/group';\nimport { Select } from '/@/shared/components/select/select';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { TextInput } from '/@/shared/components/text-input/text-input';\n\nconst QUERY_VALUE_INPUT_TYPES = [\n    { label: 'Boolean', value: 'boolean' },\n    { label: 'Date', value: 'date' },\n    { label: 'Date Range', value: 'dateRange' },\n    { label: 'Number', value: 'number' },\n    { label: 'Playlist', value: 'playlist' },\n    { label: 'String', value: 'string' },\n] as const;\n\nexport const QueryBuilderSettings = memo(() => {\n    const { t } = useTranslation();\n    const queryBuilder = useQueryBuilderSettings();\n    const { setSettings } = useSettingsStoreActions();\n\n    const handleAddCustomField = () => {\n        setSettings({\n            queryBuilder: {\n                tag: [\n                    ...queryBuilder.tag,\n                    {\n                        label: '',\n                        type: 'string',\n                        value: '',\n                    },\n                ],\n            },\n        });\n    };\n\n    const handleRemoveCustomField = (index: number) => {\n        setSettings({\n            queryBuilder: {\n                tag: queryBuilder.tag.filter((_, i) => i !== index),\n            },\n        });\n    };\n\n    const handleUpdateCustomField = (\n        index: number,\n        field: 'label' | 'type' | 'value',\n        newValue: string,\n    ) => {\n        setSettings({\n            queryBuilder: {\n                tag: queryBuilder.tag.map((item, i) =>\n                    i === index ? { ...item, [field]: newValue } : item,\n                ),\n            },\n        });\n    };\n\n    const customFieldsOptions: SettingOption[] = [\n        {\n            control: (\n                <Stack gap=\"md\">\n                    {queryBuilder.tag.length > 0 && (\n                        <Stack gap=\"sm\">\n                            {queryBuilder.tag.map((field, index) => (\n                                <Group gap=\"sm\" key={index}>\n                                    <TextInput\n                                        onChange={(e) =>\n                                            handleUpdateCustomField(\n                                                index,\n                                                'label',\n                                                e.currentTarget.value,\n                                            )\n                                        }\n                                        placeholder={t(\n                                            'setting.queryBuilderCustomFields_inputLabel',\n                                            {\n                                                postProcess: 'sentenceCase',\n                                            },\n                                        )}\n                                        value={field.label}\n                                        width=\"30%\"\n                                    />\n                                    <Select\n                                        data={QUERY_VALUE_INPUT_TYPES}\n                                        onChange={(e) =>\n                                            handleUpdateCustomField(index, 'type', e || 'string')\n                                        }\n                                        value={field.type}\n                                        width=\"25%\"\n                                    />\n                                    <TextInput\n                                        onChange={(e) =>\n                                            handleUpdateCustomField(\n                                                index,\n                                                'value',\n                                                e.currentTarget.value,\n                                            )\n                                        }\n                                        placeholder={t(\n                                            'setting.queryBuilderCustomFields_inputTag',\n                                            {\n                                                postProcess: 'sentenceCase',\n                                            },\n                                        )}\n                                        value={field.value}\n                                        width=\"30%\"\n                                    />\n                                    <ActionIcon\n                                        icon=\"remove\"\n                                        onClick={() => handleRemoveCustomField(index)}\n                                        size=\"sm\"\n                                        variant=\"subtle\"\n                                    />\n                                </Group>\n                            ))}\n                        </Stack>\n                    )}\n                    <Group grow>\n                        <Button onClick={handleAddCustomField} variant=\"filled\">\n                            {t('common.add', { postProcess: 'titleCase' })}\n                        </Button>\n                    </Group>\n                </Stack>\n            ),\n            description: t('setting.queryBuilderCustomFields', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            title: t('setting.queryBuilderCustomFields', {\n                postProcess: 'sentenceCase',\n            }),\n        },\n    ];\n\n    return (\n        <SettingsSection\n            options={customFieldsOptions}\n            title={t('page.setting.queryBuilder', {\n                postProcess: 'sentenceCase',\n            })}\n        />\n    );\n});\n"
  },
  {
    "path": "src/renderer/features/settings/components/general/scrobble-settings.tsx",
    "content": "import { memo } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport {\n    SettingOption,\n    SettingsSection,\n} from '/@/renderer/features/settings/components/settings-section';\nimport { usePlaybackSettings, useSettingsStoreActions } from '/@/renderer/store/settings.store';\nimport { NumberInput } from '/@/shared/components/number-input/number-input';\nimport { Slider } from '/@/shared/components/slider/slider';\nimport { Switch } from '/@/shared/components/switch/switch';\nimport { toast } from '/@/shared/components/toast/toast';\n\nexport const ScrobbleSettings = memo(() => {\n    const { t } = useTranslation();\n    const settings = usePlaybackSettings();\n    const { setSettings } = useSettingsStoreActions();\n\n    const scrobbleOptions: SettingOption[] = [\n        {\n            control: (\n                <Switch\n                    aria-label=\"Toggle scrobble\"\n                    defaultChecked={settings.scrobble.enabled}\n                    onChange={(e) => {\n                        setSettings({\n                            playback: {\n                                scrobble: {\n                                    enabled: e.currentTarget.checked,\n                                },\n                            },\n                        });\n                    }}\n                />\n            ),\n            description: t('setting.scrobble', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            title: t('setting.scrobble', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <Slider\n                    aria-label=\"Scrobble percentage\"\n                    defaultValue={settings.scrobble.scrobbleAtPercentage}\n                    label={`${settings.scrobble.scrobbleAtPercentage}%`}\n                    max={90}\n                    min={25}\n                    onChange={(e) => {\n                        setSettings({\n                            playback: {\n                                scrobble: {\n                                    scrobbleAtPercentage: e,\n                                },\n                            },\n                        });\n                    }}\n                    w={100}\n                />\n            ),\n            description: t('setting.minimumScrobblePercentage', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            title: t('setting.minimumScrobblePercentage', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <NumberInput\n                    aria-label=\"Scrobble duration in seconds\"\n                    defaultValue={settings.scrobble.scrobbleAtDuration}\n                    max={1200}\n                    min={0}\n                    onChange={(e) => {\n                        if (e === '') return;\n                        setSettings({\n                            playback: {\n                                scrobble: {\n                                    scrobbleAtDuration: Number(e),\n                                },\n                            },\n                        });\n                    }}\n                    width={75}\n                />\n            ),\n            description: t('setting.minimumScrobbleSeconds', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            title: t('setting.minimumScrobbleSeconds', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <Switch\n                    aria-label=\"Toggle notify\"\n                    defaultChecked={settings.scrobble.notify}\n                    onChange={async (e) => {\n                        if (Notification.permission === 'denied') {\n                            toast.error({\n                                message: t('error.notificationDenied', {\n                                    postProcess: 'sentenceCase',\n                                }),\n                            });\n                            return;\n                        }\n\n                        if (Notification.permission !== 'granted') {\n                            const permissions = await Notification.requestPermission();\n                            if (permissions !== 'granted') {\n                                toast.error({\n                                    message: t('error.notificationDenied', {\n                                        postProcess: 'sentenceCase',\n                                    }),\n                                });\n                                return;\n                            }\n                        }\n\n                        setSettings({\n                            playback: {\n                                scrobble: {\n                                    notify: e.currentTarget.checked,\n                                },\n                            },\n                        });\n                    }}\n                />\n            ),\n            description: t('setting.notify', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: !('Notification' in window),\n            title: t('setting.notify', { postProcess: 'sentenceCase' }),\n        },\n    ];\n\n    return (\n        <SettingsSection\n            options={scrobbleOptions}\n            title={t('page.setting.scrobble', { postProcess: 'sentenceCase' })}\n        />\n    );\n});\n"
  },
  {
    "path": "src/renderer/features/settings/components/general/sidebar-reorder.tsx",
    "content": "import { useMemo } from 'react';\n\nimport { DraggableItems } from '/@/renderer/features/settings/components/general/draggable-items';\nimport {\n    sidebarItems as defaultSidebarItems,\n    SidebarItem,\n    SidebarItemType,\n    useGeneralSettings,\n    useSettingsStoreActions,\n} from '/@/renderer/store';\n\nconst SIDEBAR_ITEMS: Array<[string, string]> = [\n    [SidebarItem.ALBUMS, 'page.sidebar.albums'],\n    [SidebarItem.ARTISTS, 'page.sidebar.albumArtists'],\n    [SidebarItem.ARTISTS_ALL, 'page.sidebar.artists'],\n    [SidebarItem.FAVORITES, 'page.sidebar.favorites'],\n    [SidebarItem.FOLDERS, 'page.sidebar.folders'],\n    [SidebarItem.GENRES, 'page.sidebar.genres'],\n    [SidebarItem.HOME, 'page.sidebar.home'],\n    [SidebarItem.NOW_PLAYING, 'page.sidebar.nowPlaying'],\n    [SidebarItem.PLAYLISTS, 'page.sidebar.playlists'],\n    [SidebarItem.COLLECTIONS, 'page.sidebar.collections'],\n    [SidebarItem.RADIO, 'page.sidebar.radio'],\n    [SidebarItem.SEARCH, 'page.sidebar.search'],\n    [SidebarItem.SETTINGS, 'page.sidebar.settings'],\n    [SidebarItem.TRACKS, 'page.sidebar.tracks'],\n];\n\nexport const SidebarReorder = () => {\n    const { sidebarItems } = useGeneralSettings();\n    const { setSidebarItems } = useSettingsStoreActions();\n\n    const mergedSidebarItems = useMemo(() => {\n        const settingsMap = new Map(sidebarItems.map((item) => [item.id, item]));\n        const defaultMap = new Map(defaultSidebarItems.map((item) => [item.id, item]));\n\n        const merged = sidebarItems.map((item) => ({\n            ...item,\n            id: item.id,\n        }));\n\n        SIDEBAR_ITEMS.forEach(([itemId]) => {\n            if (!settingsMap.has(itemId)) {\n                const defaultItem = defaultMap.get(itemId);\n                merged.push({\n                    disabled: true,\n                    id: itemId,\n                    label: defaultItem?.label ?? itemId,\n                    route: defaultItem?.route ?? '',\n                });\n            }\n        });\n\n        // Remove any duplicates\n        const uniqueMerged = merged.filter(\n            (item, index, self) => index === self.findIndex((t) => t.id === item.id),\n        );\n\n        return uniqueMerged;\n    }, [sidebarItems]);\n\n    return (\n        <DraggableItems\n            description=\"setting.sidebarConfiguration\"\n            itemLabels={SIDEBAR_ITEMS}\n            items={mergedSidebarItems as unknown as SidebarItemType[]}\n            setItems={setSidebarItems}\n            title=\"setting.sidebarConfiguration\"\n        />\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/settings/components/general/sidebar-settings.tsx",
    "content": "import { ChangeEvent, memo, useEffect, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport { SidebarReorder } from '/@/renderer/features/settings/components/general/sidebar-reorder';\nimport {\n    SettingOption,\n    SettingsSection,\n} from '/@/renderer/features/settings/components/settings-section';\nimport { useGeneralSettings, useSettingsStoreActions } from '/@/renderer/store';\nimport { Switch } from '/@/shared/components/switch/switch';\nimport { TextInput } from '/@/shared/components/text-input/text-input';\nimport { useDebouncedCallback } from '/@/shared/hooks/use-debounced-callback';\n\nexport const SidebarSettings = memo(() => {\n    const { t } = useTranslation();\n    const settings = useGeneralSettings();\n    const { setSettings } = useSettingsStoreActions();\n\n    const handleSetSidebarPlaylistList = (e: ChangeEvent<HTMLInputElement>) => {\n        setSettings({\n            general: {\n                sidebarPlaylistList: e.target.checked,\n            },\n        });\n    };\n\n    const handleSetSidebarPlaylistSorting = (e: ChangeEvent<HTMLInputElement>) => {\n        setSettings({\n            general: {\n                sidebarPlaylistSorting: e.target.checked,\n            },\n        });\n    };\n\n    const handleSetSidebarCollapsedNavigation = (e: ChangeEvent<HTMLInputElement>) => {\n        setSettings({\n            general: {\n                sidebarCollapsedNavigation: e.target.checked,\n            },\n        });\n    };\n\n    const [localFilterRegex, setLocalFilterRegex] = useState(\n        settings.sidebarPlaylistListFilterRegex,\n    );\n\n    useEffect(() => {\n        setLocalFilterRegex(settings.sidebarPlaylistListFilterRegex);\n    }, [settings.sidebarPlaylistListFilterRegex]);\n\n    const debouncedSetFilterRegex = useDebouncedCallback((value: string) => {\n        setSettings({\n            general: {\n                sidebarPlaylistListFilterRegex: value,\n            },\n        });\n    }, 500);\n\n    const options: SettingOption[] = [\n        {\n            control: (\n                <Switch\n                    checked={settings.sidebarPlaylistList}\n                    onChange={handleSetSidebarPlaylistList}\n                />\n            ),\n            description: t('setting.sidebarPlaylistList', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            title: t('setting.sidebarPlaylistList', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <TextInput\n                    onChange={(e) => {\n                        const value = e.currentTarget.value;\n                        setLocalFilterRegex(value);\n                        debouncedSetFilterRegex(value);\n                    }}\n                    placeholder={t('setting.sidebarPlaylistListFilterRegex_placeholder', {\n                        postProcess: 'sentenceCase',\n                    })}\n                    value={localFilterRegex}\n                />\n            ),\n            description: t('setting.sidebarPlaylistListFilterRegex', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            title: t('setting.sidebarPlaylistListFilterRegex', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <Switch\n                    checked={settings.sidebarPlaylistSorting}\n                    onChange={handleSetSidebarPlaylistSorting}\n                />\n            ),\n            description: t('setting.sidebarPlaylistSorting', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            title: t('setting.sidebarPlaylistSorting', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <Switch\n                    checked={settings.sidebarCollapsedNavigation}\n                    onChange={handleSetSidebarCollapsedNavigation}\n                />\n            ),\n            description: t('setting.sidebarCollapsedNavigation', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            title: t('setting.sidebarCollapsedNavigation', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <Switch\n                    aria-label=\"Show lyrics in attached play queue\"\n                    defaultChecked={settings.showLyricsInSidebar}\n                    onChange={(e) => {\n                        setSettings({\n                            general: {\n                                showLyricsInSidebar: e.currentTarget.checked,\n                            },\n                        });\n                    }}\n                />\n            ),\n            description: t('setting.showLyricsInSidebar', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            title: t('setting.showLyricsInSidebar', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <Switch\n                    aria-label=\"Show visualizer in sidebar\"\n                    defaultChecked={settings.showVisualizerInSidebar}\n                    onChange={(e) => {\n                        setSettings({\n                            general: {\n                                showVisualizerInSidebar: e.currentTarget.checked,\n                            },\n                        });\n                    }}\n                />\n            ),\n            description: t('setting.showVisualizerInSidebar', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            title: t('setting.showVisualizerInSidebar', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <Switch\n                    aria-label=\"Combine lyrics and visualizer\"\n                    defaultChecked={settings.combinedLyricsAndVisualizer}\n                    onChange={(e) => {\n                        setSettings({\n                            general: {\n                                combinedLyricsAndVisualizer: e.currentTarget.checked,\n                            },\n                        });\n                    }}\n                />\n            ),\n            description: t('setting.combinedLyricsAndVisualizer', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            title: t('setting.combinedLyricsAndVisualizer', { postProcess: 'sentenceCase' }),\n        },\n    ];\n\n    return (\n        <SettingsSection\n            extra={<SidebarReorder />}\n            options={options}\n            title={t('page.setting.sidebar', { postProcess: 'sentenceCase' })}\n        />\n    );\n});\n"
  },
  {
    "path": "src/renderer/features/settings/components/general/theme-settings.tsx",
    "content": "import isElectron from 'is-electron';\nimport { memo, useMemo } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport i18n from '/@/i18n/i18n';\nimport { StylesSettings } from '/@/renderer/features/settings/components/advanced/styles-settings';\nimport {\n    SettingOption,\n    SettingsSection,\n} from '/@/renderer/features/settings/components/settings-section';\nimport { useGeneralSettings, useSettingsStoreActions } from '/@/renderer/store/settings.store';\nimport { THEME_DATA, useSetColorScheme } from '/@/renderer/themes/use-app-theme';\nimport { ColorInput } from '/@/shared/components/color-input/color-input';\nimport { Group } from '/@/shared/components/group/group';\nimport { Select } from '/@/shared/components/select/select';\nimport { Slider } from '/@/shared/components/slider/slider';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { Switch } from '/@/shared/components/switch/switch';\nimport { getAppTheme } from '/@/shared/themes/app-theme';\nimport { AppTheme } from '/@/shared/themes/app-theme-types';\n\nconst localSettings = isElectron() ? window.api.localSettings : null;\n\nconst getThemeSwatchColors = (theme: AppTheme) => {\n    const themeConfig = getAppTheme(theme);\n    return {\n        background: themeConfig.colors?.background || 'rgb(0, 0, 0)',\n        foreground: themeConfig.colors?.foreground || 'rgb(255, 255, 255)',\n        primary:\n            themeConfig.colors?.primary ||\n            themeConfig.colors?.['state-info'] ||\n            'rgb(53, 116, 252)',\n        surface: themeConfig.colors?.surface || themeConfig.colors?.background || 'rgb(0, 0, 0)',\n    };\n};\n\nconst getGroupedThemeData = () => {\n    const darkThemes = THEME_DATA.filter((theme) => theme.type === 'dark').sort((a, b) =>\n        a.label.localeCompare(b.label),\n    );\n    const lightThemes = THEME_DATA.filter((theme) => theme.type === 'light').sort((a, b) =>\n        a.label.localeCompare(b.label),\n    );\n\n    return [\n        {\n            group: i18n.t('setting.themeDark', { postProcess: 'sentenceCase' }),\n            items: darkThemes,\n        },\n        {\n            group: i18n.t('setting.themeLight', { postProcess: 'sentenceCase' }),\n            items: lightThemes,\n        },\n    ];\n};\n\nconst ColorSwatch = ({ color }: { color: string }) => {\n    return (\n        <div\n            style={{\n                backgroundColor: color,\n                border: '1px solid rgba(0, 0, 0, 0.1)',\n                borderRadius: '3px',\n                boxShadow: '0 0 0 1px rgba(0, 0, 0, 0.05)',\n                height: '14px',\n                width: '14px',\n            }}\n        />\n    );\n};\n\nconst renderThemeOption = ({ option }: { option: { label: string; value: string } }) => {\n    const themeValue = option.value as AppTheme;\n    const colors = getThemeSwatchColors(themeValue);\n\n    return (\n        <Group gap=\"sm\" style={{ alignItems: 'center', flex: 1 }}>\n            <Group gap={4} style={{ alignItems: 'center', flexShrink: 0 }}>\n                <ColorSwatch color={String(colors.background)} />\n                <ColorSwatch color={String(colors.surface)} />\n                <ColorSwatch color={String(colors.foreground)} />\n                <ColorSwatch color={String(colors.primary)} />\n            </Group>\n            <span style={{ flex: 1 }}>{option.label}</span>\n        </Group>\n    );\n};\n\nexport const ThemeSettings = memo(() => {\n    const { t } = useTranslation();\n    const settings = useGeneralSettings();\n    const { setSettings } = useSettingsStoreActions();\n    const { setColorScheme } = useSetColorScheme();\n\n    const groupedThemeData = useMemo(() => getGroupedThemeData(), []);\n\n    const themeOptions: SettingOption[] = [\n        {\n            control: (\n                <Switch\n                    defaultChecked={settings.followSystemTheme}\n                    onChange={(e) => {\n                        setSettings({\n                            general: {\n                                followSystemTheme: e.currentTarget.checked,\n                            },\n                        });\n\n                        if (localSettings) {\n                            localSettings.themeSet(\n                                e.currentTarget.checked\n                                    ? 'system'\n                                    : (getAppTheme(settings.theme).mode ?? 'dark'),\n                            );\n                        }\n                    }}\n                />\n            ),\n            description: t('setting.useSystemTheme', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: false,\n            title: t('setting.useSystemTheme', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <Select\n                    data={groupedThemeData}\n                    defaultValue={settings.theme}\n                    onChange={(e) => {\n                        const theme = e as AppTheme;\n\n                        setSettings({\n                            general: {\n                                theme,\n                            },\n                        });\n\n                        const colorScheme = getAppTheme(theme).mode ?? 'dark';\n\n                        setColorScheme(colorScheme);\n\n                        if (localSettings) {\n                            localSettings.themeSet(colorScheme);\n                        }\n                    }}\n                    renderOption={renderThemeOption}\n                    width={240}\n                />\n            ),\n            description: t('setting.theme', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: settings.followSystemTheme,\n            title: t('setting.theme', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <Select\n                    data={groupedThemeData}\n                    defaultValue={settings.themeDark}\n                    onChange={(e) => {\n                        setSettings({\n                            general: {\n                                themeDark: e as AppTheme,\n                            },\n                        });\n                    }}\n                    renderOption={renderThemeOption}\n                    width={240}\n                />\n            ),\n            description: t('setting.themeDark', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: !settings.followSystemTheme,\n            title: t('setting.themeDark', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <Select\n                    data={groupedThemeData}\n                    defaultValue={settings.themeLight}\n                    onChange={(e) => {\n                        setSettings({\n                            general: {\n                                themeLight: e as AppTheme,\n                            },\n                        });\n                    }}\n                    renderOption={renderThemeOption}\n                    width={240}\n                />\n            ),\n            description: t('setting.themeLight', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: !settings.followSystemTheme,\n            title: t('setting.themeLight', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <Switch\n                    checked={settings.useThemeAccentColor}\n                    onChange={(e) => {\n                        setSettings({\n                            general: {\n                                useThemeAccentColor: e.currentTarget.checked,\n                            },\n                        });\n                    }}\n                />\n            ),\n            description: t('setting.useThemeAccentColor', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: false,\n            title: t('setting.useThemeAccentColor', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <Stack align=\"center\">\n                    <ColorInput\n                        defaultValue={settings.accent}\n                        disabled={settings.useThemeAccentColor}\n                        format=\"rgb\"\n                        onChangeEnd={(e) => {\n                            setSettings({\n                                general: {\n                                    accent: e,\n                                },\n                            });\n                        }}\n                        swatches={[\n                            'rgb(53, 116, 252)',\n                            'rgb(240, 170, 22)',\n                            'rgb(29, 185, 84)',\n                            'rgb(214, 81, 63)',\n                            'rgb(170, 110, 216)',\n                        ]}\n                        swatchesPerRow={5}\n                        withEyeDropper={false}\n                    />\n                </Stack>\n            ),\n            description: t('setting.accentColor', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            title: t('setting.accentColor', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <Switch\n                    checked={settings.useThemePrimaryShade}\n                    onChange={(e) => {\n                        setSettings({\n                            general: {\n                                useThemePrimaryShade: e.currentTarget.checked,\n                            },\n                        });\n                    }}\n                />\n            ),\n            description: t('setting.useThemePrimaryShade', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: false,\n            title: t('setting.useThemePrimaryShade', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <Slider\n                    defaultValue={settings.primaryShade}\n                    label={(value) => value}\n                    max={9}\n                    min={0}\n                    onChangeEnd={(value) => {\n                        setSettings({\n                            general: {\n                                primaryShade: value,\n                            },\n                        });\n                    }}\n                    step={1}\n                    w={120}\n                />\n            ),\n            description: t('setting.primaryShade', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: settings.useThemePrimaryShade,\n            title: t('setting.primaryShade', { postProcess: 'sentenceCase' }),\n        },\n    ];\n\n    return (\n        <SettingsSection\n            extra={<StylesSettings />}\n            options={themeOptions}\n            title={t('page.setting.theme', { postProcess: 'sentenceCase' })}\n        />\n    );\n});\n"
  },
  {
    "path": "src/renderer/features/settings/components/hotkeys/hotkey-manager-settings.tsx",
    "content": "import isElectron from 'is-electron';\nimport debounce from 'lodash/debounce';\nimport { ChangeEvent, KeyboardEvent, memo, useCallback, useMemo, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport styles from './hotkeys-manager-settings.module.css';\n\nimport i18n from '/@/i18n/i18n';\nimport { SettingsOptions } from '/@/renderer/features/settings/components/settings-option';\nimport {\n    SettingOption,\n    SettingsSection,\n} from '/@/renderer/features/settings/components/settings-section';\nimport { useSettingSearchContext } from '/@/renderer/features/settings/context/search-context';\nimport { BindingActions, useHotkeySettings, useSettingsStoreActions } from '/@/renderer/store';\nimport { ActionIcon } from '/@/shared/components/action-icon/action-icon';\nimport { Checkbox } from '/@/shared/components/checkbox/checkbox';\nimport { Icon } from '/@/shared/components/icon/icon';\nimport { Table } from '/@/shared/components/table/table';\nimport { TextInput } from '/@/shared/components/text-input/text-input';\n\nconst ipc = isElectron() ? window.api.ipc : null;\n\nconst BINDINGS_MAP: Record<BindingActions, string> = {\n    browserBack: i18n.t('setting.hotkey', { context: 'browserBack', postProcess: 'sentenceCase' }),\n    browserForward: i18n.t('setting.hotkey', {\n        context: 'browserForward',\n        postProcess: 'sentenceCase',\n    }),\n    favoriteCurrentAdd: i18n.t('setting.hotkey', {\n        context: 'favoriteCurrentSong',\n        postProcess: 'sentenceCase',\n    }),\n    favoriteCurrentRemove: i18n.t('setting.hotkey', {\n        context: 'unfavoriteCurrentSong',\n        postProcess: 'sentenceCase',\n    }),\n    favoriteCurrentToggle: i18n.t('setting.hotkey', {\n        context: 'toggleCurrentSongFavorite',\n        postProcess: 'sentenceCase',\n    }),\n    favoritePreviousAdd: i18n.t('setting.hotkey', {\n        context: 'favoritePreviousSong',\n        postProcess: 'sentenceCase',\n    }),\n    favoritePreviousRemove: i18n.t('setting.hotkey', {\n        context: 'unfavoritePreviousSong',\n        postProcess: 'sentenceCase',\n    }),\n    favoritePreviousToggle: i18n.t('setting.hotkey', {\n        context: 'togglePreviousSongFavorite',\n        postProcess: 'sentenceCase',\n    }),\n    globalSearch: i18n.t('setting.hotkey', {\n        context: 'globalSearch',\n        postProcess: 'sentenceCase',\n    }),\n    listNavigateToPage: i18n.t('setting.hotkey', {\n        context: 'listNavigateToPage',\n        postProcess: 'sentenceCase',\n    }),\n    listPlayDefault: i18n.t('setting.hotkey', {\n        context: 'listPlayDefault',\n        postProcess: 'sentenceCase',\n    }),\n    listPlayLast: i18n.t('setting.hotkey', {\n        context: 'listPlayLast',\n        postProcess: 'sentenceCase',\n    }),\n    listPlayNext: i18n.t('setting.hotkey', {\n        context: 'listPlayNext',\n        postProcess: 'sentenceCase',\n    }),\n    listPlayNow: i18n.t('setting.hotkey', { context: 'listPlayNow', postProcess: 'sentenceCase' }),\n    localSearch: i18n.t('setting.hotkey', { context: 'localSearch', postProcess: 'sentenceCase' }),\n    navigateHome: i18n.t('setting.hotkey', {\n        context: 'navigateHome',\n        postProcess: 'sentenceCase',\n    }),\n    next: i18n.t('setting.hotkey', { context: 'playbackNext', postProcess: 'sentenceCase' }),\n    pause: i18n.t('setting.hotkey', { context: 'playbackPause', postProcess: 'sentenceCase' }),\n    play: i18n.t('setting.hotkey', { context: 'playbackPlay', postProcess: 'sentenceCase' }),\n    playPause: i18n.t('setting.hotkey', {\n        context: 'playbackPlayPause',\n        postProcess: 'sentenceCase',\n    }),\n    previous: i18n.t('setting.hotkey', {\n        context: 'playbackPrevious',\n        postProcess: 'sentenceCase',\n    }),\n    rate0: i18n.t('setting.hotkey', { context: 'rate0', postProcess: 'sentenceCase' }),\n    rate1: i18n.t('setting.hotkey', { context: 'rate1', postProcess: 'sentenceCase' }),\n    rate2: i18n.t('setting.hotkey', { context: 'rate2', postProcess: 'sentenceCase' }),\n    rate3: i18n.t('setting.hotkey', { context: 'rate3', postProcess: 'sentenceCase' }),\n    rate4: i18n.t('setting.hotkey', { context: 'rate4', postProcess: 'sentenceCase' }),\n    rate5: i18n.t('setting.hotkey', { context: 'rate5', postProcess: 'sentenceCase' }),\n    skipBackward: i18n.t('setting.hotkey', {\n        context: 'skipBackward',\n        postProcess: 'sentenceCase',\n    }),\n    skipForward: i18n.t('setting.hotkey', { context: 'skipForward', postProcess: 'sentenceCase' }),\n    stop: i18n.t('setting.hotkey', { context: 'playbackStop', postProcess: 'sentenceCase' }),\n    toggleFullscreenPlayer: i18n.t('setting.hotkey', {\n        context: 'toggleFullScreenPlayer',\n        postProcess: 'sentenceCase',\n    }),\n    toggleQueue: i18n.t('setting.hotkey', { context: 'toggleQueue', postProcess: 'sentenceCase' }),\n    toggleRepeat: i18n.t('setting.hotkey', {\n        context: 'toggleRepeat',\n        postProcess: 'sentenceCase',\n    }),\n    toggleShuffle: i18n.t('setting.hotkey', {\n        context: 'toggleShuffle',\n        postProcess: 'sentenceCase',\n    }),\n    volumeDown: i18n.t('setting.hotkey', { context: 'volumeDown', postProcess: 'sentenceCase' }),\n    volumeMute: i18n.t('setting.hotkey', { context: 'volumeMute', postProcess: 'sentenceCase' }),\n    volumeUp: i18n.t('setting.hotkey', { context: 'volumeUp', postProcess: 'sentenceCase' }),\n    zoomIn: i18n.t('setting.hotkey', { context: 'zoomIn', postProcess: 'sentenceCase' }),\n    zoomOut: i18n.t('setting.hotkey', { context: 'zoomOut', postProcess: 'sentenceCase' }),\n};\n\nexport const HotkeyManagerSettings = memo(() => {\n    const { t } = useTranslation();\n    const { bindings } = useHotkeySettings();\n    const { setSettings } = useSettingsStoreActions();\n    const [selected, setSelected] = useState<BindingActions | null>(null);\n    const keyword = useSettingSearchContext();\n\n    const debouncedSetHotkey = debounce(\n        (binding: BindingActions, e: KeyboardEvent<HTMLInputElement>) => {\n            e.preventDefault();\n            const IGNORED_KEYS = ['Control', 'Alt', 'Shift', 'Meta', ' ', 'Escape'];\n            const keys: string[] = [];\n            if (e.ctrlKey) keys.push('mod');\n            if (e.altKey) keys.push('alt');\n            if (e.shiftKey) keys.push('shift');\n            if (e.metaKey) keys.push('meta');\n            if (e.key === ' ') keys.push('space');\n            if (!IGNORED_KEYS.includes(e.key)) {\n                if (e.code.includes('Numpad')) {\n                    if (e.key === '+') keys.push('numpadadd');\n                    else if (e.key === '-') keys.push('numpadsubtract');\n                    else if (e.key === '*') keys.push('numpadmultiply');\n                    else if (e.key === '/') keys.push('numpaddivide');\n                    else if (e.key === '.') keys.push('numpaddecimal');\n                    else keys.push(`numpad${e.key}`.toLowerCase());\n                } else if (e.key === '+') {\n                    keys.push('equal');\n                } else {\n                    keys.push(e.key?.toLowerCase());\n                }\n            }\n\n            const bindingString = keys.join('+');\n\n            const updatedBindings = {\n                ...bindings,\n                [binding]: { ...bindings[binding], hotkey: bindingString },\n            };\n\n            setSettings({\n                hotkeys: {\n                    bindings: updatedBindings,\n                },\n            });\n\n            ipc?.send('set-global-shortcuts', updatedBindings);\n        },\n        20,\n    );\n\n    const handleSetHotkey = useCallback(\n        (binding: BindingActions, e: KeyboardEvent<HTMLInputElement>) => {\n            debouncedSetHotkey(binding, e);\n        },\n        [debouncedSetHotkey],\n    );\n\n    const handleSetGlobalHotkey = useCallback(\n        (binding: BindingActions, e: ChangeEvent<HTMLInputElement>) => {\n            const updatedBindings = {\n                ...bindings,\n                [binding]: { ...bindings[binding], isGlobal: e.currentTarget.checked },\n            };\n\n            setSettings({\n                hotkeys: {\n                    bindings: updatedBindings,\n                },\n            });\n\n            ipc?.send('set-global-shortcuts', updatedBindings);\n        },\n        [bindings, setSettings],\n    );\n\n    const handleClearHotkey = useCallback(\n        (binding: BindingActions) => {\n            const updatedBindings = {\n                ...bindings,\n                [binding]: { ...bindings[binding], hotkey: '', isGlobal: false },\n            };\n\n            setSettings({\n                hotkeys: {\n                    bindings: updatedBindings,\n                },\n            });\n\n            ipc?.send('set-global-shortcuts', updatedBindings);\n        },\n        [bindings, setSettings],\n    );\n\n    const duplicateHotkeyMap = useMemo(() => {\n        const countPerHotkey = Object.values(bindings).reduce(\n            (acc, key) => {\n                const hotkey = key.hotkey;\n                if (!hotkey) return acc;\n\n                if (acc[hotkey]) {\n                    acc[hotkey] += 1;\n                } else {\n                    acc[hotkey] = 1;\n                }\n\n                return acc;\n            },\n            {} as Record<string, number>,\n        );\n\n        const duplicateKeys = Object.keys(countPerHotkey).filter((key) => countPerHotkey[key] > 1);\n\n        return duplicateKeys;\n    }, [bindings]);\n\n    const filteredBindings = useMemo(() => {\n        const base = Object.keys(bindings);\n\n        if (keyword === '') {\n            return base.filter((binding) => BINDINGS_MAP[binding as keyof typeof BINDINGS_MAP]);\n        }\n\n        return base.filter((binding) => {\n            const item = BINDINGS_MAP[binding as keyof typeof BINDINGS_MAP];\n            if (!item) return false;\n\n            return item.toLocaleLowerCase().includes(keyword);\n        });\n    }, [bindings, keyword]);\n\n    const options: SettingOption[] = [];\n\n    return (\n        <SettingsSection\n            extra={\n                <>\n                    <SettingsOptions\n                        control={<></>}\n                        description={t('setting.applicationHotkeys', {\n                            context: 'description',\n                            postProcess: 'sentenceCase',\n                        })}\n                        title={t('setting.applicationHotkeys', { postProcess: 'sentenceCase' })}\n                    />\n                    <div className={styles.container}>\n                        <Table withColumnBorders withRowBorders>\n                            <Table.Tbody>\n                                {filteredBindings.map((binding) => (\n                                    <Table.Tr key={`hotkey-${binding}`}>\n                                        <Table.Td style={{ userSelect: 'none' }}>\n                                            {BINDINGS_MAP[binding as keyof typeof BINDINGS_MAP]}\n                                        </Table.Td>\n                                        <Table.Td>\n                                            <TextInput\n                                                id={`hotkey-${binding}`}\n                                                leftSection={<Icon icon=\"keyboard\" />}\n                                                onBlur={() => setSelected(null)}\n                                                onChange={() => {}}\n                                                onKeyDownCapture={(e) => {\n                                                    if (selected !== (binding as BindingActions))\n                                                        return;\n                                                    handleSetHotkey(binding as BindingActions, e);\n                                                }}\n                                                readOnly\n                                                rightSection={\n                                                    <ActionIcon\n                                                        icon=\"edit\"\n                                                        onClick={() => {\n                                                            setSelected(binding as BindingActions);\n                                                            document\n                                                                .getElementById(`hotkey-${binding}`)\n                                                                ?.focus();\n                                                        }}\n                                                        variant=\"transparent\"\n                                                    />\n                                                }\n                                                style={{\n                                                    opacity:\n                                                        selected === (binding as BindingActions)\n                                                            ? 0.8\n                                                            : 1,\n                                                    outline: duplicateHotkeyMap.includes(\n                                                        bindings[\n                                                            binding as keyof typeof BINDINGS_MAP\n                                                        ].hotkey!,\n                                                    )\n                                                        ? '1px dashed red'\n                                                        : undefined,\n                                                }}\n                                                value={\n                                                    bindings[binding as keyof typeof BINDINGS_MAP]\n                                                        .hotkey\n                                                }\n                                            />\n                                        </Table.Td>\n                                        {isElectron() && (\n                                            <Table.Td>\n                                                <Checkbox\n                                                    checked={\n                                                        bindings[\n                                                            binding as keyof typeof BINDINGS_MAP\n                                                        ].isGlobal\n                                                    }\n                                                    disabled={\n                                                        bindings[\n                                                            binding as keyof typeof BINDINGS_MAP\n                                                        ].hotkey === ''\n                                                    }\n                                                    onChange={(e) =>\n                                                        handleSetGlobalHotkey(\n                                                            binding as BindingActions,\n                                                            e,\n                                                        )\n                                                    }\n                                                    size=\"md\"\n                                                    style={{\n                                                        opacity: bindings[\n                                                            binding as keyof typeof BINDINGS_MAP\n                                                        ].allowGlobal\n                                                            ? 1\n                                                            : 0,\n                                                    }}\n                                                />\n                                            </Table.Td>\n                                        )}\n                                        {bindings[binding as keyof typeof BINDINGS_MAP].hotkey && (\n                                            <Table.Td>\n                                                <ActionIcon\n                                                    icon=\"x\"\n                                                    iconProps={{\n                                                        color: 'error',\n                                                    }}\n                                                    onClick={() =>\n                                                        handleClearHotkey(binding as BindingActions)\n                                                    }\n                                                    variant=\"transparent\"\n                                                />\n                                            </Table.Td>\n                                        )}\n                                    </Table.Tr>\n                                ))}\n                            </Table.Tbody>\n                        </Table>\n                    </div>\n                </>\n            }\n            options={options}\n        />\n    );\n});\n"
  },
  {
    "path": "src/renderer/features/settings/components/hotkeys/hotkeys-manager-settings.module.css",
    "content": ".container {\n    display: flex;\n    flex-direction: column;\n    gap: var(--theme-spacing-md);\n    justify-content: center;\n    width: 100%;\n}\n"
  },
  {
    "path": "src/renderer/features/settings/components/hotkeys/hotkeys-tab.tsx",
    "content": "import isElectron from 'is-electron';\nimport { memo } from 'react';\nimport { Fragment } from 'react/jsx-runtime';\n\nimport { HotkeyManagerSettings } from '/@/renderer/features/settings/components/hotkeys/hotkey-manager-settings';\nimport { MediaSessionSettings } from '/@/renderer/features/settings/components/hotkeys/media-session-settings';\nimport { WindowHotkeySettings } from '/@/renderer/features/settings/components/hotkeys/window-hotkey-settings';\nimport { Divider } from '/@/shared/components/divider/divider';\nimport { Stack } from '/@/shared/components/stack/stack';\n\nconst sections = [\n    { component: WindowHotkeySettings, hidden: !isElectron(), key: 'window' },\n    { component: MediaSessionSettings, key: 'media-session' },\n    { component: HotkeyManagerSettings, key: 'hotkey-manager' },\n];\n\nexport const HotkeysTab = memo(() => {\n    return (\n        <Stack gap=\"md\">\n            {sections.map(({ component: Section, hidden, key }, index) => (\n                <Fragment key={key}>\n                    {!hidden && <Section />}\n                    {index < sections.length - 1 && <Divider />}\n                </Fragment>\n            ))}\n        </Stack>\n    );\n});\n"
  },
  {
    "path": "src/renderer/features/settings/components/hotkeys/media-session-settings.tsx",
    "content": "import isElectron from 'is-electron';\nimport { memo } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport {\n    SettingOption,\n    SettingsSection,\n} from '/@/renderer/features/settings/components/settings-section';\nimport { openRestartRequiredToast } from '/@/renderer/features/settings/restart-toast';\nimport { usePlaybackSettings, useSettingsStoreActions } from '/@/renderer/store/settings.store';\nimport { Switch } from '/@/shared/components/switch/switch';\nimport { PlayerType } from '/@/shared/types/types';\n\nconst isLinux = isElectron() ? window.api.utils.isLinux() : false;\nconst isDesktop = isElectron();\nconst localSettings = isElectron() ? window.api.localSettings : null;\n\nexport const MediaSessionSettings = memo(() => {\n    const { t } = useTranslation();\n    const { mediaSession, type: playbackType } = usePlaybackSettings();\n    const { setSettings } = useSettingsStoreActions();\n\n    function handleMediaSessionChange(e: boolean) {\n        // If media session is enabled, disable global media hotkeys\n        if (e) {\n            localSettings!.set('global_media_hotkeys', false);\n            setSettings({\n                hotkeys: {\n                    globalMediaHotkeys: false,\n                },\n            });\n        }\n\n        localSettings!.set('mediaSession', e);\n        setSettings({\n            playback: {\n                mediaSession: e,\n            },\n        });\n\n        // Restart is always required because the media session is a startup setting\n        openRestartRequiredToast();\n    }\n\n    const mediaSessionOptions: SettingOption[] = [\n        {\n            control: (\n                <Switch\n                    aria-label=\"Toggle media Session\"\n                    checked={mediaSession}\n                    disabled={isLinux || !isDesktop || playbackType !== PlayerType.WEB}\n                    onChange={(e) => handleMediaSessionChange(e.currentTarget.checked)}\n                />\n            ),\n            description: t('setting.mediaSession', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: isLinux || !isDesktop,\n            note: t('common.restartRequired', { postProcess: 'sentenceCase' }),\n            title: t('setting.mediaSession', { postProcess: 'sentenceCase' }),\n        },\n    ];\n\n    return <SettingsSection options={mediaSessionOptions} />;\n});\n"
  },
  {
    "path": "src/renderer/features/settings/components/hotkeys/window-hotkey-settings.tsx",
    "content": "import isElectron from 'is-electron';\nimport { memo } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport {\n    SettingOption,\n    SettingsSection,\n} from '/@/renderer/features/settings/components/settings-section';\nimport { openRestartRequiredToast } from '/@/renderer/features/settings/restart-toast';\nimport { useHotkeySettings, usePlaybackSettings, useSettingsStoreActions } from '/@/renderer/store';\nimport { Switch } from '/@/shared/components/switch/switch';\n\nconst localSettings = isElectron() ? window.api.localSettings : null;\n\nexport const WindowHotkeySettings = memo(() => {\n    const { t } = useTranslation();\n    const settings = useHotkeySettings();\n    const { setSettings } = useSettingsStoreActions();\n    const { mediaSession } = usePlaybackSettings();\n\n    const options: SettingOption[] = [\n        {\n            control: (\n                <Switch\n                    checked={settings.globalMediaHotkeys}\n                    disabled={!isElectron()}\n                    onChange={(e) => {\n                        localSettings!.set('global_media_hotkeys', e.currentTarget.checked);\n                        setSettings({\n                            hotkeys: {\n                                globalMediaHotkeys: e.currentTarget.checked,\n                            },\n                        });\n\n                        if (e.currentTarget.checked) {\n                            localSettings!.enableMediaKeys();\n                        } else {\n                            localSettings!.disableMediaKeys();\n                        }\n\n                        // Restart is required if media session was previously enabled\n                        // Though the global hotkey should override the media session, it's better to restart to be safe\n                        if (e.currentTarget.checked && mediaSession) {\n                            localSettings!.set('mediaSession', false);\n                            setSettings({\n                                playback: {\n                                    mediaSession: false,\n                                },\n                            });\n                            openRestartRequiredToast();\n                        }\n                    }}\n                />\n            ),\n            description: t('setting.globalMediaHotkeys', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: !isElectron(),\n            title: t('setting.globalMediaHotkeys', { postProcess: 'sentenceCase' }),\n        },\n    ];\n\n    return <SettingsSection options={options} />;\n});\n"
  },
  {
    "path": "src/renderer/features/settings/components/playback/audio-settings.tsx",
    "content": "import { t } from 'i18next';\nimport isElectron from 'is-electron';\nimport { memo, useEffect, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport {\n    SettingOption,\n    SettingsSection,\n} from '/@/renderer/features/settings/components/settings-section';\nimport { usePlaybackType, usePlayerStatus } from '/@/renderer/store';\nimport { usePlaybackSettings, useSettingsStoreActions } from '/@/renderer/store/settings.store';\nimport { Select } from '/@/shared/components/select/select';\nimport { Switch } from '/@/shared/components/switch/switch';\nimport { toast } from '/@/shared/components/toast/toast';\nimport { PlayerStatus, PlayerType } from '/@/shared/types/types';\n\nconst ipc = isElectron() ? window.api.ipc : null;\nconst mpvPlayer = isElectron() ? window.api.mpvPlayer : null;\n\nconst getAudioDevices = async () => {\n    const devices = await navigator.mediaDevices.enumerateDevices();\n    return (devices || []).filter((dev: MediaDeviceInfo) => dev.kind === 'audiooutput');\n};\n\nconst getMpvAudioDevices = async () => {\n    if (!mpvPlayer) {\n        console.log('mpvPlayer not found');\n        return [];\n    }\n\n    try {\n        return await mpvPlayer.getAudioDevices();\n    } catch (error) {\n        console.error('Failed to get MPV audio devices:', error);\n        return [];\n    }\n};\n\nexport type AudioDeviceOption = { label: string; value: string };\n\nexport const useAudioDevices = (playbackType: PlayerType) => {\n    const [audioDevices, setAudioDevices] = useState<AudioDeviceOption[]>([]);\n\n    useEffect(() => {\n        const fetchAudioDevices = async () => {\n            if (!isElectron()) {\n                return;\n            }\n\n            if (playbackType === PlayerType.WEB) {\n                getAudioDevices()\n                    .then((dev) => {\n                        const uniqueDevices = dev.filter(\n                            (d, index, self) =>\n                                index === self.findIndex((t) => t.deviceId === d.deviceId),\n                        );\n                        setAudioDevices(\n                            uniqueDevices.map((d) => ({ label: d.label, value: d.deviceId })),\n                        );\n                    })\n                    .catch(() =>\n                        toast.error({\n                            message: t('error.audioDeviceFetchError', {\n                                postProcess: 'sentenceCase',\n                            }),\n                        }),\n                    );\n            } else if (playbackType === PlayerType.LOCAL && mpvPlayer) {\n                try {\n                    const devices = await getMpvAudioDevices();\n                    const uniqueDevices = devices.filter(\n                        (d, index, self) => index === self.findIndex((t) => t.value === d.value),\n                    );\n                    setAudioDevices(uniqueDevices);\n                } catch {\n                    toast.error({\n                        message: t('error.audioDeviceFetchError', {\n                            postProcess: 'sentenceCase',\n                        }),\n                    });\n                }\n            }\n        };\n\n        fetchAudioDevices();\n    }, [playbackType]);\n\n    return audioDevices;\n};\n\nexport const AudioSettings = memo(() => {\n    const { t } = useTranslation();\n    const settings = usePlaybackSettings();\n    const { setSettings } = useSettingsStoreActions();\n    const status = usePlayerStatus();\n    const playbackType = usePlaybackType();\n\n    const audioDevices = useAudioDevices(playbackType);\n    const audioDeviceId =\n        playbackType === PlayerType.LOCAL ? settings.mpvAudioDeviceId : settings.audioDeviceId;\n\n    const audioOptions: SettingOption[] = [\n        {\n            control: (\n                <Select\n                    data={[\n                        {\n                            disabled: !isElectron(),\n                            label: 'MPV',\n                            value: PlayerType.LOCAL,\n                        },\n                        { label: 'Web', value: PlayerType.WEB },\n                    ]}\n                    defaultValue={settings.type}\n                    disabled={status === PlayerStatus.PLAYING}\n                    onChange={(e) => {\n                        setSettings({ playback: { type: e as PlayerType } });\n                        ipc?.send('settings-set', { property: 'playbackType', value: e });\n                    }}\n                />\n            ),\n            description: t('setting.audioPlayer', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: !isElectron(),\n            note:\n                status === PlayerStatus.PLAYING\n                    ? t('common.playerMustBePaused', { postProcess: 'sentenceCase' })\n                    : undefined,\n            title: t('setting.audioPlayer', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <Select\n                    clearable\n                    data={audioDevices}\n                    defaultValue={audioDeviceId}\n                    disabled={!isElectron()}\n                    onChange={(e) =>\n                        setSettings({\n                            playback:\n                                playbackType === PlayerType.LOCAL\n                                    ? { mpvAudioDeviceId: e }\n                                    : { audioDeviceId: e },\n                        })\n                    }\n                />\n            ),\n            description: t('setting.audioDevice', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: !isElectron(),\n            title: t('setting.audioDevice', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <Switch\n                    defaultChecked={settings.webAudio}\n                    onChange={(e) => {\n                        setSettings({\n                            playback: { webAudio: e.currentTarget.checked },\n                        });\n                    }}\n                />\n            ),\n            description: t('setting.webAudio', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: settings.type !== PlayerType.WEB,\n            note: t('common.restartRequired', { postProcess: 'sentenceCase' }),\n            title: t('setting.webAudio', {\n                postProcess: 'sentenceCase',\n            }),\n        },\n        {\n            control: (\n                <Switch\n                    defaultChecked={settings.preservePitch}\n                    onChange={(e) => {\n                        setSettings({\n                            playback: { preservePitch: e.currentTarget.checked },\n                        });\n                    }}\n                />\n            ),\n            description: t('setting.preservePitch', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: settings.type !== PlayerType.WEB,\n            title: t('setting.preservePitch', {\n                postProcess: 'sentenceCase',\n            }),\n        },\n        {\n            control: (\n                <Switch\n                    defaultChecked={settings.audioFadeOnStatusChange}\n                    onChange={(e) => {\n                        setSettings({\n                            playback: {\n                                audioFadeOnStatusChange: e.currentTarget.checked,\n                            },\n                        });\n                    }}\n                />\n            ),\n            description: t('setting.audioFadeOnStatusChange', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            title: t('setting.audioFadeOnStatusChange', {\n                postProcess: 'sentenceCase',\n            }),\n        },\n    ];\n\n    return (\n        <SettingsSection\n            options={audioOptions}\n            title={t('page.setting.audio', { postProcess: 'sentenceCase' })}\n        />\n    );\n});\n"
  },
  {
    "path": "src/renderer/features/settings/components/playback/auto-dj-settings.tsx",
    "content": "import { memo } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport {\n    SettingOption,\n    SettingsSection,\n} from '/@/renderer/features/settings/components/settings-section';\nimport { useAutoDJSettings, useSettingsStoreActions } from '/@/renderer/store/settings.store';\nimport { NumberInput } from '/@/shared/components/number-input/number-input';\n\nexport const AutoDJSettings = memo(() => {\n    const { t } = useTranslation();\n    const settings = useAutoDJSettings();\n    const { setSettings } = useSettingsStoreActions();\n\n    const autoDJOptions: SettingOption[] = [\n        {\n            control: (\n                <NumberInput\n                    aria-label=\"Auto DJ item count\"\n                    hideControls={false}\n                    max={50}\n                    min={1}\n                    onChange={(e) => {\n                        setSettings({\n                            autoDJ: {\n                                itemCount: Number(e),\n                            },\n                        });\n                    }}\n                    value={Number(settings.itemCount)}\n                />\n            ),\n            description: t('setting.autoDJ_itemCount', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            title: t('setting.autoDJ_itemCount', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <NumberInput\n                    aria-label=\"Auto DJ timing\"\n                    hideControls={false}\n                    max={5}\n                    min={1}\n                    onChange={(e) => {\n                        setSettings({\n                            autoDJ: {\n                                timing: Number(e),\n                            },\n                        });\n                    }}\n                    value={Number(settings.timing)}\n                />\n            ),\n            description: t('setting.autoDJ_timing', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            title: t('setting.autoDJ_timing', { postProcess: 'sentenceCase' }),\n        },\n    ];\n\n    return (\n        <SettingsSection\n            options={autoDJOptions}\n            title={t('setting.autoDJ', { postProcess: 'titleCase' })}\n        />\n    );\n});\n"
  },
  {
    "path": "src/renderer/features/settings/components/playback/mpv-properties.ts",
    "content": "import type { SettingsState } from '/@/renderer/store/settings.store';\n\nexport const getMpvSetting = (\n    key: keyof SettingsState['playback']['mpvProperties'],\n    value: any,\n) => {\n    switch (key) {\n        case 'audioExclusiveMode':\n            return { 'audio-exclusive': value || 'no' };\n        case 'audioSampleRateHz':\n            return { 'audio-samplerate': value };\n        case 'gaplessAudio':\n            return { 'gapless-audio': value || 'weak' };\n        case 'replayGainClip':\n            return { 'replaygain-clip': value || 'no' };\n        case 'replayGainFallbackDB':\n            return { 'replaygain-fallback': value };\n        case 'replayGainMode':\n            return { replaygain: value || 'no' };\n        case 'replayGainPreampDB':\n            return { 'replaygain-preamp': value || 0 };\n        default:\n            return { 'audio-format': value };\n    }\n};\n\nexport const getMpvProperties = (settings: SettingsState['playback']['mpvProperties']) => {\n    const properties: Record<string, any> = {\n        'audio-exclusive': settings.audioExclusiveMode || 'no',\n        'audio-samplerate':\n            settings.audioSampleRateHz === 0 ? undefined : settings.audioSampleRateHz,\n        'gapless-audio': settings.gaplessAudio || 'weak',\n        replaygain: settings.replayGainMode || 'no',\n        'replaygain-clip': settings.replayGainClip || 'no',\n        'replaygain-fallback': settings.replayGainFallbackDB,\n        'replaygain-preamp': settings.replayGainPreampDB || 0,\n    };\n\n    Object.keys(properties).forEach((key) =>\n        properties[key] === undefined ? delete properties[key] : {},\n    );\n\n    return properties;\n};\n"
  },
  {
    "path": "src/renderer/features/settings/components/playback/mpv-settings.tsx",
    "content": "import isElectron from 'is-electron';\nimport { memo, useEffect, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport { getMpvSetting } from './mpv-properties';\n\nimport { eventEmitter } from '/@/renderer/events/event-emitter';\nimport { usePlayer } from '/@/renderer/features/player/context/player-context';\nimport {\n    SettingOption,\n    SettingsSection,\n} from '/@/renderer/features/settings/components/settings-section';\nimport {\n    SettingsState,\n    usePlaybackSettings,\n    useSettingsStoreActions,\n} from '/@/renderer/store/settings.store';\nimport { ActionIcon } from '/@/shared/components/action-icon/action-icon';\nimport { Group } from '/@/shared/components/group/group';\nimport { NumberInput } from '/@/shared/components/number-input/number-input';\nimport { Select } from '/@/shared/components/select/select';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { Switch } from '/@/shared/components/switch/switch';\nimport { TextInput } from '/@/shared/components/text-input/text-input';\nimport { Text } from '/@/shared/components/text/text';\nimport { Textarea } from '/@/shared/components/textarea/textarea';\nimport { PlayerType } from '/@/shared/types/types';\n\nconst localSettings = isElectron() ? window.api.localSettings : null;\nconst mpvPlayer = isElectron() ? window.api.mpvPlayer : null;\n\nexport const MpvSettings = memo(() => {\n    const { t } = useTranslation();\n    const settings = usePlaybackSettings();\n    const { setSettings } = useSettingsStoreActions();\n    // const { pause } = usePlayerControls();\n    // const { clearQueue } = useQueueControls();\n\n    const [mpvPath, setMpvPath] = useState('');\n\n    const handleSetMpvPath = async (clear?: boolean) => {\n        if (clear) {\n            localSettings?.set('mpv_path', undefined);\n            setMpvPath('');\n            return;\n        }\n\n        const result = await localSettings?.openFileSelector();\n\n        if (result === null) {\n            localSettings?.set('mpv_path', undefined);\n            setMpvPath('');\n            return;\n        }\n\n        localSettings?.set('mpv_path', result);\n        setMpvPath(result);\n    };\n\n    useEffect(() => {\n        const getMpvPath = async () => {\n            if (!localSettings) return setMpvPath('');\n            const mpvPath = (await localSettings.get('mpv_path')) as string | undefined;\n            return setMpvPath(mpvPath || '');\n        };\n\n        getMpvPath();\n    }, []);\n\n    const handleSetMpvProperty = (\n        setting: keyof SettingsState['playback']['mpvProperties'],\n        value: any,\n    ) => {\n        setSettings({\n            playback: {\n                mpvProperties: {\n                    [setting]: value,\n                },\n            },\n        });\n\n        const mpvSetting = getMpvSetting(setting, value);\n\n        mpvPlayer?.setProperties(mpvSetting);\n    };\n\n    const player = usePlayer();\n\n    const handleReloadMpv = () => {\n        player.mediaStop();\n        eventEmitter.emit('MPV_RELOAD', {});\n    };\n\n    const handleSetExtraParameters = (data: string[]) => {\n        setSettings({\n            playback: {\n                mpvExtraParameters: data,\n            },\n        });\n    };\n\n    const options: SettingOption[] = [\n        {\n            control: (\n                <Group gap=\"sm\">\n                    <ActionIcon\n                        icon=\"refresh\"\n                        onClick={handleReloadMpv}\n                        tooltip={{\n                            label: t('common.reload', { postProcess: 'titleCase' }),\n                            openDelay: 0,\n                        }}\n                        variant=\"subtle\"\n                    />\n                    <TextInput\n                        onChange={(e) => {\n                            setMpvPath(e.currentTarget.value);\n\n                            // Transform backslashes to forward slashes\n                            const transformedValue = e.currentTarget.value.replace(/\\\\/g, '/');\n                            localSettings?.set('mpv_path', transformedValue);\n                        }}\n                        onClick={() => handleSetMpvPath()}\n                        rightSection={\n                            mpvPath && (\n                                <ActionIcon\n                                    icon=\"x\"\n                                    onClick={() => handleSetMpvPath(true)}\n                                    variant=\"transparent\"\n                                />\n                            )\n                        }\n                        value={mpvPath}\n                        width={200}\n                    />\n                </Group>\n            ),\n            description: t('setting.mpvExecutablePath', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: settings.type !== PlayerType.LOCAL,\n            note: 'Restart required',\n            title: t('setting.mpvExecutablePath', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <Stack gap=\"xs\">\n                    <Textarea\n                        autosize\n                        defaultValue={settings.mpvExtraParameters.join('\\n')}\n                        minRows={4}\n                        onBlur={(e) => {\n                            handleSetExtraParameters(e.currentTarget.value.split('\\n'));\n                        }}\n                        placeholder={`(${t('setting.mpvExtraParameters', {\n                            context: 'help',\n                            postProcess: 'sentenceCase',\n                        })}):\\n--gapless-audio=weak\\n--prefetch-playlist=yes`}\n                        width={225}\n                    />\n                </Stack>\n            ),\n            description: (\n                <Stack gap={0}>\n                    <Text isMuted isNoSelect size=\"sm\">\n                        {t('setting.mpvExtraParameters', {\n                            context: 'description',\n                            postProcess: 'sentenceCase',\n                        })}\n                    </Text>\n                    <Text size=\"sm\">\n                        <a\n                            href=\"https://mpv.io/manual/stable/#audio\"\n                            rel=\"noreferrer\"\n                            target=\"_blank\"\n                        >\n                            https://mpv.io/manual/stable/#audio\n                        </a>\n                    </Text>\n                </Stack>\n            ),\n            isHidden: settings.type !== PlayerType.LOCAL,\n            note: t('common.restartRequired', {\n                postProcess: 'sentenceCase',\n            }),\n            title: t('setting.mpvExtraParameters', {\n                postProcess: 'sentenceCase',\n            }),\n        },\n    ];\n\n    const generalOptions: SettingOption[] = [\n        {\n            control: (\n                <Select\n                    data={[\n                        { label: t('common.no', { postProcess: 'titleCase' }), value: 'no' },\n                        { label: t('common.yes', { postProcess: 'titleCase' }), value: 'yes' },\n                        {\n                            label: t('setting.gaplessAudio', {\n                                context: 'optionWeak',\n                                postProcess: 'sentenceCase',\n                            }),\n                            value: 'weak',\n                        },\n                    ]}\n                    defaultValue={settings.mpvProperties.gaplessAudio}\n                    onChange={(e) => handleSetMpvProperty('gaplessAudio', e)}\n                />\n            ),\n            description: t('setting.gaplessAudio', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: settings.type !== PlayerType.LOCAL,\n            title: t('setting.gaplessAudio', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <NumberInput\n                    defaultValue={settings.mpvProperties.audioSampleRateHz || undefined}\n                    max={192000}\n                    min={0}\n                    onBlur={(e) => {\n                        const value = Number(e.currentTarget.value);\n                        // Setting a value of `undefined` causes an error for MPV. Use 0 instead\n                        handleSetMpvProperty('audioSampleRateHz', value >= 8000 ? value : value);\n                    }}\n                    placeholder=\"48000\"\n                    rightSection={<Text size=\"xs\">Hz</Text>}\n                    width={100}\n                />\n            ),\n            description: t('setting.sampleRate', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            note: 'Page refresh required for web player',\n            title: t('setting.sampleRate', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <Switch\n                    defaultChecked={settings.mpvProperties.audioExclusiveMode === 'yes'}\n                    onChange={(e) =>\n                        handleSetMpvProperty(\n                            'audioExclusiveMode',\n                            e.currentTarget.checked ? 'yes' : 'no',\n                        )\n                    }\n                />\n            ),\n\n            description: t('setting.audioExclusiveMode', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: settings.type !== PlayerType.LOCAL,\n            title: t('setting.audioExclusiveMode', { postProcess: 'sentenceCase' }),\n        },\n    ];\n\n    const replayGainOptions: SettingOption[] = [\n        {\n            control: (\n                <Select\n                    data={[\n                        {\n                            label: t('setting.replayGainMode', {\n                                context: 'optionNone',\n                                postProcess: 'titleCase',\n                            }),\n                            value: 'no',\n                        },\n                        {\n                            label: t('setting.replayGainMode', {\n                                context: 'optionTrack',\n                                postProcess: 'titleCase',\n                            }),\n                            value: 'track',\n                        },\n                        {\n                            label: t('setting.replayGainMode', {\n                                context: 'optionAlbum',\n                                postProcess: 'titleCase',\n                            }),\n                            value: 'album',\n                        },\n                    ]}\n                    defaultValue={settings.mpvProperties.replayGainMode}\n                    onChange={(e) => handleSetMpvProperty('replayGainMode', e)}\n                />\n            ),\n            description: t('setting.replayGainMode', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n                ReplayGain: 'ReplayGain',\n            }),\n            note: t('common.restartRequired', { postProcess: 'sentenceCase' }),\n            title: t('setting.replayGainMode', {\n                postProcess: 'sentenceCase',\n                ReplayGain: 'ReplayGain',\n            }),\n        },\n        {\n            control: (\n                <NumberInput\n                    defaultValue={settings.mpvProperties.replayGainPreampDB}\n                    onChange={(e) => handleSetMpvProperty('replayGainPreampDB', Number(e) || 0)}\n                    width={75}\n                />\n            ),\n            description: t('setting.replayGainMode', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n                ReplayGain: 'ReplayGain',\n            }),\n            title: t('setting.replayGainPreamp', {\n                postProcess: 'sentenceCase',\n                ReplayGain: 'ReplayGain',\n            }),\n        },\n        {\n            control: (\n                <Switch\n                    defaultChecked={settings.mpvProperties.replayGainClip}\n                    onChange={(e) =>\n                        handleSetMpvProperty('replayGainClip', e.currentTarget.checked)\n                    }\n                />\n            ),\n            description: t('setting.replayGainClipping', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n                ReplayGain: 'ReplayGain',\n            }),\n            title: t('setting.replayGainClipping', {\n                postProcess: 'sentenceCase',\n                ReplayGain: 'ReplayGain',\n            }),\n        },\n        {\n            control: (\n                <NumberInput\n                    defaultValue={settings.mpvProperties.replayGainFallbackDB}\n                    onBlur={(e) =>\n                        handleSetMpvProperty('replayGainFallbackDB', Number(e.currentTarget.value))\n                    }\n                    width={75}\n                />\n            ),\n            description: t('setting.replayGainFallback', {\n                postProcess: 'sentenceCase',\n                ReplayGain: 'ReplayGain',\n            }),\n            title: t('setting.replayGainFallback', {\n                postProcess: 'sentenceCase',\n                ReplayGain: 'ReplayGain',\n            }),\n        },\n    ];\n\n    return (\n        <>\n            <SettingsSection options={options} />\n            <SettingsSection options={generalOptions} />\n            <SettingsSection options={replayGainOptions} />\n        </>\n    );\n});\n"
  },
  {
    "path": "src/renderer/features/settings/components/playback/playback-tab.tsx",
    "content": "import isElectron from 'is-electron';\nimport { lazy, memo, Suspense, useMemo } from 'react';\nimport { shallow } from 'zustand/shallow';\n\nimport { AudioSettings } from '/@/renderer/features/settings/components/playback/audio-settings';\nimport { AutoDJSettings } from '/@/renderer/features/settings/components/playback/auto-dj-settings';\nimport { PlayerFilterSettings } from '/@/renderer/features/settings/components/playback/player-filter-settings';\nimport { TranscodeSettings } from '/@/renderer/features/settings/components/playback/transcode-settings';\nimport { useSettingsStore } from '/@/renderer/store';\nimport { Divider } from '/@/shared/components/divider/divider';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { PlayerType } from '/@/shared/types/types';\n\nconst MpvSettings = lazy(() =>\n    import('/@/renderer/features/settings/components/playback/mpv-settings').then((module) => {\n        return { default: module.MpvSettings };\n    }),\n);\n\nexport const PlaybackTab = memo(() => {\n    const { audioType, useWebAudio } = useSettingsStore(\n        (state) => ({\n            audioType: state.playback.type,\n            useWebAudio: state.playback.webAudio,\n        }),\n        shallow,\n    );\n\n    const hasFancyAudio = useMemo(() => {\n        return (\n            (isElectron() && audioType === PlayerType.LOCAL) ||\n            (useWebAudio && 'AudioContext' in window)\n        );\n    }, [audioType, useWebAudio]);\n\n    return (\n        <Stack gap=\"md\">\n            <AudioSettings />\n            <Suspense fallback={<></>}>{hasFancyAudio && <MpvSettings />}</Suspense>\n            <Divider />\n            <TranscodeSettings />\n            <Divider />\n            <PlayerFilterSettings />\n            <Divider />\n            <AutoDJSettings />\n        </Stack>\n    );\n});\n"
  },
  {
    "path": "src/renderer/features/settings/components/playback/player-filter-settings.tsx",
    "content": "import { nanoid } from 'nanoid/non-secure';\nimport { memo, useCallback, useMemo } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport {\n    SettingOption,\n    SettingsSection,\n} from '/@/renderer/features/settings/components/settings-section';\nimport {\n    PlayerFilter,\n    PlayerFilterField,\n    PlayerFilterOperator,\n    useSettingsStore,\n    useSettingsStoreActions,\n} from '/@/renderer/store';\nimport {\n    NDSongQueryBooleanOperators,\n    NDSongQueryDateOperators,\n    NDSongQueryNumberOperators,\n    NDSongQueryStringOperators,\n} from '/@/shared/api/navidrome/navidrome-types';\nimport { ActionIcon } from '/@/shared/components/action-icon/action-icon';\nimport { Button } from '/@/shared/components/button/button';\nimport { Checkbox } from '/@/shared/components/checkbox/checkbox';\nimport { DateInput } from '/@/shared/components/date-picker/date-picker';\nimport { Group } from '/@/shared/components/group/group';\nimport { NumberInput } from '/@/shared/components/number-input/number-input';\nimport { Select } from '/@/shared/components/select/select';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { TextInput } from '/@/shared/components/text-input/text-input';\n\ntype FilterFieldConfig = {\n    label: string;\n    type: 'boolean' | 'date' | 'number' | 'string';\n    value: PlayerFilterField;\n};\n\nconst getFilterFields = (t: (key: string, options?: any) => string): FilterFieldConfig[] => [\n    {\n        label: t('table.config.label.title', { postProcess: 'titleCase' }),\n        type: 'string',\n        value: 'name',\n    },\n    {\n        label: t('table.config.label.albumArtist', { postProcess: 'titleCase' }),\n        type: 'string',\n        value: 'albumArtist',\n    },\n    {\n        label: t('table.config.label.artist', { postProcess: 'titleCase' }),\n        type: 'string',\n        value: 'artist',\n    },\n    {\n        label: t('table.config.label.duration', { postProcess: 'titleCase' }),\n        type: 'number',\n        value: 'duration',\n    },\n    {\n        label: t('table.config.label.genre', { postProcess: 'titleCase' }),\n        type: 'string',\n        value: 'genre',\n    },\n    {\n        label: t('table.config.label.year', { postProcess: 'titleCase' }),\n        type: 'number',\n        value: 'year',\n    },\n    {\n        label: t('table.config.label.note', { postProcess: 'titleCase' }),\n        type: 'string',\n        value: 'note',\n    },\n    {\n        label: t('table.config.label.path', { postProcess: 'titleCase' }),\n        type: 'string',\n        value: 'path',\n    },\n    {\n        label: t('table.config.label.playCount', { postProcess: 'titleCase' }),\n        type: 'number',\n        value: 'playCount',\n    },\n    {\n        label: t('table.config.label.favorite', { postProcess: 'titleCase' }),\n        type: 'boolean',\n        value: 'favorite',\n    },\n    {\n        label: t('table.config.label.rating', { postProcess: 'titleCase' }),\n        type: 'number',\n        value: 'rating',\n    },\n];\n\nconst getOperatorsForFieldType = (\n    t: (key: string, options?: any) => string,\n    type: 'boolean' | 'date' | 'number' | 'string',\n): { label: string; value: PlayerFilterOperator }[] => {\n    const translateOperator = (operator: PlayerFilterOperator): string => {\n        const operatorKeyMap: Record<PlayerFilterOperator, string> = {\n            after: 'filterOperator.after',\n            afterDate: 'filterOperator.afterDate',\n            before: 'filterOperator.before',\n            beforeDate: 'filterOperator.beforeDate',\n            contains: 'filterOperator.contains',\n            endsWith: 'filterOperator.endsWith',\n            gt: 'filterOperator.isGreaterThan',\n            inTheLast: 'filterOperator.inTheLast',\n            inTheRange: 'filterOperator.inTheRange',\n            inTheRangeDate: 'filterOperator.inTheRangeDate',\n            is: 'filterOperator.is',\n            isNot: 'filterOperator.isNot',\n            lt: 'filterOperator.isLessThan',\n            notContains: 'filterOperator.notContains',\n            notInTheLast: 'filterOperator.notInTheLast',\n            regex: 'filterOperator.matchesRegex',\n            startsWith: 'filterOperator.startsWith',\n        };\n\n        return t(operatorKeyMap[operator] || operator, { postProcess: 'titleCase' });\n    };\n\n    switch (type) {\n        case 'boolean': {\n            return (\n                NDSongQueryBooleanOperators as { label: string; value: PlayerFilterOperator }[]\n            ).map((op) => ({\n                label: translateOperator(op.value),\n                value: op.value,\n            }));\n        }\n        case 'date': {\n            return (\n                NDSongQueryDateOperators as { label: string; value: PlayerFilterOperator }[]\n            ).map((op) => ({\n                label: translateOperator(op.value),\n                value: op.value,\n            }));\n        }\n        case 'number': {\n            const numberOperators = (\n                NDSongQueryNumberOperators as {\n                    label: string;\n                    value: PlayerFilterOperator;\n                }[]\n            ).filter((op) => op.value !== 'inTheRange');\n            return numberOperators.map((op) => ({\n                label: translateOperator(op.value),\n                value: op.value,\n            }));\n        }\n        case 'string': {\n            const stringOperators = [\n                ...(NDSongQueryStringOperators as { label: string; value: PlayerFilterOperator }[]),\n                { label: 'matches regex', value: 'regex' as PlayerFilterOperator },\n            ];\n            return stringOperators.map((op) => ({\n                label: translateOperator(op.value),\n                value: op.value,\n            }));\n        }\n        default:\n            return [];\n    }\n};\n\nconst FilterValueInput = ({\n    disabled,\n    field,\n    filterFields,\n    onChange,\n    operator,\n    value,\n}: {\n    disabled?: boolean;\n    field: PlayerFilterField;\n    filterFields: FilterFieldConfig[];\n    onChange: (value: (number | string)[] | boolean | number | string) => void;\n    operator: PlayerFilterOperator;\n    value: (number | string)[] | boolean | number | string | undefined;\n}) => {\n    const fieldConfig = filterFields.find((f) => f.value === field);\n    const fieldType = fieldConfig?.type || 'string';\n\n    // Parse date value helper\n    const parseDateValue = (val: any): Date | null => {\n        if (!val) return null;\n        if (val instanceof Date) return val;\n        if (typeof val === 'string') {\n            const parsed = new Date(val);\n            if (isNaN(parsed.getTime())) return null;\n            return parsed;\n        }\n        return null;\n    };\n\n    const isDatePickerOperator =\n        operator === 'beforeDate' || operator === 'afterDate' || operator === 'inTheRangeDate';\n\n    switch (fieldType) {\n        case 'boolean':\n            return (\n                <Select\n                    data={[\n                        { label: 'true', value: 'true' },\n                        { label: 'false', value: 'false' },\n                    ]}\n                    disabled={disabled}\n                    onChange={(e) => onChange(e === 'true')}\n                    value={value?.toString() || 'false'}\n                    width=\"30%\"\n                />\n            );\n        case 'date':\n            if (isDatePickerOperator && operator !== 'inTheRangeDate') {\n                const dateValue = value ? parseDateValue(value) : null;\n                return (\n                    <DateInput\n                        clearable\n                        defaultLevel=\"year\"\n                        disabled={disabled}\n                        maxWidth={170}\n                        onChange={(date) => onChange(date || '')}\n                        size=\"sm\"\n                        value={dateValue}\n                        valueFormat=\"YYYY-MM-DD\"\n                        width=\"30%\"\n                    />\n                );\n            }\n            return (\n                <TextInput\n                    disabled={disabled}\n                    onChange={(e) => onChange(e.currentTarget.value)}\n                    size=\"sm\"\n                    value={(value as string) || ''}\n                    width=\"30%\"\n                />\n            );\n        case 'number':\n            return (\n                <NumberInput\n                    disabled={disabled}\n                    onChange={(e) => onChange(Number(e) || 0)}\n                    size=\"sm\"\n                    value={value !== undefined && value !== null ? Number(value) : undefined}\n                    width=\"30%\"\n                />\n            );\n        case 'string':\n        default:\n            return (\n                <TextInput\n                    disabled={disabled}\n                    onChange={(e) => onChange(e.currentTarget.value)}\n                    size=\"sm\"\n                    value={(value as string) || ''}\n                    width=\"30%\"\n                />\n            );\n    }\n};\n\nexport const PlayerFilterSettings = memo(() => {\n    const { t } = useTranslation();\n    const filters = useSettingsStore((state) => state.playback.filters);\n    const { setPlaybackFilters } = useSettingsStoreActions();\n\n    const filterFields = useMemo(() => getFilterFields(t), [t]);\n\n    const handleAddFilter = useCallback(() => {\n        const newFilter: PlayerFilter = {\n            field: 'name',\n            id: nanoid(),\n            isEnabled: true,\n            operator: 'is',\n            value: '',\n        };\n        setPlaybackFilters([...filters, newFilter]);\n    }, [filters, setPlaybackFilters]);\n\n    const handleRemoveFilter = useCallback(\n        (id: string) => {\n            setPlaybackFilters(filters.filter((f) => f.id !== id));\n        },\n        [filters, setPlaybackFilters],\n    );\n\n    const handleFieldChange = useCallback(\n        (id: string, field: PlayerFilterField) => {\n            const fieldConfig = filterFields.find((f) => f.value === field);\n            const defaultOperator = getOperatorsForFieldType(t, fieldConfig?.type || 'string')[0]\n                .value;\n            const defaultValue =\n                fieldConfig?.type === 'boolean'\n                    ? false\n                    : fieldConfig?.type === 'number'\n                      ? 0\n                      : fieldConfig?.type === 'date'\n                        ? ''\n                        : '';\n\n            setPlaybackFilters(\n                filters.map((f) =>\n                    f.id === id\n                        ? {\n                              ...f,\n                              field,\n                              operator: defaultOperator,\n                              value: defaultValue,\n                          }\n                        : f,\n                ),\n            );\n        },\n        [filterFields, filters, setPlaybackFilters, t],\n    );\n\n    const handleOperatorChange = useCallback(\n        (id: string, operator: PlayerFilterOperator) => {\n            setPlaybackFilters(filters.map((f) => (f.id === id ? { ...f, operator } : f)));\n        },\n        [filters, setPlaybackFilters],\n    );\n\n    const handleValueChange = useCallback(\n        (id: string, value: (number | string)[] | boolean | number | string) => {\n            setPlaybackFilters(filters.map((f) => (f.id === id ? { ...f, value } : f)));\n        },\n        [filters, setPlaybackFilters],\n    );\n\n    const handleToggleEnabled = useCallback(\n        (id: string, isEnabled: boolean) => {\n            setPlaybackFilters(filters.map((f) => (f.id === id ? { ...f, isEnabled } : f)));\n        },\n        [filters, setPlaybackFilters],\n    );\n\n    const fieldOptions = useMemo(\n        () => filterFields.map((f) => ({ label: f.label, value: f.value })),\n        [filterFields],\n    );\n\n    const filterOptions: SettingOption[] = [\n        {\n            control: (\n                <Stack gap=\"md\">\n                    {filters.length > 0 && (\n                        <Stack gap=\"sm\">\n                            {filters.map((filter) => {\n                                const fieldConfig = filterFields.find(\n                                    (f) => f.value === filter.field,\n                                );\n                                const operators = getOperatorsForFieldType(\n                                    t,\n                                    fieldConfig?.type || 'string',\n                                );\n\n                                return (\n                                    <Group gap=\"sm\" key={filter.id}>\n                                        <Checkbox\n                                            checked={filter.isEnabled ?? true}\n                                            onChange={(e) =>\n                                                handleToggleEnabled(\n                                                    filter.id,\n                                                    e.currentTarget.checked,\n                                                )\n                                            }\n                                        />\n                                        <Select\n                                            data={fieldOptions}\n                                            disabled={!filter.isEnabled}\n                                            onChange={(e) =>\n                                                handleFieldChange(filter.id, e as PlayerFilterField)\n                                            }\n                                            value={filter.field}\n                                            width=\"25%\"\n                                        />\n                                        <Select\n                                            data={operators}\n                                            disabled={!filter.isEnabled}\n                                            onChange={(e) =>\n                                                handleOperatorChange(\n                                                    filter.id,\n                                                    e as PlayerFilterOperator,\n                                                )\n                                            }\n                                            value={filter.operator}\n                                            width=\"25%\"\n                                        />\n                                        <FilterValueInput\n                                            disabled={!filter.isEnabled}\n                                            field={filter.field}\n                                            filterFields={filterFields}\n                                            onChange={(value) =>\n                                                handleValueChange(filter.id, value)\n                                            }\n                                            operator={filter.operator}\n                                            value={filter.value}\n                                        />\n                                        <ActionIcon\n                                            icon=\"remove\"\n                                            onClick={() => handleRemoveFilter(filter.id)}\n                                            size=\"sm\"\n                                            variant=\"subtle\"\n                                        />\n                                    </Group>\n                                );\n                            })}\n                        </Stack>\n                    )}\n                    <Group grow>\n                        <Button onClick={handleAddFilter} variant=\"filled\">\n                            {t('common.add', { postProcess: 'titleCase' })}\n                        </Button>\n                    </Group>\n                </Stack>\n            ),\n            description: t('setting.playerFilters', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            title: t('setting.playerFilters', { postProcess: 'sentenceCase' }),\n        },\n    ];\n\n    return (\n        <SettingsSection\n            options={filterOptions}\n            title={t('page.setting.playerFilters', { postProcess: 'sentenceCase' })}\n        />\n    );\n});\n"
  },
  {
    "path": "src/renderer/features/settings/components/playback/transcode-settings.tsx",
    "content": "import { memo } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport {\n    SettingOption,\n    SettingsSection,\n} from '/@/renderer/features/settings/components/settings-section';\nimport { usePlaybackSettings, useSettingsStoreActions } from '/@/renderer/store/settings.store';\nimport { NumberInput } from '/@/shared/components/number-input/number-input';\nimport { Switch } from '/@/shared/components/switch/switch';\nimport { TextInput } from '/@/shared/components/text-input/text-input';\n\nexport const TranscodeSettings = memo(() => {\n    const { t } = useTranslation();\n    const { transcode } = usePlaybackSettings();\n    const { setTranscodingConfig } = useSettingsStoreActions();\n    const note = t('setting.transcodeNote', { postProcess: 'sentenceCase' });\n\n    const transcodeOptions: SettingOption[] = [\n        {\n            control: (\n                <Switch\n                    aria-label=\"Toggle transcode\"\n                    defaultChecked={transcode.enabled}\n                    onChange={(e) => {\n                        setTranscodingConfig({\n                            ...transcode,\n                            enabled: e.currentTarget.checked,\n                        });\n                    }}\n                />\n            ),\n            description: t('setting.transcode', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            note,\n            title: t('setting.transcode', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <NumberInput\n                    aria-label=\"Transcode bitrate\"\n                    defaultValue={transcode.bitrate}\n                    min={0}\n                    onBlur={(e) => {\n                        setTranscodingConfig({\n                            ...transcode,\n                            bitrate: e.currentTarget.value\n                                ? Number(e.currentTarget.value)\n                                : undefined,\n                        });\n                    }}\n                    w={100}\n                />\n            ),\n            description: t('setting.transcodeBitrate', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: !transcode.enabled,\n            note,\n            title: t('setting.transcodeBitrate', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <TextInput\n                    aria-label=\"transcoding format\"\n                    defaultValue={transcode.format}\n                    onBlur={(e) => {\n                        setTranscodingConfig({\n                            ...transcode,\n                            format: e.currentTarget.value || undefined,\n                        });\n                    }}\n                    placeholder=\"mp3, opus\"\n                    width={100}\n                />\n            ),\n            description: t('setting.transcodeFormat', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: !transcode.enabled,\n            note,\n            title: t('setting.transcodeFormat', { postProcess: 'sentenceCase' }),\n        },\n    ];\n\n    return (\n        <SettingsSection\n            options={transcodeOptions}\n            title={t('page.setting.transcoding', { postProcess: 'sentenceCase' })}\n        />\n    );\n});\n"
  },
  {
    "path": "src/renderer/features/settings/components/settings-content.tsx",
    "content": "import isElectron from 'is-electron';\nimport { lazy, Suspense } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport { LibraryContainer } from '/@/renderer/features/shared/components/library-container';\nimport { useSettingsStore, useSettingsStoreActions } from '/@/renderer/store/settings.store';\nimport { Spinner } from '/@/shared/components/spinner/spinner';\nimport { Tabs } from '/@/shared/components/tabs/tabs';\n\nconst GeneralTab = lazy(() =>\n    import('/@/renderer/features/settings/components/general/general-tab').then((module) => ({\n        default: module.GeneralTab,\n    })),\n);\n\nconst PlaybackTab = lazy(() =>\n    import('/@/renderer/features/settings/components/playback/playback-tab').then((module) => ({\n        default: module.PlaybackTab,\n    })),\n);\n\nconst HotkeysTab = lazy(() =>\n    import('/@/renderer/features/settings/components/hotkeys/hotkeys-tab').then((module) => ({\n        default: module.HotkeysTab,\n    })),\n);\n\nconst WindowTab = lazy(() =>\n    import('/@/renderer/features/settings/components/window/window-tab').then((module) => ({\n        default: module.WindowTab,\n    })),\n);\n\nconst AdvancedTab = lazy(() =>\n    import('/@/renderer/features/settings/components/advanced/advanced-tab').then((module) => ({\n        default: module.AdvancedTab,\n    })),\n);\n\nexport const SettingsContent = () => {\n    const { t } = useTranslation();\n    const currentTab = useSettingsStore((state) => state.tab);\n    const { setSettings } = useSettingsStoreActions();\n\n    return (\n        <LibraryContainer>\n            <div style={{ height: '100%', overflow: 'scroll', padding: '1rem', width: '100%' }}>\n                <Tabs\n                    keepMounted={false}\n                    onChange={(e) => e && setSettings({ tab: e })}\n                    orientation=\"horizontal\"\n                    value={currentTab}\n                    variant=\"default\"\n                >\n                    <Tabs.List>\n                        <Tabs.Tab value=\"general\">\n                            {t('page.setting.generalTab', { postProcess: 'sentenceCase' })}\n                        </Tabs.Tab>\n                        <Tabs.Tab value=\"playback\">\n                            {t('page.setting.playbackTab', { postProcess: 'sentenceCase' })}\n                        </Tabs.Tab>\n                        <Tabs.Tab value=\"hotkeys\">\n                            {t('page.setting.hotkeysTab', { postProcess: 'sentenceCase' })}\n                        </Tabs.Tab>\n                        {isElectron() && (\n                            <Tabs.Tab value=\"window\">\n                                {t('page.setting.windowTab', { postProcess: 'sentenceCase' })}\n                            </Tabs.Tab>\n                        )}\n                        <Tabs.Tab value=\"advanced\">\n                            {t('page.setting.advanced', { postProcess: 'sentenceCase' })}\n                        </Tabs.Tab>\n                    </Tabs.List>\n                    <Tabs.Panel value=\"general\">\n                        <Suspense fallback={<Spinner container />}>\n                            <GeneralTab />\n                        </Suspense>\n                    </Tabs.Panel>\n                    <Tabs.Panel value=\"playback\">\n                        <Suspense fallback={<Spinner container />}>\n                            <PlaybackTab />\n                        </Suspense>\n                    </Tabs.Panel>\n                    <Tabs.Panel value=\"hotkeys\">\n                        <Suspense fallback={<Spinner container />}>\n                            <HotkeysTab />\n                        </Suspense>\n                    </Tabs.Panel>\n                    {isElectron() && (\n                        <Tabs.Panel value=\"window\">\n                            <Suspense fallback={<Spinner container />}>\n                                <WindowTab />\n                            </Suspense>\n                        </Tabs.Panel>\n                    )}\n                    <Tabs.Panel value=\"advanced\">\n                        <Suspense fallback={<Spinner container />}>\n                            <AdvancedTab />\n                        </Suspense>\n                    </Tabs.Panel>\n                </Tabs>\n            </div>\n        </LibraryContainer>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/settings/components/settings-header.tsx",
    "content": "import { closeAllModals, openModal } from '@mantine/modals';\nimport { useTranslation } from 'react-i18next';\n\nimport { useSettingSearchContext } from '/@/renderer/features/settings/context/search-context';\nimport { LibraryHeaderBar } from '/@/renderer/features/shared/components/library-header-bar';\nimport { SearchInput } from '/@/renderer/features/shared/components/search-input';\nimport { useSettingsStoreActions } from '/@/renderer/store/settings.store';\nimport { Button } from '/@/shared/components/button/button';\nimport { Flex } from '/@/shared/components/flex/flex';\nimport { Group } from '/@/shared/components/group/group';\nimport { Icon } from '/@/shared/components/icon/icon';\nimport { ConfirmModal } from '/@/shared/components/modal/modal';\nimport { Text } from '/@/shared/components/text/text';\n\nexport type SettingsHeaderProps = {\n    setSearch: (search: string) => void;\n};\n\nexport const SettingsHeader = ({ setSearch }: SettingsHeaderProps) => {\n    const { t } = useTranslation();\n    const { reset } = useSettingsStoreActions();\n    const search = useSettingSearchContext();\n\n    const handleResetToDefault = () => {\n        reset();\n        closeAllModals();\n    };\n\n    const openResetConfirmModal = () => {\n        openModal({\n            children: (\n                <ConfirmModal onConfirm={handleResetToDefault}>\n                    <Text>{t('common.areYouSure', { postProcess: 'sentenceCase' })}</Text>\n                </ConfirmModal>\n            ),\n            title: t('common.resetToDefault', { postProcess: 'sentenceCase' }),\n        });\n    };\n\n    return (\n        <Flex>\n            <LibraryHeaderBar>\n                <Flex align=\"center\" justify=\"space-between\" w=\"100%\">\n                    <Group wrap=\"nowrap\">\n                        <Icon icon=\"settings\" size=\"5xl\" />\n                        <LibraryHeaderBar.Title>\n                            {t('common.setting', { count: 2, postProcess: 'titleCase' })}\n                        </LibraryHeaderBar.Title>\n                    </Group>\n                    <Group>\n                        <SearchInput\n                            defaultValue={search}\n                            onChange={(event) => setSearch(event.target.value.toLocaleLowerCase())}\n                        />\n                        <Button onClick={openResetConfirmModal} variant=\"default\">\n                            {t('common.resetToDefault', { postProcess: 'sentenceCase' })}\n                        </Button>\n                    </Group>\n                </Flex>\n            </LibraryHeaderBar>\n        </Flex>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/settings/components/settings-modal.tsx",
    "content": "import { useState } from 'react';\n\nimport { SettingsContent } from '/@/renderer/features/settings/components/settings-content';\nimport { SettingsHeader } from '/@/renderer/features/settings/components/settings-header';\nimport { SettingSearchContext } from '/@/renderer/features/settings/context/search-context';\n\nexport const SettingsContextModal = () => {\n    const [search, setSearch] = useState('');\n\n    return (\n        <SettingSearchContext.Provider value={search}>\n            <SettingsHeader setSearch={setSearch} />\n            <SettingsContent />\n        </SettingSearchContext.Provider>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/settings/components/settings-option.tsx",
    "content": "import React, { memo } from 'react';\n\nimport { Group } from '/@/shared/components/group/group';\nimport { Icon } from '/@/shared/components/icon/icon';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { Text } from '/@/shared/components/text/text';\nimport { Tooltip } from '/@/shared/components/tooltip/tooltip';\n\ninterface SettingsOptionProps {\n    control: React.ReactNode;\n    description?: React.ReactNode | string;\n    note?: string;\n    title: React.ReactNode | string;\n}\n\nexport const SettingsOptions = memo(\n    ({ control, description, note, title }: SettingsOptionProps) => {\n        return (\n            <>\n                <Group justify=\"space-between\" style={{ alignItems: 'center' }} wrap=\"nowrap\">\n                    <Stack\n                        gap=\"xs\"\n                        style={{\n                            alignSelf: 'flex-start',\n                            display: 'flex',\n                            maxWidth: '50%',\n                        }}\n                    >\n                        <Group>\n                            <Text isNoSelect size=\"md\">\n                                {title}\n                            </Text>\n                            {note && (\n                                <Tooltip label={note} openDelay={0}>\n                                    <Icon icon=\"info\" />\n                                </Tooltip>\n                            )}\n                        </Group>\n                        {React.isValidElement(description) ? (\n                            description\n                        ) : (\n                            <Text isMuted isNoSelect size=\"sm\">\n                                {description}\n                            </Text>\n                        )}\n                    </Stack>\n                    <Group justify=\"flex-end\">{control}</Group>\n                </Group>\n            </>\n        );\n    },\n);\n"
  },
  {
    "path": "src/renderer/features/settings/components/settings-section.tsx",
    "content": "import { ReactNode } from 'react';\n\nimport { SettingsOptions } from '/@/renderer/features/settings/components/settings-option';\nimport { useSettingSearchContext } from '/@/renderer/features/settings/context/search-context';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { TextTitle } from '/@/shared/components/text-title/text-title';\n\nexport type SettingOption = {\n    control: ReactNode;\n    description: ReactNode | string;\n    isHidden?: boolean;\n    note?: string;\n    title: string;\n};\n\ninterface SettingsSectionProps {\n    extra?: ReactNode;\n    options: SettingOption[];\n    title?: ReactNode;\n}\n\nexport const SettingsSection = ({ extra, options, title }: SettingsSectionProps) => {\n    const keyword = useSettingSearchContext();\n    const hasKeyword = keyword !== '';\n\n    const values = options.filter(\n        (o) => !o.isHidden && (!hasKeyword || o.title.toLocaleLowerCase().includes(keyword)),\n    );\n\n    return (\n        <>\n            {title && (\n                <TextTitle fw={600} order={4}>\n                    {title}\n                </TextTitle>\n            )}\n            <Stack gap=\"xl\" px=\"xl\">\n                {values.map((option) => (\n                    <SettingsOptions key={`option-${option.title}`} {...option} />\n                ))}\n                {extra}\n            </Stack>\n        </>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/settings/components/window/cache-settngs.tsx",
    "content": "import { closeAllModals, openModal } from '@mantine/modals';\nimport { useQueryClient } from '@tanstack/react-query';\nimport isElectron from 'is-electron';\nimport { memo, useCallback, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport {\n    SettingOption,\n    SettingsSection,\n} from '/@/renderer/features/settings/components/settings-section';\nimport { Button } from '/@/shared/components/button/button';\nimport { ConfirmModal } from '/@/shared/components/modal/modal';\nimport { toast } from '/@/shared/components/toast/toast';\n\nconst browser = isElectron() ? window.api.browser : null;\n\nexport const CacheSettings = memo(() => {\n    const [isClearing, setIsClearing] = useState(false);\n    const queryClient = useQueryClient();\n    const { t } = useTranslation();\n\n    const clearCache = useCallback(\n        async (full: boolean) => {\n            setIsClearing(true);\n\n            try {\n                queryClient.clear();\n\n                if (full && browser) {\n                    await browser.clearCache();\n                }\n\n                toast.success({\n                    message: t('setting.clearCacheSuccess', { postProcess: 'sentenceCase' }),\n                });\n            } catch (error) {\n                console.error(error);\n                toast.error({ message: (error as Error).message });\n            }\n\n            setIsClearing(false);\n            closeAllModals();\n        },\n        [queryClient, t],\n    );\n\n    const openResetConfirmModal = (full: boolean) => {\n        const key = full ? 'clearCache' : 'clearQueryCache';\n        openModal({\n            children: (\n                <ConfirmModal onConfirm={() => clearCache(full)}>\n                    {t(`common.areYouSure`, { postProcess: 'sentenceCase' })}\n                </ConfirmModal>\n            ),\n            title: t(`setting.${key}`, { postProcess: 'sentenceCase' }),\n        });\n    };\n\n    const options: SettingOption[] = [\n        {\n            control: (\n                <Button\n                    disabled={isClearing}\n                    onClick={() => openResetConfirmModal(false)}\n                    size=\"compact-md\"\n                    variant=\"filled\"\n                >\n                    {t('common.clear', { postProcess: 'sentenceCase' })}\n                </Button>\n            ),\n            description: t('setting.clearQueryCache', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            title: t('setting.clearQueryCache', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <Button\n                    disabled={isClearing}\n                    onClick={() => openResetConfirmModal(true)}\n                    size=\"compact-md\"\n                    variant=\"filled\"\n                >\n                    {t('common.clear', { postProcess: 'sentenceCase' })}\n                </Button>\n            ),\n            description: t('setting.clearCache', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: !browser,\n            title: t('setting.clearCache', { postProcess: 'sentenceCase' }),\n        },\n    ];\n\n    const handleOpenApplicationDirectory = async () => {\n        if (isElectron() && window.api?.utils) {\n            await window.api.utils.openApplicationDirectory();\n        }\n    };\n\n    return (\n        <>\n            <SettingsSection\n                options={options}\n                title={t('page.setting.cache', { postProcess: 'sentenceCase' })}\n            />\n            {isElectron() && (\n                <Button onClick={handleOpenApplicationDirectory} variant=\"default\">\n                    {t('action.openApplicationDirectory', {\n                        postProcess: 'sentenceCase',\n                    })}\n                </Button>\n            )}\n        </>\n    );\n});\n"
  },
  {
    "path": "src/renderer/features/settings/components/window/discord-settings.tsx",
    "content": "import isElectron from 'is-electron';\nimport { memo } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport {\n    SettingOption,\n    SettingsSection,\n} from '/@/renderer/features/settings/components/settings-section';\nimport {\n    DiscordDisplayType,\n    DiscordLinkType,\n    useDiscordSettings,\n    useGeneralSettings,\n    useSettingsStoreActions,\n} from '/@/renderer/store';\nimport { Select } from '/@/shared/components/select/select';\nimport { Switch } from '/@/shared/components/switch/switch';\nimport { TextInput } from '/@/shared/components/text-input/text-input';\n\nexport const DiscordSettings = memo(() => {\n    const { t } = useTranslation();\n    const settings = useDiscordSettings();\n    const generalSettings = useGeneralSettings();\n    const { setSettings } = useSettingsStoreActions();\n\n    const discordOptions: SettingOption[] = [\n        {\n            control: (\n                <Switch\n                    checked={settings.enabled}\n                    onChange={(e) => {\n                        setSettings({\n                            discord: {\n                                enabled: e.currentTarget.checked,\n                            },\n                        });\n                    }}\n                />\n            ),\n            description: t('setting.discordRichPresence', {\n                context: 'description',\n                discord: 'Discord',\n                icon: 'icon',\n                paused: 'paused',\n                playing: 'playing',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: !isElectron(),\n            title: t('setting.discordRichPresence', {\n                discord: 'Discord',\n                postProcess: 'sentenceCase',\n            }),\n        },\n        {\n            control: (\n                <TextInput\n                    defaultValue={settings.clientId}\n                    onBlur={(e) => {\n                        setSettings({\n                            discord: {\n                                clientId: e.currentTarget.value,\n                            },\n                        });\n                    }}\n                />\n            ),\n            description: t('setting.discordApplicationId', {\n                context: 'description',\n                defaultId: '1165957668758900787',\n                discord: 'Discord',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: !isElectron(),\n            title: t('setting.discordApplicationId', {\n                discord: 'Discord',\n                postProcess: 'sentenceCase',\n            }),\n        },\n        {\n            control: (\n                <Switch\n                    checked={settings.showPaused}\n                    onChange={(e) => {\n                        setSettings({\n                            discord: {\n                                showPaused: e.currentTarget.checked,\n                            },\n                        });\n                    }}\n                />\n            ),\n            description: t('setting.discordPausedStatus', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: !isElectron(),\n            title: t('setting.discordPausedStatus', {\n                postProcess: 'sentenceCase',\n            }),\n        },\n        {\n            control: (\n                <Switch\n                    checked={settings.showStateIcon}\n                    onChange={(e) => {\n                        setSettings({\n                            discord: {\n                                showStateIcon: e.currentTarget.checked,\n                            },\n                        });\n                    }}\n                />\n            ),\n            description: t('setting.discordStateIcon', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: !isElectron(),\n            title: t('setting.discordStateIcon', {\n                postProcess: 'sentenceCase',\n            }),\n        },\n        {\n            control: (\n                <Switch\n                    checked={settings.showAsListening}\n                    onChange={(e) => {\n                        setSettings({\n                            discord: {\n                                showAsListening: e.currentTarget.checked,\n                            },\n                        });\n                    }}\n                />\n            ),\n            description: t('setting.discordListening', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: !isElectron(),\n            title: t('setting.discordListening', {\n                postProcess: 'sentenceCase',\n            }),\n        },\n        {\n            control: (\n                <Select\n                    aria-label={t('setting.discordDisplayType')}\n                    clearable={false}\n                    data={[\n                        { label: 'Feishin', value: DiscordDisplayType.FEISHIN },\n                        {\n                            label: t('setting.discordDisplayType', {\n                                context: 'songname',\n                                postProcess: 'sentenceCase',\n                            }),\n                            value: DiscordDisplayType.SONG_NAME,\n                        },\n                        {\n                            label: t('setting.discordDisplayType_artistname', {\n                                context: 'artistname',\n                                postProcess: 'sentenceCase',\n                            }),\n                            value: DiscordDisplayType.ARTIST_NAME,\n                        },\n                    ]}\n                    defaultValue={settings.displayType}\n                    onChange={(e) => {\n                        if (!e) return;\n                        setSettings({\n                            discord: {\n                                displayType: e as DiscordDisplayType,\n                            },\n                        });\n                    }}\n                />\n            ),\n            description: t('setting.discordDisplayType', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: !isElectron(),\n            title: t('setting.discordDisplayType', {\n                discord: 'Discord',\n                musicbrainz: 'musicbrainz',\n                postProcess: 'sentenceCase',\n            }),\n        },\n        {\n            control: (\n                <Select\n                    aria-label={t('setting.discordLinkType')}\n                    clearable={false}\n                    data={[\n                        {\n                            label: t('setting.discordLinkType_none', {\n                                postProcess: 'sentenceCase',\n                            }),\n                            value: DiscordLinkType.NONE,\n                        },\n                        { label: 'last.fm', value: DiscordLinkType.LAST_FM },\n                        { label: 'musicbrainz', value: DiscordLinkType.MBZ },\n                        {\n                            label: t('setting.discordLinkType_mbz_lastfm', {\n                                lastfm: 'last.fm',\n                                musicbrainz: 'musicbrainz',\n                            }),\n                            value: DiscordLinkType.MBZ_LAST_FM,\n                        },\n                    ]}\n                    defaultValue={settings.linkType}\n                    onChange={(e) => {\n                        if (!e) return;\n                        setSettings({\n                            discord: {\n                                linkType: e as DiscordLinkType,\n                            },\n                        });\n                    }}\n                />\n            ),\n            description: t('setting.discordLinkType', {\n                context: 'description',\n                discord: 'Discord',\n                lastfm: 'last.fm',\n                musicbrainz: 'musicbrainz',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: !isElectron(),\n            title: t('setting.discordLinkType', {\n                discord: 'Discord',\n                postProcess: 'sentenceCase',\n            }),\n        },\n        {\n            control: (\n                <Switch\n                    checked={settings.showServerImage}\n                    onChange={(e) => {\n                        setSettings({\n                            discord: {\n                                showServerImage: e.currentTarget.checked,\n                            },\n                        });\n                    }}\n                />\n            ),\n            description: t('setting.discordServeImage', {\n                context: 'description',\n\n                discord: 'Discord',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: !isElectron(),\n            title: t('setting.discordServeImage', {\n                discord: 'Discord',\n                postProcess: 'sentenceCase',\n            }),\n        },\n        {\n            control: (\n                <TextInput\n                    defaultValue={generalSettings.lastfmApiKey}\n                    onBlur={(e) => {\n                        setSettings({\n                            general: {\n                                lastfmApiKey: e.currentTarget.value,\n                            },\n                        });\n                    }}\n                />\n            ),\n            description: t('setting.lastfmApiKey', {\n                context: 'description',\n                lastfm: 'Last.fm',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: !isElectron(),\n            title: t('setting.lastfmApiKey', {\n                lastfm: 'Last.fm',\n                postProcess: 'sentenceCase',\n            }),\n        },\n    ];\n\n    return (\n        <SettingsSection\n            options={discordOptions}\n            title={t('page.setting.discord', { postProcess: 'sentenceCase' })}\n        />\n    );\n});\n"
  },
  {
    "path": "src/renderer/features/settings/components/window/password-settings.tsx",
    "content": "import isElectron from 'is-electron';\nimport { memo } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport {\n    SettingOption,\n    SettingsSection,\n} from '/@/renderer/features/settings/components/settings-section';\nimport { useGeneralSettings, useSettingsStoreActions } from '/@/renderer/store';\nimport { Select } from '/@/shared/components/select/select';\n\nconst localSettings = isElectron() ? window.api.localSettings : null;\n\nconst PASSWORD_SETTINGS: { label: string; value: string }[] = [\n    { label: 'libsecret', value: 'gnome_libsecret' },\n    { label: 'KDE 4 (kwallet4)', value: 'kwallet' },\n    { label: 'KDE 5 (kwallet5)', value: 'kwallet5' },\n    { label: 'KDE 6 (kwallet6)', value: 'kwallet6' },\n];\n\nexport const PasswordSettings = memo(() => {\n    const { t } = useTranslation();\n    const settings = useGeneralSettings();\n    const { setSettings } = useSettingsStoreActions();\n\n    const updateOptions: SettingOption[] = [\n        {\n            control: (\n                <Select\n                    aria-label={t('setting.passwordStore')}\n                    clearable={false}\n                    data={PASSWORD_SETTINGS}\n                    defaultValue={settings.passwordStore ?? 'gnome_libsecret'}\n                    disabled={!isElectron()}\n                    onChange={(e) => {\n                        if (!e) return;\n                        localSettings?.set('password_store', e);\n                        setSettings({\n                            general: {\n                                ...settings,\n                                passwordStore: e,\n                            },\n                        });\n                    }}\n                />\n            ),\n            description: t('setting.passwordStore', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: !isElectron(),\n            title: t('setting.passwordStore', { postProcess: 'sentenceCase' }),\n        },\n    ];\n\n    return <SettingsSection options={updateOptions} />;\n});\n"
  },
  {
    "path": "src/renderer/features/settings/components/window/remote-settings.tsx",
    "content": "import isElectron from 'is-electron';\nimport debounce from 'lodash/debounce';\nimport { memo } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport { SettingsSection } from '/@/renderer/features/settings/components/settings-section';\nimport { useRemoteSettings, useSettingsStoreActions } from '/@/renderer/store';\nimport { NumberInput } from '/@/shared/components/number-input/number-input';\nimport { Switch } from '/@/shared/components/switch/switch';\nimport { TextInput } from '/@/shared/components/text-input/text-input';\nimport { Text } from '/@/shared/components/text/text';\nimport { toast } from '/@/shared/components/toast/toast';\n\nconst remote = isElectron() ? window.api.remote : null;\n\nexport const RemoteSettings = memo(() => {\n    const { t } = useTranslation();\n    const settings = useRemoteSettings();\n    const { setSettings } = useSettingsStoreActions();\n\n    const url = `http://localhost:${settings.port}`;\n\n    const debouncedEnableRemote = debounce(async (enabled: boolean) => {\n        const errorMsg = await remote!.setRemoteEnabled(enabled);\n\n        if (errorMsg === null) {\n            setSettings({\n                remote: {\n                    enabled,\n                },\n            });\n        } else {\n            toast.error({\n                message: errorMsg,\n                title: enabled\n                    ? t('error.remoteEnableError', { postProcess: 'sentenceCase' })\n                    : t('error.remoteDisableError', { postProcess: 'sentenceCase' }),\n            });\n        }\n    }, 50);\n\n    const debouncedChangeRemotePort = debounce(async (port: number) => {\n        const errorMsg = await remote!.setRemotePort(port);\n        if (!errorMsg) {\n            setSettings({\n                remote: {\n                    port,\n                },\n            });\n            toast.warn({\n                message: t('error.remotePortWarning', { postProcess: 'sentenceCase' }),\n            });\n        } else {\n            toast.error({\n                message: errorMsg,\n                title: t('error.remotePortError', { postProcess: 'sentenceCase' }),\n            });\n        }\n    }, 100);\n\n    const isHidden = !isElectron();\n\n    const controlOptions = [\n        {\n            control: (\n                <Switch\n                    defaultChecked={settings.enabled}\n                    onChange={async (e) => {\n                        const enabled = e.currentTarget.checked;\n                        await debouncedEnableRemote(enabled);\n                    }}\n                />\n            ),\n            description: (\n                <Text isMuted isNoSelect size=\"sm\">\n                    {t('setting.enableRemote', {\n                        context: 'description',\n                        postProcess: 'sentenceCase',\n                    })}{' '}\n                    <a href={url} rel=\"noreferrer noopener\" target=\"_blank\">\n                        {url}\n                    </a>\n                </Text>\n            ),\n            isHidden,\n            title: t('setting.enableRemote', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <NumberInput\n                    max={65535}\n                    onBlur={async (e) => {\n                        if (!e) return;\n                        const port = Number(e.currentTarget.value);\n                        await debouncedChangeRemotePort(port);\n                    }}\n                    value={settings.port}\n                />\n            ),\n            description: t('setting.remotePort', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden,\n            title: t('setting.remotePort', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <TextInput\n                    defaultValue={settings.username}\n                    onBlur={(e) => {\n                        const username = e.currentTarget.value;\n                        if (username === settings.username) return;\n                        remote!.updateUsername(username);\n                        setSettings({\n                            remote: {\n                                username,\n                            },\n                        });\n                    }}\n                />\n            ),\n            description: t('setting.remoteUsername', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden,\n            title: t('setting.remoteUsername', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <TextInput\n                    defaultValue={settings.password}\n                    onBlur={(e) => {\n                        const password = e.currentTarget.value;\n                        if (password === settings.password) return;\n                        remote!.updatePassword(password);\n                        setSettings({\n                            remote: {\n                                password,\n                            },\n                        });\n                    }}\n                />\n            ),\n            description: t('setting.remotePassword', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden,\n            title: t('setting.remotePassword', { postProcess: 'sentenceCase' }),\n        },\n    ];\n\n    return (\n        <SettingsSection\n            options={controlOptions}\n            title={t('page.setting.remote', { postProcess: 'sentenceCase' })}\n        />\n    );\n});\n"
  },
  {
    "path": "src/renderer/features/settings/components/window/update-settings.tsx",
    "content": "import isElectron from 'is-electron';\nimport { memo } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport {\n    SettingOption,\n    SettingsSection,\n} from '/@/renderer/features/settings/components/settings-section';\nimport { useSettingsStoreActions, useWindowSettings } from '/@/renderer/store';\nimport { Select } from '/@/shared/components/select/select';\nimport { Switch } from '/@/shared/components/switch/switch';\n\nconst localSettings = isElectron() ? window.api.localSettings : null;\nconst utils = isElectron() ? window.api.utils : null;\n\nfunction disableAutoUpdates(): boolean {\n    return Boolean(!isElectron() || utils?.disableAutoUpdates());\n}\n\nexport const UpdateSettings = memo(() => {\n    const { t } = useTranslation();\n    const settings = useWindowSettings();\n    const { setSettings } = useSettingsStoreActions();\n\n    const updateOptions: SettingOption[] = [\n        {\n            control: (\n                <Select\n                    data={[\n                        {\n                            label: t('setting.releaseChannel', {\n                                context: 'optionLatest',\n                                postProcess: 'titleCase',\n                            }),\n                            value: 'latest',\n                        },\n                        {\n                            label: t('setting.releaseChannel', {\n                                context: 'optionBeta',\n                                postProcess: 'titleCase',\n                            }),\n                            value: 'beta',\n                        },\n                        {\n                            label: t('setting.releaseChannel', {\n                                context: 'optionAlpha',\n                                postProcess: 'titleCase',\n                            }),\n                            value: 'alpha',\n                        },\n                    ]}\n                    defaultValue={settings.releaseChannel || 'latest'}\n                    onChange={(value) => {\n                        if (!value) return;\n                        localSettings?.set('release_channel', value);\n                        setSettings({\n                            window: {\n                                releaseChannel: value as 'alpha' | 'beta' | 'latest',\n                            },\n                        });\n                    }}\n                />\n            ),\n            description: t('setting.releaseChannel', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: disableAutoUpdates(),\n            title: t('setting.releaseChannel', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <Switch\n                    aria-label={t('setting.automaticUpdates', { postProcess: 'sentenceCase' })}\n                    defaultChecked={!settings.disableAutoUpdate}\n                    disabled={disableAutoUpdates()}\n                    onChange={(e) => {\n                        if (!e) return;\n                        const enabled = e.currentTarget.checked;\n                        localSettings?.set('disable_auto_updates', !enabled);\n                        setSettings({\n                            window: {\n                                disableAutoUpdate: !enabled,\n                            },\n                        });\n                    }}\n                />\n            ),\n            description: t('setting.automaticUpdates', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: disableAutoUpdates(),\n            title: t('setting.automaticUpdates', { postProcess: 'sentenceCase' }),\n        },\n    ];\n\n    return (\n        <SettingsSection\n            options={updateOptions}\n            title={t('page.setting.updates', { postProcess: 'sentenceCase' })}\n        />\n    );\n});\n"
  },
  {
    "path": "src/renderer/features/settings/components/window/window-settings.tsx",
    "content": "import isElectron from 'is-electron';\nimport { memo } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport {\n    SettingOption,\n    SettingsSection,\n} from '/@/renderer/features/settings/components/settings-section';\nimport { openRestartRequiredToast } from '/@/renderer/features/settings/restart-toast';\nimport { useSettingsStoreActions, useWindowSettings } from '/@/renderer/store';\nimport { Select } from '/@/shared/components/select/select';\nimport { Switch } from '/@/shared/components/switch/switch';\nimport { Platform } from '/@/shared/types/types';\n\nconst WINDOW_BAR_OPTIONS = [\n    { label: 'Web (hidden)', value: Platform.WEB },\n    { label: 'Windows', value: Platform.WINDOWS },\n    { label: 'macOS', value: Platform.MACOS },\n    { label: 'Native', value: Platform.LINUX },\n];\n\nconst localSettings = isElectron() ? window.api.localSettings : null;\n\nexport const WindowSettings = memo(() => {\n    const { t } = useTranslation();\n    const settings = useWindowSettings();\n    const { setSettings } = useSettingsStoreActions();\n\n    const windowOptions: SettingOption[] = [\n        {\n            control: (\n                <Select\n                    data={WINDOW_BAR_OPTIONS}\n                    disabled={!isElectron()}\n                    onChange={(e) => {\n                        if (!e) return;\n\n                        // Platform.LINUX is used as the native frame option regardless of the actual platform\n                        const hasFrame = localSettings?.get('window_has_frame') as\n                            | boolean\n                            | undefined;\n                        const isSwitchingToFrame = !hasFrame && e === Platform.LINUX;\n                        const isSwitchingToNoFrame = hasFrame && e !== Platform.LINUX;\n\n                        const requireRestart = isSwitchingToFrame || isSwitchingToNoFrame;\n\n                        if (requireRestart) {\n                            openRestartRequiredToast();\n                        }\n\n                        localSettings?.set('window_window_bar_style', e as Platform);\n                        setSettings({\n                            window: {\n                                windowBarStyle: e as Platform,\n                            },\n                        });\n                    }}\n                    value={settings.windowBarStyle}\n                />\n            ),\n            description: t('setting.windowBarStyle', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: !isElectron(),\n            title: t('setting.windowBarStyle', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <Switch\n                    aria-label=\"toggle hiding tray\"\n                    defaultChecked={settings.tray}\n                    disabled={!isElectron()}\n                    onChange={(e) => {\n                        if (!e) return;\n                        localSettings?.set('window_enable_tray', e.currentTarget.checked);\n                        if (e.currentTarget.checked) {\n                            setSettings({\n                                window: {\n                                    tray: true,\n                                },\n                            });\n                        } else {\n                            localSettings?.set('window_start_minimized', false);\n                            localSettings?.set('window_exit_to_tray', false);\n                            localSettings?.set('window_minimize_to_tray', false);\n\n                            setSettings({\n                                window: {\n                                    exitToTray: false,\n                                    minimizeToTray: false,\n                                    startMinimized: false,\n                                    tray: false,\n                                },\n                            });\n                        }\n                    }}\n                />\n            ),\n            description: t('setting.trayEnabled', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: !isElectron(),\n            note: t('common.restartRequired', {\n                postProcess: 'sentenceCase',\n            }),\n            title: t('setting.trayEnabled', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <Switch\n                    aria-label=\"Toggle minimize to tray\"\n                    defaultChecked={settings.tray}\n                    disabled={!isElectron()}\n                    onChange={(e) => {\n                        if (!e) return;\n                        localSettings?.set('window_minimize_to_tray', e.currentTarget.checked);\n                        setSettings({\n                            window: {\n                                minimizeToTray: e.currentTarget.checked,\n                            },\n                        });\n                    }}\n                />\n            ),\n            description: t('setting.minimizeToTray', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: !isElectron() || !settings.tray,\n            title: t('setting.minimizeToTray', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <Switch\n                    aria-label=\"Toggle exit to tray\"\n                    defaultChecked={settings.exitToTray}\n                    disabled={!isElectron()}\n                    onChange={(e) => {\n                        if (!e) return;\n                        localSettings?.set('window_exit_to_tray', e.currentTarget.checked);\n                        setSettings({\n                            window: {\n                                exitToTray: e.currentTarget.checked,\n                            },\n                        });\n                    }}\n                />\n            ),\n            description: t('setting.exitToTray', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: !isElectron() || !settings.tray,\n            title: t('setting.exitToTray', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <Switch\n                    aria-label=\"Toggle start in tray\"\n                    defaultChecked={settings.startMinimized}\n                    disabled={!isElectron()}\n                    onChange={(e) => {\n                        if (!e) return;\n                        localSettings?.set('window_start_minimized', e.currentTarget.checked);\n                        setSettings({\n                            window: {\n                                startMinimized: e.currentTarget.checked,\n                            },\n                        });\n                    }}\n                />\n            ),\n            description: t('setting.startMinimized', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: !isElectron() || !settings.tray,\n            title: t('setting.startMinimized', { postProcess: 'sentenceCase' }),\n        },\n        {\n            control: (\n                <Switch\n                    aria-label=\"Toggle prevent sleep on playback\"\n                    defaultChecked={settings.preventSleepOnPlayback}\n                    disabled={!isElectron()}\n                    onChange={(e) => {\n                        if (!e) return;\n                        localSettings?.set(\n                            'window_prevent_sleep_on_playback',\n                            e.currentTarget.checked,\n                        );\n                        setSettings({\n                            window: {\n                                preventSleepOnPlayback: e.currentTarget.checked,\n                            },\n                        });\n                    }}\n                />\n            ),\n            description: t('setting.preventSleepOnPlayback', {\n                context: 'description',\n                postProcess: 'sentenceCase',\n            }),\n            isHidden: !isElectron(),\n            title: t('setting.preventSleepOnPlayback', { postProcess: 'sentenceCase' }),\n        },\n    ];\n\n    return (\n        <SettingsSection\n            options={windowOptions}\n            title={t('page.setting.application', { postProcess: 'sentenceCase' })}\n        />\n    );\n});\n"
  },
  {
    "path": "src/renderer/features/settings/components/window/window-tab.tsx",
    "content": "import isElectron from 'is-electron';\nimport { memo } from 'react';\nimport { Fragment } from 'react/jsx-runtime';\n\nimport { DiscordSettings } from '/@/renderer/features/settings/components/window/discord-settings';\nimport { PasswordSettings } from '/@/renderer/features/settings/components/window/password-settings';\nimport { RemoteSettings } from '/@/renderer/features/settings/components/window/remote-settings';\nimport { WindowSettings } from '/@/renderer/features/settings/components/window/window-settings';\nimport { Divider } from '/@/shared/components/divider/divider';\nimport { Stack } from '/@/shared/components/stack/stack';\n\nconst utils = isElectron() ? window.api.utils : null;\n\nconst sections = [\n    { component: WindowSettings, key: 'window' },\n    { component: DiscordSettings, key: 'discord' },\n    { component: RemoteSettings, key: 'remote' },\n    { component: PasswordSettings, hidden: !utils?.isLinux(), key: 'password' },\n];\n\nexport const WindowTab = memo(() => {\n    return (\n        <Stack gap=\"md\">\n            {sections.map(({ component: Section, hidden, key }, index) => (\n                <Fragment key={key}>\n                    {!hidden && <Section />}\n                    {index < sections.length - 1 && <Divider />}\n                </Fragment>\n            ))}\n        </Stack>\n    );\n});\n"
  },
  {
    "path": "src/renderer/features/settings/context/search-context.tsx",
    "content": "import { createContext, useContext } from 'react';\n\nexport const SettingSearchContext = createContext<string>('');\n\nexport const useSettingSearchContext = () => {\n    const ctxValue = useContext(SettingSearchContext);\n    return ctxValue;\n};\n"
  },
  {
    "path": "src/renderer/features/settings/restart-toast.ts",
    "content": "import { t } from 'i18next';\nimport isElectron from 'is-electron';\n\nimport { toast } from '/@/shared/components/toast/toast';\n\nconst ipc = isElectron() ? window.api.ipc : null;\n\nexport const openRestartRequiredToast = (message?: string) => {\n    return toast.warn({\n        autoClose: false,\n        id: 'restart-toast',\n        message:\n            message ||\n            t('common.forceRestartRequired', {\n                postProcess: 'sentenceCase',\n            }),\n        onClose: () => {\n            ipc?.send('app-restart');\n        },\n        title: t('common.restartRequired', {\n            postProcess: 'sentenceCase',\n        }),\n    });\n};\n"
  },
  {
    "path": "src/renderer/features/settings/routes/settings-route.tsx",
    "content": "import { lazy, Suspense, useState } from 'react';\n\nimport { SettingsContent } from '/@/renderer/features/settings/components/settings-content';\nimport { SettingSearchContext } from '/@/renderer/features/settings/context/search-context';\nimport { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';\nimport { LibraryContainer } from '/@/renderer/features/shared/components/library-container';\nimport { Flex } from '/@/shared/components/flex/flex';\n\nconst SettingsHeader = lazy(() =>\n    import('/@/renderer/features/settings/components/settings-header').then((module) => ({\n        default: module.SettingsHeader,\n    })),\n);\n\nconst SettingsRoute = () => {\n    const [search, setSearch] = useState('');\n\n    return (\n        <AnimatedPage>\n            <SettingSearchContext.Provider value={search}>\n                <LibraryContainer>\n                    <Flex direction=\"column\" h=\"100%\" w=\"100%\">\n                        <Suspense fallback={<></>}>\n                            <SettingsHeader setSearch={setSearch} />\n                        </Suspense>\n                        <SettingsContent />\n                    </Flex>\n                </LibraryContainer>\n            </SettingSearchContext.Provider>\n        </AnimatedPage>\n    );\n};\n\nexport default SettingsRoute;\n"
  },
  {
    "path": "src/renderer/features/settings/utils/open-settings-modal.ts",
    "content": "import { openContextModal } from '@mantine/modals';\n\nexport const openSettingsModal = () => {\n    openContextModal({\n        innerProps: {},\n        modalKey: 'settings',\n        overlayProps: {\n            opacity: 1,\n        },\n        size: '60rem',\n        styles: {\n            content: {\n                height: '100%',\n                maxWidth: '90%',\n                width: '100%',\n            },\n        },\n        transitionProps: {\n            transition: 'pop',\n        },\n    });\n};\n"
  },
  {
    "path": "src/renderer/features/shared/api/shared-api.ts",
    "content": "import { queryOptions } from '@tanstack/react-query';\n\nimport { api } from '/@/renderer/api';\nimport { queryKeys } from '/@/renderer/api/query-keys';\nimport { QueryHookArgs } from '/@/renderer/lib/react-query';\nimport { MusicFolderListQuery, TagListQuery, UserListQuery } from '/@/shared/types/domain-types';\n\nexport const sharedQueries = {\n    musicFolders: (args: QueryHookArgs<MusicFolderListQuery>) => {\n        return queryOptions({\n            queryFn: ({ signal }) => {\n                return api.controller.getMusicFolderList({\n                    apiClientProps: { serverId: args.serverId, signal },\n                });\n            },\n            queryKey: queryKeys.musicFolders.list(args.serverId),\n            ...args.options,\n        });\n    },\n    roles: (args: QueryHookArgs<object>) => {\n        return queryOptions({\n            queryFn: ({ signal }) => {\n                return api.controller.getRoles({\n                    apiClientProps: { serverId: args.serverId, signal },\n                });\n            },\n            queryKey: queryKeys.roles.list(args.serverId || ''),\n            ...args.options,\n        });\n    },\n    tagList: (args: QueryHookArgs<TagListQuery>) => {\n        return queryOptions({\n            gcTime: 1000 * 60 * 24,\n            queryFn: ({ signal }) => {\n                return api.controller.getTagList({\n                    apiClientProps: { serverId: args.serverId, signal },\n                    query: args.query,\n                });\n            },\n            queryKey: queryKeys.tags.list(args.serverId || '', args.query.type),\n            staleTime: 1000 * 60 * 24,\n            structuralSharing: false,\n            ...args.options,\n        });\n    },\n    users: (args: QueryHookArgs<UserListQuery>) => {\n        return queryOptions({\n            queryFn: ({ signal }) => {\n                return api.controller.getUserList({\n                    apiClientProps: { serverId: args.serverId, signal },\n                    query: args.query,\n                });\n            },\n            queryKey: queryKeys.users.list(args.serverId || '', args.query),\n            ...args.options,\n        });\n    },\n};\n"
  },
  {
    "path": "src/renderer/features/shared/components/animated-page.module.css",
    "content": ".animated-page {\n    position: relative;\n    display: flex;\n    flex-direction: column;\n    width: 100%;\n    height: 100%;\n    container-type: inline-size;\n    overflow: hidden;\n}\n"
  },
  {
    "path": "src/renderer/features/shared/components/animated-page.tsx",
    "content": "import type { ReactNode, Ref } from 'react';\n\nimport { motion } from 'motion/react';\nimport { forwardRef } from 'react';\n\nimport styles from './animated-page.module.css';\n\nimport { animationProps } from '/@/shared/components/animations/animation-props';\n\ninterface AnimatedPageProps {\n    children: ReactNode;\n}\n\nexport const AnimatedPage = forwardRef(\n    ({ children }: AnimatedPageProps, ref: Ref<HTMLDivElement>) => {\n        return (\n            <motion.main\n                className={styles.animatedPage}\n                ref={ref}\n                {...{ ...animationProps.fadeIn, transition: { duration: 0.3, ease: 'anticipate' } }}\n            >\n                {children}\n            </motion.main>\n        );\n    },\n);\n"
  },
  {
    "path": "src/renderer/features/shared/components/component-error-boundary.tsx",
    "content": "import { ErrorBoundary } from 'react-error-boundary';\nimport { useTranslation } from 'react-i18next';\n\nimport { Box } from '/@/shared/components/box/box';\nimport { Button } from '/@/shared/components/button/button';\nimport { Center } from '/@/shared/components/center/center';\nimport { Group } from '/@/shared/components/group/group';\nimport { Icon } from '/@/shared/components/icon/icon';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { TextTitle } from '/@/shared/components/text-title/text-title';\n\ninterface ComponentErrorFallbackProps {\n    error: Error;\n    resetErrorBoundary: () => void;\n}\n\nconst ComponentErrorFallback = ({ resetErrorBoundary }: ComponentErrorFallbackProps) => {\n    const { t } = useTranslation();\n\n    return (\n        <Box h=\"100%\" pos=\"relative\" w=\"100%\">\n            <Center h=\"100%\" p=\"md\" w=\"100%\">\n                <Stack maw=\"800px\">\n                    <Group gap=\"xs\">\n                        <Icon fill=\"error\" icon=\"error\" size=\"lg\" />\n                        <TextTitle fw={600} order={4}>\n                            {t('error.genericError', { postProcess: 'sentenceCase' })}\n                        </TextTitle>\n                    </Group>\n                    <Group grow>\n                        <Button onClick={resetErrorBoundary} size=\"xs\" variant=\"default\">\n                            {t('common.reload', { postProcess: 'sentenceCase' })}\n                        </Button>\n                    </Group>\n                </Stack>\n            </Center>\n        </Box>\n    );\n};\n\ninterface ComponentErrorBoundaryProps {\n    children: React.ReactNode;\n}\n\nexport const ComponentErrorBoundary = ({ children }: ComponentErrorBoundaryProps) => {\n    return <ErrorBoundary FallbackComponent={ComponentErrorFallback}>{children}</ErrorBoundary>;\n};\n"
  },
  {
    "path": "src/renderer/features/shared/components/display-type-toggle-button.tsx",
    "content": "import { useTranslation } from 'react-i18next';\n\nimport { ActionIcon, ActionIconProps } from '/@/shared/components/action-icon/action-icon';\nimport { ListDisplayType } from '/@/shared/types/types';\n\ninterface DisplayTypeToggleButtonProps {\n    buttonProps?: Partial<ActionIconProps>;\n    displayType: ListDisplayType;\n    onToggle: () => void;\n}\n\nexport const DisplayTypeToggleButton = ({\n    buttonProps,\n    displayType,\n    onToggle,\n}: DisplayTypeToggleButtonProps) => {\n    const { t } = useTranslation();\n    const isGrid = displayType === ListDisplayType.GRID;\n    const isDetail = displayType === ListDisplayType.DETAIL;\n\n    return (\n        <ActionIcon\n            icon={isGrid ? 'layoutGrid' : isDetail ? 'layoutDetail' : 'layoutTable'}\n            iconProps={{\n                size: 'lg',\n            }}\n            onClick={onToggle}\n            tooltip={{\n                label: isGrid\n                    ? t('table.config.view.grid', { postProcess: 'sentenceCase' })\n                    : isDetail\n                      ? t('table.config.view.detail', { postProcess: 'sentenceCase' })\n                      : t('table.config.view.table', { postProcess: 'sentenceCase' }),\n            }}\n            variant=\"subtle\"\n            {...buttonProps}\n        />\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/shared/components/filter-bar.module.css",
    "content": ".filter-bar {\n    z-index: 1;\n    padding: var(--theme-spacing-sm);\n    border-bottom: 1px solid var(--theme-colors-border);\n}\n"
  },
  {
    "path": "src/renderer/features/shared/components/filter-bar.tsx",
    "content": "import styles from './filter-bar.module.css';\n\nexport const FilterBar = ({ children, ...props }: React.HTMLAttributes<HTMLDivElement>) => {\n    return (\n        <div className={styles.filterBar} {...props}>\n            {children}\n        </div>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/shared/components/filter-button.tsx",
    "content": "import { useTranslation } from 'react-i18next';\n\nimport { ActionIcon, ActionIconProps } from '/@/shared/components/action-icon/action-icon';\n\ninterface FilterButtonProps extends ActionIconProps {\n    isActive?: boolean;\n}\n\nexport const FilterButton = ({ isActive, onClick, ...props }: FilterButtonProps) => {\n    const { t } = useTranslation();\n\n    return (\n        <ActionIcon\n            icon=\"filter\"\n            iconProps={{\n                fill: isActive ? 'primary' : undefined,\n                size: 'lg',\n                ...props.iconProps,\n            }}\n            onClick={onClick}\n            tooltip={{\n                label: t('common.filters', { count: 2, postProcess: 'sentenceCase' }),\n                ...props.tooltip,\n            }}\n            variant=\"subtle\"\n            {...props}\n        />\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/shared/components/folder-button.tsx",
    "content": "import { useTranslation } from 'react-i18next';\n\nimport { ActionIcon, ActionIconProps } from '/@/shared/components/action-icon/action-icon';\n\ninterface FolderButtonProps extends ActionIconProps {\n    isActive?: boolean;\n}\n\nexport const FolderButton = ({ isActive, ...props }: FolderButtonProps) => {\n    const { t } = useTranslation();\n\n    return (\n        <ActionIcon\n            icon=\"folder\"\n            iconProps={{\n                color: isActive ? 'primary' : undefined,\n                size: 'lg',\n                ...props.iconProps,\n            }}\n            tooltip={{\n                label: t('entity.folder', { count: 1, postProcess: 'sentenceCase' }),\n                ...props.tooltip,\n            }}\n            variant=\"subtle\"\n            {...props}\n        />\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/shared/components/grid-config.tsx",
    "content": "import {\n    attachClosestEdge,\n    type Edge,\n    extractClosestEdge,\n} from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge';\nimport { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';\nimport {\n    draggable,\n    dropTargetForElements,\n} from '@atlaskit/pragmatic-drag-and-drop/element/adapter';\nimport { disableNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/disable-native-drag-preview';\nimport clsx from 'clsx';\nimport Fuse, { FuseResultMatch } from 'fuse.js';\nimport { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport styles from './table-config.module.css';\n\nimport { ListConfigTable } from '/@/renderer/features/shared/components/list-config-menu';\nimport {\n    DataGridProps,\n    ItemGridListRowConfig,\n    ItemListSettings,\n    useSettingsStore,\n    useSettingsStoreActions,\n} from '/@/renderer/store';\nimport { ActionIcon, ActionIconGroup } from '/@/shared/components/action-icon/action-icon';\nimport { Badge } from '/@/shared/components/badge/badge';\nimport { Checkbox } from '/@/shared/components/checkbox/checkbox';\nimport { Divider } from '/@/shared/components/divider/divider';\nimport { Group } from '/@/shared/components/group/group';\nimport { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control';\nimport { Slider } from '/@/shared/components/slider/slider';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { TextInput } from '/@/shared/components/text-input/text-input';\nimport { Text } from '/@/shared/components/text/text';\nimport { useDebouncedState } from '/@/shared/hooks/use-debounced-state';\nimport { dndUtils, DragData, DragOperation, DragTarget } from '/@/shared/types/drag-and-drop';\nimport { ItemListKey, ListPaginationType } from '/@/shared/types/types';\n\ntype GridConfigProps = {\n    extraOptions?: {\n        component: React.ReactNode;\n        id: string;\n        label: string;\n    }[];\n    gridRowsData: { label: string; value: string }[];\n    listKey: ItemListKey;\n    optionsConfig?: {\n        [key: string]: {\n            disabled?: boolean;\n            hidden?: boolean;\n        };\n    };\n};\n\nexport const GridConfig = ({\n    extraOptions,\n    gridRowsData,\n    listKey,\n    optionsConfig,\n}: GridConfigProps) => {\n    const { t } = useTranslation();\n\n    const list = useSettingsStore((state) => state.lists[listKey]) as ItemListSettings;\n    const grid = list.grid as DataGridProps;\n    const { setList } = useSettingsStoreActions();\n\n    const options = useMemo(() => {\n        const allOptions = [\n            {\n                component: (\n                    <SegmentedControl\n                        data={[\n                            {\n                                label: t('table.config.general.pagination_infinite', {\n                                    postProcess: 'sentenceCase',\n                                }),\n                                value: ListPaginationType.INFINITE,\n                            },\n                            {\n                                label: t('table.config.general.pagination_paginate', {\n                                    postProcess: 'sentenceCase',\n                                }),\n                                value: ListPaginationType.PAGINATED,\n                            },\n                        ]}\n                        onChange={(value) =>\n                            setList(listKey, {\n                                ...list,\n                                pagination: value as ListPaginationType,\n                            })\n                        }\n                        size=\"sm\"\n                        value={list.pagination}\n                        w=\"100%\"\n                    />\n                ),\n                id: 'pagination',\n                label: t('table.config.general.pagination', { postProcess: 'sentenceCase' }),\n                size: 'sm',\n            },\n            {\n                component: (\n                    <Slider\n                        defaultValue={list.itemsPerPage}\n                        marks={[\n                            { value: 25 },\n                            { value: 50 },\n                            { value: 100 },\n                            { value: 150 },\n                            { value: 200 },\n                            { value: 250 },\n                            { value: 300 },\n                            { value: 400 },\n                            { value: 500 },\n                        ]}\n                        max={500}\n                        min={25}\n                        onChangeEnd={(value) => setList(listKey, { ...list, itemsPerPage: value })}\n                        restrictToMarks\n                        w=\"100%\"\n                    />\n                ),\n                id: 'itemsPerPage',\n                label: (\n                    <Group>\n                        {t('table.config.general.pagination_itemsPerPage', {\n                            postProcess: 'sentenceCase',\n                        })}\n                        <Badge>{list.itemsPerPage}</Badge>\n                    </Group>\n                ),\n            },\n            {\n                component: (\n                    <Group gap=\"xs\" grow w=\"100%\">\n                        <ActionIcon\n                            disabled={grid.itemGap === 'xl'}\n                            icon=\"arrowUp\"\n                            iconProps={{ size: 'lg' }}\n                            onClick={() => {\n                                if (grid.itemGap === 'xl') return;\n\n                                if (grid.itemGap === 'lg') {\n                                    return setList(listKey, { grid: { itemGap: 'xl' } });\n                                }\n\n                                if (grid.itemGap === 'md') {\n                                    return setList(listKey, { grid: { itemGap: 'lg' } });\n                                }\n\n                                if (grid.itemGap === 'sm') {\n                                    return setList(listKey, { grid: { itemGap: 'md' } });\n                                }\n\n                                return setList(listKey, { grid: { itemGap: 'sm' } });\n                            }}\n                            size=\"xs\"\n                        />\n                        <ActionIcon\n                            disabled={grid.itemGap === 'xs'}\n                            icon=\"arrowDown\"\n                            iconProps={{ size: 'lg' }}\n                            onClick={() => {\n                                if (grid.itemGap === 'xs') return;\n\n                                if (grid.itemGap === 'sm') {\n                                    return setList(listKey, { grid: { itemGap: 'xs' } });\n                                }\n\n                                if (grid.itemGap === 'md') {\n                                    return setList(listKey, { grid: { itemGap: 'sm' } });\n                                }\n\n                                if (grid.itemGap === 'lg') {\n                                    return setList(listKey, { grid: { itemGap: 'md' } });\n                                }\n\n                                return setList(listKey, { grid: { itemGap: 'lg' } });\n                            }}\n                            size=\"xs\"\n                        />\n                    </Group>\n                ),\n                id: 'itemGap',\n                label: (\n                    <Group>\n                        {t('table.config.general.gap', { postProcess: 'sentenceCase' })}\n                        <Badge>{grid.itemGap}</Badge>\n                    </Group>\n                ),\n            },\n            {\n                component: (\n                    <Slider\n                        defaultValue={grid.itemsPerRow}\n                        max={20}\n                        min={2}\n                        onChangeEnd={(value) => setList(listKey, { grid: { itemsPerRow: value } })}\n                        w=\"100%\"\n                    />\n                ),\n                id: 'itemsPerRow',\n                label: (\n                    <Group justify=\"space-between\" w=\"100%\" wrap=\"nowrap\">\n                        <Group>\n                            {t('table.config.general.itemsPerRow', { postProcess: 'sentenceCase' })}\n                            <Badge>{grid.itemsPerRow}</Badge>\n                        </Group>\n                        <Checkbox\n                            checked={grid.itemsPerRowEnabled}\n                            label={t('common.enable', { postProcess: 'titleCase' })}\n                            onChange={(e) =>\n                                setList(listKey, {\n                                    grid: { itemsPerRowEnabled: e.target.checked },\n                                })\n                            }\n                            pr=\"md\"\n                            size=\"xs\"\n                        />\n                    </Group>\n                ),\n            },\n            {\n                component: (\n                    <SegmentedControl\n                        data={[\n                            {\n                                label: t('table.config.general.size_compact', {\n                                    postProcess: 'titleCase',\n                                }),\n                                value: 'compact',\n                            },\n                            {\n                                label: t('table.config.general.size_default', {\n                                    postProcess: 'titleCase',\n                                }),\n                                value: 'default',\n                            },\n                            {\n                                label: t('table.config.general.size_large', {\n                                    postProcess: 'titleCase',\n                                }),\n                                value: 'large',\n                            },\n                        ]}\n                        onChange={(value) =>\n                            setList(listKey, {\n                                grid: { size: value as 'compact' | 'default' | 'large' },\n                            })\n                        }\n                        size=\"sm\"\n                        value={grid.size || 'default'}\n                        w=\"100%\"\n                    />\n                ),\n                id: 'size',\n                label: t('table.config.general.size', { postProcess: 'sentenceCase' }),\n                size: 'sm',\n            },\n\n            ...(extraOptions || []),\n        ];\n\n        // Filter and apply config (hidden/disabled)\n        return allOptions\n            .map((option) => {\n                const config = optionsConfig?.[option.id];\n                if (config?.hidden) {\n                    return null;\n                }\n                return {\n                    ...option,\n                    disabled: config?.disabled || false,\n                };\n            })\n            .filter(\n                (option): option is (typeof allOptions)[0] & { disabled: boolean } =>\n                    option !== null,\n            );\n    }, [list, t, grid, extraOptions, optionsConfig, setList, listKey]);\n\n    return (\n        <>\n            <ListConfigTable options={options} />\n            <Divider />\n            <GridRowConfig\n                data={gridRowsData}\n                onChange={(rows) => setList(listKey, { grid: { rows } })}\n                value={grid.rows}\n            />\n        </>\n    );\n};\n\nconst GridRowConfig = ({\n    data,\n    onChange,\n    value,\n}: {\n    data: { label: string; value: string }[];\n    onChange: (value: ItemGridListRowConfig[]) => void;\n    value: ItemGridListRowConfig[];\n}) => {\n    const { t } = useTranslation();\n\n    const valueRef = useRef(value);\n    const onChangeRef = useRef(onChange);\n\n    useLayoutEffect(() => {\n        valueRef.current = value;\n        onChangeRef.current = onChange;\n    });\n\n    const labelMap = useMemo(() => {\n        return data.reduce(\n            (acc, item) => {\n                acc[item.value] = item.label;\n                return acc;\n            },\n            {} as Record<string, string>,\n        );\n    }, [data]);\n\n    const handleChangeEnabled = useCallback((item: ItemGridListRowConfig, checked: boolean) => {\n        const currentValue = valueRef.current;\n        const index = currentValue.findIndex((v) => v.id === item.id);\n        const newValues = [...currentValue];\n        newValues[index] = { ...newValues[index], isEnabled: checked };\n        onChangeRef.current(newValues);\n    }, []);\n\n    const handleMoveUp = useCallback((item: ItemGridListRowConfig) => {\n        const currentValue = valueRef.current;\n        const index = currentValue.findIndex((v) => v.id === item.id);\n        if (index === 0) return;\n        const newValues = [...currentValue];\n        [newValues[index], newValues[index - 1]] = [newValues[index - 1], newValues[index]];\n        onChangeRef.current(newValues);\n    }, []);\n\n    const handleMoveDown = useCallback((item: ItemGridListRowConfig) => {\n        const currentValue = valueRef.current;\n        const index = currentValue.findIndex((v) => v.id === item.id);\n        if (index === currentValue.length - 1) return;\n        const newValues = [...currentValue];\n        [newValues[index], newValues[index + 1]] = [newValues[index + 1], newValues[index]];\n        onChangeRef.current(newValues);\n    }, []);\n\n    const handleAlignLeft = useCallback((item: ItemGridListRowConfig) => {\n        const currentValue = valueRef.current;\n        const index = currentValue.findIndex((v) => v.id === item.id);\n        const newValues = [...currentValue];\n        newValues[index] = { ...newValues[index], align: 'start' };\n        onChangeRef.current(newValues);\n    }, []);\n\n    const handleAlignCenter = useCallback((item: ItemGridListRowConfig) => {\n        const currentValue = valueRef.current;\n        const index = currentValue.findIndex((v) => v.id === item.id);\n        const newValues = [...currentValue];\n        newValues[index] = { ...newValues[index], align: 'center' };\n        onChangeRef.current(newValues);\n    }, []);\n\n    const handleAlignRight = useCallback((item: ItemGridListRowConfig) => {\n        const currentValue = valueRef.current;\n        const index = currentValue.findIndex((v) => v.id === item.id);\n        const newValues = [...currentValue];\n        newValues[index] = { ...newValues[index], align: 'end' };\n        onChangeRef.current(newValues);\n    }, []);\n\n    const [searchRows, setSearchRows] = useDebouncedState('', 300);\n\n    const fuse = useMemo(() => {\n        return new Fuse(value, {\n            getFn: (obj) => {\n                return labelMap[obj.id] || '';\n            },\n            includeMatches: true,\n            includeScore: true,\n            keys: ['id', 'label'],\n            threshold: 0.3,\n        });\n    }, [value, labelMap]);\n\n    const filteredRows = useMemo(() => {\n        if (!searchRows.trim()) {\n            return value.map((item) => ({ item, matches: null }));\n        }\n\n        const results = fuse.search(searchRows);\n        const resultMap = new Map(results.map((result) => [result.item.id, result.matches]));\n\n        return value.map((item) => ({\n            item,\n            matches: resultMap.get(item.id) || null,\n        }));\n    }, [value, searchRows, fuse]);\n\n    const handleReorder = useCallback((idFrom: string, idTo: string, edge: Edge | null) => {\n        const currentValue = valueRef.current;\n        const idList = currentValue.map((item) => item.id);\n        const newIdOrder = dndUtils.reorderById({\n            edge,\n            idFrom,\n            idTo,\n            list: idList,\n        });\n\n        // Map the new ID order back to full items\n        const newOrder = newIdOrder.map((id) => currentValue.find((item) => item.id === id)!);\n        onChangeRef.current(newOrder);\n    }, []);\n\n    return (\n        <Stack gap=\"xs\">\n            <Group justify=\"space-between\" mb=\"md\">\n                <Text size=\"sm\">{t('common.gridRows', { postProcess: 'sentenceCase' })}</Text>\n                <TextInput\n                    onChange={(e) => setSearchRows(e.currentTarget.value)}\n                    placeholder={t('common.search', {\n                        postProcess: 'sentenceCase',\n                    })}\n                    size=\"xs\"\n                />\n            </Group>\n            <div style={{ userSelect: 'none' }}>\n                {filteredRows.map(({ item, matches }) => (\n                    <GridRowItem\n                        handleAlignCenter={handleAlignCenter}\n                        handleAlignLeft={handleAlignLeft}\n                        handleAlignRight={handleAlignRight}\n                        handleChangeEnabled={handleChangeEnabled}\n                        handleMoveDown={handleMoveDown}\n                        handleMoveUp={handleMoveUp}\n                        handleReorder={handleReorder}\n                        item={item}\n                        key={item.id}\n                        label={labelMap[item.id]}\n                        matches={matches}\n                    />\n                ))}\n            </div>\n        </Stack>\n    );\n};\n\nconst DragHandle = ({\n    dragHandleRef,\n}: {\n    dragHandleRef: React.RefObject<HTMLButtonElement | null>;\n}) => {\n    return (\n        <ActionIcon\n            icon=\"dragVertical\"\n            iconProps={{\n                size: 'md',\n            }}\n            ref={dragHandleRef}\n            size=\"xs\"\n            style={{ cursor: 'grab' }}\n            variant=\"default\"\n        />\n    );\n};\n\nconst GridRowItem = memo(\n    ({\n        handleAlignCenter,\n        handleAlignLeft,\n        handleAlignRight,\n        handleChangeEnabled,\n        handleMoveDown,\n        handleMoveUp,\n        handleReorder,\n        item,\n        label,\n        matches,\n    }: {\n        handleAlignCenter: (item: ItemGridListRowConfig) => void;\n        handleAlignLeft: (item: ItemGridListRowConfig) => void;\n        handleAlignRight: (item: ItemGridListRowConfig) => void;\n        handleChangeEnabled: (item: ItemGridListRowConfig, checked: boolean) => void;\n        handleMoveDown: (item: ItemGridListRowConfig) => void;\n        handleMoveUp: (item: ItemGridListRowConfig) => void;\n        handleReorder: (idFrom: string, idTo: string, edge: Edge | null) => void;\n        item: ItemGridListRowConfig;\n        label: string;\n        matches: null | readonly FuseResultMatch[];\n    }) => {\n        const { t } = useTranslation();\n        const ref = useRef<HTMLDivElement>(null);\n        const dragHandleRef = useRef<HTMLButtonElement | null>(null);\n        const [isDragging, setIsDragging] = useState(false);\n        const [isDraggedOver, setIsDraggedOver] = useState<Edge | null>(null);\n\n        useEffect(() => {\n            if (!ref.current || !dragHandleRef.current) {\n                return;\n            }\n\n            return combine(\n                draggable({\n                    element: dragHandleRef.current,\n                    getInitialData: () => {\n                        const data = dndUtils.generateDragData({\n                            id: [item.id],\n                            operation: [DragOperation.REORDER],\n                            type: DragTarget.GRID_ROW,\n                        });\n                        return data;\n                    },\n                    onDragStart: () => {\n                        setIsDragging(true);\n                    },\n                    onDrop: () => {\n                        setIsDragging(false);\n                    },\n                    onGenerateDragPreview: (data) => {\n                        disableNativeDragPreview({ nativeSetDragImage: data.nativeSetDragImage });\n                    },\n                }),\n                dropTargetForElements({\n                    canDrop: (args) => {\n                        const data = args.source.data as unknown as DragData;\n                        const isSelf = (args.source.data.id as string[])[0] === item.id;\n                        return dndUtils.isDropTarget(data.type, [DragTarget.GRID_ROW]) && !isSelf;\n                    },\n                    element: ref.current,\n                    getData: ({ element, input }) => {\n                        const data = dndUtils.generateDragData({\n                            id: [item.id],\n                            operation: [DragOperation.REORDER],\n                            type: DragTarget.GRID_ROW,\n                        });\n\n                        return attachClosestEdge(data, {\n                            allowedEdges: ['top', 'bottom'],\n                            element,\n                            input,\n                        });\n                    },\n                    onDrag: (args) => {\n                        const closestEdgeOfTarget: Edge | null = extractClosestEdge(args.self.data);\n                        setIsDraggedOver(closestEdgeOfTarget);\n                    },\n                    onDragLeave: () => {\n                        setIsDraggedOver(null);\n                    },\n                    onDrop: (args) => {\n                        const closestEdgeOfTarget: Edge | null = extractClosestEdge(args.self.data);\n\n                        const from = args.source.data.id as string[];\n                        const to = args.self.data.id as string[];\n\n                        handleReorder(from[0], to[0], closestEdgeOfTarget);\n                        setIsDraggedOver(null);\n                    },\n                }),\n            );\n        }, [item.id, handleReorder]);\n\n        return (\n            <div\n                className={clsx(styles.item, {\n                    [styles.draggedOverBottom]: isDraggedOver === 'bottom',\n                    [styles.draggedOverTop]: isDraggedOver === 'top',\n                    [styles.dragging]: isDragging,\n                    [styles.matched]: matches && matches.length > 0,\n                })}\n                ref={ref}\n            >\n                <Group wrap=\"nowrap\">\n                    <DragHandle dragHandleRef={dragHandleRef} />\n                    <Checkbox\n                        checked={item.isEnabled}\n                        id={item.id}\n                        label={label}\n                        onChange={(e) => handleChangeEnabled(item, e.currentTarget.checked)}\n                        size=\"sm\"\n                    />\n                </Group>\n                <Group wrap=\"nowrap\">\n                    <ActionIconGroup className={styles.group}>\n                        <ActionIcon\n                            icon=\"arrowUp\"\n                            iconProps={{ size: 'md' }}\n                            onClick={() => handleMoveUp(item)}\n                            size=\"xs\"\n                            tooltip={{\n                                label: t('table.config.general.moveUp', {\n                                    postProcess: 'sentenceCase',\n                                }),\n                            }}\n                            variant=\"subtle\"\n                        />\n                        <ActionIcon\n                            icon=\"arrowDown\"\n                            iconProps={{ size: 'md' }}\n                            onClick={() => handleMoveDown(item)}\n                            size=\"xs\"\n                            tooltip={{\n                                label: t('table.config.general.moveDown', {\n                                    postProcess: 'sentenceCase',\n                                }),\n                            }}\n                            variant=\"subtle\"\n                        />\n                    </ActionIconGroup>\n                    <ActionIconGroup className={styles.group}>\n                        <ActionIcon\n                            icon=\"alignLeft\"\n                            iconProps={{ size: 'md' }}\n                            onClick={() => handleAlignLeft(item)}\n                            size=\"xs\"\n                            tooltip={{\n                                label: t('table.config.general.alignLeft', {\n                                    postProcess: 'sentenceCase',\n                                }),\n                            }}\n                            variant={item.align === 'start' ? 'filled' : 'subtle'}\n                        />\n                        <ActionIcon\n                            icon=\"alignCenter\"\n                            iconProps={{ size: 'md' }}\n                            onClick={() => handleAlignCenter(item)}\n                            size=\"xs\"\n                            tooltip={{\n                                label: t('table.config.general.alignCenter', {\n                                    postProcess: 'sentenceCase',\n                                }),\n                            }}\n                            variant={item.align === 'center' ? 'filled' : 'subtle'}\n                        />\n                        <ActionIcon\n                            icon=\"alignRight\"\n                            iconProps={{ size: 'md' }}\n                            onClick={() => handleAlignRight(item)}\n                            size=\"xs\"\n                            tooltip={{\n                                label: t('table.config.general.alignRight', {\n                                    postProcess: 'sentenceCase',\n                                }),\n                            }}\n                            variant={item.align === 'end' ? 'filled' : 'subtle'}\n                        />\n                    </ActionIconGroup>\n                </Group>\n            </div>\n        );\n    },\n    (prevProps, nextProps) => {\n        return (\n            prevProps.item.id === nextProps.item.id &&\n            prevProps.item.isEnabled === nextProps.item.isEnabled &&\n            prevProps.item.align === nextProps.item.align &&\n            prevProps.label === nextProps.label &&\n            prevProps.matches === nextProps.matches\n        );\n    },\n);\n"
  },
  {
    "path": "src/renderer/features/shared/components/json-preview.module.css",
    "content": ".preview {\n    font-size: var(--theme-font-size-md);\n    background: var(--theme-colors-surface);\n}\n"
  },
  {
    "path": "src/renderer/features/shared/components/json-preview.tsx",
    "content": "import styles from './json-preview.module.css';\n\nimport { Code } from '/@/shared/components/code/code';\n\ninterface JsonPreviewProps {\n    value: Record<string, any> | string;\n}\n\nexport const JsonPreview = ({ value }: JsonPreviewProps) => {\n    return (\n        <Code block className={styles.preview} lang=\"json\" p=\"md\">\n            {JSON.stringify(value, null, 2)}\n        </Code>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/shared/components/library-background-overlay.module.css",
    "content": ".overlay {\n    position: absolute;\n    z-index: -1;\n    width: 100%;\n    min-height: 200px;\n    pointer-events: none;\n    user-select: none;\n    background-image: var(--theme-overlay-subheader);\n}\n\n.background-overlay {\n    --color-from: var(--background-base-min-contrast);\n    --color-to: transparent;\n    --dither: none;\n    --direction-and-possibly-color-interpolation: to bottom;\n\n    position: absolute;\n    z-index: -1;\n    width: 100%;\n    min-height: 200px;\n    pointer-events: none;\n    user-select: none;\n    background-color: var(--color-from);\n    background-image:\n        linear-gradient(\n            var(--direction-and-possibly-color-interpolation),\n            var(--color-from),\n            var(--color-to)\n        ),\n        var(--dither);\n}\n\n.background-image {\n    position: absolute;\n    top: 0;\n    z-index: 0;\n    width: 100%;\n    background-position: center !important;\n    background-size: cover !important;\n    opacity: 0.7;\n}\n\n.background-image-overlay {\n    position: absolute;\n    top: 0;\n    left: 0;\n    z-index: 0;\n    width: 100%;\n    background: var(--theme-overlay-subheader);\n    opacity: 0.9;\n}\n"
  },
  {
    "path": "src/renderer/features/shared/components/library-background-overlay.tsx",
    "content": "import { generateColors } from '@mantine/colors-generator';\nimport clsx from 'clsx';\nimport { useEffect, useState } from 'react';\n\nimport styles from './library-background-overlay.module.css';\n\nimport { useAppThemeColors } from '/@/renderer/themes/use-app-theme';\n\ninterface LibraryBackgroundOverlayProps {\n    backgroundColor?: string;\n    headerRef: React.RefObject<HTMLDivElement | null>;\n    opacity?: number;\n}\n\nexport const LibraryBackgroundOverlay = ({\n    backgroundColor,\n    headerRef,\n    opacity = 0.7,\n}: LibraryBackgroundOverlayProps) => {\n    const height = useHeaderHeight(headerRef);\n\n    return (\n        <div\n            className={styles.overlay}\n            style={{\n                backgroundColor,\n                height: height ? `${height + 64}px` : undefined,\n                opacity,\n            }}\n        />\n    );\n};\n\ninterface BackgroundOverlayProps {\n    backgroundColor?: string;\n    direction?: string;\n    height?: number | string;\n    opacity?: number;\n}\n\nexport const BackgroundOverlay = ({\n    backgroundColor,\n    direction = 'to bottom',\n    height = '100%',\n    opacity,\n}: BackgroundOverlayProps) => {\n    const theme = useAppThemeColors();\n\n    const colors = generateColors(backgroundColor || theme.color['--theme-colors-background']);\n\n    return (\n        <div\n            className={clsx(styles.backgroundOverlay)}\n            style={\n                {\n                    '--color-from': colors[6],\n                    '--color-to': colors[9],\n                    '--direction-and-possibly-color-interpolation': direction,\n                    '--dither': 'none',\n                    backgroundColor: backgroundColor,\n                    height,\n                    opacity,\n                } as React.CSSProperties\n            }\n        />\n    );\n};\n\ninterface LibraryBackgroundProps {\n    blur?: number;\n    headerRef: React.RefObject<HTMLDivElement | null>;\n    imageUrl: null | string;\n}\n\nexport const LibraryBackgroundImage = ({ blur, headerRef, imageUrl }: LibraryBackgroundProps) => {\n    const url = imageUrl ? `url(${imageUrl})` : undefined;\n    const height = useHeaderHeight(headerRef);\n    return (\n        <>\n            <div\n                className={styles.backgroundImage}\n                style={{\n                    background: url,\n                    filter: `blur(${blur ?? 0}rem)`,\n                    height: height ? `${height - 64}px` : undefined,\n                }}\n            />\n            <div\n                className={styles.backgroundImageOverlay}\n                style={{\n                    height: height ? `${height + 64}px` : undefined,\n                }}\n            />\n        </>\n    );\n};\n\nconst useHeaderHeight = (headerRef: React.RefObject<HTMLDivElement | null>) => {\n    const [headerHeight, setHeaderHeight] = useState<number>(0);\n\n    useEffect(() => {\n        if (!headerRef?.current) return;\n\n        const updateHeight = () => {\n            if (headerRef?.current) {\n                setHeaderHeight(headerRef.current.offsetHeight);\n            }\n        };\n\n        updateHeight();\n\n        const resizeObserver = new ResizeObserver(updateHeight);\n        resizeObserver.observe(headerRef.current);\n\n        return () => {\n            resizeObserver.disconnect();\n        };\n    }, [headerRef]);\n\n    return headerHeight;\n};\n"
  },
  {
    "path": "src/renderer/features/shared/components/library-container.module.css",
    "content": ".container {\n    position: relative;\n    display: flex;\n    flex-direction: column;\n    width: 100%;\n    max-width: var(--theme-content-max-width);\n    height: 100%;\n    min-height: 0;\n    margin: 0 auto;\n}\n"
  },
  {
    "path": "src/renderer/features/shared/components/library-container.tsx",
    "content": "import { ReactNode } from 'react';\n\nimport styles from './library-container.module.css';\n\ninterface LibraryContainerProps {\n    children: ReactNode;\n}\n\nexport const LibraryContainer = ({ children }: LibraryContainerProps) => {\n    return <div className={styles.container}>{children}</div>;\n};\n"
  },
  {
    "path": "src/renderer/features/shared/components/library-header-bar.module.css",
    "content": ".header-container {\n    display: flex;\n    flex-wrap: nowrap;\n    gap: 1rem;\n    align-items: center;\n    width: 100%;\n    max-width: var(--theme-content-max-width);\n    height: 100%;\n    padding: 0 1rem;\n}\n\n.play-button-container {\n    position: relative;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n}\n"
  },
  {
    "path": "src/renderer/features/shared/components/library-header-bar.tsx",
    "content": "import { closeAllModals } from '@mantine/modals';\nimport { AnimatePresence } from 'motion/react';\nimport { CSSProperties, memo, ReactNode, useCallback, useRef, useState } from 'react';\n\nimport styles from './library-header-bar.module.css';\n\nimport { useIsPlayerFetching, usePlayer } from '/@/renderer/features/player/context/player-context';\nimport { DefaultPlayButton } from '/@/renderer/features/shared/components/play-button';\nimport { PlayButtonGroupPopover } from '/@/renderer/features/shared/components/play-button-group';\nimport { useCurrentServerId } from '/@/renderer/store';\nimport { Badge, BadgeProps } from '/@/shared/components/badge/badge';\nimport { Spinner } from '/@/shared/components/spinner/spinner';\nimport { TextTitle } from '/@/shared/components/text-title/text-title';\nimport { LibraryItem, Song } from '/@/shared/types/domain-types';\nimport { Play } from '/@/shared/types/types';\n\ninterface LibraryHeaderBarProps {\n    children: ReactNode;\n    ignoreMaxWidth?: boolean;\n}\n\nconst LibraryHeaderBarComponent = ({ children, ignoreMaxWidth }: LibraryHeaderBarProps) => {\n    return (\n        <div\n            className={styles.headerContainer}\n            style={ignoreMaxWidth ? ({ maxWidth: 'none' } as CSSProperties) : undefined}\n        >\n            {children}\n        </div>\n    );\n};\n\ninterface HeaderPlayButtonProps {\n    className?: string;\n    ids?: string[];\n    itemType: LibraryItem;\n    listQuery?: Record<string, any>;\n    songs?: Song[];\n    variant?: 'default' | 'filled';\n}\n\ninterface TitleProps {\n    children: ReactNode;\n    order?: number;\n}\n\nconst HeaderPlayButton = ({\n    className,\n    ids,\n    itemType,\n    listQuery,\n    songs,\n    variant = 'filled',\n    ...props\n}: HeaderPlayButtonProps) => {\n    const serverId = useCurrentServerId();\n    const player = usePlayer();\n\n    const handlePlay = useCallback(\n        (playType: Play) => {\n            if (listQuery) {\n                player.addToQueueByListQuery(serverId, listQuery, itemType, playType);\n            } else if (ids) {\n                player.addToQueueByFetch(serverId, ids, itemType, playType);\n            } else if (songs) {\n                player.addToQueueByData(songs, playType);\n            }\n\n            closeAllModals();\n        },\n        [listQuery, ids, songs, player, serverId, itemType],\n    );\n\n    const isPlayerFetching = useIsPlayerFetching();\n\n    const [isOpen, setIsOpen] = useState(false);\n    const buttonRef = useRef<HTMLButtonElement>(null);\n\n    return (\n        <div className={styles.playButtonContainer}>\n            <DefaultPlayButton\n                className={className}\n                loading={isPlayerFetching}\n                onClick={() => setIsOpen((prev) => !prev)}\n                ref={buttonRef}\n                variant={variant}\n                {...props}\n            />\n            <AnimatePresence>\n                {isOpen && (\n                    <PlayButtonGroupPopover\n                        loading={isPlayerFetching}\n                        onClose={() => setIsOpen(false)}\n                        onPlay={handlePlay}\n                        position=\"bottom\"\n                        triggerRef={buttonRef}\n                    />\n                )}\n            </AnimatePresence>\n        </div>\n    );\n};\n\nconst Title = ({ children, order = 1 }: TitleProps) => {\n    return (\n        <TextTitle fw={700} order={order as any} overflow=\"hidden\">\n            {children}\n        </TextTitle>\n    );\n};\n\ninterface HeaderBadgeProps extends BadgeProps {\n    isLoading?: boolean;\n}\n\nconst HeaderBadge = ({ children, isLoading, ...props }: HeaderBadgeProps) => {\n    return <Badge {...props}>{isLoading ? <Spinner /> : children}</Badge>;\n};\n\nexport const LibraryHeaderBar = Object.assign(memo(LibraryHeaderBarComponent), {\n    Badge: HeaderBadge,\n    PlayButton: HeaderPlayButton,\n    Title,\n});\n"
  },
  {
    "path": "src/renderer/features/shared/components/library-header.module.css",
    "content": ".top-right {\n    position: absolute;\n    top: var(--theme-spacing-lg);\n    right: var(--theme-spacing-md);\n    z-index: 20;\n}\n\n.library-header {\n    position: relative;\n    display: grid;\n    grid-template-areas: 'image' 'info';\n    grid-template-rows: auto 1fr;\n    grid-template-columns: 1fr;\n    gap: var(--theme-spacing-md);\n    align-items: center;\n    justify-items: center;\n    width: 100%;\n    max-width: 100%;\n    height: auto;\n    padding: 2rem 1rem;\n\n    :global(.item-image-placeholder) {\n        width: 175px !important;\n        height: 175px;\n    }\n\n    .image {\n        width: 250px !important;\n        height: 250px;\n    }\n\n    @container (min-width: $mantine-breakpoint-sm) {\n        grid-template-areas: 'image info';\n        grid-template-rows: auto;\n        grid-template-columns: 225px minmax(0, 1fr);\n        align-items: flex-end;\n        justify-items: start;\n        height: auto;\n        min-height: 340px;\n        padding: 5rem 2rem 2rem;\n\n        .image {\n            width: 225px !important;\n            height: 225px;\n        }\n\n        :global(.item-image-placeholder) {\n            width: 225px !important;\n            height: 225px;\n        }\n    }\n\n    @container (min-width: $mantine-breakpoint-lg) {\n        grid-template-columns: 250px minmax(0, 1fr);\n\n        .image {\n            width: 250px !important;\n            height: 250px;\n        }\n\n        :global(.item-image-placeholder) {\n            width: 250px !important;\n            height: 250px;\n        }\n    }\n\n    &.compact {\n        min-height: unset;\n        padding: var(--theme-spacing-md) var(--theme-spacing-xs);\n\n        :global(.item-image-placeholder) {\n            width: 250px !important;\n            height: 250px;\n        }\n\n        .image {\n            width: 250px !important;\n            height: 250px;\n        }\n\n        @container (min-width: $mantine-breakpoint-sm) {\n            grid-template-columns: 200px minmax(0, 1fr);\n            min-height: unset;\n            padding: var(--theme-spacing-md) var(--theme-spacing-sm);\n\n            .image {\n                width: 200px !important;\n                height: 200px;\n            }\n\n            :global(.item-image-placeholder) {\n                width: 200px !important;\n                height: 200px;\n            }\n        }\n\n        @container (min-width: $mantine-breakpoint-lg) {\n            grid-template-columns: 200px minmax(0, 1fr);\n            padding: var(--theme-spacing-md) var(--theme-spacing-md);\n\n            .image {\n                width: 200px !important;\n                height: 200px;\n            }\n\n            :global(.item-image-placeholder) {\n                width: 200px !important;\n                height: 200px;\n            }\n        }\n    }\n}\n\n.image-section {\n    z-index: 15;\n    display: flex;\n    grid-area: image;\n    align-items: center;\n    justify-content: center;\n    filter: drop-shadow(0 0 8px rgb(0 0 0 / 50%));\n\n    @container (min-width: $mantine-breakpoint-sm) {\n        align-items: flex-end;\n    }\n}\n\n.metadata-section {\n    z-index: 15;\n    display: flex;\n    flex-direction: column;\n    grid-area: info;\n    gap: var(--theme-spacing-sm);\n    align-items: center;\n    justify-content: center;\n    width: 100%;\n    height: 100%;\n    text-align: center;\n\n    @container (min-width: $mantine-breakpoint-sm) {\n        align-items: flex-start;\n        justify-content: flex-end;\n        text-align: left;\n    }\n}\n\n.image {\n    object-fit: var(--theme-image-fit);\n    border-radius: 5px;\n}\n\n.title {\n    display: flex;\n    margin: 0;\n    font-size: clamp(1.75rem, 3dvw, 2.75rem);\n    font-weight: 800;\n    line-height: 1.2;\n    word-break: keep-all;\n    text-wrap: pretty;\n}\n\n.library-header-menu {\n    display: flex;\n    flex-direction: column;\n    gap: var(--theme-spacing-sm);\n    align-items: center;\n    justify-content: center;\n    width: 100%;\n\n    @container (min-width: $mantine-breakpoint-sm) {\n        display: flex;\n        flex-direction: row;\n        justify-content: space-between;\n    }\n}\n"
  },
  {
    "path": "src/renderer/features/shared/components/library-header.tsx",
    "content": "import { closeAllModals, openModal } from '@mantine/modals';\nimport clsx from 'clsx';\nimport { forwardRef, ReactNode, Ref, useCallback } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { Link } from 'react-router';\n\nimport styles from './library-header.module.css';\n\nimport { getItemImageUrl, ItemImage } from '/@/renderer/components/item-image/item-image';\nimport { useIsPlayerFetching } from '/@/renderer/features/player/context/player-context';\nimport {\n    PlayLastTextButton,\n    PlayNextTextButton,\n    PlayTextButton,\n} from '/@/renderer/features/shared/components/play-button';\nimport { LONG_PRESS_PLAY_BEHAVIOR } from '/@/renderer/features/shared/components/play-button-group';\nimport { usePlayButtonClick } from '/@/renderer/features/shared/hooks/use-play-button-click';\nimport { useIsMutatingCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation';\nimport { useIsMutatingDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation';\nimport { useIsMutatingRating } from '/@/renderer/features/shared/mutations/set-rating-mutation';\nimport { useGeneralSettings } from '/@/renderer/store';\nimport { ActionIcon } from '/@/shared/components/action-icon/action-icon';\nimport { Button } from '/@/shared/components/button/button';\nimport { Center } from '/@/shared/components/center/center';\nimport { Group } from '/@/shared/components/group/group';\nimport { Icon } from '/@/shared/components/icon/icon';\nimport { BaseImage } from '/@/shared/components/image/image';\nimport { Rating } from '/@/shared/components/rating/rating';\nimport { Spinner } from '/@/shared/components/spinner/spinner';\nimport { Text } from '/@/shared/components/text/text';\nimport { ExplicitStatus, LibraryItem } from '/@/shared/types/domain-types';\nimport { Play } from '/@/shared/types/types';\n\ninterface LibraryHeaderProps {\n    children?: ReactNode;\n    compact?: boolean;\n    containerClassName?: string;\n    imagePlaceholderUrl?: null | string;\n    imageUrl?: null | string;\n    item: {\n        children?: ReactNode;\n        explicitStatus?: ExplicitStatus | null;\n        imageId?: null | string;\n        imageUrl?: null | string;\n        route: string;\n        type?: LibraryItem;\n    };\n    loading?: boolean;\n    title: string;\n    topRight?: ReactNode;\n}\n\nexport const LibraryHeader = forwardRef(\n    (\n        {\n            children,\n            compact,\n            containerClassName,\n            imageUrl,\n            item,\n            title,\n            topRight,\n        }: LibraryHeaderProps,\n        ref: Ref<HTMLDivElement>,\n    ) => {\n        const { t } = useTranslation();\n        const { blurExplicitImages } = useGeneralSettings();\n\n        const itemTypeString = (): string => {\n            switch (item.type) {\n                case LibraryItem.ALBUM:\n                    return t('entity.album', { count: 1 });\n                case LibraryItem.ALBUM_ARTIST:\n                    return t('entity.albumArtist', { count: 1 });\n                case LibraryItem.ARTIST:\n                    return t('entity.artist', { count: 1 });\n                case LibraryItem.PLAYLIST:\n                    return t('entity.playlist', { count: 1 });\n                case LibraryItem.SONG:\n                    return t('entity.track', { count: 1 });\n                default:\n                    return t('common.unknown');\n            }\n        };\n\n        const openImage = useCallback(() => {\n            const imageId = item.imageId;\n            const itemType = item.type as LibraryItem;\n\n            if (!imageId || !itemType) {\n                return;\n            }\n\n            const imageUrl = getItemImageUrl({\n                id: imageId,\n                itemType,\n            });\n\n            if (!imageUrl) {\n                console.error('No image URL found');\n                return;\n            }\n\n            openModal({\n                children: (\n                    <Center\n                        onClick={() => closeAllModals()}\n                        style={{\n                            cursor: 'pointer',\n                            height: 'calc(100vh - 80px)',\n                            width: '100%',\n                        }}\n                    >\n                        <BaseImage\n                            alt=\"cover\"\n                            enableDebounce={false}\n                            enableViewport={false}\n                            fetchPriority=\"high\"\n                            isExplicit={\n                                blurExplicitImages &&\n                                item.explicitStatus === ExplicitStatus.EXPLICIT\n                            }\n                            src={imageUrl}\n                            style={{\n                                maxHeight: '100%',\n                                maxWidth: '100%',\n                                objectFit: 'contain',\n                            }}\n                            unloaderIcon=\"emptyImage\"\n                        />\n                    </Center>\n                ),\n                fullScreen: true,\n            });\n        }, [blurExplicitImages, item.explicitStatus, item.imageId, item.type]);\n\n        return (\n            <div\n                className={clsx(\n                    styles.libraryHeader,\n                    containerClassName,\n                    compact && styles.compact,\n                )}\n                ref={ref}\n            >\n                {topRight && <div className={styles.topRight}>{topRight}</div>}\n                <div\n                    className={styles.imageSection}\n                    onClick={() => {\n                        openImage();\n                    }}\n                    onKeyDown={(event) =>\n                        [' ', 'Enter', 'Spacebar'].includes(event.key) && openImage()\n                    }\n                    role=\"button\"\n                    style={{ cursor: 'pointer' }}\n                    tabIndex={0}\n                >\n                    <ItemImage\n                        className={styles.image}\n                        containerClassName={styles.image}\n                        enableDebounce={false}\n                        enableViewport={false}\n                        explicitStatus={item.explicitStatus ?? null}\n                        fetchPriority=\"high\"\n                        id={item.imageId}\n                        itemType={item.type as LibraryItem}\n                        src={imageUrl || ''}\n                        type=\"header\"\n                    />\n                </div>\n                {title && (\n                    <div className={styles.metadataSection}>\n                        {item.children ? (\n                            <div className={styles.itemType}>{item.children}</div>\n                        ) : (\n                            <Text\n                                className={styles.itemType}\n                                component={Link}\n                                fw={600}\n                                isLink\n                                size=\"md\"\n                                to={item.route}\n                                tt=\"uppercase\"\n                            >\n                                {itemTypeString()}\n                            </Text>\n                        )}\n\n                        <h1\n                            className={styles.title}\n                            style={{\n                                fontSize: calculateTitleSize(title),\n                            }}\n                        >\n                            {title}\n                        </h1>\n                        {children}\n                    </div>\n                )}\n            </div>\n        );\n    },\n);\n\nexport const isAsianCharacter = (char: string): boolean => {\n    const codePoint = char.codePointAt(0);\n\n    if (!codePoint) return false;\n\n    // CJK Unified Ideographs: U+4E00–U+9FFF\n    if (codePoint >= 0x4e00 && codePoint <= 0x9fff) return true;\n\n    // Hiragana: U+3040–U+309F\n    if (codePoint >= 0x3040 && codePoint <= 0x309f) return true;\n\n    // Katakana: U+30A0–U+30FF\n    if (codePoint >= 0x30a0 && codePoint <= 0x30ff) return true;\n\n    // CJK Extension A: U+3400–U+4DBF\n    if (codePoint >= 0x3400 && codePoint <= 0x4dbf) return true;\n\n    // CJK Compatibility Ideographs: U+F900–U+FAFF\n    if (codePoint >= 0xf900 && codePoint <= 0xfaff) return true;\n\n    // Fullwidth forms (some Asian characters): U+FF00–U+FFEF\n    // Only count fullwidth letters/numbers as Asian\n    if (codePoint >= 0xff01 && codePoint <= 0xff5e) return true;\n\n    return false;\n};\n\nexport const calculateWeightedLength = (str: string): number => {\n    let length = 0;\n    for (const char of str) {\n        length += isAsianCharacter(char) ? 2.5 : 1;\n    }\n    return length;\n};\n\nexport const calculateTitleSize = (title: string) => {\n    const titleLength = calculateWeightedLength(title);\n    let baseSize = '3dvw';\n\n    if (titleLength > 20) {\n        baseSize = '2.5dvw';\n    }\n\n    if (titleLength > 30) {\n        baseSize = '2.25dvw';\n    }\n\n    if (titleLength > 40) {\n        baseSize = '2dvw';\n    }\n\n    if (titleLength > 50) {\n        baseSize = '1.875dvw';\n    }\n\n    if (titleLength > 60) {\n        baseSize = '1.75dvw';\n    }\n\n    if (titleLength > 70) {\n        baseSize = '1.5dvw';\n    }\n\n    if (titleLength > 80) {\n        baseSize = '1.4dvw';\n    }\n\n    if (titleLength > 90) {\n        baseSize = '1.3dvw';\n    }\n\n    return `clamp(1.75rem, ${baseSize}, 2.75rem)`;\n};\n\ninterface LibraryHeaderMenuProps {\n    favorite?: boolean;\n    onAlbumRadio?: () => void;\n    onArtistRadio?: () => void;\n    onFavorite?: (e: React.MouseEvent<HTMLButtonElement>) => void;\n    onMore?: (e: React.MouseEvent<HTMLButtonElement>) => void;\n    onPlay?: (type: Play) => void;\n    onRating?: (rating: number) => void;\n    onShuffle?: (e: React.MouseEvent<HTMLButtonElement>) => void;\n    rating?: number;\n}\n\nexport const LibraryHeaderMenu = ({\n    favorite,\n    onAlbumRadio,\n    onArtistRadio,\n    onFavorite,\n    onMore,\n    onPlay,\n    onRating,\n    rating,\n}: LibraryHeaderMenuProps) => {\n    const { t } = useTranslation();\n    const isMutatingRating = useIsMutatingRating();\n    const isMutatingCreateFavorite = useIsMutatingCreateFavorite();\n    const isMutatingDeleteFavorite = useIsMutatingDeleteFavorite();\n    const isMutatingFavorite = isMutatingCreateFavorite || isMutatingDeleteFavorite;\n    const isPlayerFetching = useIsPlayerFetching();\n\n    const handlePlayNow = usePlayButtonClick({\n        onClick: () => {\n            onPlay?.(Play.NOW);\n        },\n        onLongPress: () => {\n            onPlay?.(LONG_PRESS_PLAY_BEHAVIOR[Play.NOW]);\n        },\n    });\n\n    const handlePlayNext = usePlayButtonClick({\n        onClick: () => {\n            onPlay?.(Play.NEXT);\n        },\n        onLongPress: () => {\n            onPlay?.(LONG_PRESS_PLAY_BEHAVIOR[Play.NEXT]);\n        },\n    });\n\n    const handlePlayLast = usePlayButtonClick({\n        onClick: () => {\n            onPlay?.(Play.LAST);\n        },\n        onLongPress: () => {\n            onPlay?.(LONG_PRESS_PLAY_BEHAVIOR[Play.LAST]);\n        },\n    });\n\n    return (\n        <div className={styles.libraryHeaderMenu}>\n            <Group wrap=\"nowrap\">\n                {onPlay && <PlayTextButton {...handlePlayNow.handlers} {...handlePlayNow.props} />}\n                {onPlay && (\n                    <PlayNextTextButton {...handlePlayNext.handlers} {...handlePlayNext.props} />\n                )}\n                {onPlay && (\n                    <PlayLastTextButton {...handlePlayLast.handlers} {...handlePlayLast.props} />\n                )}\n                {onAlbumRadio && (\n                    <Button\n                        disabled={isPlayerFetching}\n                        leftSection={\n                            isPlayerFetching ? (\n                                <Spinner color=\"white\" />\n                            ) : (\n                                <Icon icon=\"radio\" size=\"lg\" />\n                            )\n                        }\n                        onClick={onAlbumRadio}\n                        size=\"md\"\n                        variant=\"transparent\"\n                    >\n                        {t('player.albumRadio', { postProcess: 'sentenceCase' })}\n                    </Button>\n                )}\n                {onArtistRadio && (\n                    <Button\n                        disabled={isPlayerFetching}\n                        leftSection={\n                            isPlayerFetching ? (\n                                <Spinner color=\"white\" />\n                            ) : (\n                                <Icon icon=\"radio\" size=\"lg\" />\n                            )\n                        }\n                        onClick={onArtistRadio}\n                        size=\"md\"\n                        variant=\"transparent\"\n                    >\n                        {t('player.artistRadio', { postProcess: 'sentenceCase' })}\n                    </Button>\n                )}\n            </Group>\n            <Group gap=\"sm\" wrap=\"nowrap\">\n                {onRating && (\n                    <Rating\n                        onChange={onRating}\n                        readOnly={isMutatingRating}\n                        size=\"lg\"\n                        value={rating || 0}\n                    />\n                )}\n                {onFavorite && (\n                    <ActionIcon\n                        disabled={isMutatingFavorite}\n                        icon=\"favorite\"\n                        iconProps={{\n                            fill: favorite ? 'primary' : undefined,\n                        }}\n                        onClick={onFavorite}\n                        size=\"lg\"\n                        variant=\"transparent\"\n                    />\n                )}\n                {onMore && (\n                    <ActionIcon\n                        icon=\"ellipsisHorizontal\"\n                        onClick={onMore}\n                        size=\"lg\"\n                        variant=\"transparent\"\n                    />\n                )}\n            </Group>\n        </div>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/shared/components/list-config-menu.tsx",
    "content": "import { ReactNode, useMemo } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport i18n from '/@/i18n/i18n';\nimport { GridConfig } from '/@/renderer/features/shared/components/grid-config';\nimport { SettingsButton } from '/@/renderer/features/shared/components/settings-button';\nimport { TableConfig } from '/@/renderer/features/shared/components/table-config';\nimport { useSettingsStore, useSettingsStoreActions } from '/@/renderer/store';\nimport { ActionIconProps } from '/@/shared/components/action-icon/action-icon';\nimport { Divider } from '/@/shared/components/divider/divider';\nimport { Group } from '/@/shared/components/group/group';\nimport { Icon } from '/@/shared/components/icon/icon';\nimport { Modal } from '/@/shared/components/modal/modal';\nimport { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { Switch } from '/@/shared/components/switch/switch';\nimport { Table } from '/@/shared/components/table/table';\nimport { useDisclosure } from '/@/shared/hooks/use-disclosure';\nimport { ItemListKey, ListDisplayType } from '/@/shared/types/types';\n\nexport const SONG_DISPLAY_TYPES: ListConfigMenuDisplayTypeConfig[] = [\n    { hidden: true, value: ListDisplayType.DETAIL },\n];\n\nconst DISPLAY_TYPES = [\n    {\n        label: (\n            <Group align=\"center\" justify=\"center\" p=\"sm\">\n                <Icon icon=\"layoutTable\" size=\"lg\" />\n                {i18n.t('table.config.view.table', { postProcess: 'sentenceCase' }) as string}\n            </Group>\n        ),\n        value: ListDisplayType.TABLE,\n    },\n    {\n        label: (\n            <Group align=\"center\" justify=\"center\" p=\"sm\">\n                <Icon icon=\"layoutGrid\" size=\"lg\" />\n                {i18n.t('table.config.view.grid', { postProcess: 'sentenceCase' }) as string}\n            </Group>\n        ),\n        value: ListDisplayType.GRID,\n    },\n    {\n        label: (\n            <Group align=\"center\" justify=\"center\" p=\"sm\">\n                <Icon icon=\"layoutDetail\" size=\"lg\" />\n                {i18n.t('table.config.view.detail', { postProcess: 'sentenceCase' }) as string}\n            </Group>\n        ),\n        value: ListDisplayType.DETAIL,\n    },\n    // {\n    //     disabled: true,\n    //     label: (\n    //         <Stack align=\"center\" p=\"sm\">\n    //             <Icon icon=\"layoutList\" size=\"lg\" />\n    //             {i18n.t('table.config.view.list', { postProcess: 'sentenceCase' }) as string}\n    //         </Stack>\n    //     ),\n    //     value: ListDisplayType.LIST,\n    // },\n];\n\nexport const ListConfigBooleanControl = ({\n    onChange,\n    value,\n}: {\n    onChange: (value: boolean) => void;\n    value: boolean;\n}) => {\n    return (\n        <Group justify=\"flex-end\" w=\"100%\">\n            <Switch checked={value} onChange={(e) => onChange(e.currentTarget.checked)} />\n        </Group>\n    );\n};\n\nexport interface ListConfigMenuDetailConfig {\n    optionsConfig?: ListConfigMenuOptionsConfig['detail'];\n    tableColumnsData: { label: string; value: string }[];\n    tableKey: 'detail';\n}\n\nexport interface ListConfigMenuDisplayTypeConfig {\n    disabled?: boolean;\n    hidden?: boolean;\n    value: ListDisplayType;\n}\n\nexport interface ListConfigMenuOptionConfig {\n    disabled?: boolean;\n    hidden?: boolean;\n}\n\nexport interface ListConfigMenuOptionsConfig {\n    detail?: {\n        [key: string]: ListConfigMenuOptionConfig;\n    };\n    grid?: {\n        [key: string]: ListConfigMenuOptionConfig;\n    };\n    table?: {\n        [key: string]: ListConfigMenuOptionConfig;\n    };\n}\n\ninterface ListConfigMenuProps {\n    buttonProps?: ActionIconProps;\n    detailConfig?: ListConfigMenuDetailConfig;\n    displayTypes?: ListConfigMenuDisplayTypeConfig[];\n    listKey: ItemListKey;\n    optionsConfig?: ListConfigMenuOptionsConfig;\n    tableColumnsData: { label: string; value: string }[];\n}\n\nexport const ListConfigMenu = (props: ListConfigMenuProps) => {\n    const { t } = useTranslation();\n    const displayType = useSettingsStore(\n        (state) => state.lists[props.listKey]?.display,\n    ) as ListDisplayType;\n    const { setList } = useSettingsStoreActions();\n    const [isOpen, handlers] = useDisclosure(false);\n\n    // Filter display types based on config\n    const availableDisplayTypes = useMemo(() => {\n        if (!props.displayTypes) {\n            return DISPLAY_TYPES;\n        }\n\n        const filtered = DISPLAY_TYPES.map((type) => {\n            const config = props.displayTypes?.find((c) => c.value === type.value);\n            if (config?.hidden) {\n                return null;\n            }\n            const result: (typeof DISPLAY_TYPES)[0] & { disabled?: boolean } = {\n                ...type,\n            };\n            if (config?.disabled) {\n                result.disabled = true;\n            }\n            return result;\n        }).filter((type): type is NonNullable<typeof type> => type !== null);\n\n        return filtered;\n    }, [props.displayTypes]);\n\n    return (\n        <>\n            <SettingsButton {...props.buttonProps} onClick={handlers.toggle} />\n            <Modal\n                handlers={handlers}\n                opened={isOpen}\n                size=\"xl\"\n                title={t('common.configure', { postProcess: 'sentenceCase' })}\n            >\n                <Stack gap=\"xs\">\n                    {availableDisplayTypes.length > 1 && (\n                        <ListConfigTable\n                            options={[\n                                {\n                                    component: (\n                                        <SegmentedControl\n                                            data={availableDisplayTypes}\n                                            fullWidth\n                                            onChange={(value) => {\n                                                setList(props.listKey, {\n                                                    display: value as ListDisplayType,\n                                                });\n                                            }}\n                                            size=\"sm\"\n                                            value={displayType}\n                                            withItemsBorders={false}\n                                        />\n                                    ),\n                                    id: 'displayType',\n                                    label: t('table.config.general.displayType', {\n                                        postProcess: 'sentenceCase',\n                                    }),\n                                },\n                            ]}\n                        />\n                    )}\n                    <Config displayType={displayType} {...props} />\n                </Stack>\n            </Modal>\n        </>\n    );\n};\n\nconst Config = ({\n    displayType,\n    optionsConfig,\n    tableColumnsData,\n    ...props\n}: ListConfigMenuProps & { displayType: ListDisplayType }) => {\n    switch (displayType) {\n        case ListDisplayType.DETAIL:\n            if (props.detailConfig) {\n                return (\n                    <TableConfig\n                        enablePinColumnButtons={false}\n                        listKey={props.listKey}\n                        optionsConfig={props.detailConfig.optionsConfig}\n                        tableColumnsData={props.detailConfig.tableColumnsData}\n                        tableKey=\"detail\"\n                    />\n                );\n            }\n            return null;\n\n        case ListDisplayType.GRID:\n            return (\n                <GridConfig\n                    {...props}\n                    gridRowsData={tableColumnsData}\n                    optionsConfig={optionsConfig?.grid}\n                />\n            );\n\n        case ListDisplayType.TABLE:\n            return (\n                <TableConfig\n                    {...props}\n                    optionsConfig={optionsConfig?.table}\n                    tableColumnsData={tableColumnsData}\n                />\n            );\n\n        default:\n            return null;\n    }\n};\n\nexport const ListConfigTable = ({\n    options,\n}: {\n    options: { component: ReactNode; id: string; isDivider?: boolean; label: ReactNode | string }[];\n}) => {\n    return (\n        <Table\n            onClick={(e) => e.stopPropagation()}\n            style={{ borderRadius: '1rem' }}\n            styles={{ th: { backgroundColor: 'initial', padding: 'var(--theme-spacing-md) 0' } }}\n            variant=\"vertical\"\n            withColumnBorders={false}\n            withRowBorders={false}\n            withTableBorder={false}\n        >\n            <Table.Tbody>\n                {options.map((option) => {\n                    if (option.isDivider) {\n                        return (\n                            <Table.Tr key={option.id}>\n                                <Table.Td colSpan={2} px={0} py=\"md\">\n                                    <Divider />\n                                </Table.Td>\n                            </Table.Tr>\n                        );\n                    }\n                    return (\n                        <Table.Tr key={option.id}>\n                            <Table.Th w=\"50%\">{option.label}</Table.Th>\n                            <Table.Td p={0}>{option.component}</Table.Td>\n                        </Table.Tr>\n                    );\n                })}\n            </Table.Tbody>\n        </Table>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/shared/components/list-display-type-toggle-button.tsx",
    "content": "import { DisplayTypeToggleButton } from '/@/renderer/features/shared/components/display-type-toggle-button';\nimport { useSettingsStore, useSettingsStoreActions } from '/@/renderer/store';\nimport { ItemListKey, ListDisplayType } from '/@/shared/types/types';\n\ninterface ListDisplayTypeToggleButtonProps {\n    enableDetail?: boolean;\n    listKey: ItemListKey;\n}\n\nexport const ListDisplayTypeToggleButton = ({\n    enableDetail = false,\n    listKey,\n}: ListDisplayTypeToggleButtonProps) => {\n    const displayType = useSettingsStore(\n        (state) => state.lists[listKey]?.display,\n    ) as ListDisplayType;\n    const { setList } = useSettingsStoreActions();\n\n    const handleToggleDisplayType = () => {\n        let newDisplayType: ListDisplayType;\n\n        if (enableDetail) {\n            if (displayType === ListDisplayType.DETAIL) {\n                newDisplayType = ListDisplayType.TABLE;\n            } else if (displayType === ListDisplayType.TABLE) {\n                newDisplayType = ListDisplayType.GRID;\n            } else if (displayType === ListDisplayType.GRID) {\n                newDisplayType = ListDisplayType.DETAIL;\n            } else {\n                newDisplayType = ListDisplayType.GRID;\n            }\n        } else {\n            if (displayType === ListDisplayType.GRID) {\n                newDisplayType = ListDisplayType.TABLE;\n            } else if (displayType === ListDisplayType.TABLE) {\n                newDisplayType = ListDisplayType.GRID;\n            } else {\n                newDisplayType = ListDisplayType.GRID;\n            }\n        }\n\n        setList(listKey, {\n            display: newDisplayType,\n        });\n\n        return;\n    };\n\n    return <DisplayTypeToggleButton displayType={displayType} onToggle={handleToggleDisplayType} />;\n};\n"
  },
  {
    "path": "src/renderer/features/shared/components/list-filters.tsx",
    "content": "import { Suspense } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport { useListContext } from '/@/renderer/context/list-context';\nimport { JellyfinAlbumFilters } from '/@/renderer/features/albums/components/jellyfin-album-filters';\nimport { NavidromeAlbumFilters } from '/@/renderer/features/albums/components/navidrome-album-filters';\nimport { SubsonicAlbumFilters } from '/@/renderer/features/albums/components/subsonic-album-filters';\nimport { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album-list-filters';\nimport { ComponentErrorBoundary } from '/@/renderer/features/shared/components/component-error-boundary';\nimport { FilterButton } from '/@/renderer/features/shared/components/filter-button';\nimport { SaveAsCollectionButton } from '/@/renderer/features/shared/components/save-as-collection-button';\nimport { JellyfinSongFilters } from '/@/renderer/features/songs/components/jellyfin-song-filters';\nimport { NavidromeSongFilters } from '/@/renderer/features/songs/components/navidrome-song-filters';\nimport { SubsonicSongFilters } from '/@/renderer/features/songs/components/subsonic-song-filters';\nimport { useSongListFilters } from '/@/renderer/features/songs/hooks/use-song-list-filters';\nimport { useCurrentServer } from '/@/renderer/store';\nimport { ActionIcon } from '/@/shared/components/action-icon/action-icon';\nimport { Button } from '/@/shared/components/button/button';\nimport { Group } from '/@/shared/components/group/group';\nimport { Modal } from '/@/shared/components/modal/modal';\nimport { Spinner } from '/@/shared/components/spinner/spinner';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { Text } from '/@/shared/components/text/text';\nimport { useDisclosure } from '/@/shared/hooks/use-disclosure';\nimport { LibraryItem, ServerType } from '/@/shared/types/domain-types';\nimport { ItemListKey } from '/@/shared/types/types';\n\ninterface ListFiltersProps {\n    isActive?: boolean;\n    itemType: LibraryItem;\n}\n\nexport const isFilterValueSet = (value: unknown): boolean => {\n    if (value === undefined || value === null) return false;\n    if (typeof value === 'string' && value.trim() === '') return false;\n    if (Array.isArray(value) && value.length === 0) return false;\n    if (typeof value === 'object' && Object.keys(value).length === 0) return false;\n    return true;\n};\n\nexport const ListFiltersModal = ({ isActive, itemType }: ListFiltersProps) => {\n    const { t } = useTranslation();\n    const server = useCurrentServer();\n    const { isSidebarOpen, pageKey, setIsSidebarOpen } = useListContext();\n\n    const serverType = server.type;\n\n    const FilterComponent = FILTERS[serverType][itemType];\n\n    const [isOpen, handlers] = useDisclosure(false);\n\n    const albumListFilters = useAlbumListFilters(pageKey as ItemListKey);\n    const songListFilters = useSongListFilters(pageKey as ItemListKey);\n    const clear = itemType === LibraryItem.ALBUM ? albumListFilters.clear : songListFilters.clear;\n\n    const handlePin = () => {\n        setIsSidebarOpen?.(!isSidebarOpen);\n    };\n\n    const handleReset = () => {\n        clear();\n    };\n\n    const canPin = Boolean(setIsSidebarOpen);\n\n    const disableArtistFilter = pageKey === ItemListKey.ALBUM_ARTIST_ALBUM;\n    const disableGenreFilter =\n        pageKey === ItemListKey.GENRE_ALBUM || pageKey === ItemListKey.GENRE_SONG;\n\n    return (\n        <>\n            <FilterButton isActive={isActive} onClick={handlers.toggle} />\n            <Modal\n                handlers={handlers}\n                opened={isOpen}\n                size=\"lg\"\n                styles={{\n                    content: {\n                        height: '100%',\n                        maxHeight: '640px',\n                        maxWidth: 'var(--theme-content-max-width)',\n                        width: '100%',\n                    },\n                }}\n                title={\n                    <Group justify=\"space-between\" style={{ paddingRight: '3rem', width: '100%' }}>\n                        <Group>\n                            {canPin && (\n                                <ActionIcon\n                                    icon={isSidebarOpen ? 'unpin' : 'pin'}\n                                    onClick={handlePin}\n                                    variant=\"subtle\"\n                                />\n                            )}\n\n                            {t('common.filters', { postProcess: 'sentenceCase' })}\n                        </Group>\n                        <Button onClick={handleReset} size=\"compact-sm\" variant=\"subtle\">\n                            {t('common.reset', { postProcess: 'sentenceCase' })}\n                        </Button>\n                    </Group>\n                }\n            >\n                <FilterComponent\n                    disableArtistFilter={disableArtistFilter}\n                    disableGenreFilter={disableGenreFilter}\n                />\n                <Stack p=\"md\">\n                    <SaveAsCollectionButton\n                        fullWidth\n                        itemType={itemType as LibraryItem.ALBUM | LibraryItem.SONG}\n                    />\n                </Stack>\n            </Modal>\n        </>\n    );\n};\n\nexport const ListFilters = ({ itemType }: ListFiltersProps) => {\n    const server = useCurrentServer();\n    const serverType = server.type;\n    const FilterComponent = FILTERS[serverType][itemType];\n    const { pageKey } = useListContext();\n\n    const disableArtistFilter = pageKey === ItemListKey.ALBUM_ARTIST_ALBUM;\n    const disableGenreFilter =\n        pageKey === ItemListKey.GENRE_ALBUM || pageKey === ItemListKey.GENRE_SONG;\n\n    return (\n        <ComponentErrorBoundary>\n            <Suspense fallback={<Spinner container />}>\n                <FilterComponent\n                    disableArtistFilter={disableArtistFilter}\n                    disableGenreFilter={disableGenreFilter}\n                />\n            </Suspense>\n        </ComponentErrorBoundary>\n    );\n};\n\ninterface ListFiltersTitleProps {\n    itemType: LibraryItem;\n}\n\nexport const ListFiltersTitle = ({ itemType }: ListFiltersTitleProps) => {\n    const { t } = useTranslation();\n    const { pageKey, setIsSidebarOpen } = useListContext();\n\n    const handleUnpin = () => {\n        setIsSidebarOpen?.(false);\n    };\n\n    const canUnpin = Boolean(setIsSidebarOpen);\n\n    const albumListFilters = useAlbumListFilters(pageKey as ItemListKey);\n    const songListFilters = useSongListFilters(pageKey as ItemListKey);\n    const clear = itemType === LibraryItem.ALBUM ? albumListFilters.clear : songListFilters.clear;\n\n    return (\n        <Group justify=\"space-between\" pb={0} pl=\"md\" pr=\"md\" pt=\"md\">\n            <Text fw={500} size=\"xl\">\n                {t('common.filters', { postProcess: 'sentenceCase' })}\n            </Text>\n            <Group gap=\"xs\">\n                <Button onClick={clear} size=\"compact-sm\" variant=\"subtle\">\n                    {t('common.reset', { postProcess: 'sentenceCase' })}\n                </Button>\n                {canUnpin && (\n                    <ActionIcon\n                        icon=\"unpin\"\n                        onClick={handleUnpin}\n                        size=\"compact-sm\"\n                        variant=\"subtle\"\n                    />\n                )}\n            </Group>\n        </Group>\n    );\n};\n\nconst FILTERS = {\n    [ServerType.JELLYFIN]: {\n        [LibraryItem.ALBUM]: JellyfinAlbumFilters,\n        [LibraryItem.SONG]: JellyfinSongFilters,\n    },\n    [ServerType.NAVIDROME]: {\n        [LibraryItem.ALBUM]: NavidromeAlbumFilters,\n        [LibraryItem.SONG]: NavidromeSongFilters,\n    },\n    [ServerType.SUBSONIC]: {\n        [LibraryItem.ALBUM]: SubsonicAlbumFilters,\n        [LibraryItem.SONG]: SubsonicSongFilters,\n    },\n};\n"
  },
  {
    "path": "src/renderer/features/shared/components/list-music-folder-dropdown.tsx",
    "content": "import { useQuery } from '@tanstack/react-query';\n\nimport { sharedQueries } from '/@/renderer/features/shared/api/shared-api';\nimport { FolderButton } from '/@/renderer/features/shared/components/folder-button';\nimport { useMusicFolderIdFilter } from '/@/renderer/features/shared/hooks/use-music-folder-id-filter';\nimport { useCurrentServer } from '/@/renderer/store';\nimport { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu';\nimport { ItemListKey } from '/@/shared/types/types';\n\ninterface ListMusicFolderDropdownProps {\n    listKey: ItemListKey;\n}\n\nexport const ListMusicFolderDropdown = ({ listKey }: ListMusicFolderDropdownProps) => {\n    const server = useCurrentServer();\n    const { data: musicFolders } = useQuery(\n        sharedQueries.musicFolders({ query: null, serverId: server.id }),\n    );\n\n    const { musicFolderId, setMusicFolderId } = useMusicFolderIdFilter('', listKey);\n\n    const handleSetMusicFolder = (e: string) => {\n        if (e === musicFolderId) {\n            setMusicFolderId('');\n            return;\n        }\n\n        setMusicFolderId(e);\n    };\n\n    return (\n        <DropdownMenu position=\"bottom-start\">\n            <DropdownMenu.Target>\n                <FolderButton isActive={!!musicFolderId} />\n            </DropdownMenu.Target>\n            <DropdownMenu.Dropdown>\n                {musicFolders?.items.map((folder) => (\n                    <DropdownMenu.Item\n                        isSelected={musicFolderId === folder.id}\n                        key={`musicFolder-${folder.id}`}\n                        onClick={() => handleSetMusicFolder(folder.id)}\n                        value={folder.id}\n                    >\n                        {folder.name}\n                    </DropdownMenu.Item>\n                ))}\n            </DropdownMenu.Dropdown>\n        </DropdownMenu>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/shared/components/list-refresh-button.tsx",
    "content": "import { useIsMutating } from '@tanstack/react-query';\nimport { useCallback } from 'react';\n\nimport { eventEmitter } from '/@/renderer/events/event-emitter';\nimport { RefreshButton } from '/@/renderer/features/shared/components/refresh-button';\nimport { ItemListKey } from '/@/shared/types/types';\n\ninterface ListRefreshButtonProps {\n    disabled?: boolean;\n    listKey: ItemListKey;\n}\n\nexport const ListRefreshButton = ({ disabled, listKey }: ListRefreshButtonProps) => {\n    const isRefreshing = useIsMutating({ mutationKey: getListRefreshMutationKey(listKey) }) > 0;\n\n    const handleRefresh = useCallback(() => {\n        eventEmitter.emit('ITEM_LIST_REFRESH', { key: listKey });\n    }, [listKey]);\n\n    return <RefreshButton disabled={disabled} loading={isRefreshing} onClick={handleRefresh} />;\n};\n\nexport const LIST_REFRESH_MUTATION_KEY = 'item-list-refresh';\n\nexport const getListRefreshMutationKey = (listKey: string) =>\n    [LIST_REFRESH_MUTATION_KEY, listKey] as const;\n"
  },
  {
    "path": "src/renderer/features/shared/components/list-search-input.tsx",
    "content": "import { useLocation } from 'react-router';\n\nimport { SearchInput } from '/@/renderer/features/shared/components/search-input';\nimport { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-search-term-filter';\n\nfunction navigationIdFromState(state: unknown): string | undefined {\n    if (state && typeof state === 'object' && 'navigationId' in state) {\n        const id = (state as { navigationId: unknown }).navigationId;\n        return typeof id === 'string' ? id : undefined;\n    }\n    return undefined;\n}\n\nexport const ListSearchInput = () => {\n    const { searchTerm, setSearchTerm } = useSearchTermFilter();\n    const { state } = useLocation();\n    const navigationId = navigationIdFromState(state);\n\n    return (\n        <SearchInput\n            defaultValue={searchTerm}\n            key={navigationId ?? 'list-search-input'}\n            onChange={(e) => setSearchTerm(e.target.value || null)}\n        />\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/shared/components/list-select-filter.tsx",
    "content": "import { useSelectFilter } from '/@/renderer/features/shared/hooks/use-select-filter';\nimport { Button } from '/@/shared/components/button/button';\nimport { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu';\nimport { Select } from '/@/shared/components/select/select';\nimport { ItemListKey } from '/@/shared/types/types';\n\nexport type SelectOption = string | { label: string; value: string };\n\ninterface ListSelectFilterProps {\n    data?: Array<SelectOption>;\n    filterKey: string;\n    listKey: ItemListKey;\n}\n\nexport const ListSelectFilter = ({ data, filterKey, listKey }: ListSelectFilterProps) => {\n    const selectData = data || [];\n\n    const { setValue, value } = useSelectFilter(filterKey, '', listKey);\n\n    const handleSetValue = (newValue: string) => {\n        if (newValue === value) {\n            setValue('');\n            return;\n        }\n\n        setValue(newValue);\n    };\n\n    const getOptionLabel = (option: SelectOption): string => {\n        if (typeof option === 'string') {\n            return option;\n        }\n        return option.label;\n    };\n\n    const getOptionValue = (option: SelectOption): string => {\n        if (typeof option === 'string') {\n            return option;\n        }\n        return option.value;\n    };\n\n    const selectedOption = selectData.find((option) => getOptionValue(option) === value);\n    const selectedLabel = selectedOption ? getOptionLabel(selectedOption) : '—';\n\n    return (\n        <Select\n            data={selectData}\n            onChange={(value) => handleSetValue(value ?? '')}\n            value={value ?? ''}\n        />\n    );\n\n    return (\n        <DropdownMenu position=\"bottom-start\">\n            <DropdownMenu.Target>\n                <Button variant=\"subtle\">{selectedLabel}</Button>\n            </DropdownMenu.Target>\n            <DropdownMenu.Dropdown>\n                {selectData.map((option) => {\n                    const optionValue = getOptionValue(option);\n                    const optionLabel = getOptionLabel(option);\n\n                    return (\n                        <DropdownMenu.Item\n                            isSelected={value === optionValue}\n                            key={`${filterKey}-${optionValue}`}\n                            onClick={() => handleSetValue(optionValue)}\n                            value={optionValue}\n                        >\n                            {optionLabel}\n                        </DropdownMenu.Item>\n                    );\n                })}\n            </DropdownMenu.Dropdown>\n        </DropdownMenu>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/shared/components/list-sort-by-dropdown.tsx",
    "content": "import { Dispatch, SetStateAction } from 'react';\n\nimport i18n from '/@/i18n/i18n';\nimport { useSortByFilter } from '/@/renderer/features/shared/hooks/use-sort-by-filter';\nimport { useCurrentServer } from '/@/renderer/store';\nimport { Button } from '/@/shared/components/button/button';\nimport { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu';\nimport {\n    AlbumArtistListSort,\n    AlbumListSort,\n    ArtistListSort,\n    GenreListSort,\n    LibraryItem,\n    PlaylistListSort,\n    RadioListSort,\n    ServerType,\n    SongListSort,\n    SortOrder,\n} from '/@/shared/types/domain-types';\nimport { ItemListKey } from '/@/shared/types/types';\n\ninterface ListSortByDropdownProps {\n    defaultSortByValue: string;\n    disabled?: boolean;\n    includeId?: boolean;\n    itemType: LibraryItem;\n    listKey: ItemListKey;\n    onChange?: (value: string) => void;\n    target?: React.ReactNode;\n}\n\nexport const ListSortByDropdown = ({\n    defaultSortByValue,\n    disabled,\n    itemType,\n    listKey,\n    onChange,\n    target,\n}: ListSortByDropdownProps) => {\n    const server = useCurrentServer();\n\n    const { setSortBy, sortBy } = useSortByFilter(defaultSortByValue, listKey);\n\n    const sortByLabel =\n        (itemType && FILTERS[itemType][server.type].find((f) => f.value === sortBy)?.name) || '—';\n\n    const handleSortByChange = (sortBy: string) => {\n        setSortBy(sortBy);\n        onChange?.(sortBy);\n    };\n\n    return (\n        <DropdownMenu disabled={disabled} position=\"bottom-start\">\n            <DropdownMenu.Target>\n                {target ? (\n                    target\n                ) : (\n                    <Button disabled={disabled} variant=\"subtle\">\n                        {sortByLabel}\n                    </Button>\n                )}\n            </DropdownMenu.Target>\n            <DropdownMenu.Dropdown>\n                {FILTERS[itemType][server.type].map((f) => (\n                    <DropdownMenu.Item\n                        isSelected={f.value === sortBy}\n                        key={`filter-${f.name}`}\n                        onClick={() => handleSortByChange(f.value)}\n                        value={f.value}\n                    >\n                        {f.name}\n                    </DropdownMenu.Item>\n                ))}\n            </DropdownMenu.Dropdown>\n        </DropdownMenu>\n    );\n};\n\ninterface ListSortByDropdownControlledProps {\n    disabled?: boolean;\n    filters?: Array<{ defaultOrder: SortOrder; name: string; value: string }>;\n    itemType: LibraryItem;\n    setSortBy: Dispatch<SetStateAction<string>>;\n    sortBy: string;\n    target?: React.ReactNode;\n}\n\nexport const ListSortByDropdownControlled = ({\n    disabled,\n    filters,\n    itemType,\n    setSortBy,\n    sortBy,\n    target,\n}: ListSortByDropdownControlledProps) => {\n    const server = useCurrentServer();\n\n    const availableFilters = filters || (itemType && FILTERS[itemType]?.[server.type]) || [];\n\n    const sortByLabel = availableFilters.find((f) => f.value === sortBy)?.name || '—';\n\n    const handleSortByChange = (sortBy: string) => {\n        setSortBy(sortBy);\n    };\n\n    return (\n        <DropdownMenu disabled={disabled} position=\"bottom-start\">\n            <DropdownMenu.Target>\n                {target ? (\n                    target\n                ) : (\n                    <Button disabled={disabled} variant=\"subtle\">\n                        {sortByLabel}\n                    </Button>\n                )}\n            </DropdownMenu.Target>\n            <DropdownMenu.Dropdown>\n                {availableFilters.map((f) => (\n                    <DropdownMenu.Item\n                        isSelected={f.value === sortBy}\n                        key={`filter-${f.name}`}\n                        onClick={() => handleSortByChange(f.value)}\n                        value={f.value}\n                    >\n                        {f.name}\n                    </DropdownMenu.Item>\n                ))}\n            </DropdownMenu.Dropdown>\n        </DropdownMenu>\n    );\n};\n\nexport const CLIENT_SIDE_SONG_FILTERS = [\n    {\n        defaultOrder: SortOrder.ASC,\n        name: i18n.t('filter.id', { postProcess: 'titleCase' }),\n        value: SongListSort.ID,\n    },\n    {\n        defaultOrder: SortOrder.ASC,\n        name: i18n.t('filter.album', { postProcess: 'titleCase' }),\n        value: SongListSort.ALBUM,\n    },\n    {\n        defaultOrder: SortOrder.ASC,\n        name: i18n.t('filter.albumArtist', { postProcess: 'titleCase' }),\n        value: SongListSort.ALBUM_ARTIST,\n    },\n    {\n        defaultOrder: SortOrder.ASC,\n        name: i18n.t('filter.artist', { postProcess: 'titleCase' }),\n        value: SongListSort.ARTIST,\n    },\n    {\n        defaultOrder: SortOrder.DESC,\n        name: i18n.t('filter.bpm', { postProcess: 'titleCase' }),\n        value: SongListSort.BPM,\n    },\n    {\n        defaultOrder: SortOrder.ASC,\n        name: i18n.t('common.channel', { count: 2, postProcess: 'titleCase' }),\n        value: SongListSort.CHANNELS,\n    },\n    {\n        defaultOrder: SortOrder.ASC,\n        name: i18n.t('filter.comment', { postProcess: 'titleCase' }),\n        value: SongListSort.COMMENT,\n    },\n    {\n        defaultOrder: SortOrder.DESC,\n        name: i18n.t('filter.duration', { postProcess: 'titleCase' }),\n        value: SongListSort.DURATION,\n    },\n    {\n        defaultOrder: SortOrder.DESC,\n        name: i18n.t('filter.isFavorited', { postProcess: 'titleCase' }),\n        value: SongListSort.FAVORITED,\n    },\n    {\n        defaultOrder: SortOrder.ASC,\n        name: i18n.t('filter.genre', { postProcess: 'titleCase' }),\n        value: SongListSort.GENRE,\n    },\n    {\n        defaultOrder: SortOrder.ASC,\n        name: i18n.t('filter.name', { postProcess: 'titleCase' }),\n        value: SongListSort.NAME,\n    },\n    {\n        defaultOrder: SortOrder.ASC,\n        name: i18n.t('filter.sortName', { postProcess: 'titleCase' }),\n        value: SongListSort.SORT_NAME,\n    },\n    {\n        defaultOrder: SortOrder.DESC,\n        name: i18n.t('filter.playCount', { postProcess: 'titleCase' }),\n        value: SongListSort.PLAY_COUNT,\n    },\n    {\n        defaultOrder: SortOrder.DESC,\n        name: i18n.t('filter.rating', { postProcess: 'titleCase' }),\n        value: SongListSort.RATING,\n    },\n    {\n        defaultOrder: SortOrder.DESC,\n        name: i18n.t('filter.recentlyAdded', { postProcess: 'titleCase' }),\n        value: SongListSort.RECENTLY_ADDED,\n    },\n    {\n        defaultOrder: SortOrder.DESC,\n        name: i18n.t('filter.recentlyPlayed', { postProcess: 'titleCase' }),\n        value: SongListSort.RECENTLY_PLAYED,\n    },\n    {\n        defaultOrder: SortOrder.DESC,\n        name: i18n.t('filter.releaseYear', { postProcess: 'titleCase' }),\n        value: SongListSort.YEAR,\n    },\n];\n\nexport const CLIENT_SIDE_ALBUM_FILTERS = [\n    {\n        defaultOrder: SortOrder.ASC,\n        name: i18n.t('filter.albumArtist', { postProcess: 'titleCase' }),\n        value: AlbumListSort.ALBUM_ARTIST,\n    },\n    {\n        defaultOrder: SortOrder.ASC,\n        name: i18n.t('filter.id', { postProcess: 'titleCase' }),\n        value: AlbumListSort.ID,\n    },\n    {\n        defaultOrder: SortOrder.DESC,\n        name: i18n.t('filter.duration', { postProcess: 'titleCase' }),\n        value: AlbumListSort.DURATION,\n    },\n    {\n        defaultOrder: SortOrder.DESC,\n        name: i18n.t('filter.favorited', { postProcess: 'titleCase' }),\n        value: AlbumListSort.FAVORITED,\n    },\n    {\n        defaultOrder: SortOrder.ASC,\n        name: i18n.t('filter.name', { postProcess: 'titleCase' }),\n        value: AlbumListSort.NAME,\n    },\n    {\n        defaultOrder: SortOrder.ASC,\n        name: i18n.t('filter.sortName', { postProcess: 'titleCase' }),\n        value: AlbumListSort.SORT_NAME,\n    },\n    {\n        defaultOrder: SortOrder.DESC,\n        name: i18n.t('filter.playCount', { postProcess: 'titleCase' }),\n        value: AlbumListSort.PLAY_COUNT,\n    },\n    {\n        defaultOrder: SortOrder.ASC,\n        name: i18n.t('filter.random', { postProcess: 'titleCase' }),\n        value: AlbumListSort.RANDOM,\n    },\n    {\n        defaultOrder: SortOrder.DESC,\n        name: i18n.t('filter.rating', { postProcess: 'titleCase' }),\n        value: AlbumListSort.RATING,\n    },\n    {\n        defaultOrder: SortOrder.DESC,\n        name: i18n.t('filter.recentlyAdded', { postProcess: 'titleCase' }),\n        value: AlbumListSort.RECENTLY_ADDED,\n    },\n    {\n        defaultOrder: SortOrder.DESC,\n        name: i18n.t('filter.recentlyPlayed', { postProcess: 'titleCase' }),\n        value: AlbumListSort.RECENTLY_PLAYED,\n    },\n    {\n        defaultOrder: SortOrder.DESC,\n        name: i18n.t('filter.releaseDate', { postProcess: 'titleCase' }),\n        value: AlbumListSort.RELEASE_DATE,\n    },\n    {\n        defaultOrder: SortOrder.DESC,\n        name: i18n.t('filter.releaseYear', { postProcess: 'titleCase' }),\n        value: AlbumListSort.YEAR,\n    },\n    {\n        defaultOrder: SortOrder.DESC,\n        name: i18n.t('filter.songCount', { postProcess: 'titleCase' }),\n        value: AlbumListSort.SONG_COUNT,\n    },\n];\n\nconst ALBUM_LIST_FILTERS: Partial<\n    Record<ServerType, Array<{ defaultOrder: SortOrder; name: string; value: string }>>\n> = {\n    [ServerType.JELLYFIN]: [\n        {\n            defaultOrder: SortOrder.ASC,\n            name: i18n.t('filter.albumArtist', { postProcess: 'titleCase' }),\n            value: AlbumListSort.ALBUM_ARTIST,\n        },\n        {\n            defaultOrder: SortOrder.ASC,\n            name: i18n.t('filter.id', { postProcess: 'titleCase' }),\n            value: AlbumListSort.ID,\n        },\n        {\n            defaultOrder: SortOrder.DESC,\n            name: i18n.t('filter.communityRating', { postProcess: 'titleCase' }),\n            value: AlbumListSort.COMMUNITY_RATING,\n        },\n        {\n            defaultOrder: SortOrder.DESC,\n            name: i18n.t('filter.criticRating', { postProcess: 'titleCase' }),\n            value: AlbumListSort.CRITIC_RATING,\n        },\n        {\n            defaultOrder: SortOrder.ASC,\n            name: i18n.t('filter.name', { postProcess: 'titleCase' }),\n            value: AlbumListSort.NAME,\n        },\n        {\n            defaultOrder: SortOrder.DESC,\n            name: i18n.t('filter.playCount', { postProcess: 'titleCase' }),\n            value: AlbumListSort.PLAY_COUNT,\n        },\n        {\n            defaultOrder: SortOrder.ASC,\n            name: i18n.t('filter.random', { postProcess: 'titleCase' }),\n            value: AlbumListSort.RANDOM,\n        },\n        {\n            defaultOrder: SortOrder.DESC,\n            name: i18n.t('filter.recentlyAdded', { postProcess: 'titleCase' }),\n            value: AlbumListSort.RECENTLY_ADDED,\n        },\n        {\n            defaultOrder: SortOrder.DESC,\n            name: i18n.t('filter.releaseDate', { postProcess: 'titleCase' }),\n            value: AlbumListSort.RELEASE_DATE,\n        },\n    ],\n    [ServerType.NAVIDROME]: [\n        {\n            defaultOrder: SortOrder.ASC,\n            name: i18n.t('filter.albumArtist', { postProcess: 'titleCase' }),\n            value: AlbumListSort.ALBUM_ARTIST,\n        },\n        {\n            defaultOrder: SortOrder.ASC,\n            name: i18n.t('filter.id', { postProcess: 'titleCase' }),\n            value: AlbumListSort.ID,\n        },\n        {\n            defaultOrder: SortOrder.ASC,\n            name: i18n.t('filter.artist', { postProcess: 'titleCase' }),\n            value: AlbumListSort.ARTIST,\n        },\n        {\n            defaultOrder: SortOrder.DESC,\n            name: i18n.t('filter.duration', { postProcess: 'titleCase' }),\n            value: AlbumListSort.DURATION,\n        },\n        {\n            defaultOrder: SortOrder.DESC,\n            name: i18n.t('filter.mostPlayed', { postProcess: 'titleCase' }),\n            value: AlbumListSort.PLAY_COUNT,\n        },\n        {\n            defaultOrder: SortOrder.ASC,\n            name: i18n.t('filter.name', { postProcess: 'titleCase' }),\n            value: AlbumListSort.NAME,\n        },\n        {\n            defaultOrder: SortOrder.ASC,\n            name: i18n.t('filter.random', { postProcess: 'titleCase' }),\n            value: AlbumListSort.RANDOM,\n        },\n        {\n            defaultOrder: SortOrder.DESC,\n            name: i18n.t('filter.rating', { postProcess: 'titleCase' }),\n            value: AlbumListSort.RATING,\n        },\n        {\n            defaultOrder: SortOrder.DESC,\n            name: i18n.t('filter.recentlyAdded', { postProcess: 'titleCase' }),\n            value: AlbumListSort.RECENTLY_ADDED,\n        },\n        {\n            defaultOrder: SortOrder.DESC,\n            name: i18n.t('filter.recentlyPlayed', { postProcess: 'titleCase' }),\n            value: AlbumListSort.RECENTLY_PLAYED,\n        },\n        {\n            defaultOrder: SortOrder.DESC,\n            name: i18n.t('filter.songCount', { postProcess: 'titleCase' }),\n            value: AlbumListSort.SONG_COUNT,\n        },\n        {\n            defaultOrder: SortOrder.DESC,\n            name: i18n.t('filter.favorited', { postProcess: 'titleCase' }),\n            value: AlbumListSort.FAVORITED,\n        },\n        {\n            defaultOrder: SortOrder.DESC,\n            name: i18n.t('filter.releaseYear', { postProcess: 'titleCase' }),\n            value: AlbumListSort.YEAR,\n        },\n    ],\n    [ServerType.SUBSONIC]: [\n        {\n            defaultOrder: SortOrder.ASC,\n            name: i18n.t('filter.albumArtist', { postProcess: 'titleCase' }),\n            value: AlbumListSort.ALBUM_ARTIST,\n        },\n        {\n            defaultOrder: SortOrder.ASC,\n            name: i18n.t('filter.id', { postProcess: 'titleCase' }),\n            value: AlbumListSort.ID,\n        },\n        {\n            defaultOrder: SortOrder.DESC,\n            name: i18n.t('filter.mostPlayed', { postProcess: 'titleCase' }),\n            value: AlbumListSort.PLAY_COUNT,\n        },\n        {\n            defaultOrder: SortOrder.ASC,\n            name: i18n.t('filter.name', { postProcess: 'titleCase' }),\n            value: AlbumListSort.NAME,\n        },\n        {\n            defaultOrder: SortOrder.ASC,\n            name: i18n.t('filter.random', { postProcess: 'titleCase' }),\n            value: AlbumListSort.RANDOM,\n        },\n        {\n            defaultOrder: SortOrder.DESC,\n            name: i18n.t('filter.recentlyAdded', { postProcess: 'titleCase' }),\n            value: AlbumListSort.RECENTLY_ADDED,\n        },\n        {\n            defaultOrder: SortOrder.DESC,\n            name: i18n.t('filter.recentlyPlayed', { postProcess: 'titleCase' }),\n            value: AlbumListSort.RECENTLY_PLAYED,\n        },\n        {\n            defaultOrder: SortOrder.DESC,\n            name: i18n.t('filter.favorited', { postProcess: 'titleCase' }),\n            value: AlbumListSort.FAVORITED,\n        },\n        {\n            defaultOrder: SortOrder.DESC,\n            name: i18n.t('filter.releaseYear', { postProcess: 'titleCase' }),\n            value: AlbumListSort.YEAR,\n        },\n    ],\n};\n\nconst SONG_LIST_FILTERS: Partial<\n    Record<ServerType, Array<{ defaultOrder: SortOrder; name: string; value: string }>>\n> = {\n    [ServerType.JELLYFIN]: [\n        {\n            defaultOrder: SortOrder.ASC,\n            name: i18n.t('filter.album', { postProcess: 'titleCase' }),\n            value: SongListSort.ALBUM,\n        },\n        {\n            defaultOrder: SortOrder.ASC,\n            name: i18n.t('filter.albumArtist', { postProcess: 'titleCase' }),\n            value: SongListSort.ALBUM_ARTIST,\n        },\n        {\n            defaultOrder: SortOrder.ASC,\n            name: i18n.t('filter.artist', { postProcess: 'titleCase' }),\n            value: SongListSort.ARTIST,\n        },\n        {\n            defaultOrder: SortOrder.ASC,\n            name: i18n.t('filter.duration', { postProcess: 'titleCase' }),\n            value: SongListSort.DURATION,\n        },\n        {\n            defaultOrder: SortOrder.ASC,\n            name: i18n.t('filter.playCount', { postProcess: 'titleCase' }),\n            value: SongListSort.PLAY_COUNT,\n        },\n        {\n            defaultOrder: SortOrder.ASC,\n            name: i18n.t('filter.name', { postProcess: 'titleCase' }),\n            value: SongListSort.NAME,\n        },\n        {\n            defaultOrder: SortOrder.ASC,\n            name: i18n.t('filter.random', { postProcess: 'titleCase' }),\n            value: SongListSort.RANDOM,\n        },\n        {\n            defaultOrder: SortOrder.ASC,\n            name: i18n.t('filter.recentlyAdded', { postProcess: 'titleCase' }),\n            value: SongListSort.RECENTLY_ADDED,\n        },\n        {\n            defaultOrder: SortOrder.ASC,\n            name: i18n.t('filter.recentlyPlayed', { postProcess: 'titleCase' }),\n            value: SongListSort.RECENTLY_PLAYED,\n        },\n        {\n            defaultOrder: SortOrder.ASC,\n            name: i18n.t('filter.releaseDate', { postProcess: 'titleCase' }),\n            value: SongListSort.RELEASE_DATE,\n        },\n    ],\n    [ServerType.NAVIDROME]: [\n        {\n            defaultOrder: SortOrder.ASC,\n            name: i18n.t('filter.album', { postProcess: 'titleCase' }),\n            value: SongListSort.ALBUM,\n        },\n        {\n            defaultOrder: SortOrder.ASC,\n            name: i18n.t('filter.albumArtist', { postProcess: 'titleCase' }),\n            value: SongListSort.ALBUM_ARTIST,\n        },\n        {\n            defaultOrder: SortOrder.ASC,\n            name: i18n.t('filter.artist', { postProcess: 'titleCase' }),\n            value: SongListSort.ARTIST,\n        },\n        {\n            defaultOrder: SortOrder.DESC,\n            name: i18n.t('filter.bpm', { postProcess: 'titleCase' }),\n            value: SongListSort.BPM,\n        },\n        {\n            defaultOrder: SortOrder.ASC,\n            name: i18n.t('common.channel', { count: 2, postProcess: 'titleCase' }),\n            value: SongListSort.CHANNELS,\n        },\n        {\n            defaultOrder: SortOrder.ASC,\n            name: i18n.t('filter.comment', { postProcess: 'titleCase' }),\n            value: SongListSort.COMMENT,\n        },\n        {\n            defaultOrder: SortOrder.DESC,\n            name: i18n.t('filter.duration', { postProcess: 'titleCase' }),\n            value: SongListSort.DURATION,\n        },\n        {\n            defaultOrder: SortOrder.DESC,\n            name: i18n.t('filter.isFavorited', { postProcess: 'titleCase' }),\n            value: SongListSort.FAVORITED,\n        },\n        {\n            defaultOrder: SortOrder.ASC,\n            name: i18n.t('filter.genre', { postProcess: 'titleCase' }),\n            value: SongListSort.GENRE,\n        },\n        {\n            defaultOrder: SortOrder.ASC,\n            name: i18n.t('filter.name', { postProcess: 'titleCase' }),\n            value: SongListSort.NAME,\n        },\n        {\n            defaultOrder: SortOrder.DESC,\n            name: i18n.t('filter.playCount', { postProcess: 'titleCase' }),\n            value: SongListSort.PLAY_COUNT,\n        },\n        {\n            defaultOrder: SortOrder.ASC,\n            name: i18n.t('filter.random', { postProcess: 'titleCase' }),\n            value: SongListSort.RANDOM,\n        },\n        {\n            defaultOrder: SortOrder.DESC,\n            name: i18n.t('filter.rating', { postProcess: 'titleCase' }),\n            value: SongListSort.RATING,\n        },\n        {\n            defaultOrder: SortOrder.DESC,\n            name: i18n.t('filter.recentlyAdded', { postProcess: 'titleCase' }),\n            value: SongListSort.RECENTLY_ADDED,\n        },\n        {\n            defaultOrder: SortOrder.DESC,\n            name: i18n.t('filter.recentlyPlayed', { postProcess: 'titleCase' }),\n            value: SongListSort.RECENTLY_PLAYED,\n        },\n        {\n            defaultOrder: SortOrder.DESC,\n            name: i18n.t('filter.releaseYear', { postProcess: 'titleCase' }),\n            value: SongListSort.YEAR,\n        },\n    ],\n    [ServerType.SUBSONIC]: [\n        {\n            defaultOrder: SortOrder.ASC,\n            name: i18n.t('filter.name', { postProcess: 'titleCase' }),\n            value: SongListSort.NAME,\n        },\n    ],\n};\n\nconst FOLDER_LIST_FILTERS: Partial<\n    Record<ServerType, Array<{ defaultOrder: SortOrder; name: string; value: string }>>\n> = {\n    [ServerType.JELLYFIN]: [\n        {\n            defaultOrder: SortOrder.ASC,\n            name: i18n.t('filter.id', { postProcess: 'titleCase' }),\n            value: SongListSort.ID,\n        },\n        ...(SONG_LIST_FILTERS[ServerType.JELLYFIN] || []),\n    ],\n    [ServerType.NAVIDROME]: [\n        {\n            defaultOrder: SortOrder.ASC,\n            name: i18n.t('filter.id', { postProcess: 'titleCase' }),\n            value: SongListSort.ID,\n        },\n        ...(SONG_LIST_FILTERS[ServerType.NAVIDROME] || []),\n    ],\n    [ServerType.SUBSONIC]: [\n        {\n            defaultOrder: SortOrder.ASC,\n            name: i18n.t('filter.id', { postProcess: 'titleCase' }),\n            value: SongListSort.ID,\n        },\n        ...(SONG_LIST_FILTERS[ServerType.SUBSONIC] || []),\n    ],\n};\n\nconst PLAYLIST_SONG_LIST_FILTERS: Partial<\n    Record<ServerType, Array<{ defaultOrder: SortOrder; name: string; value: string }>>\n> = {\n    [ServerType.JELLYFIN]: CLIENT_SIDE_SONG_FILTERS,\n    [ServerType.NAVIDROME]: CLIENT_SIDE_SONG_FILTERS,\n    [ServerType.SUBSONIC]: CLIENT_SIDE_SONG_FILTERS,\n};\n\nconst ALBUM_ARTIST_LIST_FILTERS: Partial<\n    Record<ServerType, Array<{ defaultOrder: SortOrder; name: string; value: string }>>\n> = {\n    [ServerType.JELLYFIN]: [\n        {\n            defaultOrder: SortOrder.ASC,\n            name: i18n.t('filter.album', { postProcess: 'titleCase' }),\n            value: AlbumArtistListSort.ALBUM,\n        },\n        {\n            defaultOrder: SortOrder.DESC,\n            name: i18n.t('filter.duration', { postProcess: 'titleCase' }),\n            value: AlbumArtistListSort.DURATION,\n        },\n        {\n            defaultOrder: SortOrder.ASC,\n            name: i18n.t('filter.name', { postProcess: 'titleCase' }),\n            value: AlbumArtistListSort.NAME,\n        },\n        {\n            defaultOrder: SortOrder.ASC,\n            name: i18n.t('filter.random', { postProcess: 'titleCase' }),\n            value: AlbumArtistListSort.RANDOM,\n        },\n        {\n            defaultOrder: SortOrder.DESC,\n            name: i18n.t('filter.recentlyAdded', { postProcess: 'titleCase' }),\n            value: AlbumArtistListSort.RECENTLY_ADDED,\n        },\n    ],\n    [ServerType.NAVIDROME]: [\n        {\n            defaultOrder: SortOrder.DESC,\n            name: i18n.t('filter.albumCount', { postProcess: 'titleCase' }),\n            value: AlbumArtistListSort.ALBUM_COUNT,\n        },\n        {\n            defaultOrder: SortOrder.DESC,\n            name: i18n.t('filter.isFavorited', { postProcess: 'titleCase' }),\n            value: AlbumArtistListSort.FAVORITED,\n        },\n        {\n            defaultOrder: SortOrder.DESC,\n            name: i18n.t('filter.mostPlayed', { postProcess: 'titleCase' }),\n            value: AlbumArtistListSort.PLAY_COUNT,\n        },\n        {\n            defaultOrder: SortOrder.ASC,\n            name: i18n.t('filter.name', { postProcess: 'titleCase' }),\n            value: AlbumArtistListSort.NAME,\n        },\n        {\n            defaultOrder: SortOrder.DESC,\n            name: i18n.t('filter.rating', { postProcess: 'titleCase' }),\n            value: AlbumArtistListSort.RATING,\n        },\n        {\n            defaultOrder: SortOrder.DESC,\n            name: i18n.t('filter.songCount', { postProcess: 'titleCase' }),\n            value: AlbumArtistListSort.SONG_COUNT,\n        },\n    ],\n    [ServerType.SUBSONIC]: [\n        {\n            defaultOrder: SortOrder.DESC,\n            name: i18n.t('filter.albumCount', { postProcess: 'titleCase' }),\n            value: AlbumArtistListSort.ALBUM_COUNT,\n        },\n        {\n            defaultOrder: SortOrder.DESC,\n            name: i18n.t('filter.isFavorited', { postProcess: 'titleCase' }),\n            value: AlbumArtistListSort.FAVORITED,\n        },\n        {\n            defaultOrder: SortOrder.ASC,\n            name: i18n.t('filter.name', { postProcess: 'titleCase' }),\n            value: AlbumArtistListSort.NAME,\n        },\n        {\n            defaultOrder: SortOrder.DESC,\n            name: i18n.t('filter.rating', { postProcess: 'titleCase' }),\n            value: AlbumArtistListSort.RATING,\n        },\n    ],\n};\n\nconst ARTIST_LIST_FILTERS: Partial<\n    Record<ServerType, Array<{ defaultOrder: SortOrder; name: string; value: string }>>\n> = {\n    [ServerType.JELLYFIN]: [\n        {\n            defaultOrder: SortOrder.ASC,\n            name: i18n.t('filter.album', { postProcess: 'titleCase' }),\n            value: ArtistListSort.ALBUM,\n        },\n        {\n            defaultOrder: SortOrder.DESC,\n            name: i18n.t('filter.duration', { postProcess: 'titleCase' }),\n            value: ArtistListSort.DURATION,\n        },\n        {\n            defaultOrder: SortOrder.ASC,\n            name: i18n.t('filter.name', { postProcess: 'titleCase' }),\n            value: ArtistListSort.NAME,\n        },\n        {\n            defaultOrder: SortOrder.ASC,\n            name: i18n.t('filter.random', { postProcess: 'titleCase' }),\n            value: ArtistListSort.RANDOM,\n        },\n        {\n            defaultOrder: SortOrder.DESC,\n            name: i18n.t('filter.recentlyAdded', { postProcess: 'titleCase' }),\n            value: ArtistListSort.RECENTLY_ADDED,\n        },\n    ],\n    [ServerType.NAVIDROME]: [\n        {\n            defaultOrder: SortOrder.DESC,\n            name: i18n.t('filter.albumCount', { postProcess: 'titleCase' }),\n            value: ArtistListSort.ALBUM_COUNT,\n        },\n        {\n            defaultOrder: SortOrder.DESC,\n            name: i18n.t('filter.isFavorited', { postProcess: 'titleCase' }),\n            value: ArtistListSort.FAVORITED,\n        },\n        {\n            defaultOrder: SortOrder.DESC,\n            name: i18n.t('filter.mostPlayed', { postProcess: 'titleCase' }),\n            value: ArtistListSort.PLAY_COUNT,\n        },\n        {\n            defaultOrder: SortOrder.ASC,\n            name: i18n.t('filter.name', { postProcess: 'titleCase' }),\n            value: ArtistListSort.NAME,\n        },\n        {\n            defaultOrder: SortOrder.DESC,\n            name: i18n.t('filter.rating', { postProcess: 'titleCase' }),\n            value: ArtistListSort.RATING,\n        },\n        {\n            defaultOrder: SortOrder.DESC,\n            name: i18n.t('filter.songCount', { postProcess: 'titleCase' }),\n            value: ArtistListSort.SONG_COUNT,\n        },\n    ],\n    [ServerType.SUBSONIC]: [\n        {\n            defaultOrder: SortOrder.DESC,\n            name: i18n.t('filter.albumCount', { postProcess: 'titleCase' }),\n            value: ArtistListSort.ALBUM_COUNT,\n        },\n        {\n            defaultOrder: SortOrder.DESC,\n            name: i18n.t('filter.isFavorited', { postProcess: 'titleCase' }),\n            value: ArtistListSort.FAVORITED,\n        },\n        {\n            defaultOrder: SortOrder.ASC,\n            name: i18n.t('filter.name', { postProcess: 'titleCase' }),\n            value: ArtistListSort.NAME,\n        },\n        {\n            defaultOrder: SortOrder.DESC,\n            name: i18n.t('filter.rating', { postProcess: 'titleCase' }),\n            value: ArtistListSort.RATING,\n        },\n    ],\n};\n\nconst GENRE_LIST_FILTERS: Partial<\n    Record<ServerType, Array<{ defaultOrder: SortOrder; name: string; value: string }>>\n> = {\n    [ServerType.JELLYFIN]: [\n        {\n            defaultOrder: SortOrder.ASC,\n            name: i18n.t('filter.name', { postProcess: 'titleCase' }),\n            value: GenreListSort.NAME,\n        },\n    ],\n    [ServerType.NAVIDROME]: [\n        {\n            defaultOrder: SortOrder.ASC,\n            name: i18n.t('filter.name', { postProcess: 'titleCase' }),\n            value: GenreListSort.NAME,\n        },\n    ],\n    [ServerType.SUBSONIC]: [\n        {\n            defaultOrder: SortOrder.ASC,\n            name: i18n.t('filter.name', { postProcess: 'titleCase' }),\n            value: GenreListSort.NAME,\n        },\n    ],\n};\n\nconst PLAYLIST_LIST_FILTERS: Partial<\n    Record<ServerType, Array<{ defaultOrder: SortOrder; name: string; value: string }>>\n> = {\n    [ServerType.JELLYFIN]: [\n        {\n            defaultOrder: SortOrder.DESC,\n            name: i18n.t('filter.duration', { postProcess: 'titleCase' }),\n            value: PlaylistListSort.DURATION,\n        },\n        {\n            defaultOrder: SortOrder.ASC,\n            name: i18n.t('filter.name', { postProcess: 'titleCase' }),\n            value: PlaylistListSort.NAME,\n        },\n        {\n            defaultOrder: SortOrder.DESC,\n            name: i18n.t('filter.songCount', { postProcess: 'titleCase' }),\n            value: PlaylistListSort.SONG_COUNT,\n        },\n    ],\n    [ServerType.NAVIDROME]: [\n        {\n            defaultOrder: SortOrder.DESC,\n            name: i18n.t('filter.duration', { postProcess: 'titleCase' }),\n            value: PlaylistListSort.DURATION,\n        },\n        {\n            defaultOrder: SortOrder.ASC,\n            name: i18n.t('filter.name', { postProcess: 'titleCase' }),\n            value: PlaylistListSort.NAME,\n        },\n        {\n            defaultOrder: SortOrder.ASC,\n            name: i18n.t('filter.owner', { postProcess: 'titleCase' }),\n            value: PlaylistListSort.OWNER,\n        },\n        {\n            defaultOrder: SortOrder.DESC,\n            name: i18n.t('filter.isPublic', { postProcess: 'titleCase' }),\n            value: PlaylistListSort.PUBLIC,\n        },\n        {\n            defaultOrder: SortOrder.DESC,\n            name: i18n.t('filter.songCount', { postProcess: 'titleCase' }),\n            value: PlaylistListSort.SONG_COUNT,\n        },\n        {\n            defaultOrder: SortOrder.DESC,\n            name: i18n.t('filter.recentlyUpdated', { postProcess: 'titleCase' }),\n            value: PlaylistListSort.UPDATED_AT,\n        },\n    ],\n    [ServerType.SUBSONIC]: [\n        {\n            defaultOrder: SortOrder.ASC,\n            name: i18n.t('filter.name', { postProcess: 'titleCase' }),\n            value: PlaylistListSort.NAME,\n        },\n    ],\n};\n\nconst RADIO_LIST_FILTERS: Partial<\n    Record<ServerType, Array<{ defaultOrder: SortOrder; name: string; value: string }>>\n> = {\n    [ServerType.JELLYFIN]: [\n        {\n            defaultOrder: SortOrder.ASC,\n            name: i18n.t('filter.id', { postProcess: 'titleCase' }),\n            value: RadioListSort.ID,\n        },\n        {\n            defaultOrder: SortOrder.ASC,\n            name: i18n.t('filter.name', { postProcess: 'titleCase' }),\n            value: RadioListSort.NAME,\n        },\n    ],\n    [ServerType.NAVIDROME]: [\n        {\n            defaultOrder: SortOrder.ASC,\n            name: i18n.t('filter.id', { postProcess: 'titleCase' }),\n            value: RadioListSort.ID,\n        },\n        {\n            defaultOrder: SortOrder.ASC,\n            name: i18n.t('filter.name', { postProcess: 'titleCase' }),\n            value: RadioListSort.NAME,\n        },\n    ],\n    [ServerType.SUBSONIC]: [\n        {\n            defaultOrder: SortOrder.ASC,\n            name: i18n.t('filter.id', { postProcess: 'titleCase' }),\n            value: RadioListSort.ID,\n        },\n        {\n            defaultOrder: SortOrder.ASC,\n            name: i18n.t('filter.name', { postProcess: 'titleCase' }),\n            value: RadioListSort.NAME,\n        },\n    ],\n};\n\nconst FILTERS: Partial<Record<LibraryItem, any>> = {\n    [LibraryItem.ALBUM]: ALBUM_LIST_FILTERS,\n    [LibraryItem.ALBUM_ARTIST]: ALBUM_ARTIST_LIST_FILTERS,\n    [LibraryItem.ARTIST]: ARTIST_LIST_FILTERS,\n    [LibraryItem.FOLDER]: FOLDER_LIST_FILTERS,\n    [LibraryItem.GENRE]: GENRE_LIST_FILTERS,\n    [LibraryItem.PLAYLIST]: PLAYLIST_LIST_FILTERS,\n    [LibraryItem.PLAYLIST_SONG]: PLAYLIST_SONG_LIST_FILTERS,\n    [LibraryItem.RADIO_STATION]: RADIO_LIST_FILTERS,\n    [LibraryItem.SONG]: SONG_LIST_FILTERS,\n};\n"
  },
  {
    "path": "src/renderer/features/shared/components/list-sort-order-toggle-button.tsx",
    "content": "import { OrderToggleButton } from '/@/renderer/features/shared/components/order-toggle-button';\nimport { useSortOrderFilter } from '/@/renderer/features/shared/hooks/use-sort-order-filter';\nimport { SortOrder } from '/@/shared/types/domain-types';\nimport { ItemListKey } from '/@/shared/types/types';\n\ninterface ListSortOrderToggleButtonProps {\n    defaultSortOrder: SortOrder;\n    disabled?: boolean;\n    listKey: ItemListKey;\n}\n\nexport const ListSortOrderToggleButton = ({\n    defaultSortOrder,\n    disabled,\n    listKey,\n}: ListSortOrderToggleButtonProps) => {\n    const { setSortOrder, sortOrder } = useSortOrderFilter(defaultSortOrder, listKey);\n\n    const handleToggleSortOrder = () => {\n        const newSortOrder = sortOrder === SortOrder.ASC ? SortOrder.DESC : SortOrder.ASC;\n        setSortOrder(newSortOrder);\n    };\n\n    return (\n        <OrderToggleButton\n            disabled={disabled}\n            onToggle={handleToggleSortOrder}\n            sortOrder={sortOrder as SortOrder}\n        />\n    );\n};\n\ninterface ListSortOrderToggleButtonControlledProps {\n    disabled?: boolean;\n    setSortOrder: (sortOrder: SortOrder) => void;\n    sortOrder: SortOrder;\n}\n\nexport const ListSortOrderToggleButtonControlled = ({\n    disabled,\n    setSortOrder,\n    sortOrder,\n}: ListSortOrderToggleButtonControlledProps) => {\n    return (\n        <OrderToggleButton\n            disabled={disabled}\n            onToggle={() =>\n                setSortOrder(sortOrder === SortOrder.ASC ? SortOrder.DESC : SortOrder.ASC)\n            }\n            sortOrder={sortOrder as SortOrder}\n        />\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/shared/components/list-with-sidebar-container.module.css",
    "content": ".container {\n    position: relative;\n    display: flex;\n    flex-direction: row;\n    width: 100%;\n    min-width: 0;\n    height: 100%;\n    container-type: inline-size;\n    overflow: hidden;\n}\n\n.sidebar-container {\n    position: relative;\n    display: none;\n    flex-shrink: 0;\n    width: 300px;\n    min-width: 300px;\n    max-width: 300px;\n    height: 100%;\n    overflow: hidden;\n    border-right: 1px solid var(--theme-colors-border);\n}\n\n@container (min-width: $mantine-breakpoint-xs) {\n    .container[data-sidebar-open='true'] .sidebar-container {\n        display: block;\n    }\n\n    @container (min-width: $mantine-breakpoint-lg) {\n        .container[data-use-breakpoint='true'] .sidebar-container {\n            display: block;\n        }\n    }\n}\n\n.content-container {\n    position: relative;\n    display: flex;\n    flex: 1;\n    flex-direction: column;\n    width: 100%;\n    min-width: 0;\n    height: 100%;\n    overflow: hidden;\n}\n"
  },
  {
    "path": "src/renderer/features/shared/components/list-with-sidebar-container.tsx",
    "content": "import { motion } from 'motion/react';\nimport { createContext, ReactNode, useContext, useMemo, useRef } from 'react';\n\nimport styles from './list-with-sidebar-container.module.css';\n\nimport { useListContext } from '/@/renderer/context/list-context';\nimport { animationProps } from '/@/shared/components/animations/animation-props';\nimport { Portal } from '/@/shared/components/portal/portal';\n\ninterface ListWithSidebarContainerContextValue {\n    sidebarRef: React.RefObject<HTMLDivElement | null>;\n}\n\nconst ListWithSidebarContainerContext = createContext<ListWithSidebarContainerContextValue | null>(\n    null,\n);\n\ninterface ListWithSidebarContainerProps {\n    children: ReactNode;\n    sidebarBreakpoint?: number;\n    useBreakpoint?: boolean;\n}\n\ninterface SidebarPortalProps {\n    children: ReactNode;\n}\n\ninterface SidebarProps {\n    children: ReactNode;\n}\n\nfunction Sidebar({ children }: SidebarProps) {\n    const context = useContext(ListWithSidebarContainerContext);\n\n    if (!context) {\n        throw new Error('Sidebar must be used within ListWithSidebarContainer');\n    }\n\n    if (!context.sidebarRef?.current) {\n        return null;\n    }\n\n    return (\n        <Portal target={context.sidebarRef.current}>\n            <motion.div {...animationProps.slideInLeft} style={{ height: '100%', width: '100%' }}>\n                {children}\n            </motion.div>\n        </Portal>\n    );\n}\n\nfunction SidebarPortal({ children }: SidebarPortalProps) {\n    const context = useContext(ListWithSidebarContainerContext);\n\n    if (!context) {\n        throw new Error('SidebarPortal must be used within ListWithSidebarContainer');\n    }\n\n    if (!context.sidebarRef?.current) {\n        return null;\n    }\n\n    return <Portal target={context.sidebarRef.current}>{children}</Portal>;\n}\n\nexport const ListWithSidebarContainer = ({\n    children,\n    useBreakpoint = false,\n}: ListWithSidebarContainerProps) => {\n    const sidebarRef = useRef<HTMLDivElement>(null);\n    const { isSidebarOpen = false } = useListContext();\n\n    const contextValue = useMemo(\n        () => ({\n            sidebarRef,\n        }),\n        [],\n    );\n\n    return (\n        <ListWithSidebarContainerContext.Provider value={contextValue}>\n            <div\n                className={styles.container}\n                data-sidebar-open={useBreakpoint ? undefined : isSidebarOpen}\n                data-use-breakpoint={useBreakpoint}\n            >\n                <div className={styles.sidebarContainer} ref={sidebarRef} />\n                <div className={styles.contentContainer}>{children}</div>\n            </div>\n        </ListWithSidebarContainerContext.Provider>\n    );\n};\n\nListWithSidebarContainer.Sidebar = Sidebar;\nListWithSidebarContainer.SidebarPortal = SidebarPortal;\n"
  },
  {
    "path": "src/renderer/features/shared/components/more-button.tsx",
    "content": "import { ActionIcon, ActionIconProps } from '/@/shared/components/action-icon/action-icon';\n\ninterface MoreButtonProps extends ActionIconProps {}\n\nexport const MoreButton = ({ ...props }: MoreButtonProps) => {\n    return (\n        <ActionIcon\n            icon=\"ellipsisHorizontal\"\n            iconProps={{\n                size: 'lg',\n                ...props.iconProps,\n            }}\n            variant=\"subtle\"\n            {...props}\n        />\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/shared/components/multi-select-rows.module.css",
    "content": ".row {\n    padding: var(--theme-spacing-xs) var(--theme-spacing-sm);\n    border: 1px solid transparent;\n    border-radius: var(--theme-radius-md);\n}\n\n.row:hover {\n    cursor: pointer;\n    background-color: alpha(var(--theme-colors-background), 0.5);\n}\n\n.row-image {\n    flex-shrink: 0;\n    width: 40px;\n    height: 40px;\n}\n\n.row-content {\n    flex: 1;\n    min-width: 0;\n}\n\n.row.selected {\n    background-color: var(--theme-colors-surface);\n}\n\n.row[data-focused='true'] {\n    border: 1px solid var(--theme-colors-primary);\n}\n\n.row.disabled {\n    cursor: not-allowed;\n    opacity: 0.6;\n}\n\n.row.disabled:hover {\n    cursor: not-allowed;\n    background-color: transparent;\n}\n"
  },
  {
    "path": "src/renderer/features/shared/components/multi-select-rows.tsx",
    "content": "import { useCallback } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { RowComponentProps } from 'react-window-v2';\n\nimport styles from './multi-select-rows.module.css';\n\nimport { ItemImage } from '/@/renderer/components/item-image/item-image';\nimport { Group } from '/@/shared/components/group/group';\nimport { VirtualMultiSelectOption } from '/@/shared/components/multi-select/virtual-multi-select';\nimport { Text } from '/@/shared/components/text/text';\nimport { LibraryItem } from '/@/shared/types/domain-types';\n\nexport function ArtistMultiSelectRow({\n    disabled = false,\n    displayCountType = 'album',\n    focusedIndex,\n    index,\n    onToggle,\n    options,\n    style,\n}: RowComponentProps<{\n    disabled?: boolean;\n    displayCountType?: 'album' | 'song';\n    focusedIndex: null | number;\n    onToggle: (value: string) => void;\n    options: VirtualMultiSelectOption<{\n        albumCount: null | number;\n        imageUrl: string | undefined;\n        songCount: null | number;\n    }>[];\n    value: string[];\n}>) {\n    const { t } = useTranslation();\n\n    const handleClick = useCallback(() => {\n        onToggle(options[index].value);\n    }, [onToggle, options, index]);\n\n    const isFocused = focusedIndex === index;\n    const count =\n        displayCountType === 'song' ? options[index].songCount : options[index].albumCount;\n    const countEntity = displayCountType === 'song' ? 'song' : 'album';\n\n    return (\n        <Group\n            className={`${styles.row} ${disabled ? styles.disabled : ''}`}\n            gap=\"sm\"\n            onClick={disabled ? undefined : handleClick}\n            style={{ ...style }}\n            {...(isFocused && !disabled && { 'data-focused': true })}\n        >\n            <ItemImage\n                containerClassName={styles.rowImage}\n                enableDebounce={true}\n                enableViewport={false}\n                itemType={LibraryItem.ARTIST}\n                src={options[index].imageUrl}\n                type=\"table\"\n            />\n            <div className={styles.rowContent}>\n                <Text isNoSelect overflow=\"hidden\" size=\"sm\">\n                    {options[index].label}\n                </Text>\n                <Text isMuted overflow=\"hidden\" size=\"xs\">\n                    {count ? (\n                        <>\n                            {count} {t(`entity.${countEntity}`, { count })}\n                        </>\n                    ) : null}\n                </Text>\n            </div>\n        </Group>\n    );\n}\n\nexport function GenreMultiSelectRow({\n    disabled = false,\n    displayCountType = 'album',\n    focusedIndex,\n    index,\n    onToggle,\n    options,\n    style,\n}: RowComponentProps<{\n    disabled?: boolean;\n    displayCountType?: 'album' | 'song';\n    focusedIndex: null | number;\n    onToggle: (value: string) => void;\n    options: VirtualMultiSelectOption<{\n        albumCount: null | number;\n        songCount: null | number;\n    }>[];\n    value: string[];\n}>) {\n    const { t } = useTranslation();\n\n    const handleClick = useCallback(() => {\n        onToggle(options[index].value);\n    }, [onToggle, options, index]);\n\n    const isFocused = focusedIndex === index;\n    const count =\n        displayCountType === 'song' ? options[index].songCount : options[index].albumCount;\n    const countEntity = displayCountType === 'song' ? 'song' : 'album';\n\n    return (\n        <Group\n            className={`${styles.row} ${disabled ? styles.disabled : ''}`}\n            gap=\"sm\"\n            onClick={disabled ? undefined : handleClick}\n            style={{ ...style }}\n            {...(isFocused && !disabled && { 'data-focused': true })}\n        >\n            <div className={styles.rowContent}>\n                <Text isNoSelect overflow=\"hidden\" size=\"sm\">\n                    {options[index].label}\n                </Text>\n                <Text isMuted overflow=\"hidden\" size=\"xs\">\n                    {count ? (\n                        <>\n                            {count} {t(`entity.${countEntity}`, { count })}\n                        </>\n                    ) : null}\n                </Text>\n            </div>\n        </Group>\n    );\n}\n"
  },
  {
    "path": "src/renderer/features/shared/components/order-toggle-button.tsx",
    "content": "import { useTranslation } from 'react-i18next';\n\nimport { ActionIcon, ActionIconProps } from '/@/shared/components/action-icon/action-icon';\nimport { SortOrder } from '/@/shared/types/domain-types';\n\ninterface OrderToggleButtonProps {\n    buttonProps?: Partial<ActionIconProps>;\n    disabled?: boolean;\n    onToggle: () => void;\n    sortOrder: SortOrder;\n}\n\nexport const OrderToggleButton = ({\n    buttonProps,\n    disabled,\n    onToggle,\n    sortOrder,\n}: OrderToggleButtonProps) => {\n    const { t } = useTranslation();\n\n    return (\n        <ActionIcon\n            disabled={disabled}\n            icon={sortOrder === SortOrder.ASC ? 'sortAsc' : 'sortDesc'}\n            iconProps={{\n                size: 'lg',\n            }}\n            onClick={onToggle}\n            tooltip={{\n                label:\n                    sortOrder === SortOrder.ASC\n                        ? t('common.ascending', { postProcess: 'sentenceCase' })\n                        : t('common.descending', { postProcess: 'sentenceCase' }),\n            }}\n            variant=\"subtle\"\n            {...buttonProps}\n        />\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/shared/components/page-error-boundary.tsx",
    "content": "import { ErrorBoundary } from 'react-error-boundary';\nimport { useTranslation } from 'react-i18next';\n\nimport { ServerSelector } from '/@/renderer/features/sidebar/components/server-selector';\nimport { Box } from '/@/shared/components/box/box';\nimport { Button } from '/@/shared/components/button/button';\nimport { Center } from '/@/shared/components/center/center';\nimport { Code } from '/@/shared/components/code/code';\nimport { Group } from '/@/shared/components/group/group';\nimport { Icon } from '/@/shared/components/icon/icon';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { TextTitle } from '/@/shared/components/text-title/text-title';\nimport { Text } from '/@/shared/components/text/text';\n\ninterface PageErrorFallbackProps {\n    error: Error;\n    resetErrorBoundary: () => void;\n}\n\nconst PageErrorFallback = ({ error, resetErrorBoundary }: PageErrorFallbackProps) => {\n    const { t } = useTranslation();\n\n    const handleRefresh = () => {\n        window.location.reload();\n    };\n\n    return (\n        <Box h=\"100%\" pos=\"relative\" w=\"100%\">\n            <Box\n                style={{\n                    padding: 'var(--theme-spacing-md)',\n                    position: 'absolute',\n                    right: 0,\n                    top: 0,\n                    zIndex: 100,\n                }}\n            >\n                <ServerSelector />\n            </Box>\n            <Center h=\"100%\" p=\"md\" w=\"100%\">\n                <Stack maw=\"800px\">\n                    <Group gap=\"xs\">\n                        <Icon fill=\"error\" icon=\"error\" size=\"lg\" />\n                        <TextTitle fw={700} order={3}>\n                            {t('error.genericError', { postProcess: 'sentenceCase' })}\n                        </TextTitle>\n                    </Group>\n                    <Text style={{ wordBreak: 'break-word' }}>\n                        {error?.message || t('error.genericError', { postProcess: 'sentenceCase' })}\n                    </Text>\n                    {process.env.NODE_ENV === 'development' && error?.stack && (\n                        <Code\n                            p=\"md\"\n                            style={{\n                                backgroundColor: 'var(--theme-colors-surface)',\n                                fontFamily: 'monospace',\n                                maxHeight: '300px',\n                                overflow: 'auto',\n                                wordBreak: 'break-word',\n                            }}\n                        >\n                            {error.stack}\n                        </Code>\n                    )}\n                    <Group grow>\n                        <Button onClick={resetErrorBoundary} size=\"md\" variant=\"default\">\n                            {t('common.reload', { postProcess: 'sentenceCase' })}\n                        </Button>\n                        <Button onClick={handleRefresh} size=\"md\" variant=\"filled\">\n                            {t('common.refresh', { postProcess: 'sentenceCase' })}\n                        </Button>\n                    </Group>\n                </Stack>\n            </Center>\n        </Box>\n    );\n};\n\ninterface PageErrorBoundaryProps {\n    children: React.ReactNode;\n}\n\nexport const PageErrorBoundary = ({ children }: PageErrorBoundaryProps) => {\n    return (\n        <ErrorBoundary\n            FallbackComponent={PageErrorFallback}\n            onError={(error, errorInfo) => {\n                if (process.env.NODE_ENV === 'development') {\n                    console.error('Page error boundary caught an error:', error, errorInfo);\n                }\n            }}\n            onReset={() => {}}\n        >\n            {children}\n        </ErrorBoundary>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/shared/components/play-button-group.module.css",
    "content": ".play-button-group {\n    display: flex;\n    flex-direction: row;\n    flex-wrap: nowrap;\n    gap: var(--theme-spacing-sm);\n    align-items: center;\n    justify-content: center;\n    width: 100%;\n    height: 100%;\n}\n\n.play-button-group-vertical {\n    flex-direction: column;\n    width: auto;\n    height: auto;\n}\n"
  },
  {
    "path": "src/renderer/features/shared/components/play-button-group.tsx",
    "content": "import { motion } from 'motion/react';\nimport { useEffect, useRef, useState } from 'react';\n\nimport styles from './play-button-group.module.css';\n\nimport i18n from '/@/i18n/i18n';\nimport { PlayButton } from '/@/renderer/features/shared/components/play-button';\nimport { AppIconSelection } from '/@/shared/components/icon/icon';\nimport { Portal } from '/@/shared/components/portal/portal';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { Text } from '/@/shared/components/text/text';\nimport { Tooltip } from '/@/shared/components/tooltip/tooltip';\nimport { useClickOutside } from '/@/shared/hooks/use-click-outside';\nimport { Play } from '/@/shared/types/types';\n\nconst playButtons: {\n    icon: AppIconSelection;\n    label: React.ReactNode | string;\n    secondary: boolean;\n    type: Play;\n}[] = [\n    {\n        icon: 'mediaPlayNext',\n        label: (\n            <Stack gap=\"xs\" justify=\"center\">\n                <Text fw={500} ta=\"center\">\n                    {i18n.t('player.addNext', { postProcess: 'sentenceCase' })}\n                </Text>\n                <Text fw={500} isMuted size=\"xs\" ta=\"center\">\n                    {i18n.t('player.holdToShuffle', { postProcess: 'sentenceCase' })}\n                </Text>\n            </Stack>\n        ),\n\n        secondary: true,\n        type: Play.NEXT,\n    },\n    {\n        icon: 'mediaPlay',\n        label: (\n            <Stack gap=\"xs\" justify=\"center\">\n                <Text fw={500} ta=\"center\">\n                    {i18n.t('player.play', { postProcess: 'sentenceCase' })}\n                </Text>\n                <Text fw={500} isMuted size=\"xs\" ta=\"center\">\n                    {i18n.t('player.holdToShuffle', { postProcess: 'sentenceCase' })}\n                </Text>\n            </Stack>\n        ),\n        secondary: false,\n        type: Play.NOW,\n    },\n    {\n        icon: 'mediaPlayLast',\n        label: (\n            <Stack gap=\"xs\" justify=\"center\">\n                <Text fw={500} ta=\"center\">\n                    {i18n.t('player.addLast', { postProcess: 'sentenceCase' })}\n                </Text>\n                <Text fw={500} isMuted size=\"xs\" ta=\"center\">\n                    {i18n.t('player.holdToShuffle', { postProcess: 'sentenceCase' })}\n                </Text>\n            </Stack>\n        ),\n        secondary: true,\n        type: Play.LAST,\n    },\n];\n\nexport const LONG_PRESS_PLAY_BEHAVIOR = {\n    [Play.LAST]: Play.LAST_SHUFFLE,\n    [Play.NEXT]: Play.NEXT_SHUFFLE,\n    [Play.NOW]: Play.SHUFFLE,\n};\n\nconst PLAY_BEHAVIOR_TO_LABEL = {\n    [Play.LAST]: i18n.t('player.addLast', { postProcess: 'sentenceCase' }),\n    [Play.NEXT]: i18n.t('player.addNext', { postProcess: 'sentenceCase' }),\n    [Play.NOW]: i18n.t('player.play', { postProcess: 'sentenceCase' }),\n};\n\nconst TooltipLabel = ({ label }: { label: React.ReactNode | string; type: Play }) => {\n    return (\n        <Stack gap=\"xs\" justify=\"center\">\n            <Text fw={500} ta=\"center\">\n                {label}\n            </Text>\n            <Text fw={500} isMuted size=\"xs\" ta=\"center\">\n                {i18n.t('player.holdToShuffle', { postProcess: 'sentenceCase' })}\n            </Text>\n        </Stack>\n    );\n};\n\nexport const PlayTooltip = ({\n    children,\n    disabled,\n    type,\n}: {\n    children: React.ReactNode;\n    disabled?: boolean;\n    type: Play;\n}) => {\n    return (\n        <Tooltip\n            disabled={disabled}\n            label={<TooltipLabel label={PLAY_BEHAVIOR_TO_LABEL[type]} type={type} />}\n        >\n            {children}\n        </Tooltip>\n    );\n};\n\ninterface PlayButtonGroupPopoverProps extends PlayButtonGroupProps {\n    onClose?: () => void;\n    position?: 'bottom' | 'left' | 'right' | 'top';\n    triggerRef?: React.RefObject<HTMLElement | null>;\n}\n\ninterface PlayButtonGroupProps {\n    loading?: boolean | Play;\n    onPlay: (type: Play) => void;\n}\n\ntype PopoverPosition = 'bottom' | 'left' | 'right' | 'top';\n\nexport const PlayButtonGroup = ({ loading, onPlay }: PlayButtonGroupProps) => {\n    return (\n        <div className={styles.playButtonGroup}>\n            <Tooltip.Group>\n                {playButtons.map((button) => (\n                    <Tooltip key={button.type} label={button.label}>\n                        <PlayButton\n                            fill={button.type === Play.NOW}\n                            icon={button.icon}\n                            isSecondary={button.secondary}\n                            loading={loading === button.type}\n                            onClick={() => onPlay(button.type)}\n                            onLongPress={() => onPlay(LONG_PRESS_PLAY_BEHAVIOR[button.type])}\n                        />\n                    </Tooltip>\n                ))}\n            </Tooltip.Group>\n        </div>\n    );\n};\n\nconst containerVariants = {\n    exit: {\n        opacity: 0,\n    },\n    hidden: { opacity: 0 },\n    visible: {\n        opacity: 1,\n        transition: {\n            delayChildren: 0.1,\n            staggerChildren: 0.1,\n        },\n    },\n};\n\nconst getItemVariants = (position: PopoverPosition) => {\n    const baseTransition = {\n        damping: 24,\n        stiffness: 300,\n        type: 'spring' as const,\n    };\n\n    switch (position) {\n        case 'bottom':\n            return {\n                exit: {\n                    opacity: 0,\n                    scale: 0.8,\n                    transition: {\n                        duration: 0.2,\n                    },\n                    y: -10,\n                },\n                hidden: { opacity: 0, scale: 0.8, y: -10 },\n                visible: {\n                    opacity: 1,\n                    scale: 1,\n                    transition: baseTransition,\n                    y: 0,\n                },\n            };\n        case 'left':\n            return {\n                exit: {\n                    opacity: 0,\n                    scale: 0.8,\n                    transition: {\n                        duration: 0.2,\n                    },\n                    x: 10,\n                },\n                hidden: { opacity: 0, scale: 0.8, x: 10 },\n                visible: {\n                    opacity: 1,\n                    scale: 1,\n                    transition: baseTransition,\n                    x: 0,\n                },\n            };\n        case 'right':\n            return {\n                exit: {\n                    opacity: 0,\n                    scale: 0.8,\n                    transition: {\n                        duration: 0.2,\n                    },\n                    x: -10,\n                },\n                hidden: { opacity: 0, scale: 0.8, x: -10 },\n                visible: {\n                    opacity: 1,\n                    scale: 1,\n                    transition: baseTransition,\n                    x: 0,\n                },\n            };\n        case 'top':\n            return {\n                exit: {\n                    opacity: 0,\n                    scale: 0.8,\n                    transition: {\n                        duration: 0.2,\n                    },\n                    y: 10,\n                },\n                hidden: { opacity: 0, scale: 0.8, y: 10 },\n                visible: {\n                    opacity: 1,\n                    scale: 1,\n                    transition: baseTransition,\n                    y: 0,\n                },\n            };\n    }\n};\n\nconst getPositionStyles = (\n    position: PopoverPosition,\n    triggerRect: DOMRect | null,\n): React.CSSProperties => {\n    if (!triggerRect) {\n        return { display: 'none' };\n    }\n\n    const gap = 8;\n\n    switch (position) {\n        case 'bottom':\n            return {\n                height: '64px',\n                left: triggerRect.left + triggerRect.width / 2,\n                position: 'fixed' as const,\n                top: triggerRect.bottom + gap,\n                transform: 'translateX(-50%)',\n                zIndex: 1000,\n            };\n        case 'left':\n            return {\n                height: '64px',\n                left: triggerRect.left - gap,\n                position: 'fixed' as const,\n                top: triggerRect.top + triggerRect.height / 2,\n                transform: 'translate(-100%, -50%)',\n                zIndex: 1000,\n            };\n        case 'right':\n            return {\n                height: '64px',\n                left: triggerRect.right + gap,\n                position: 'fixed' as const,\n                top: triggerRect.top + triggerRect.height / 2,\n                transform: 'translateY(-50%)',\n                zIndex: 1000,\n            };\n        case 'top':\n            return {\n                height: '64px',\n                left: triggerRect.left + triggerRect.width / 2,\n                position: 'fixed' as const,\n                top: triggerRect.top - gap,\n                transform: 'translate(-50%, -100%)',\n                zIndex: 1000,\n            };\n    }\n};\n\nconst getArchOffset = (index: number, position: PopoverPosition): { x?: number; y?: number } => {\n    const archCurve = 16;\n    const isVertical = position === 'left' || position === 'right';\n    const isMiddle = index === 1;\n\n    if (isMiddle) {\n        return {};\n    }\n\n    if (isVertical) {\n        // For left/right positions, offset horizontally toward the parent\n        if (position === 'right') {\n            return { x: -archCurve };\n        } else {\n            return { x: archCurve };\n        }\n    } else {\n        // For top/bottom positions, offset vertically toward the parent\n        if (position === 'bottom') {\n            return { y: -archCurve };\n        } else {\n            return { y: archCurve };\n        }\n    }\n};\n\nexport const PlayButtonGroupPopover = ({\n    loading,\n    onClose,\n    onPlay,\n    position = 'bottom',\n    triggerRef,\n}: PlayButtonGroupPopoverProps) => {\n    const [triggerRect, setTriggerRect] = useState<DOMRect | null>(null);\n    const itemVariants = getItemVariants(position);\n    const isVertical = position === 'left' || position === 'right';\n    const popoverRef = useRef<HTMLDivElement>(null);\n\n    useClickOutside(\n        () => {\n            onClose?.();\n        },\n        ['click', 'touchstart'],\n        [popoverRef, triggerRef].map((ref) => ref?.current).filter(Boolean) as HTMLElement[],\n    );\n\n    useEffect(() => {\n        if (!triggerRef?.current) return;\n\n        const updatePosition = () => {\n            if (triggerRef.current) {\n                const rect = triggerRef.current.getBoundingClientRect();\n                setTriggerRect(rect);\n            }\n        };\n\n        requestAnimationFrame(updatePosition);\n\n        window.addEventListener('scroll', updatePosition, true);\n        window.addEventListener('resize', updatePosition);\n\n        return () => {\n            window.removeEventListener('scroll', updatePosition, true);\n            window.removeEventListener('resize', updatePosition);\n        };\n    }, [triggerRef]);\n\n    const positionStyles = getPositionStyles(position, triggerRect);\n\n    const content = (\n        <motion.div\n            animate=\"visible\"\n            className={`${styles.playButtonGroup} ${isVertical ? styles.playButtonGroupVertical : ''}`}\n            exit=\"exit\"\n            initial=\"hidden\"\n            ref={popoverRef}\n            style={positionStyles}\n            variants={containerVariants}\n        >\n            <Tooltip.Group>\n                {playButtons.map((button, index) => {\n                    const archOffset = getArchOffset(index, position);\n                    const combinedVariants = {\n                        ...itemVariants,\n                        exit: {\n                            ...itemVariants.exit,\n                            x: (itemVariants.exit.x ?? 0) + (archOffset.x ?? 0),\n                            y: (itemVariants.exit.y ?? 0) + (archOffset.y ?? 0),\n                        },\n                        hidden: {\n                            ...itemVariants.hidden,\n                            x: (itemVariants.hidden.x ?? 0) + (archOffset.x ?? 0),\n                            y: (itemVariants.hidden.y ?? 0) + (archOffset.y ?? 0),\n                        },\n                        visible: {\n                            ...itemVariants.visible,\n                            x: (itemVariants.visible.x ?? 0) + (archOffset.x ?? 0),\n                            y: (itemVariants.visible.y ?? 0) + (archOffset.y ?? 0),\n                        },\n                    };\n\n                    return (\n                        <motion.div key={button.type} variants={combinedVariants}>\n                            <Tooltip label={button.label}>\n                                <PlayButton\n                                    fill={button.type === Play.NOW}\n                                    icon={button.icon}\n                                    isSecondary={button.secondary}\n                                    loading={loading === button.type}\n                                    onClick={() => onPlay(button.type)}\n                                    onLongPress={() =>\n                                        onPlay(LONG_PRESS_PLAY_BEHAVIOR[button.type])\n                                    }\n                                />\n                            </Tooltip>\n                        </motion.div>\n                    );\n                })}\n            </Tooltip.Group>\n        </motion.div>\n    );\n\n    return <Portal>{content}</Portal>;\n};\n"
  },
  {
    "path": "src/renderer/features/shared/components/play-button.module.css",
    "content": ".text-button {\n    width: 3rem;\n    height: 3rem;\n    border-radius: 50%;\n    opacity: 0.8;\n    transition: background-color 0.2s ease-in-out;\n    transition: transform 0.2s ease-in-out;\n\n    &:active {\n        transform: scale(0.95);\n    }\n\n    &:disabled {\n        opacity: 0.6;\n    }\n}\n\n.text-button.unthemed {\n    @mixin light {\n        color: white;\n        background: black;\n\n        svg {\n            color: white;\n            fill: white;\n        }\n\n        &:hover {\n            background: lighten(black, 10%);\n        }\n    }\n\n    @mixin dark {\n        color: black;\n        background: white;\n\n        svg {\n            color: black;\n            fill: black;\n        }\n\n        &:hover {\n            background: darken(white, 20%);\n        }\n    }\n}\n\n.wide-text-button {\n    padding-right: var(--theme-spacing-xl);\n    padding-left: var(--theme-spacing-xl);\n    background: white;\n    border-radius: var(--theme-radius-xl);\n    transition: background-color 0.2s ease-in-out !important;\n\n    &[data-variant='subtle'] {\n        transition: background-color 0.2s ease-in-out !important;\n\n        &:hover,\n        &:active,\n        &:focus-visible {\n            transition: background-color 0.2s ease-in-out !important;\n        }\n    }\n}\n\n.wide-text-button.unthemed {\n    transition: background-color 0.2s ease-in-out !important;\n\n    &[data-variant='subtle'] {\n        transition: background-color 0.2s ease-in-out !important;\n    }\n\n    @mixin light {\n        background: black;\n\n        svg {\n            color: white;\n            fill: white;\n        }\n\n        &[data-variant='subtle']:hover,\n        &[data-variant='subtle']:active,\n        &[data-variant='subtle']:focus-visible {\n            background: lighten(black, 10%) !important;\n            transition: background-color 0.2s ease-in-out !important;\n        }\n    }\n\n    @mixin dark {\n        background: white;\n\n        svg {\n            color: black;\n            fill: black;\n        }\n\n        &[data-variant='subtle']:hover,\n        &[data-variant='subtle']:active,\n        &[data-variant='subtle']:focus-visible {\n            background: darken(white, 20%) !important;\n            transition: background-color 0.2s ease-in-out !important;\n        }\n    }\n}\n\n.wide-text-button-label {\n    font-size: var(--theme-font-size-md);\n    font-weight: 600;\n    color: black;\n\n    @mixin light {\n        color: white;\n    }\n\n    svg {\n        color: black;\n        fill: black;\n    }\n}\n\n.no-fill {\n    fill: none !important;\n}\n\n.play-button {\n    all: unset;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    width: 48px;\n    height: 48px;\n    overflow: visible;\n    background-color: #fff;\n    border: none;\n    border-radius: 100%;\n    isolation: isolate;\n    opacity: 1;\n    transition: opacity 0.1s ease-in-out;\n    transition: transform 0.1s ease-in-out;\n\n    --play-button-scale: 1;\n    --long-press-duration: 500ms;\n\n    &::before {\n        position: absolute;\n        top: 50%;\n        left: 50%;\n        z-index: 0;\n        width: 100%;\n        height: 100%;\n        pointer-events: none;\n        content: '';\n        background-color: var(--theme-colors-primary);\n        border-radius: 50%;\n        opacity: 1;\n        transform: scale(0);\n        transition: transform 0.15s ease-out;\n    }\n\n    &[data-pressing='true']::before {\n        transition: none;\n        animation: expand-long-press var(--long-press-duration) linear 100ms forwards;\n    }\n\n    &:hover {\n        opacity: 1;\n        transform: scale(1.1);\n    }\n\n    &:active {\n        opacity: 1;\n        transform: scale(0.9);\n    }\n\n    svg {\n        position: relative;\n        z-index: 1;\n        stroke: rgb(0 0 0);\n    }\n}\n\n.play-button.fill {\n    svg {\n        fill: rgb(0 0 0);\n    }\n}\n\n.play-button.secondary {\n    width: 32px;\n    height: 32px;\n}\n\n@keyframes expand-long-press {\n    0% {\n        opacity: 0.2;\n        transform: translate(-50%, -50%) scale(0);\n    }\n\n    100% {\n        opacity: 0.8;\n        transform: translate(-50%, -50%) scale(1.05);\n    }\n}\n\n.play-button.disabled,\n.play-button.loading {\n    cursor: not-allowed;\n    opacity: 0.5;\n    transform: translate(-50%, -50%) scale(1);\n}\n"
  },
  {
    "path": "src/renderer/features/shared/components/play-button.tsx",
    "content": "import clsx from 'clsx';\nimport { t } from 'i18next';\nimport { forwardRef, memo } from 'react';\n\nimport styles from './play-button.module.css';\n\nimport { PlayTooltip } from '/@/renderer/features/shared/components/play-button-group';\nimport { usePlayButtonClick } from '/@/renderer/features/shared/hooks/use-play-button-click';\nimport { ActionIcon, ActionIconProps } from '/@/shared/components/action-icon/action-icon';\nimport { Button, ButtonProps } from '/@/shared/components/button/button';\nimport { Group } from '/@/shared/components/group/group';\nimport { AppIcon, Icon } from '/@/shared/components/icon/icon';\nimport { Spinner } from '/@/shared/components/spinner/spinner';\nimport { Play } from '/@/shared/types/types';\n\nexport interface DefaultPlayButtonProps extends ActionIconProps {\n    size?: number | string;\n}\n\nexport const DefaultPlayButton = forwardRef<HTMLButtonElement, DefaultPlayButtonProps>(\n    ({ className, variant = 'filled', ...props }, ref) => {\n        return (\n            <ActionIcon\n                className={clsx(styles.textButton, className, {\n                    [styles.unthemed]: variant !== 'filled',\n                })}\n                icon=\"mediaPlay\"\n                iconProps={{\n                    size: 'xl',\n                }}\n                ref={ref}\n                variant={variant}\n                {...props}\n            />\n        );\n    },\n);\n\nDefaultPlayButton.displayName = 'DefaultPlayButton';\n\ninterface TextPlayButtonProps extends ButtonProps {\n    onLongPress?: (e: React.MouseEvent<HTMLButtonElement>) => void;\n    showTooltip?: boolean;\n}\n\nexport const PlayTextButton = ({\n    className,\n    showTooltip = true,\n    variant = 'default',\n    ...props\n}: TextPlayButtonProps) => {\n    const button = (\n        <Button\n            className={clsx(styles.wideTextButton, className, {\n                [styles.unthemed]: variant !== 'filled',\n            })}\n            classNames={{\n                label: styles.wideTextButtonLabel,\n                root: styles.wideTextButton,\n            }}\n            variant=\"subtle\"\n            {...props}\n        >\n            {props.children || (\n                <Group gap=\"sm\" wrap=\"nowrap\">\n                    <Icon icon=\"mediaPlay\" size=\"lg\" />\n                    {t('player.play', { postProcess: 'sentenceCase' })}\n                </Group>\n            )}\n        </Button>\n    );\n\n    const hasLongPress = Boolean(\n        props.onLongPress || (props as any).onMouseDown || (props as any).onTouchStart,\n    );\n\n    if (hasLongPress && showTooltip) {\n        return <PlayTooltip type={Play.NOW}>{button}</PlayTooltip>;\n    }\n\n    return button;\n};\n\nexport const PlayNextTextButton = ({ ...props }: TextPlayButtonProps) => {\n    const button = (\n        <PlayTextButton {...props} showTooltip={false}>\n            <Group gap=\"sm\" wrap=\"nowrap\">\n                <Icon className={styles.noFill} icon=\"mediaPlayNext\" size=\"lg\" />\n                {t('player.addNext', { postProcess: 'sentenceCase' })}\n            </Group>\n        </PlayTextButton>\n    );\n\n    const hasLongPress = Boolean(\n        props.onLongPress || (props as any).onMouseDown || (props as any).onTouchStart,\n    );\n\n    if (hasLongPress) {\n        return <PlayTooltip type={Play.NEXT}>{button}</PlayTooltip>;\n    }\n\n    return button;\n};\n\nexport const PlayLastTextButton = ({ ...props }: TextPlayButtonProps) => {\n    const button = (\n        <PlayTextButton {...props} showTooltip={false}>\n            <Group gap=\"sm\" wrap=\"nowrap\">\n                <Icon className={styles.noFill} icon=\"mediaPlayLast\" size=\"lg\" />\n                {t('player.addLast', { postProcess: 'sentenceCase' })}\n            </Group>\n        </PlayTextButton>\n    );\n\n    const hasLongPress = Boolean(\n        props.onLongPress || (props as any).onMouseDown || (props as any).onTouchStart,\n    );\n\n    if (hasLongPress) {\n        return <PlayTooltip type={Play.LAST}>{button}</PlayTooltip>;\n    }\n\n    return button;\n};\n\nexport const WideShuffleButton = ({ ...props }: TextPlayButtonProps) => {\n    return (\n        <PlayTextButton {...props}>\n            <Group gap=\"sm\" wrap=\"nowrap\">\n                <Icon fill=\"default\" icon=\"mediaShuffle\" size=\"lg\" />\n                {t('action.shuffle', { postProcess: 'sentenceCase' })}\n            </Group>\n        </PlayTextButton>\n    );\n};\n\ninterface PlayButtonProps {\n    classNames?: string;\n    fill?: boolean;\n    icon?: keyof typeof AppIcon;\n    isSecondary?: boolean;\n    loading?: boolean;\n    onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;\n    onLongPress?: (e: React.MouseEvent<HTMLButtonElement>) => void;\n}\n\nconst PlayButtonBase = forwardRef<HTMLButtonElement, PlayButtonProps>(\n    (\n        {\n            classNames,\n            fill,\n            icon = 'mediaPlay',\n            isSecondary,\n            loading,\n            onClick,\n            onLongPress,\n        }: PlayButtonProps,\n        ref,\n    ) => {\n        const clickHandlers = usePlayButtonClick({\n            loading,\n            onClick,\n            onLongPress,\n        });\n\n        return (\n            <button\n                className={clsx(styles.playButton, classNames, {\n                    [styles.fill]: fill,\n                    [styles.secondary]: isSecondary,\n                })}\n                ref={ref}\n                {...clickHandlers.handlers}\n                {...clickHandlers.props}\n            >\n                {loading ? <Spinner color=\"black\" /> : <Icon icon={icon} size=\"lg\" />}\n            </button>\n        );\n    },\n);\n\nexport const PlayButton = memo(PlayButtonBase);\n\nPlayButton.displayName = 'PlayButton';\n"
  },
  {
    "path": "src/renderer/features/shared/components/refresh-button.tsx",
    "content": "import { useTranslation } from 'react-i18next';\n\nimport { ActionIcon, ActionIconProps } from '/@/shared/components/action-icon/action-icon';\n\ninterface RefreshButtonProps extends ActionIconProps {\n    loading?: boolean;\n}\n\nexport const RefreshButton = ({ loading, onClick, ...props }: RefreshButtonProps) => {\n    const { t } = useTranslation();\n\n    return (\n        <ActionIcon\n            icon=\"refresh\"\n            iconProps={{\n                size: 'lg',\n                ...props.iconProps,\n            }}\n            loading={loading}\n            onClick={onClick}\n            tooltip={{\n                label: t('common.refresh', { postProcess: 'sentenceCase' }),\n                ...props.tooltip,\n            }}\n            variant=\"subtle\"\n            {...props}\n        />\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/shared/components/resize-handle.module.css",
    "content": ".handle {\n    position: absolute;\n    z-index: 90;\n    width: 4px;\n    height: 100%;\n    cursor: ew-resize;\n    background-color: var(--theme-colors-border);\n    opacity: 0;\n\n    &:hover {\n        opacity: 0.6;\n    }\n\n    &::before {\n        position: absolute;\n        width: 1px;\n        height: 100%;\n        content: '';\n    }\n}\n\n.handle-top {\n    top: 0;\n    left: 0;\n    width: 100%;\n    height: 4px;\n    cursor: ns-resize;\n}\n\n.handle-right {\n    right: 0;\n}\n\n.handle-bottom {\n    bottom: 0;\n    left: 0;\n    width: 100%;\n    height: 4px;\n    cursor: ns-resize;\n}\n\n.handle-left {\n    left: 0;\n}\n\n.handle.resizing {\n    opacity: 1;\n}\n"
  },
  {
    "path": "src/renderer/features/shared/components/resize-handle.tsx",
    "content": "import clsx from 'clsx';\nimport { forwardRef, HTMLAttributes } from 'react';\n\nimport styles from './resize-handle.module.css';\n\ninterface ResizeHandleProps extends HTMLAttributes<HTMLDivElement> {\n    isResizing: boolean;\n    placement: 'bottom' | 'left' | 'right' | 'top';\n}\n\nexport const ResizeHandle = forwardRef<HTMLDivElement, ResizeHandleProps>(\n    ({ isResizing, placement, ...props }: ResizeHandleProps, ref) => {\n        return (\n            <div\n                className={clsx({\n                    [styles.handle]: true,\n                    [styles.resizing]: isResizing,\n                    [styles[`handle-${placement}`]]: true,\n                })}\n                ref={ref}\n                {...props}\n            />\n        );\n    },\n);\n"
  },
  {
    "path": "src/renderer/features/shared/components/router-error-boundary.tsx",
    "content": "import { ErrorBoundary } from 'react-error-boundary';\nimport { useTranslation } from 'react-i18next';\n\nimport { ServerSelector } from '/@/renderer/features/sidebar/components/server-selector';\nimport { Box } from '/@/shared/components/box/box';\nimport { Button } from '/@/shared/components/button/button';\nimport { Center } from '/@/shared/components/center/center';\nimport { Code } from '/@/shared/components/code/code';\nimport { Group } from '/@/shared/components/group/group';\nimport { Icon } from '/@/shared/components/icon/icon';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { TextTitle } from '/@/shared/components/text-title/text-title';\nimport { Text } from '/@/shared/components/text/text';\n\ninterface RouterErrorFallbackProps {\n    error: Error;\n    resetErrorBoundary: () => void;\n}\n\nconst RouterErrorFallback = ({ error, resetErrorBoundary }: RouterErrorFallbackProps) => {\n    const { t } = useTranslation();\n\n    const handleRefresh = () => {\n        window.location.reload();\n    };\n\n    return (\n        <Box\n            style={{\n                backgroundColor: 'var(--theme-colors-background)',\n                height: '100vh',\n                width: '100vw',\n            }}\n        >\n            <Box\n                style={{\n                    padding: 'var(--theme-spacing-md)',\n                    position: 'absolute',\n                    right: 0,\n                    top: 0,\n                    zIndex: 1000,\n                }}\n            >\n                <ServerSelector />\n            </Box>\n            <Center h=\"100vh\" p=\"md\" w=\"100%\">\n                <Stack maw=\"800px\">\n                    <Group gap=\"xs\">\n                        <Icon fill=\"error\" icon=\"error\" size=\"lg\" />\n                        <TextTitle fw={700} order={3}>\n                            {t('error.genericError', { postProcess: 'sentenceCase' })}\n                        </TextTitle>\n                    </Group>\n                    <Text style={{ wordBreak: 'break-word' }}>\n                        {error?.message || t('error.genericError', { postProcess: 'sentenceCase' })}\n                    </Text>\n                    {process.env.NODE_ENV === 'development' && error?.stack && (\n                        <Code\n                            p=\"md\"\n                            style={{\n                                backgroundColor: 'var(--theme-colors-surface)',\n                                fontFamily: 'monospace',\n                                maxHeight: '300px',\n                                overflow: 'auto',\n                                wordBreak: 'break-word',\n                            }}\n                        >\n                            {error.stack}\n                        </Code>\n                    )}\n                    <Group grow>\n                        <Button onClick={resetErrorBoundary} size=\"md\" variant=\"default\">\n                            {t('common.reload', { postProcess: 'sentenceCase' })}\n                        </Button>\n                        <Button onClick={handleRefresh} size=\"md\" variant=\"filled\">\n                            {t('common.refresh', { postProcess: 'sentenceCase' })}\n                        </Button>\n                    </Group>\n                </Stack>\n            </Center>\n        </Box>\n    );\n};\n\ninterface RouterErrorBoundaryProps {\n    children: React.ReactNode;\n}\n\nexport const RouterErrorBoundary = ({ children }: RouterErrorBoundaryProps) => {\n    return (\n        <ErrorBoundary\n            FallbackComponent={RouterErrorFallback}\n            onError={(error, errorInfo) => {\n                if (process.env.NODE_ENV === 'development') {\n                    console.error('Root error boundary caught an error:', error, errorInfo);\n                }\n            }}\n            onReset={() => {}}\n        >\n            {children}\n        </ErrorBoundary>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/shared/components/save-as-collection-button.module.css",
    "content": ".list {\n    flex: 0 0 200px;\n    height: 200px;\n    overflow: hidden;\n    border: 1px solid var(--theme-colors-border);\n}\n\n.row {\n    display: flex;\n    gap: var(--theme-spacing-xs);\n    align-items: center;\n    width: 100%;\n    padding: var(--theme-spacing-xs) var(--theme-spacing-sm);\n}\n"
  },
  {
    "path": "src/renderer/features/shared/components/save-as-collection-button.tsx",
    "content": "import { nanoid } from 'nanoid';\nimport { useCallback, useMemo, useRef } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { useSearchParams } from 'react-router';\n\nimport styles from './save-as-collection-button.module.css';\n\nimport { useListContext } from '/@/renderer/context/list-context';\nimport { useCollections, useSettingsStoreActions } from '/@/renderer/store';\nimport { getFilterQueryStringFromSearchParams } from '/@/renderer/utils/query-params';\nimport { ActionIcon } from '/@/shared/components/action-icon/action-icon';\nimport { Button } from '/@/shared/components/button/button';\nimport { Group } from '/@/shared/components/group/group';\nimport { Popover } from '/@/shared/components/popover/popover';\nimport { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { TextInput } from '/@/shared/components/text-input/text-input';\nimport { Text } from '/@/shared/components/text/text';\nimport { useDisclosure } from '/@/shared/hooks/use-disclosure';\nimport { useForm } from '/@/shared/hooks/use-form';\nimport { LibraryItem, SavedCollection } from '/@/shared/types/domain-types';\n\ninterface SaveAsCollectionButtonProps {\n    fullWidth?: boolean;\n    itemType: LibraryItem.ALBUM | LibraryItem.SONG;\n}\n\nexport const SaveAsCollectionButton = ({ fullWidth, itemType }: SaveAsCollectionButtonProps) => {\n    const { t } = useTranslation();\n    const [searchParams] = useSearchParams();\n    const { customFilters } = useListContext();\n    const collections = useCollections();\n    const { addCollection, updateCollection } = useSettingsStoreActions();\n    const [isOpen, handlers] = useDisclosure(false);\n    const formRef = useRef<HTMLFormElement>(null);\n\n    const sameTypeCollections = useMemo(\n        () => collections?.filter((c): c is SavedCollection => c.type === itemType) ?? [],\n        [collections, itemType],\n    );\n\n    const form = useForm({\n        initialValues: {\n            name: '',\n        },\n    });\n\n    const handleOpen = useCallback(() => {\n        form.setValues({ name: '' });\n        handlers.open();\n    }, [form, handlers]);\n\n    const handleOverrideExisting = useCallback(\n        (collection: SavedCollection) => {\n            const filterQueryString = getFilterQueryStringFromSearchParams(\n                searchParams,\n                customFilters as Record<\n                    string,\n                    boolean | number | Record<string, unknown> | string | string[]\n                >,\n            );\n            updateCollection(collection.id, { filterQueryString });\n            handlers.close();\n        },\n        [customFilters, handlers, searchParams, updateCollection],\n    );\n\n    const handleSubmit = form.onSubmit((values) => {\n        const trimmed = values.name.trim();\n        if (!trimmed) return;\n\n        const filterQueryString = getFilterQueryStringFromSearchParams(\n            searchParams,\n            customFilters as Record<\n                string,\n                boolean | number | Record<string, unknown> | string | string[]\n            >,\n        );\n\n        addCollection({\n            filterQueryString,\n            id: nanoid(),\n            name: trimmed,\n            type: itemType,\n        });\n        handlers.close();\n    });\n\n    const handleFormKeyDown = useCallback((e: React.KeyboardEvent<HTMLFormElement>) => {\n        if (e.key === 'Enter') {\n            e.preventDefault();\n            formRef.current?.requestSubmit();\n        }\n    }, []);\n\n    return (\n        <Popover onClose={handlers.close} opened={isOpen} width=\"target\">\n            <Popover.Target>\n                {fullWidth ? (\n                    <Button fullWidth onClick={handleOpen} variant=\"default\">\n                        {t('page.collections.saveAsCollection', {\n                            postProcess: 'sentenceCase',\n                        })}\n                    </Button>\n                ) : (\n                    <ActionIcon\n                        icon=\"folder\"\n                        iconProps={{ size: 'lg' }}\n                        onClick={handleOpen}\n                        tooltip={{\n                            label: t('page.collections.saveAsCollection', {\n                                postProcess: 'sentenceCase',\n                            }),\n                        }}\n                        variant=\"subtle\"\n                    />\n                )}\n            </Popover.Target>\n            <Popover.Dropdown>\n                <form onKeyDown={handleFormKeyDown} onSubmit={handleSubmit} ref={formRef}>\n                    <Stack gap=\"sm\">\n                        <Text fw={500} size=\"sm\" ta=\"center\">\n                            {t('page.collections.overrideExisting', {\n                                postProcess: 'sentenceCase',\n                            })}\n                        </Text>\n                        <div className={styles.list}>\n                            <ScrollArea>\n                                <Stack gap={0}>\n                                    {sameTypeCollections.map((collection) => (\n                                        <Button\n                                            className={styles.row}\n                                            key={collection.id}\n                                            onClick={() => handleOverrideExisting(collection)}\n                                            type=\"button\"\n                                            variant=\"subtle\"\n                                        >\n                                            <Text className={styles['row-name']} size=\"sm\">\n                                                {collection.name}\n                                            </Text>\n                                        </Button>\n                                    ))}\n                                </Stack>\n                            </ScrollArea>\n                        </div>\n                        <TextInput autoFocus maxLength={128} {...form.getInputProps('name')} />\n                        <Group gap=\"xs\" justify=\"flex-end\">\n                            <Button onClick={handlers.close} type=\"button\" variant=\"subtle\">\n                                {t('common.cancel', { postProcess: 'sentenceCase' })}\n                            </Button>\n                            <Button type=\"submit\" variant=\"filled\">\n                                {t('common.save', { postProcess: 'sentenceCase' })}\n                            </Button>\n                        </Group>\n                    </Stack>\n                </form>\n            </Popover.Dropdown>\n        </Popover>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/shared/components/search-input.tsx",
    "content": "import {\n    ChangeEvent,\n    CSSProperties,\n    KeyboardEvent,\n    useEffect,\n    useMemo,\n    useRef,\n    useState,\n} from 'react';\nimport { shallow } from 'zustand/shallow';\n\nimport { useSettingsStore } from '/@/renderer/store';\nimport { ActionIcon, ActionIconProps } from '/@/shared/components/action-icon/action-icon';\nimport { Box } from '/@/shared/components/box/box';\nimport { Icon } from '/@/shared/components/icon/icon';\nimport { TextInput, TextInputProps } from '/@/shared/components/text-input/text-input';\nimport { useHotkeys } from '/@/shared/hooks/use-hotkeys';\n\ninterface SearchInputProps extends TextInputProps {\n    buttonProps?: Partial<ActionIconProps>;\n    enableHotkey?: boolean;\n    inputProps?: Partial<TextInputProps>;\n    value?: string;\n}\n\nexport const SearchInput = ({\n    buttonProps,\n    enableHotkey = true,\n    inputProps,\n    onChange,\n    ...props\n}: SearchInputProps) => {\n    const ref = useRef<HTMLInputElement>(null);\n    const binding = useSettingsStore((state) => state.hotkeys.bindings.localSearch, shallow);\n    const [isInputMode, setIsInputMode] = useState(false);\n\n    useHotkeys([\n        [\n            binding.hotkey,\n            () => {\n                if (enableHotkey) {\n                    setIsInputMode(true);\n                    ref?.current?.focus();\n                    ref?.current?.select();\n                }\n            },\n        ],\n    ]);\n\n    const handleEscape = (e: KeyboardEvent<HTMLInputElement>) => {\n        if (e.code === 'Escape') {\n            onChange?.({ target: { value: '' } } as ChangeEvent<HTMLInputElement>);\n            if (ref.current) {\n                ref.current.value = '';\n                ref.current.blur();\n            }\n            setIsInputMode(false);\n        }\n    };\n\n    const handleClear = () => {\n        if (ref.current) {\n            ref.current.value = '';\n            ref.current.focus();\n            onChange?.({ target: { value: '' } } as ChangeEvent<HTMLInputElement>);\n        }\n    };\n\n    const timeoutRef = useRef<NodeJS.Timeout | null>(null);\n\n    useEffect(() => {\n        return () => {\n            if (timeoutRef.current) {\n                clearTimeout(timeoutRef.current);\n            }\n        };\n    }, []);\n\n    const handleButtonClick = () => {\n        setIsInputMode(true);\n        if (timeoutRef.current) {\n            clearTimeout(timeoutRef.current);\n        }\n        timeoutRef.current = setTimeout(() => {\n            ref?.current?.focus();\n            timeoutRef.current = null;\n        }, 0);\n    };\n\n    const handleBlur = () => {\n        const hasValue = props.value || ref.current?.value;\n        if (!hasValue) {\n            setIsInputMode(false);\n        }\n    };\n\n    const hasValue = props.value || ref.current?.value;\n    const shouldShowInput = isInputMode || hasValue;\n    const shouldExpand = isInputMode || hasValue;\n\n    const containerStyle: CSSProperties = useMemo(\n        () => ({\n            display: 'inline-flex',\n            overflow: 'hidden',\n            position: 'relative',\n            transition: 'width 0.3s ease-in-out',\n            width: shouldExpand ? '200px' : '36px',\n        }),\n        [shouldExpand],\n    );\n\n    const buttonStyle: CSSProperties = useMemo(\n        () => ({\n            left: 0,\n            opacity: shouldShowInput ? 0 : 1,\n            pointerEvents: shouldShowInput ? 'none' : 'auto',\n            position: 'absolute',\n            top: 0,\n            transition: 'opacity 0.2s ease-in-out',\n            zIndex: 10,\n        }),\n        [shouldShowInput],\n    );\n\n    const inputStyle: CSSProperties = useMemo(\n        () => ({\n            opacity: shouldShowInput ? 1 : 0,\n            transition: 'opacity 0.2s ease-in-out',\n            width: '100%',\n        }),\n        [shouldShowInput],\n    );\n\n    return (\n        <Box style={containerStyle}>\n            <TextInput\n                leftSection={<Icon icon=\"search\" />}\n                maw=\"20dvw\"\n                {...inputProps}\n                onBlur={handleBlur}\n                onChange={onChange}\n                onFocus={() => setIsInputMode(true)}\n                onKeyDown={handleEscape}\n                ref={ref}\n                size=\"sm\"\n                style={inputStyle}\n                {...props}\n                rightSection={\n                    ref.current?.value ? (\n                        <ActionIcon icon=\"x\" onClick={handleClear} variant=\"transparent\" />\n                    ) : null\n                }\n            />\n            <ActionIcon\n                {...buttonProps}\n                icon=\"search\"\n                iconProps={{ size: 'lg' }}\n                onClick={handleButtonClick}\n                style={buttonStyle}\n                tooltip={{ label: 'Search' }}\n                variant=\"subtle\"\n            />\n        </Box>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/shared/components/settings-button.tsx",
    "content": "import { useTranslation } from 'react-i18next';\n\nimport { ActionIcon, ActionIconProps } from '/@/shared/components/action-icon/action-icon';\n\ninterface SettingsButtonProps extends ActionIconProps {}\n\nexport const SettingsButton = ({ ...props }: SettingsButtonProps) => {\n    const { t } = useTranslation();\n\n    return (\n        <ActionIcon\n            icon=\"settings\"\n            iconProps={{\n                size: 'lg',\n                ...props.iconProps,\n            }}\n            tooltip={{\n                label: t('common.configure', { postProcess: 'sentenceCase' }),\n                ...props.tooltip,\n            }}\n            variant=\"subtle\"\n            {...props}\n        />\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/shared/components/table-config.module.css",
    "content": ".group {\n    overflow: hidden;\n    border: 1px solid var(--theme-colors-border);\n    border-radius: var(--theme-radius-md);\n}\n\n.number-input {\n    width: 140px;\n}\n\n.item {\n    position: relative;\n    display: flex;\n    flex-wrap: nowrap;\n    gap: var(--theme-spacing-md);\n    align-items: center;\n    justify-content: space-between;\n    width: 100%;\n    padding: var(--theme-spacing-xs);\n    border-radius: var(--theme-radius-sm);\n    transition: outline 0.2s ease;\n}\n\n.item.matched {\n    outline: 2px solid var(--theme-colors-primary);\n    outline-offset: 2px;\n}\n\n.item.dragging {\n    opacity: 0.5;\n}\n\n.item.dragged-over-top::before {\n    position: absolute;\n    top: 0;\n    right: 0;\n    left: 0;\n    z-index: 1;\n    height: 2px;\n    content: '';\n    background-color: var(--theme-colors-primary);\n}\n\n.item.dragged-over-bottom::before {\n    position: absolute;\n    right: 0;\n    bottom: 0;\n    left: 0;\n    z-index: 1;\n    height: 2px;\n    content: '';\n    background-color: var(--theme-colors-primary);\n}\n"
  },
  {
    "path": "src/renderer/features/shared/components/table-config.tsx",
    "content": "import {\n    attachClosestEdge,\n    type Edge,\n    extractClosestEdge,\n} from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge';\nimport { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';\nimport {\n    draggable,\n    dropTargetForElements,\n} from '@atlaskit/pragmatic-drag-and-drop/element/adapter';\nimport { disableNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/disable-native-drag-preview';\nimport clsx from 'clsx';\nimport Fuse, { type FuseResultMatch } from 'fuse.js';\nimport { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport styles from './table-config.module.css';\n\nimport { ItemTableListColumnConfig } from '/@/renderer/components/item-list/types';\nimport {\n    ListConfigBooleanControl,\n    ListConfigTable,\n} from '/@/renderer/features/shared/components/list-config-menu';\nimport {\n    type DataTableProps,\n    ItemListSettings,\n    useSettingsStore,\n    useSettingsStoreActions,\n} from '/@/renderer/store';\nimport { ActionIcon, ActionIconGroup } from '/@/shared/components/action-icon/action-icon';\nimport { Badge } from '/@/shared/components/badge/badge';\nimport { Checkbox } from '/@/shared/components/checkbox/checkbox';\nimport { Divider } from '/@/shared/components/divider/divider';\nimport { Group } from '/@/shared/components/group/group';\nimport { NumberInput } from '/@/shared/components/number-input/number-input';\nimport { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control';\nimport { Slider } from '/@/shared/components/slider/slider';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { TextInput } from '/@/shared/components/text-input/text-input';\nimport { Text } from '/@/shared/components/text/text';\nimport { Tooltip } from '/@/shared/components/tooltip/tooltip';\nimport { useDebouncedState } from '/@/shared/hooks/use-debounced-state';\nimport { dndUtils, DragData, DragOperation, DragTarget } from '/@/shared/types/drag-and-drop';\nimport { ItemListKey, ListPaginationType } from '/@/shared/types/types';\n\ninterface TableConfigProps {\n    enablePinColumnButtons?: boolean;\n    extraOptions?: {\n        component: React.ReactNode;\n        id: string;\n        label: string;\n    }[];\n    listKey: ItemListKey;\n    optionsConfig?: {\n        [key: string]: {\n            disabled?: boolean;\n            hidden?: boolean;\n        };\n    };\n    tableColumnsData: { label: string; value: string }[];\n    tableKey?: 'detail' | 'main';\n}\n\nexport const TableConfig = ({\n    enablePinColumnButtons = true,\n    extraOptions,\n    listKey,\n    optionsConfig,\n    tableColumnsData,\n    tableKey = 'main',\n}: TableConfigProps) => {\n    const { t } = useTranslation();\n\n    const list = useSettingsStore((state) => state.lists[listKey]) as ItemListSettings;\n    const { setList } = useSettingsStoreActions();\n\n    const table = tableKey === 'detail' ? (list?.detail ?? list?.table) : list?.table;\n\n    const setTableUpdate = useCallback(\n        (patch: Partial<DataTableProps>) => {\n            if (tableKey === 'detail') {\n                setList(listKey, { detail: patch } as Parameters<\n                    ReturnType<typeof useSettingsStoreActions>['setList']\n                >[1]);\n            } else {\n                setList(listKey, { table: patch });\n            }\n        },\n        [listKey, setList, tableKey],\n    );\n\n    const advancedSettings = useMemo(() => {\n        const allOptions = [\n            {\n                component: (\n                    <SegmentedControl\n                        data={[\n                            {\n                                label: t('table.config.general.pagination_infinite', {\n                                    postProcess: 'sentenceCase',\n                                }),\n                                value: ListPaginationType.INFINITE,\n                            },\n                            {\n                                label: t('table.config.general.pagination_paginate', {\n                                    postProcess: 'sentenceCase',\n                                }),\n                                value: ListPaginationType.PAGINATED,\n                            },\n                        ]}\n                        onChange={(value) =>\n                            setList(listKey, { pagination: value as ListPaginationType })\n                        }\n                        size=\"sm\"\n                        value={list.pagination}\n                        w=\"100%\"\n                    />\n                ),\n                id: 'pagination',\n                label: t('table.config.general.pagination', { postProcess: 'sentenceCase' }),\n                size: 'sm',\n            },\n            {\n                component: (\n                    <Slider\n                        defaultValue={list.itemsPerPage}\n                        marks={[\n                            { value: 25 },\n                            { value: 50 },\n                            { value: 100 },\n                            { value: 150 },\n                            { value: 200 },\n                            { value: 250 },\n                            { value: 300 },\n                            { value: 400 },\n                            { value: 500 },\n                        ]}\n                        max={500}\n                        min={25}\n                        onChangeEnd={(value) => setList(listKey, { itemsPerPage: value })}\n                        restrictToMarks\n                        w=\"100%\"\n                    />\n                ),\n                id: 'itemsPerPage',\n                label: (\n                    <Group>\n                        {t('table.config.general.pagination_itemsPerPage', {\n                            postProcess: 'sentenceCase',\n                        })}\n                        <Badge>{list.itemsPerPage}</Badge>\n                    </Group>\n                ),\n            },\n            {\n                component: (\n                    <SegmentedControl\n                        data={[\n                            {\n                                label: t('table.config.general.size_compact', {\n                                    postProcess: 'titleCase',\n                                }),\n                                value: 'compact',\n                            },\n                            {\n                                label: t('table.config.general.size_default', {\n                                    postProcess: 'titleCase',\n                                }),\n                                value: 'default',\n                            },\n                            {\n                                label: t('table.config.general.size_large', {\n                                    postProcess: 'titleCase',\n                                }),\n                                value: 'large',\n                            },\n                        ]}\n                        onChange={(value) =>\n                            setTableUpdate({\n                                size: value as 'compact' | 'default' | 'large',\n                            })\n                        }\n                        size=\"sm\"\n                        value={table?.size ?? 'default'}\n                        w=\"100%\"\n                    />\n                ),\n                id: 'size',\n                label: t('table.config.general.size', {\n                    postProcess: 'titleCase',\n                }),\n            },\n            {\n                component: (\n                    <ListConfigBooleanControl\n                        onChange={(e) => setTableUpdate({ enableHeader: e })}\n                        value={table.enableHeader}\n                    />\n                ),\n                id: 'enableHeader',\n                label: t('table.config.general.showHeader', {\n                    postProcess: 'sentenceCase',\n                }),\n            },\n            {\n                component: (\n                    <ListConfigBooleanControl\n                        onChange={(e) => setTableUpdate({ enableRowHoverHighlight: e })}\n                        value={table.enableRowHoverHighlight}\n                    />\n                ),\n                id: 'enableRowHoverHighlight',\n                label: t('table.config.general.rowHoverHighlight', {\n                    postProcess: 'sentenceCase',\n                }),\n            },\n            {\n                component: (\n                    <ListConfigBooleanControl\n                        onChange={(e) => setTableUpdate({ enableAlternateRowColors: e })}\n                        value={table.enableAlternateRowColors}\n                    />\n                ),\n                id: 'enableAlternateRowColors',\n                label: t('table.config.general.alternateRowColors', {\n                    postProcess: 'sentenceCase',\n                }),\n            },\n            {\n                component: (\n                    <ListConfigBooleanControl\n                        onChange={(e) => setTableUpdate({ enableHorizontalBorders: e })}\n                        value={table.enableHorizontalBorders}\n                    />\n                ),\n                id: 'enableHorizontalBorders',\n                label: t('table.config.general.horizontalBorders', {\n                    postProcess: 'sentenceCase',\n                }),\n            },\n            {\n                component: (\n                    <ListConfigBooleanControl\n                        onChange={(e) => setTableUpdate({ enableVerticalBorders: e })}\n                        value={table.enableVerticalBorders}\n                    />\n                ),\n                id: 'enableVerticalBorders',\n                label: t('table.config.general.verticalBorders', {\n                    postProcess: 'sentenceCase',\n                }),\n            },\n            {\n                component: (\n                    <ListConfigBooleanControl\n                        onChange={(e) => setTableUpdate({ autoFitColumns: e })}\n                        value={\n                            tableKey === 'main' ? (table as DataTableProps).autoFitColumns : false\n                        }\n                    />\n                ),\n                id: 'autoFitColumns',\n                label: t('table.config.general.autoFitColumns', { postProcess: 'sentenceCase' }),\n            },\n            ...(extraOptions || []),\n        ];\n\n        // Filter and apply config (hidden/disabled)\n        return allOptions\n            .map((option) => {\n                const config = optionsConfig?.[option.id];\n                if (config?.hidden) {\n                    return null;\n                }\n                return option;\n            })\n            .filter((option): option is NonNullable<typeof option> => option !== null);\n    }, [\n        t,\n        list.pagination,\n        list.itemsPerPage,\n        table,\n        tableKey,\n        extraOptions,\n        setList,\n        listKey,\n        setTableUpdate,\n        optionsConfig,\n    ]);\n\n    return (\n        <>\n            <ListConfigTable options={advancedSettings} />\n            <Divider />\n            <TableColumnConfig\n                data={tableColumnsData}\n                enablePinColumnButtons={enablePinColumnButtons}\n                onChange={(columns) => setTableUpdate({ columns })}\n                value={table.columns}\n            />\n        </>\n    );\n};\n\nconst TableColumnConfig = ({\n    data,\n    enablePinColumnButtons,\n    onChange,\n    value,\n}: {\n    data: { label: string; value: string }[];\n    enablePinColumnButtons: boolean;\n    onChange: (value: ItemTableListColumnConfig[]) => void;\n    value: ItemTableListColumnConfig[];\n}) => {\n    const { t } = useTranslation();\n\n    const valueRef = useRef(value);\n    const onChangeRef = useRef(onChange);\n\n    useLayoutEffect(() => {\n        valueRef.current = value;\n        onChangeRef.current = onChange;\n    });\n\n    const labelMap = useMemo(() => {\n        return data.reduce(\n            (acc, item) => {\n                acc[item.value] = item.label;\n                return acc;\n            },\n            {} as Record<string, string>,\n        );\n    }, [data]);\n\n    const handleChangeEnabled = useCallback((item: ItemTableListColumnConfig, checked: boolean) => {\n        const currentValue = valueRef.current;\n        const index = currentValue.findIndex((v) => v.id === item.id);\n        const newValues = [...currentValue];\n        newValues[index] = { ...newValues[index], isEnabled: checked };\n        onChangeRef.current(newValues);\n    }, []);\n\n    const handleMoveUp = useCallback((item: ItemTableListColumnConfig) => {\n        const currentValue = valueRef.current;\n        const index = currentValue.findIndex((v) => v.id === item.id);\n        if (index === 0) return;\n        const newValues = [...currentValue];\n        [newValues[index], newValues[index - 1]] = [newValues[index - 1], newValues[index]];\n        onChangeRef.current(newValues);\n    }, []);\n\n    const handleMoveDown = useCallback((item: ItemTableListColumnConfig) => {\n        const currentValue = valueRef.current;\n        const index = currentValue.findIndex((v) => v.id === item.id);\n        if (index === currentValue.length - 1) return;\n        const newValues = [...currentValue];\n        [newValues[index], newValues[index + 1]] = [newValues[index + 1], newValues[index]];\n        onChangeRef.current(newValues);\n    }, []);\n\n    const handlePinToLeft = useCallback((item: ItemTableListColumnConfig) => {\n        const currentValue = valueRef.current;\n        const index = currentValue.findIndex((v) => v.id === item.id);\n        const newValues = [...currentValue];\n\n        const isPinned = newValues[index].pinned;\n        const isPinnedLeft = isPinned === 'left';\n\n        if (isPinnedLeft) {\n            newValues[index] = { ...newValues[index], pinned: null };\n        } else {\n            newValues[index] = { ...newValues[index], pinned: 'left' };\n        }\n\n        onChangeRef.current(newValues);\n    }, []);\n\n    const handlePinToRight = useCallback((item: ItemTableListColumnConfig) => {\n        const currentValue = valueRef.current;\n        const index = currentValue.findIndex((v) => v.id === item.id);\n        const newValues = [...currentValue];\n\n        const isPinned = newValues[index].pinned;\n        const isPinnedRight = isPinned === 'right';\n\n        if (isPinnedRight) {\n            newValues[index] = { ...newValues[index], pinned: null };\n        } else {\n            newValues[index] = { ...newValues[index], pinned: 'right' };\n        }\n\n        onChangeRef.current(newValues);\n    }, []);\n\n    const handleAlignLeft = useCallback((item: ItemTableListColumnConfig) => {\n        const currentValue = valueRef.current;\n        const index = currentValue.findIndex((v) => v.id === item.id);\n        const newValues = [...currentValue];\n        newValues[index] = { ...newValues[index], align: 'start' };\n        onChangeRef.current(newValues);\n    }, []);\n\n    const handleAlignCenter = useCallback((item: ItemTableListColumnConfig) => {\n        const currentValue = valueRef.current;\n        const index = currentValue.findIndex((v) => v.id === item.id);\n        const newValues = [...currentValue];\n        newValues[index] = { ...newValues[index], align: 'center' };\n        onChangeRef.current(newValues);\n    }, []);\n\n    const handleAlignRight = useCallback((item: ItemTableListColumnConfig) => {\n        const currentValue = valueRef.current;\n        const index = currentValue.findIndex((v) => v.id === item.id);\n        const newValues = [...currentValue];\n        newValues[index] = { ...newValues[index], align: 'end' };\n        onChangeRef.current(newValues);\n    }, []);\n\n    const handleAutoSize = useCallback((item: ItemTableListColumnConfig, checked: boolean) => {\n        const currentValue = valueRef.current;\n        const index = currentValue.findIndex((v) => v.id === item.id);\n        const newValues = [...currentValue];\n        newValues[index] = { ...newValues[index], autoSize: checked };\n        onChangeRef.current(newValues);\n    }, []);\n\n    const handleRowWidth = useCallback(\n        (item: ItemTableListColumnConfig, number: number | string) => {\n            if (typeof number !== 'number') {\n                number = 0;\n            }\n\n            if (number < 0) {\n                number = 0;\n            }\n\n            if (number > 2000) {\n                number = 2000;\n            }\n\n            const currentValue = valueRef.current;\n            const index = currentValue.findIndex((v) => v.id === item.id);\n            const newValues = [...currentValue];\n            newValues[index] = { ...newValues[index], width: number };\n            onChangeRef.current(newValues);\n        },\n        [],\n    );\n\n    const [searchColumns, setSearchColumns] = useDebouncedState('', 300);\n\n    const fuse = useMemo(() => {\n        return new Fuse(value, {\n            getFn: (obj) => {\n                return labelMap[obj.id] || '';\n            },\n            includeMatches: true,\n            includeScore: true,\n            keys: ['id', 'label'],\n            threshold: 0.3,\n        });\n    }, [value, labelMap]);\n\n    const filteredColumns = useMemo(() => {\n        if (!searchColumns.trim()) {\n            return value.map((item) => ({ item, matches: null }));\n        }\n\n        const results = fuse.search(searchColumns);\n        const resultMap = new Map(results.map((result) => [result.item.id, result.matches]));\n\n        return value.map((item) => ({\n            item,\n            matches: resultMap.get(item.id) || null,\n        }));\n    }, [value, searchColumns, fuse]);\n\n    const handleReorder = useCallback((idFrom: string, idTo: string, edge: Edge | null) => {\n        const currentValue = valueRef.current;\n        const idList = currentValue.map((item) => item.id);\n        const newIdOrder = dndUtils.reorderById({\n            edge,\n            idFrom,\n            idTo,\n            list: idList,\n        });\n\n        // Map the new ID order back to full items\n        const newOrder = newIdOrder.map((id) => currentValue.find((item) => item.id === id)!);\n        onChangeRef.current(newOrder);\n    }, []);\n\n    return (\n        <Stack gap=\"xs\">\n            <Group justify=\"space-between\" mb=\"md\">\n                <Text size=\"sm\">{t('common.tableColumns', { postProcess: 'sentenceCase' })}</Text>\n                <TextInput\n                    onChange={(e) => setSearchColumns(e.currentTarget.value)}\n                    placeholder={t('common.search', {\n                        postProcess: 'sentenceCase',\n                    })}\n                    size=\"xs\"\n                />\n            </Group>\n            <div style={{ userSelect: 'none' }}>\n                {filteredColumns.map(({ item, matches }) => (\n                    <TableColumnItem\n                        enablePinColumnButtons={enablePinColumnButtons}\n                        handleAlignCenter={handleAlignCenter}\n                        handleAlignLeft={handleAlignLeft}\n                        handleAlignRight={handleAlignRight}\n                        handleAutoSize={handleAutoSize}\n                        handleChangeEnabled={handleChangeEnabled}\n                        handleMoveDown={handleMoveDown}\n                        handleMoveUp={handleMoveUp}\n                        handlePinToLeft={handlePinToLeft}\n                        handlePinToRight={handlePinToRight}\n                        handleReorder={handleReorder}\n                        handleRowWidth={handleRowWidth}\n                        item={item}\n                        key={item.id}\n                        label={labelMap[item.id]}\n                        matches={matches}\n                    />\n                ))}\n            </div>\n        </Stack>\n    );\n};\n\nconst DragHandle = ({\n    dragHandleRef,\n}: {\n    dragHandleRef: React.RefObject<HTMLButtonElement | null>;\n}) => {\n    return (\n        <ActionIcon\n            icon=\"dragVertical\"\n            iconProps={{\n                size: 'md',\n            }}\n            ref={dragHandleRef as React.RefObject<HTMLButtonElement>}\n            size=\"xs\"\n            style={{ cursor: 'grab' }}\n            variant=\"default\"\n        />\n    );\n};\n\nconst TableColumnItem = memo(\n    ({\n        enablePinColumnButtons,\n        handleAlignCenter,\n        handleAlignLeft,\n        handleAlignRight,\n        handleAutoSize,\n        handleChangeEnabled,\n        handleMoveDown,\n        handleMoveUp,\n        handlePinToLeft,\n        handlePinToRight,\n        handleReorder,\n        handleRowWidth,\n        item,\n        label,\n        matches,\n    }: {\n        enablePinColumnButtons: boolean;\n        handleAlignCenter: (item: ItemTableListColumnConfig) => void;\n        handleAlignLeft: (item: ItemTableListColumnConfig) => void;\n        handleAlignRight: (item: ItemTableListColumnConfig) => void;\n        handleAutoSize: (item: ItemTableListColumnConfig, checked: boolean) => void;\n        handleChangeEnabled: (item: ItemTableListColumnConfig, checked: boolean) => void;\n        handleMoveDown: (item: ItemTableListColumnConfig) => void;\n        handleMoveUp: (item: ItemTableListColumnConfig) => void;\n        handlePinToLeft: (item: ItemTableListColumnConfig) => void;\n        handlePinToRight: (item: ItemTableListColumnConfig) => void;\n        handleReorder: (idFrom: string, idTo: string, edge: Edge | null) => void;\n        handleRowWidth: (item: ItemTableListColumnConfig, number: number | string) => void;\n        item: ItemTableListColumnConfig;\n        label: string;\n        matches: null | readonly FuseResultMatch[];\n    }) => {\n        const { t } = useTranslation();\n        const ref = useRef<HTMLDivElement>(null);\n        const dragHandleRef = useRef<HTMLButtonElement>(null);\n        const [isDragging, setIsDragging] = useState(false);\n        const [isDraggedOver, setIsDraggedOver] = useState<Edge | null>(null);\n\n        useEffect(() => {\n            if (!ref.current || !dragHandleRef.current) {\n                return;\n            }\n\n            return combine(\n                draggable({\n                    element: dragHandleRef.current,\n                    getInitialData: () => {\n                        const data = dndUtils.generateDragData({\n                            id: [item.id],\n                            operation: [DragOperation.REORDER],\n                            type: DragTarget.TABLE_COLUMN,\n                        });\n                        return data;\n                    },\n                    onDragStart: () => {\n                        setIsDragging(true);\n                    },\n                    onDrop: () => {\n                        setIsDragging(false);\n                    },\n                    onGenerateDragPreview: (data) => {\n                        disableNativeDragPreview({ nativeSetDragImage: data.nativeSetDragImage });\n                    },\n                }),\n                dropTargetForElements({\n                    canDrop: (args) => {\n                        const data = args.source.data as unknown as DragData;\n                        const isSelf = (args.source.data.id as string[])[0] === item.id;\n                        return (\n                            dndUtils.isDropTarget(data.type, [DragTarget.TABLE_COLUMN]) && !isSelf\n                        );\n                    },\n                    element: ref.current,\n                    getData: ({ element, input }) => {\n                        const data = dndUtils.generateDragData({\n                            id: [item.id],\n                            operation: [DragOperation.REORDER],\n                            type: DragTarget.TABLE_COLUMN,\n                        });\n\n                        return attachClosestEdge(data, {\n                            allowedEdges: ['top', 'bottom'],\n                            element,\n                            input,\n                        });\n                    },\n                    onDrag: (args) => {\n                        const closestEdgeOfTarget: Edge | null = extractClosestEdge(args.self.data);\n                        setIsDraggedOver(closestEdgeOfTarget);\n                    },\n                    onDragLeave: () => {\n                        setIsDraggedOver(null);\n                    },\n                    onDrop: (args) => {\n                        const closestEdgeOfTarget: Edge | null = extractClosestEdge(args.self.data);\n\n                        const from = args.source.data.id as string[];\n                        const to = args.self.data.id as string[];\n\n                        handleReorder(from[0], to[0], closestEdgeOfTarget);\n                        setIsDraggedOver(null);\n                    },\n                }),\n            );\n        }, [item.id, handleReorder]);\n\n        return (\n            <div\n                className={clsx(styles.item, {\n                    [styles.draggedOverBottom]: isDraggedOver === 'bottom',\n                    [styles.draggedOverTop]: isDraggedOver === 'top',\n                    [styles.dragging]: isDragging,\n                    [styles.matched]: matches && matches.length > 0,\n                })}\n                ref={ref}\n            >\n                <Group wrap=\"nowrap\">\n                    <DragHandle dragHandleRef={dragHandleRef} />\n                    <Checkbox\n                        checked={item.isEnabled}\n                        id={item.id}\n                        label={label}\n                        onChange={(e) => handleChangeEnabled(item, e.currentTarget.checked)}\n                        size=\"sm\"\n                    />\n                </Group>\n                <Group wrap=\"nowrap\">\n                    <ActionIconGroup className={styles.group}>\n                        <ActionIcon\n                            icon=\"arrowUp\"\n                            iconProps={{ size: 'md' }}\n                            onClick={() => handleMoveUp(item)}\n                            size=\"xs\"\n                            tooltip={{\n                                label: t('table.config.general.moveUp', {\n                                    postProcess: 'sentenceCase',\n                                }),\n                            }}\n                            variant=\"subtle\"\n                        />\n                        <ActionIcon\n                            icon=\"arrowDown\"\n                            iconProps={{ size: 'md' }}\n                            onClick={() => handleMoveDown(item)}\n                            size=\"xs\"\n                            tooltip={{\n                                label: t('table.config.general.moveDown', {\n                                    postProcess: 'sentenceCase',\n                                }),\n                            }}\n                            variant=\"subtle\"\n                        />\n                    </ActionIconGroup>\n                    {enablePinColumnButtons && (\n                        <ActionIconGroup className={styles.group}>\n                            <ActionIcon\n                                icon=\"arrowLeftToLine\"\n                                iconProps={{ size: 'md' }}\n                                onClick={() => handlePinToLeft(item)}\n                                size=\"xs\"\n                                tooltip={{\n                                    label: t('table.config.general.pinToLeft', {\n                                        postProcess: 'sentenceCase',\n                                    }),\n                                }}\n                                variant={item.pinned === 'left' ? 'filled' : 'subtle'}\n                            />\n                            <ActionIcon\n                                icon=\"arrowRightToLine\"\n                                iconProps={{ size: 'md' }}\n                                onClick={() => handlePinToRight(item)}\n                                size=\"xs\"\n                                tooltip={{\n                                    label: t('table.config.general.pinToRight', {\n                                        postProcess: 'sentenceCase',\n                                    }),\n                                }}\n                                variant={item.pinned === 'right' ? 'filled' : 'subtle'}\n                            />\n                        </ActionIconGroup>\n                    )}\n                    <ActionIconGroup className={styles.group}>\n                        <ActionIcon\n                            icon=\"alignLeft\"\n                            iconProps={{ size: 'md' }}\n                            onClick={() => handleAlignLeft(item)}\n                            size=\"xs\"\n                            tooltip={{\n                                label: t('table.config.general.alignLeft', {\n                                    postProcess: 'sentenceCase',\n                                }),\n                            }}\n                            variant={item.align === 'start' ? 'filled' : 'subtle'}\n                        />\n                        <ActionIcon\n                            icon=\"alignCenter\"\n                            iconProps={{ size: 'md' }}\n                            onClick={() => handleAlignCenter(item)}\n                            size=\"xs\"\n                            tooltip={{\n                                label: t('table.config.general.alignCenter', {\n                                    postProcess: 'sentenceCase',\n                                }),\n                            }}\n                            variant={item.align === 'center' ? 'filled' : 'subtle'}\n                        />\n                        <ActionIcon\n                            icon=\"alignRight\"\n                            iconProps={{ size: 'md' }}\n                            onClick={() => handleAlignRight(item)}\n                            size=\"xs\"\n                            tooltip={{\n                                label: t('table.config.general.alignRight', {\n                                    postProcess: 'sentenceCase',\n                                }),\n                            }}\n                            variant={item.align === 'end' ? 'filled' : 'subtle'}\n                        />\n                    </ActionIconGroup>\n                    <NumberInput\n                        className={clsx(styles.group, styles.numberInput)}\n                        hideControls={false}\n                        leftSection={\n                            <Tooltip\n                                label={t('table.config.general.autosize', {\n                                    postProcess: 'sentenceCase',\n                                })}\n                            >\n                                <Checkbox\n                                    checked={item.autoSize}\n                                    id={item.id}\n                                    onChange={(e) => handleAutoSize(item, e.currentTarget.checked)}\n                                    size=\"xs\"\n                                />\n                            </Tooltip>\n                        }\n                        max={2000}\n                        min={0}\n                        onChange={(value) => handleRowWidth(item, value)}\n                        size=\"xs\"\n                        step={10}\n                        stepHoldDelay={300}\n                        stepHoldInterval={100}\n                        value={item.width}\n                        variant=\"subtle\"\n                    />\n                </Group>\n            </div>\n        );\n    },\n    (prevProps, nextProps) => {\n        // Custom comparison function for better memoization\n        return (\n            prevProps.enablePinColumnButtons === nextProps.enablePinColumnButtons &&\n            prevProps.item.id === nextProps.item.id &&\n            prevProps.item.isEnabled === nextProps.item.isEnabled &&\n            prevProps.item.autoSize === nextProps.item.autoSize &&\n            prevProps.item.width === nextProps.item.width &&\n            prevProps.item.pinned === nextProps.item.pinned &&\n            prevProps.item.align === nextProps.item.align &&\n            prevProps.label === nextProps.label &&\n            prevProps.matches === nextProps.matches\n        );\n    },\n);\n"
  },
  {
    "path": "src/renderer/features/shared/components/tag-filter.tsx",
    "content": "import { useSuspenseQuery } from '@tanstack/react-query';\nimport { useCallback, useMemo } from 'react';\n\nimport { MultiSelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data';\nimport { sharedQueries } from '/@/renderer/features/shared/api/shared-api';\nimport { useCurrentServerId } from '/@/renderer/store';\nimport { titleCase } from '/@/renderer/utils';\nimport { NDSongQueryFieldsLabelMap } from '/@/shared/api/navidrome/navidrome-types';\nimport { LibraryItem } from '/@/shared/types/domain-types';\n\ninterface TagFilterItemProps {\n    label: string;\n    onChange: (value: null | string[]) => void;\n    options: Array<{ id: string; name: string }>;\n    tagValue: string;\n    value: string | string[] | undefined;\n}\n\nconst TagFilterItem = ({ label, onChange, options, tagValue, value }: TagFilterItemProps) => {\n    const selectData = useMemo(\n        () =>\n            options.map((option) => ({\n                label: option.name,\n                value: option.id,\n            })),\n        [options],\n    );\n\n    const defaultValue = useMemo(() => {\n        if (!value) return [];\n        return Array.isArray(value) ? value : [value];\n    }, [value]);\n\n    const handleChange = useCallback(\n        (e: null | string[]) => {\n            if (e && e.length > 0) {\n                onChange(e);\n            } else {\n                onChange(null);\n            }\n        },\n        [onChange],\n    );\n\n    return (\n        <MultiSelectWithInvalidData\n            clearable\n            data={selectData}\n            defaultValue={defaultValue}\n            key={tagValue}\n            label={label}\n            limit={100}\n            onChange={handleChange}\n            searchable\n        />\n    );\n};\n\nTagFilterItem.displayName = 'TagFilterItem';\n\ninterface TagFiltersProps {\n    query: Record<string, any | undefined>;\n    setCustom: (value: null | Record<string, any>) => void;\n    type: LibraryItem.ALBUM | LibraryItem.SONG;\n}\n\nexport const TagFilters = ({ query, setCustom, type }: TagFiltersProps) => {\n    const serverId = useCurrentServerId();\n\n    const tagsQuery = useSuspenseQuery(\n        sharedQueries.tagList({\n            options: {\n                gcTime: 1000 * 60 * 60,\n                staleTime: 1000 * 60 * 60,\n            },\n            query: { type },\n            serverId,\n        }),\n    );\n\n    const handleTagFilter = useMemo(\n        () => (tag: string, e: null | string[]) => {\n            setCustom({ [tag]: e || undefined });\n        },\n        [setCustom],\n    );\n\n    const enumTags = useMemo(() => {\n        const results: { label: string; options: { id: string; name: string }[]; value: string }[] =\n            [];\n\n        const excluded =\n            type === LibraryItem.ALBUM\n                ? tagsQuery.data?.excluded.album\n                : tagsQuery.data?.excluded.song;\n\n        for (const tag of tagsQuery.data?.tags || []) {\n            if (!excluded.includes(tag.name)) {\n                results.push({\n                    label: NDSongQueryFieldsLabelMap[tag.name] ?? titleCase(tag.name),\n                    options: tag.options,\n                    value: tag.name,\n                });\n            }\n        }\n\n        return results;\n    }, [tagsQuery.data?.tags, tagsQuery.data?.excluded.album, tagsQuery.data?.excluded.song, type]);\n\n    return (\n        <>\n            {enumTags.map((tag) => (\n                <TagFilterItem\n                    key={tag.value}\n                    label={tag.label}\n                    onChange={(e) => handleTagFilter(tag.value, e)}\n                    options={tag.options}\n                    tagValue={tag.value}\n                    value={query._custom?.[tag.value] as string | string[] | undefined}\n                />\n            ))}\n        </>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/shared/hooks/use-list-filter-persistence.ts",
    "content": "import { useLocalStorage } from '/@/shared/hooks/use-local-storage';\nimport { ItemListKey } from '/@/shared/types/types';\n\nexport interface ListFilterPersistence {\n    [listKey: string]: {\n        [filterKey: string]: string | undefined;\n    };\n}\n\nconst getPersistenceKey = (serverId: string) => {\n    return `${serverId}-filters`;\n};\n\nexport const useListFilterPersistence = (serverId: string, listKey: ItemListKey) => {\n    const [persistedFilters, setPersistedFilters] = useLocalStorage<ListFilterPersistence>({\n        defaultValue: {},\n        key: getPersistenceKey(serverId),\n    });\n\n    const getFilter = (filterKey: string): string | undefined => {\n        return persistedFilters?.[listKey]?.[filterKey];\n    };\n\n    const setFilter = (filterKey: string, value: string) => {\n        setPersistedFilters((prev) => ({\n            ...prev,\n            [listKey]: {\n                ...prev[listKey],\n                [filterKey]: value,\n            },\n        }));\n    };\n\n    return {\n        getFilter,\n        setFilter,\n    };\n};\n"
  },
  {
    "path": "src/renderer/features/shared/hooks/use-music-folder-id-filter.ts",
    "content": "import { useMemo } from 'react';\nimport { useSearchParams } from 'react-router';\n\nimport { useListFilterPersistence } from '/@/renderer/features/shared/hooks/use-list-filter-persistence';\nimport { FILTER_KEYS } from '/@/renderer/features/shared/utils';\nimport { useCurrentServer } from '/@/renderer/store';\nimport { parseStringParam, setSearchParam } from '/@/renderer/utils/query-params';\nimport { ItemListKey } from '/@/shared/types/types';\n\nexport const useMusicFolderIdFilter = (defaultValue: null | string, listKey: ItemListKey) => {\n    const server = useCurrentServer();\n    const { getFilter, setFilter } = useListFilterPersistence(server.id, listKey);\n    const [searchParams, setSearchParams] = useSearchParams();\n\n    const persisted = getFilter(FILTER_KEYS.SHARED.MUSIC_FOLDER_ID);\n\n    const musicFolderId = useMemo(() => {\n        const value = parseStringParam(searchParams, FILTER_KEYS.SHARED.MUSIC_FOLDER_ID);\n        return value ?? persisted ?? defaultValue ?? undefined;\n    }, [searchParams, persisted, defaultValue]);\n\n    const handleSetMusicFolderId = (musicFolderId: string) => {\n        setSearchParams(\n            (prev) => {\n                const newParams = setSearchParam(\n                    prev,\n                    FILTER_KEYS.SHARED.MUSIC_FOLDER_ID,\n                    musicFolderId,\n                );\n                return newParams;\n            },\n            { replace: true },\n        );\n        setFilter(FILTER_KEYS.SHARED.MUSIC_FOLDER_ID, musicFolderId);\n    };\n\n    return {\n        musicFolderId,\n        setMusicFolderId: handleSetMusicFolderId,\n    };\n};\n"
  },
  {
    "path": "src/renderer/features/shared/hooks/use-play-button-click.ts",
    "content": "import { useCallback, useMemo, useState } from 'react';\n\nimport { useIsPlayerFetching } from '/@/renderer/features/player/context/player-context';\nimport { useLongPress } from '/@/shared/hooks/use-long-press';\n\ninterface UsePlayButtonClickOptions {\n    disabled?: boolean;\n    loading?: boolean;\n    onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;\n    onLongPress?: (e: React.MouseEvent<HTMLButtonElement>) => void;\n}\n\ninterface UsePlayButtonClickReturn {\n    handlers: {\n        onClick: (e: React.MouseEvent<HTMLButtonElement>) => void;\n        onMouseDown: (e: React.MouseEvent<HTMLButtonElement>) => void;\n        onMouseLeave: (e: React.MouseEvent) => void;\n        onMouseUp: (e: React.MouseEvent) => void;\n        onTouchCancel: (e: React.TouchEvent) => void;\n        onTouchEnd: (e: React.TouchEvent) => void;\n        onTouchStart: (e: React.TouchEvent) => void;\n    };\n    props: {\n        'data-pressing'?: string;\n        disabled: boolean;\n        style: React.CSSProperties;\n    };\n}\n\nexport const usePlayButtonClick = ({\n    loading,\n    onClick,\n    onLongPress,\n}: UsePlayButtonClickOptions): UsePlayButtonClickReturn => {\n    const isPlayerFetching = useIsPlayerFetching();\n    const isDisabled = Boolean(isPlayerFetching || loading);\n    const [isPressing, setIsPressing] = useState(false);\n    const [isLongPressing, setIsLongPressing] = useState(false);\n\n    const longPressHandlers = useLongPress<HTMLButtonElement>({\n        onClick: (e) => {\n            if (isDisabled || loading) {\n                return;\n            }\n\n            e.stopPropagation();\n            e.preventDefault();\n            onClick?.(e as React.MouseEvent<HTMLButtonElement>);\n        },\n        onFinish: () => {\n            setIsPressing(false);\n            setIsLongPressing(false);\n        },\n        onLongPress: (e) => {\n            if (isDisabled || loading) {\n                return;\n            }\n\n            e.stopPropagation();\n            e.preventDefault();\n            setIsPressing(false);\n            setIsLongPressing(true);\n            onLongPress?.(e as React.MouseEvent<HTMLButtonElement>);\n        },\n        onStart: () => {\n            if (!isDisabled && !loading) {\n                setIsPressing(true);\n                setIsLongPressing(false);\n            }\n        },\n    });\n\n    const handleMouseDown = useCallback(\n        (e: React.MouseEvent<HTMLButtonElement>) => {\n            e.stopPropagation();\n            longPressHandlers.onMouseDown?.(e);\n        },\n        [longPressHandlers],\n    );\n\n    const handleClick = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {\n        e.stopPropagation();\n        e.preventDefault();\n    }, []);\n\n    const handlers = useMemo(\n        () => ({\n            onClick: handleClick,\n            onMouseDown: handleMouseDown,\n            onMouseLeave: longPressHandlers.onMouseLeave,\n            onMouseUp: longPressHandlers.onMouseUp,\n            onTouchCancel: longPressHandlers.onTouchCancel,\n            onTouchEnd: longPressHandlers.onTouchEnd,\n            onTouchStart: longPressHandlers.onTouchStart,\n        }),\n        [\n            handleClick,\n            handleMouseDown,\n            longPressHandlers.onMouseLeave,\n            longPressHandlers.onMouseUp,\n            longPressHandlers.onTouchCancel,\n            longPressHandlers.onTouchEnd,\n            longPressHandlers.onTouchStart,\n        ],\n    );\n\n    const props = useMemo(() => {\n        return {\n            'data-pressing': isPressing ? 'true' : undefined,\n            disabled: isDisabled,\n            style: {\n                '--long-press-duration': '300ms',\n                '--play-button-scale': isLongPressing ? 1.15 : 1,\n                opacity: isDisabled ? 0.5 : 1,\n                transition: 'opacity 0.2s ease-in-out, transform 0.2s ease-in-out',\n            } as React.CSSProperties,\n        };\n    }, [isDisabled, isPressing, isLongPressing]);\n\n    return { handlers, props };\n};\n"
  },
  {
    "path": "src/renderer/features/shared/hooks/use-search-term-filter.ts",
    "content": "import { useMemo } from 'react';\nimport { useSearchParams } from 'react-router';\n\nimport { FILTER_KEYS } from '/@/renderer/features/shared/utils';\nimport { parseStringParam, setSearchParam } from '/@/renderer/utils/query-params';\nimport { useDebouncedCallback } from '/@/shared/hooks/use-debounced-callback';\n\nexport const useSearchTermFilter = (defaultValue?: string) => {\n    const [searchParams, setSearchParams] = useSearchParams();\n\n    const searchTerm = useMemo(() => {\n        const value = parseStringParam(searchParams, FILTER_KEYS.SHARED.SEARCH_TERM);\n        return value ?? defaultValue ?? undefined;\n    }, [searchParams, defaultValue]);\n\n    const handleSetSearchTerm = (value: null | string) => {\n        setSearchParams(\n            (prev) => {\n                const newParams = setSearchParam(\n                    prev,\n                    FILTER_KEYS.SHARED.SEARCH_TERM,\n                    value === '' ? null : value,\n                );\n                return newParams;\n            },\n            { replace: true },\n        );\n    };\n\n    const debouncedSetSearchTerm = useDebouncedCallback(handleSetSearchTerm, 300);\n\n    return {\n        searchTerm: searchTerm || undefined,\n        setSearchTerm: debouncedSetSearchTerm,\n    };\n};\n"
  },
  {
    "path": "src/renderer/features/shared/hooks/use-select-filter.ts",
    "content": "import { useMemo } from 'react';\nimport { useSearchParams } from 'react-router';\n\nimport { useListFilterPersistence } from '/@/renderer/features/shared/hooks/use-list-filter-persistence';\nimport { useCurrentServer } from '/@/renderer/store';\nimport { parseStringParam, setSearchParam } from '/@/renderer/utils/query-params';\nimport { ItemListKey } from '/@/shared/types/types';\n\nexport const useSelectFilter = (\n    filterKey: string,\n    defaultValue: null | string,\n    listKey: ItemListKey,\n) => {\n    const server = useCurrentServer();\n    const { getFilter, setFilter } = useListFilterPersistence(server.id, listKey);\n    const [searchParams, setSearchParams] = useSearchParams();\n\n    const persisted = getFilter(filterKey);\n\n    const value = useMemo(() => {\n        const paramValue = parseStringParam(searchParams, filterKey);\n        return paramValue ?? persisted ?? defaultValue ?? undefined;\n    }, [searchParams, filterKey, persisted, defaultValue]);\n\n    const handleSetValue = (newValue: string) => {\n        setSearchParams(\n            (prev) => {\n                const newParams = setSearchParam(prev, filterKey, newValue);\n                return newParams;\n            },\n            { replace: true },\n        );\n        setFilter(filterKey, newValue);\n    };\n\n    return {\n        [filterKey]: value,\n        setValue: handleSetValue,\n        value,\n    };\n};\n"
  },
  {
    "path": "src/renderer/features/shared/hooks/use-set-favorite.ts",
    "content": "import { useCallback } from 'react';\n\nimport { useCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation';\nimport { useDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation';\nimport { LibraryItem } from '/@/shared/types/domain-types';\n\nexport const useSetFavorite = () => {\n    const createFavoriteMutation = useCreateFavorite({});\n    const deleteFavoriteMutation = useDeleteFavorite({});\n\n    const setFavorite = useCallback(\n        (serverId: string, id: string[], itemType: LibraryItem, isFavorite: boolean) => {\n            if (isFavorite) {\n                createFavoriteMutation.mutate({\n                    apiClientProps: { serverId },\n                    query: { id, type: itemType },\n                });\n            } else {\n                deleteFavoriteMutation.mutate({\n                    apiClientProps: { serverId },\n                    query: { id, type: itemType },\n                });\n            }\n        },\n        [createFavoriteMutation, deleteFavoriteMutation],\n    );\n\n    return setFavorite;\n};\n"
  },
  {
    "path": "src/renderer/features/shared/hooks/use-set-rating.ts",
    "content": "import { useCallback } from 'react';\n\nimport { useSetRatingMutation } from '/@/renderer/features/shared/mutations/set-rating-mutation';\nimport { LibraryItem } from '/@/shared/types/domain-types';\n\nexport const useSetRating = () => {\n    const setRatingMutation = useSetRatingMutation({});\n\n    const setRating = useCallback(\n        (serverId: string, id: string[], itemType: LibraryItem, rating: number) => {\n            setRatingMutation.mutate({\n                apiClientProps: { serverId },\n                query: { id, rating, type: itemType },\n            });\n        },\n        [setRatingMutation],\n    );\n\n    return setRating;\n};\n"
  },
  {
    "path": "src/renderer/features/shared/hooks/use-sort-by-filter.ts",
    "content": "import { useMemo } from 'react';\nimport { useSearchParams } from 'react-router';\n\nimport { useListFilterPersistence } from '/@/renderer/features/shared/hooks/use-list-filter-persistence';\nimport { FILTER_KEYS } from '/@/renderer/features/shared/utils';\nimport { useCurrentServer } from '/@/renderer/store';\nimport { parseStringParam, setSearchParam } from '/@/renderer/utils/query-params';\nimport { ItemListKey } from '/@/shared/types/types';\n\nexport const useSortByFilter = <TSortBy>(defaultValue: null | string, listKey: ItemListKey) => {\n    const server = useCurrentServer();\n    const { getFilter, setFilter } = useListFilterPersistence(server.id, listKey);\n    const [searchParams, setSearchParams] = useSearchParams();\n\n    const persisted = getFilter(FILTER_KEYS.SHARED.SORT_BY);\n\n    const sortBy = useMemo(() => {\n        const value = parseStringParam(searchParams, FILTER_KEYS.SHARED.SORT_BY);\n        return (value ?? persisted ?? defaultValue ?? undefined) as TSortBy;\n    }, [searchParams, persisted, defaultValue]);\n\n    const handleSetSortBy = (sortBy: string) => {\n        setSearchParams(\n            (prev) => {\n                const newParams = setSearchParam(prev, FILTER_KEYS.SHARED.SORT_BY, sortBy);\n                return newParams;\n            },\n            { replace: true },\n        );\n        setFilter(FILTER_KEYS.SHARED.SORT_BY, sortBy);\n    };\n\n    return {\n        setSortBy: handleSetSortBy,\n        sortBy,\n    };\n};\n"
  },
  {
    "path": "src/renderer/features/shared/hooks/use-sort-order-filter.ts",
    "content": "import { useMemo } from 'react';\nimport { useSearchParams } from 'react-router';\n\nimport { useListFilterPersistence } from '/@/renderer/features/shared/hooks/use-list-filter-persistence';\nimport { FILTER_KEYS } from '/@/renderer/features/shared/utils';\nimport { useCurrentServer } from '/@/renderer/store';\nimport { parseStringParam, setSearchParam } from '/@/renderer/utils/query-params';\nimport { SortOrder } from '/@/shared/types/domain-types';\nimport { ItemListKey } from '/@/shared/types/types';\n\nexport const useSortOrderFilter = (defaultValue: null | string, listKey: ItemListKey) => {\n    const server = useCurrentServer();\n    const { getFilter, setFilter } = useListFilterPersistence(server.id, listKey);\n    const [searchParams, setSearchParams] = useSearchParams();\n\n    const persisted = getFilter(FILTER_KEYS.SHARED.SORT_ORDER);\n\n    const sortOrder = useMemo(() => {\n        const value = parseStringParam(searchParams, FILTER_KEYS.SHARED.SORT_ORDER);\n        return (value ?? persisted ?? defaultValue ?? undefined) as SortOrder;\n    }, [searchParams, persisted, defaultValue]);\n\n    const handleSetSortOrder = (sortOrder: SortOrder) => {\n        setSearchParams(\n            (prev) => {\n                const newParams = setSearchParam(prev, FILTER_KEYS.SHARED.SORT_ORDER, sortOrder);\n                return newParams;\n            },\n            { replace: true },\n        );\n        setFilter(FILTER_KEYS.SHARED.SORT_ORDER, sortOrder);\n    };\n\n    return {\n        setSortOrder: handleSetSortOrder,\n        sortOrder,\n    };\n};\n"
  },
  {
    "path": "src/renderer/features/shared/mutations/create-favorite-mutation.ts",
    "content": "import { useIsMutating, useMutation, useQueryClient } from '@tanstack/react-query';\nimport { AxiosError } from 'axios';\nimport isElectron from 'is-electron';\nimport { useTranslation } from 'react-i18next';\n\nimport { api } from '/@/renderer/api';\nimport { queryKeys } from '/@/renderer/api/query-keys';\nimport { eventEmitter } from '/@/renderer/events/event-emitter';\nimport {\n    applyFavoriteOptimisticUpdates,\n    PreviousQueryData,\n    restoreFavoriteQueryData,\n} from '/@/renderer/features/shared/mutations/favorite-optimistic-updates';\nimport { MutationHookArgs } from '/@/renderer/lib/react-query';\nimport { toast } from '/@/shared/components/toast/toast';\nimport { FavoriteArgs, FavoriteResponse, LibraryItem } from '/@/shared/types/domain-types';\n\nconst remote = isElectron() ? window.api.remote : null;\n\nconst createFavoriteMutationKey = ['set-favorite', true];\n\nexport const useCreateFavorite = (args: MutationHookArgs) => {\n    const { options } = args || {};\n    const queryClient = useQueryClient();\n    const { t } = useTranslation();\n\n    return useMutation<FavoriteResponse, AxiosError, FavoriteArgs, PreviousQueryData[]>({\n        mutationFn: (args) => {\n            return api.controller.createFavorite({\n                ...args,\n                apiClientProps: { serverId: args.apiClientProps.serverId },\n            });\n        },\n        mutationKey: createFavoriteMutationKey,\n        onError: (_error, variables, context) => {\n            if (context) {\n                restoreFavoriteQueryData(queryClient, context);\n            }\n\n            toast.show({\n                message: _error.message,\n                title: t('error.genericError', { postProcess: 'sentenceCase' }) as string,\n                type: 'error',\n            });\n\n            eventEmitter.emit('USER_FAVORITE', {\n                favorite: false,\n                id: variables.query.id,\n                itemType: variables.query.type,\n                serverId: variables.apiClientProps.serverId,\n            });\n        },\n        onMutate: (variables) => {\n            eventEmitter.emit('USER_FAVORITE', {\n                favorite: true,\n                id: variables.query.id,\n                itemType: variables.query.type,\n                serverId: variables.apiClientProps.serverId,\n            });\n\n            return applyFavoriteOptimisticUpdates(queryClient, variables, true);\n        },\n        onSuccess: (_data, variables) => {\n            if (variables.query.type === LibraryItem.SONG) {\n                remote?.updateFavorite(true, variables.apiClientProps.serverId, variables.query.id);\n            }\n            if (\n                variables.query.type === LibraryItem.SONG ||\n                variables.query.type === LibraryItem.PLAYLIST_SONG ||\n                variables.query.type === LibraryItem.QUEUE_SONG\n            ) {\n                queryClient.invalidateQueries({\n                    queryKey: queryKeys.albumArtists.favoriteSongs(\n                        variables.apiClientProps.serverId,\n                    ),\n                });\n            }\n        },\n        ...options,\n    });\n};\n\nexport const useIsMutatingCreateFavorite = () => {\n    const mutatingCount = useIsMutating({ mutationKey: createFavoriteMutationKey });\n    return mutatingCount > 0;\n};\n"
  },
  {
    "path": "src/renderer/features/shared/mutations/delete-favorite-mutation.ts",
    "content": "import { useIsMutating, useMutation, useQueryClient } from '@tanstack/react-query';\nimport { AxiosError } from 'axios';\nimport isElectron from 'is-electron';\nimport { useTranslation } from 'react-i18next';\n\nimport { api } from '/@/renderer/api';\nimport { queryKeys } from '/@/renderer/api/query-keys';\nimport { eventEmitter } from '/@/renderer/events/event-emitter';\nimport {\n    applyFavoriteOptimisticUpdates,\n    PreviousQueryData,\n    restoreFavoriteQueryData,\n} from '/@/renderer/features/shared/mutations/favorite-optimistic-updates';\nimport { MutationHookArgs } from '/@/renderer/lib/react-query';\nimport { toast } from '/@/shared/components/toast/toast';\nimport { FavoriteArgs, FavoriteResponse, LibraryItem } from '/@/shared/types/domain-types';\n\nconst remote = isElectron() ? window.api.remote : null;\n\nconst deleteFavoriteMutationKey = ['set-favorite', false];\n\nexport const useDeleteFavorite = (args: MutationHookArgs) => {\n    const { options } = args || {};\n    const queryClient = useQueryClient();\n    const { t } = useTranslation();\n\n    return useMutation<FavoriteResponse, AxiosError, FavoriteArgs, PreviousQueryData[]>({\n        mutationFn: (args) => {\n            return api.controller.deleteFavorite({\n                ...args,\n                apiClientProps: { serverId: args.apiClientProps.serverId },\n            });\n        },\n        mutationKey: deleteFavoriteMutationKey,\n        onError: (_error, _variables, context) => {\n            if (context) {\n                restoreFavoriteQueryData(queryClient, context);\n            }\n\n            toast.show({\n                message: _error.message,\n                title: t('error.genericError', { postProcess: 'sentenceCase' }) as string,\n                type: 'error',\n            });\n\n            eventEmitter.emit('USER_FAVORITE', {\n                favorite: true,\n                id: _variables.query.id,\n                itemType: _variables.query.type,\n                serverId: _variables.apiClientProps.serverId,\n            });\n        },\n        onMutate: (variables) => {\n            eventEmitter.emit('USER_FAVORITE', {\n                favorite: false,\n                id: variables.query.id,\n                itemType: variables.query.type,\n                serverId: variables.apiClientProps.serverId,\n            });\n\n            return applyFavoriteOptimisticUpdates(queryClient, variables, false);\n        },\n        onSuccess: (_data, variables) => {\n            if (variables.query.type === LibraryItem.SONG) {\n                remote?.updateFavorite(\n                    false,\n                    variables.apiClientProps.serverId,\n                    variables.query.id,\n                );\n            }\n            if (\n                variables.query.type === LibraryItem.SONG ||\n                variables.query.type === LibraryItem.PLAYLIST_SONG ||\n                variables.query.type === LibraryItem.QUEUE_SONG\n            ) {\n                queryClient.invalidateQueries({\n                    queryKey: queryKeys.albumArtists.favoriteSongs(\n                        variables.apiClientProps.serverId,\n                    ),\n                });\n            }\n        },\n        ...options,\n    });\n};\n\nexport const useIsMutatingDeleteFavorite = () => {\n    const mutatingCount = useIsMutating({ mutationKey: deleteFavoriteMutationKey });\n    return mutatingCount > 0;\n};\n"
  },
  {
    "path": "src/renderer/features/shared/mutations/favorite-optimistic-updates.ts",
    "content": "import { QueryClient } from '@tanstack/react-query';\n\nimport { queryKeys } from '/@/renderer/api/query-keys';\nimport { infiniteLoaderDataQueryKey } from '/@/renderer/components/item-list/helpers/item-list-infinite-loader';\nimport {\n    Album,\n    AlbumArtist,\n    AlbumArtistDetailResponse,\n    AlbumArtistInfoResponse,\n    AlbumArtistListResponse,\n    AlbumDetailResponse,\n    AlbumListResponse,\n    ArtistListResponse,\n    FavoriteArgs,\n    LibraryItem,\n    PlaylistSongListResponse,\n    Song,\n    SongDetailResponse,\n    SongListResponse,\n    TopSongListResponse,\n} from '/@/shared/types/domain-types';\n\nexport interface PreviousQueryData {\n    data: unknown;\n    queryKey: readonly unknown[];\n}\n\ninterface PendingUpdate {\n    previousData: unknown;\n    queryKey: readonly unknown[];\n    updater: (prev: any) => any;\n}\n\nfunction collectAndApplyUpdates(\n    queryClient: QueryClient,\n    pendingUpdates: PendingUpdate[],\n): PreviousQueryData[] {\n    const previousQueries: PreviousQueryData[] = [];\n\n    pendingUpdates.forEach(({ previousData, queryKey, updater }) => {\n        previousQueries.push({ data: previousData, queryKey });\n        queryClient.setQueryData(queryKey, updater);\n    });\n\n    return previousQueries;\n}\n\nfunction updateItemInArray<T extends { id: string }>(\n    items: T[],\n    itemIdSet: Set<string>,\n    updater: (item: T) => T,\n): null | T[] {\n    let hasChanges = false;\n    const updatedItems = items.map((item) => {\n        if (itemIdSet.has(item.id)) {\n            hasChanges = true;\n            return updater(item);\n        }\n        return item;\n    });\n\n    return hasChanges ? updatedItems : null;\n}\n\nfunction updateItemsInPages<T extends { id: string }, P extends { items: T[] }>(\n    pages: P[],\n    itemIdSet: Set<string>,\n    updater: (item: T) => T,\n): null | P[] {\n    let hasChanges = false;\n    const updatedPages = pages.map((page) => {\n        if (!page) return page;\n        const updatedItems = updateItemInArray(page.items, itemIdSet, updater);\n        if (updatedItems) {\n            hasChanges = true;\n            return { ...page, items: updatedItems };\n        }\n        return page;\n    });\n\n    return hasChanges ? updatedPages : null;\n}\n\nexport const applyFavoriteOptimisticUpdates = (\n    queryClient: QueryClient,\n    variables: FavoriteArgs,\n    isFavorite: boolean,\n): PreviousQueryData[] => {\n    const pendingUpdates: PendingUpdate[] = [];\n    const itemIdSet = new Set<string>();\n\n    if (Array.isArray(variables.query.id)) {\n        variables.query.id.forEach((id) => {\n            itemIdSet.add(id);\n        });\n    } else {\n        itemIdSet.add(variables.query.id);\n    }\n\n    const createFavoriteUpdater = <T extends { userFavorite?: boolean }>(item: T): T => ({\n        ...item,\n        userFavorite: isFavorite,\n    });\n\n    switch (variables.query.type) {\n        case LibraryItem.ALBUM: {\n            const detailQueryKey = queryKeys.albums.detail(variables.apiClientProps.serverId);\n            const detailQueries = queryClient.getQueriesData({\n                exact: false,\n                queryKey: detailQueryKey,\n            });\n\n            detailQueries.forEach(([queryKey, data]) => {\n                if (data) {\n                    pendingUpdates.push({\n                        previousData: data,\n                        queryKey,\n                        updater: (prev: AlbumDetailResponse | undefined) => {\n                            if (prev && itemIdSet.has(prev.id)) {\n                                return { ...prev, userFavorite: isFavorite };\n                            }\n                            return prev;\n                        },\n                    });\n                }\n            });\n\n            const listQueryKey = queryKeys.albums.list(variables.apiClientProps.serverId);\n            const listQueries = queryClient.getQueriesData({\n                exact: false,\n                queryKey: listQueryKey,\n            });\n\n            listQueries.forEach(([queryKey, data]) => {\n                if (data) {\n                    pendingUpdates.push({\n                        previousData: data,\n                        queryKey,\n                        updater: (current: AlbumListResponse | undefined) => {\n                            if (!current) return current;\n                            const updatedItems = updateItemInArray(\n                                current.items,\n                                itemIdSet,\n                                (item) => createFavoriteUpdater<Album>(item),\n                            );\n                            return updatedItems ? { ...current, items: updatedItems } : current;\n                        },\n                    });\n                }\n            });\n\n            const infiniteListQueryKey = queryKeys.albums.infiniteList(\n                variables.apiClientProps.serverId,\n            );\n            const infiniteListQueries = queryClient.getQueriesData({\n                exact: false,\n                queryKey: infiniteListQueryKey,\n            });\n\n            const homeQueries = queryClient.getQueriesData({\n                exact: false,\n                queryKey: ['home', 'album'],\n            });\n\n            infiniteListQueries.concat(homeQueries).forEach(([queryKey, data]) => {\n                if (data) {\n                    pendingUpdates.push({\n                        previousData: data,\n                        queryKey,\n                        updater: (\n                            current:\n                                | undefined\n                                | { pageParams: string[]; pages: AlbumListResponse[] },\n                        ) => {\n                            if (!current) return current;\n                            const updatedPages = updateItemsInPages<Album, AlbumListResponse>(\n                                current.pages.filter((p): p is AlbumListResponse => !!p),\n                                itemIdSet,\n                                (item) => createFavoriteUpdater<Album>(item),\n                            );\n                            return updatedPages ? { ...current, pages: updatedPages } : current;\n                        },\n                    });\n                }\n            });\n\n            const infiniteLoaderQueryKey = infiniteLoaderDataQueryKey(\n                variables.apiClientProps.serverId,\n                LibraryItem.ALBUM,\n            );\n            const infiniteLoaderQueries = queryClient.getQueriesData({\n                exact: false,\n                queryKey: infiniteLoaderQueryKey,\n            });\n\n            infiniteLoaderQueries.forEach(([queryKey, data]) => {\n                if (data) {\n                    pendingUpdates.push({\n                        previousData: data,\n                        queryKey,\n                        updater: (\n                            prev:\n                                | undefined\n                                | {\n                                      data: unknown[];\n                                      pagesLoaded: Record<string, boolean>;\n                                  },\n                        ) => {\n                            if (prev && prev.data) {\n                                const updatedData = updateItemInArray(\n                                    prev.data as Array<{ id: string; userFavorite?: boolean }>,\n                                    itemIdSet,\n                                    (item) => createFavoriteUpdater(item),\n                                );\n                                return updatedData ? { ...prev, data: updatedData } : prev;\n                            }\n                            return prev;\n                        },\n                    });\n                }\n            });\n\n            break;\n        }\n        case LibraryItem.ALBUM_ARTIST: {\n            const detailQueryKey = queryKeys.albumArtists.detail(variables.apiClientProps.serverId);\n            const detailQueries = queryClient.getQueriesData({\n                exact: false,\n                queryKey: detailQueryKey,\n            });\n\n            detailQueries.forEach(([queryKey, data]) => {\n                if (data) {\n                    pendingUpdates.push({\n                        previousData: data,\n                        queryKey,\n                        updater: (prev: AlbumArtistDetailResponse | undefined) => {\n                            if (!prev) return prev;\n\n                            if (itemIdSet.has(prev.id)) {\n                                return { ...prev, userFavorite: isFavorite };\n                            }\n\n                            return prev;\n                        },\n                    });\n                }\n            });\n\n            const infoQueryKey = queryKeys.albumArtists.info(variables.apiClientProps.serverId);\n            const infoQueries = queryClient.getQueriesData({\n                exact: false,\n                queryKey: infoQueryKey,\n            });\n\n            infoQueries.forEach(([queryKey, data]) => {\n                if (data) {\n                    pendingUpdates.push({\n                        previousData: data,\n                        queryKey,\n                        updater: (prev: AlbumArtistInfoResponse | null | undefined) => {\n                            if (!prev?.similarArtists?.length) return prev;\n\n                            const hasMatching = prev.similarArtists.some((artist) =>\n                                itemIdSet.has(artist.id),\n                            );\n                            if (!hasMatching) return prev;\n\n                            return {\n                                ...prev,\n                                similarArtists: prev.similarArtists.map((artist) =>\n                                    itemIdSet.has(artist.id)\n                                        ? { ...artist, userFavorite: isFavorite }\n                                        : artist,\n                                ),\n                            };\n                        },\n                    });\n                }\n            });\n\n            const listQueryKey = queryKeys.albumArtists.list(variables.apiClientProps.serverId);\n            const listQueries = queryClient.getQueriesData({\n                exact: false,\n                queryKey: listQueryKey,\n            });\n\n            listQueries.forEach(([queryKey, data]) => {\n                if (data) {\n                    pendingUpdates.push({\n                        previousData: data,\n                        queryKey,\n                        updater: (prev: AlbumArtistListResponse | undefined) => {\n                            if (!prev) return prev;\n                            const updatedItems = updateItemInArray(\n                                prev.items.filter((item): item is AlbumArtist => !!item),\n                                itemIdSet,\n                                (item) => createFavoriteUpdater<AlbumArtist>(item),\n                            );\n                            return updatedItems ? { ...prev, items: updatedItems } : prev;\n                        },\n                    });\n                }\n            });\n\n            const infiniteListQueryKey = queryKeys.albumArtists.infiniteList(\n                variables.apiClientProps.serverId,\n            );\n            const infiniteListQueries = queryClient.getQueriesData({\n                exact: false,\n                queryKey: infiniteListQueryKey,\n            });\n\n            infiniteListQueries.forEach(([queryKey, data]) => {\n                if (data) {\n                    pendingUpdates.push({\n                        previousData: data,\n                        queryKey,\n                        updater: (\n                            prev:\n                                | undefined\n                                | { pageParams: string[]; pages: AlbumArtistListResponse[] },\n                        ) => {\n                            if (!prev) return prev;\n                            const updatedPages = updateItemsInPages<\n                                AlbumArtist,\n                                AlbumArtistListResponse\n                            >(\n                                prev.pages.filter((p): p is AlbumArtistListResponse => !!p),\n                                itemIdSet,\n                                (item) => createFavoriteUpdater<AlbumArtist>(item),\n                            );\n                            return updatedPages ? { ...prev, pages: updatedPages } : prev;\n                        },\n                    });\n                }\n            });\n\n            const infiniteLoaderQueryKey = infiniteLoaderDataQueryKey(\n                variables.apiClientProps.serverId,\n                LibraryItem.ALBUM_ARTIST,\n            );\n            const infiniteLoaderQueries = queryClient.getQueriesData({\n                exact: false,\n                queryKey: infiniteLoaderQueryKey,\n            });\n\n            infiniteLoaderQueries.forEach(([queryKey, data]) => {\n                if (data) {\n                    pendingUpdates.push({\n                        previousData: data,\n                        queryKey,\n                        updater: (\n                            prev:\n                                | undefined\n                                | {\n                                      data: unknown[];\n                                      pagesLoaded: Record<string, boolean>;\n                                  },\n                        ) => {\n                            if (prev && prev.data) {\n                                const updatedData = updateItemInArray(\n                                    prev.data as Array<{ id: string; userFavorite?: boolean }>,\n                                    itemIdSet,\n                                    (item) => createFavoriteUpdater(item),\n                                );\n                                return updatedData ? { ...prev, data: updatedData } : prev;\n                            }\n                            return prev;\n                        },\n                    });\n                }\n            });\n\n            break;\n        }\n        case LibraryItem.ARTIST: {\n            const listQueryKey = queryKeys.artists.list(variables.apiClientProps.serverId);\n            const listQueries = queryClient.getQueriesData({\n                exact: false,\n                queryKey: listQueryKey,\n            });\n\n            listQueries.forEach(([queryKey, data]) => {\n                if (data) {\n                    pendingUpdates.push({\n                        previousData: data,\n                        queryKey,\n                        updater: (prev: ArtistListResponse | undefined) => {\n                            if (!prev) return prev;\n                            const updatedItems = updateItemInArray(prev.items, itemIdSet, (item) =>\n                                createFavoriteUpdater<AlbumArtist>(item),\n                            );\n                            return updatedItems ? { ...prev, items: updatedItems } : prev;\n                        },\n                    });\n                }\n            });\n\n            const infiniteListQueryKey = queryKeys.artists.infiniteList(\n                variables.apiClientProps.serverId,\n            );\n            const infiniteListQueries = queryClient.getQueriesData({\n                exact: false,\n                queryKey: infiniteListQueryKey,\n            });\n\n            infiniteListQueries.forEach(([queryKey, data]) => {\n                if (data) {\n                    pendingUpdates.push({\n                        previousData: data,\n                        queryKey,\n                        updater: (\n                            prev: undefined | { pageParams: string[]; pages: ArtistListResponse[] },\n                        ) => {\n                            if (!prev) return prev;\n                            const updatedPages = updateItemsInPages<\n                                AlbumArtist,\n                                AlbumArtistListResponse\n                            >(\n                                prev.pages.filter((p): p is AlbumArtistListResponse => !!p),\n                                itemIdSet,\n                                (item) => createFavoriteUpdater<AlbumArtist>(item),\n                            );\n                            return updatedPages ? { ...prev, pages: updatedPages } : prev;\n                        },\n                    });\n                }\n            });\n\n            const infiniteLoaderQueryKey = infiniteLoaderDataQueryKey(\n                variables.apiClientProps.serverId,\n                LibraryItem.ARTIST,\n            );\n            const infiniteLoaderQueries = queryClient.getQueriesData({\n                exact: false,\n                queryKey: infiniteLoaderQueryKey,\n            });\n\n            infiniteLoaderQueries.forEach(([queryKey, data]) => {\n                if (data) {\n                    pendingUpdates.push({\n                        previousData: data,\n                        queryKey,\n                        updater: (\n                            prev:\n                                | undefined\n                                | {\n                                      data: unknown[];\n                                      pagesLoaded: Record<string, boolean>;\n                                  },\n                        ) => {\n                            if (prev && prev.data) {\n                                const updatedData = updateItemInArray(\n                                    prev.data as Array<{ id: string; userFavorite?: boolean }>,\n                                    itemIdSet,\n                                    (item) => createFavoriteUpdater(item),\n                                );\n                                return updatedData ? { ...prev, data: updatedData } : prev;\n                            }\n                            return prev;\n                        },\n                    });\n                }\n            });\n\n            break;\n        }\n        case LibraryItem.PLAYLIST_SONG:\n        case LibraryItem.QUEUE_SONG:\n        case LibraryItem.SONG: {\n            const albumDetailQueryKey = queryKeys.albums.detail(variables.apiClientProps.serverId);\n            const albumDetailQueries = queryClient.getQueriesData({\n                exact: false,\n                queryKey: albumDetailQueryKey,\n            });\n\n            albumDetailQueries.forEach(([queryKey, data]) => {\n                if (data) {\n                    pendingUpdates.push({\n                        previousData: data,\n                        queryKey,\n                        updater: (prev: AlbumDetailResponse | undefined) => {\n                            if (!prev || !prev.songs) return prev;\n                            const updatedSongs = updateItemInArray(prev.songs, itemIdSet, (item) =>\n                                createFavoriteUpdater<Song>(item),\n                            );\n                            return updatedSongs ? { ...prev, songs: updatedSongs } : prev;\n                        },\n                    });\n                }\n            });\n\n            const detailQueryKey = queryKeys.songs.detail(variables.apiClientProps.serverId);\n            const detailQueries = queryClient.getQueriesData({\n                exact: false,\n                queryKey: detailQueryKey,\n            });\n\n            detailQueries.forEach(([queryKey, data]) => {\n                if (data) {\n                    pendingUpdates.push({\n                        previousData: data,\n                        queryKey,\n                        updater: (prev: SongDetailResponse | undefined) => {\n                            if (prev && itemIdSet.has(prev.id)) {\n                                return { ...prev, userFavorite: isFavorite };\n                            }\n                            return prev;\n                        },\n                    });\n                }\n            });\n\n            const playlistSongListQueryKey = queryKeys.playlists.songList(\n                variables.apiClientProps.serverId,\n            );\n            const playlistSongListQueries = queryClient.getQueriesData({\n                exact: false,\n                queryKey: playlistSongListQueryKey,\n            });\n\n            playlistSongListQueries.forEach(([queryKey, data]) => {\n                if (data) {\n                    pendingUpdates.push({\n                        previousData: data,\n                        queryKey,\n                        updater: (prev: PlaylistSongListResponse | undefined) => {\n                            if (!prev) return prev;\n                            const updatedItems = updateItemInArray(prev.items, itemIdSet, (item) =>\n                                createFavoriteUpdater<Song>(item),\n                            );\n                            return updatedItems ? { ...prev, items: updatedItems } : prev;\n                        },\n                    });\n                }\n            });\n\n            const songListQueryKey = queryKeys.songs.list(variables.apiClientProps.serverId);\n            const songListQueries = queryClient.getQueriesData({\n                exact: false,\n                queryKey: songListQueryKey,\n            });\n\n            songListQueries.forEach(([queryKey, data]) => {\n                if (data) {\n                    pendingUpdates.push({\n                        previousData: data,\n                        queryKey,\n                        updater: (prev: undefined | { items: Song[] }) => {\n                            if (!prev) return prev;\n                            const updatedItems = updateItemInArray(prev.items, itemIdSet, (item) =>\n                                createFavoriteUpdater<Song>(item),\n                            );\n                            return updatedItems ? { ...prev, items: updatedItems } : prev;\n                        },\n                    });\n                }\n            });\n\n            const topSongsQueryKey = queryKeys.albumArtists.topSongs(\n                variables.apiClientProps.serverId,\n            );\n            const topSongsQueries = queryClient.getQueriesData({\n                exact: false,\n                queryKey: topSongsQueryKey,\n            });\n\n            topSongsQueries.forEach(([queryKey, data]) => {\n                if (data) {\n                    pendingUpdates.push({\n                        previousData: data,\n                        queryKey,\n                        updater: (prev: TopSongListResponse | undefined) => {\n                            if (!prev) return prev;\n                            const updatedItems = updateItemInArray(prev.items, itemIdSet, (item) =>\n                                createFavoriteUpdater<Song>(item),\n                            );\n                            return updatedItems ? { ...prev, items: updatedItems } : prev;\n                        },\n                    });\n                }\n            });\n\n            const homeQueries = queryClient.getQueriesData({\n                exact: false,\n                queryKey: ['home', 'song'],\n            });\n\n            homeQueries.concat(homeQueries).forEach(([queryKey, data]) => {\n                if (data) {\n                    pendingUpdates.push({\n                        previousData: data,\n                        queryKey,\n                        updater: (\n                            current:\n                                | undefined\n                                | { pageParams: string[]; pages: SongListResponse[] },\n                        ) => {\n                            if (!current) return current;\n                            const updatedPages = updateItemsInPages<Song, SongListResponse>(\n                                current.pages.filter((p): p is SongListResponse => !!p),\n                                itemIdSet,\n                                (item) => createFavoriteUpdater<Song>(item),\n                            );\n                            return updatedPages ? { ...current, pages: updatedPages } : current;\n                        },\n                    });\n                }\n            });\n\n            break;\n        }\n    }\n\n    return collectAndApplyUpdates(queryClient, pendingUpdates);\n};\n\nexport const applyFavoriteOptimisticUpdatesDeferred = (\n    queryClient: QueryClient,\n    variables: FavoriteArgs,\n    isFavorite: boolean,\n): PreviousQueryData[] => {\n    const previousQueries: PreviousQueryData[] = [];\n    const itemIdSet = new Set<string>();\n\n    if (Array.isArray(variables.query.id)) {\n        variables.query.id.forEach((id) => {\n            itemIdSet.add(id);\n        });\n    } else {\n        itemIdSet.add(variables.query.id);\n    }\n\n    const queryKeysToUpdate: Array<{\n        data: unknown;\n        queryKey: readonly unknown[];\n        type: string;\n    }> = [];\n\n    const collectQueries = (baseKey: readonly unknown[], type: string) => {\n        const queries = queryClient.getQueriesData({ exact: false, queryKey: baseKey });\n        queries.forEach(([queryKey, data]) => {\n            if (data) {\n                previousQueries.push({ data, queryKey });\n                queryKeysToUpdate.push({ data, queryKey, type });\n            }\n        });\n    };\n\n    switch (variables.query.type) {\n        case LibraryItem.ALBUM: {\n            collectQueries(\n                queryKeys.albums.detail(variables.apiClientProps.serverId),\n                'album-detail',\n            );\n            collectQueries(queryKeys.albums.list(variables.apiClientProps.serverId), 'album-list');\n            collectQueries(\n                queryKeys.albums.infiniteList(variables.apiClientProps.serverId),\n                'album-infinite-list',\n            );\n            collectQueries(\n                infiniteLoaderDataQueryKey(variables.apiClientProps.serverId, LibraryItem.ALBUM),\n                'album-infinite-loader',\n            );\n            break;\n        }\n        case LibraryItem.ALBUM_ARTIST: {\n            collectQueries(\n                queryKeys.albumArtists.detail(variables.apiClientProps.serverId),\n                'album-artist-detail',\n            );\n            collectQueries(\n                queryKeys.albumArtists.info(variables.apiClientProps.serverId),\n                'album-artist-info',\n            );\n            collectQueries(\n                queryKeys.albumArtists.list(variables.apiClientProps.serverId),\n                'album-artist-list',\n            );\n            collectQueries(\n                queryKeys.albumArtists.infiniteList(variables.apiClientProps.serverId),\n                'album-artist-infinite-list',\n            );\n            collectQueries(\n                infiniteLoaderDataQueryKey(\n                    variables.apiClientProps.serverId,\n                    LibraryItem.ALBUM_ARTIST,\n                ),\n                'album-artist-infinite-loader',\n            );\n            break;\n        }\n        case LibraryItem.ARTIST: {\n            collectQueries(\n                queryKeys.artists.list(variables.apiClientProps.serverId),\n                'artist-list',\n            );\n            collectQueries(\n                queryKeys.artists.infiniteList(variables.apiClientProps.serverId),\n                'artist-infinite-list',\n            );\n            collectQueries(\n                infiniteLoaderDataQueryKey(variables.apiClientProps.serverId, LibraryItem.ARTIST),\n                'artist-infinite-loader',\n            );\n            break;\n        }\n        case LibraryItem.PLAYLIST_SONG:\n        case LibraryItem.QUEUE_SONG:\n        case LibraryItem.SONG: {\n            collectQueries(\n                queryKeys.albums.detail(variables.apiClientProps.serverId),\n                'album-detail',\n            );\n            collectQueries(\n                queryKeys.songs.detail(variables.apiClientProps.serverId),\n                'song-detail',\n            );\n            collectQueries(\n                queryKeys.playlists.songList(variables.apiClientProps.serverId),\n                'playlist-song-list',\n            );\n            collectQueries(queryKeys.songs.list(variables.apiClientProps.serverId), 'song-list');\n            collectQueries(\n                queryKeys.albumArtists.topSongs(variables.apiClientProps.serverId),\n                'top-songs',\n            );\n            break;\n        }\n    }\n\n    queueMicrotask(() => {\n        queryKeysToUpdate.forEach(({ queryKey, type }) => {\n            queryClient.setQueryData(queryKey, (prev: any) => {\n                if (!prev) return prev;\n\n                switch (type) {\n                    case 'album-artist-detail':\n                    case 'album-detail':\n                    case 'song-detail': {\n                        if (itemIdSet.has(prev.id)) {\n                            return { ...prev, userFavorite: isFavorite };\n                        }\n                        if (prev.similarArtists) {\n                            const hasMatch = prev.similarArtists.some((a: any) =>\n                                itemIdSet.has(a.id),\n                            );\n                            if (hasMatch) {\n                                return {\n                                    ...prev,\n                                    similarArtists: prev.similarArtists.map((a: any) =>\n                                        itemIdSet.has(a.id)\n                                            ? { ...a, userFavorite: isFavorite }\n                                            : a,\n                                    ),\n                                };\n                            }\n                        }\n                        return prev;\n                    }\n                    case 'album-artist-infinite-list':\n                    case 'album-infinite-list':\n                    case 'artist-infinite-list': {\n                        const updatedPages = updateItemsInPages(\n                            prev.pages || [],\n                            itemIdSet,\n                            (item) => ({ ...item, userFavorite: isFavorite }),\n                        );\n                        return updatedPages ? { ...prev, pages: updatedPages } : prev;\n                    }\n                    case 'album-artist-infinite-loader':\n                    case 'album-infinite-loader':\n                    case 'artist-infinite-loader': {\n                        if (prev.data) {\n                            const updatedData = updateItemInArray(prev.data, itemIdSet, (item) => ({\n                                ...item,\n                                userFavorite: isFavorite,\n                            }));\n                            return updatedData ? { ...prev, data: updatedData } : prev;\n                        }\n                        return prev;\n                    }\n                    case 'album-artist-list':\n                    case 'album-list':\n                    case 'artist-list':\n                    case 'playlist-song-list':\n                    case 'song-list':\n                    case 'top-songs': {\n                        const updatedItems = updateItemInArray(\n                            prev.items || [],\n                            itemIdSet,\n                            (item) => ({ ...item, userFavorite: isFavorite }),\n                        );\n                        return updatedItems ? { ...prev, items: updatedItems } : prev;\n                    }\n                    default:\n                        return prev;\n                }\n            });\n        });\n    });\n\n    return previousQueries;\n};\n\nexport const restoreFavoriteQueryData = (\n    queryClient: QueryClient,\n    previousQueries: PreviousQueryData[],\n): void => {\n    previousQueries.forEach(({ data, queryKey }) => {\n        queryClient.setQueryData(queryKey, data);\n    });\n};\n"
  },
  {
    "path": "src/renderer/features/shared/mutations/rating-optimistic-updates.ts",
    "content": "import { QueryClient } from '@tanstack/react-query';\n\nimport { PreviousQueryData } from './favorite-optimistic-updates';\n\nimport { queryKeys } from '/@/renderer/api/query-keys';\nimport {\n    Album,\n    AlbumArtist,\n    AlbumArtistDetailResponse,\n    AlbumArtistInfoResponse,\n    AlbumArtistListResponse,\n    AlbumDetailResponse,\n    AlbumListResponse,\n    ArtistListResponse,\n    LibraryItem,\n    PlaylistSongListResponse,\n    SetRatingArgs,\n    Song,\n    SongDetailResponse,\n    TopSongListResponse,\n} from '/@/shared/types/domain-types';\n\ninterface PendingUpdate {\n    previousData: unknown;\n    queryKey: readonly unknown[];\n    updater: (prev: any) => any;\n}\n\nfunction collectAndApplyUpdates(\n    queryClient: QueryClient,\n    pendingUpdates: PendingUpdate[],\n): PreviousQueryData[] {\n    const previousQueries: PreviousQueryData[] = [];\n\n    // Batch all updates together - React Query will batch these internally\n    pendingUpdates.forEach(({ previousData, queryKey, updater }) => {\n        previousQueries.push({ data: previousData, queryKey });\n        queryClient.setQueryData(queryKey, updater);\n    });\n\n    return previousQueries;\n}\n\nfunction updateItemInArray<T extends { id: string }>(\n    items: T[],\n    itemIdSet: Set<string>,\n    updater: (item: T) => T,\n): null | T[] {\n    let hasChanges = false;\n    const updatedItems = items.map((item) => {\n        if (itemIdSet.has(item.id)) {\n            hasChanges = true;\n            return updater(item);\n        }\n        return item;\n    });\n\n    return hasChanges ? updatedItems : null;\n}\n\nfunction updateItemsInPages<T extends { id: string }, P extends { items: T[] }>(\n    pages: P[],\n    itemIdSet: Set<string>,\n    updater: (item: T) => T,\n): null | P[] {\n    let hasChanges = false;\n    const updatedPages = pages.map((page) => {\n        if (!page) return page;\n        const updatedItems = updateItemInArray(page.items, itemIdSet, updater);\n        if (updatedItems) {\n            hasChanges = true;\n            return { ...page, items: updatedItems };\n        }\n        return page;\n    });\n\n    return hasChanges ? updatedPages : null;\n}\n\nexport const applyRatingOptimisticUpdates = (\n    queryClient: QueryClient,\n    variables: SetRatingArgs,\n    rating: number,\n): PreviousQueryData[] => {\n    const pendingUpdates: PendingUpdate[] = [];\n    const itemIdSet = new Set<string>();\n\n    if (Array.isArray(variables.query.id)) {\n        variables.query.id.forEach((id) => {\n            itemIdSet.add(id);\n        });\n    } else {\n        itemIdSet.add(variables.query.id);\n    }\n\n    const createRatingUpdater = <T extends { userRating?: null | number }>(item: T): T => ({\n        ...item,\n        userRating: rating,\n    });\n\n    switch (variables.query.type) {\n        case LibraryItem.ALBUM: {\n            const detailQueryKey = queryKeys.albums.detail(variables.apiClientProps.serverId);\n            const detailQueries = queryClient.getQueriesData({\n                exact: false,\n                queryKey: detailQueryKey,\n            });\n\n            detailQueries.forEach(([queryKey, data]) => {\n                if (data) {\n                    pendingUpdates.push({\n                        previousData: data,\n                        queryKey,\n                        updater: (prev: AlbumDetailResponse | undefined) => {\n                            if (prev && itemIdSet.has(prev.id)) {\n                                return { ...prev, userRating: rating };\n                            }\n                            return prev;\n                        },\n                    });\n                }\n            });\n\n            const listQueryKey = queryKeys.albums.list(variables.apiClientProps.serverId);\n            const listQueries = queryClient.getQueriesData({\n                exact: false,\n                queryKey: listQueryKey,\n            });\n\n            listQueries.forEach(([queryKey, data]) => {\n                if (data) {\n                    pendingUpdates.push({\n                        previousData: data,\n                        queryKey,\n                        updater: (prev: AlbumListResponse | undefined) => {\n                            if (!prev) return prev;\n                            const updatedItems = updateItemInArray(prev.items, itemIdSet, (item) =>\n                                createRatingUpdater<Album>(item),\n                            );\n                            return updatedItems ? { ...prev, items: updatedItems } : prev;\n                        },\n                    });\n                }\n            });\n\n            const infiniteListQueryKey = queryKeys.albums.infiniteList(\n                variables.apiClientProps.serverId,\n            );\n            const infiniteListQueries = queryClient.getQueriesData({\n                exact: false,\n                queryKey: infiniteListQueryKey,\n            });\n\n            const homeQueries = queryClient.getQueriesData({\n                exact: false,\n                queryKey: ['home', 'album'],\n            });\n\n            infiniteListQueries.concat(homeQueries).forEach(([queryKey, data]) => {\n                if (data) {\n                    pendingUpdates.push({\n                        previousData: data,\n                        queryKey,\n                        updater: (\n                            prev: undefined | { pageParams: string[]; pages: AlbumListResponse[] },\n                        ) => {\n                            if (!prev) return prev;\n                            const updatedPages = updateItemsInPages<Album, AlbumListResponse>(\n                                prev.pages.filter((p): p is AlbumListResponse => !!p),\n                                itemIdSet,\n                                (item) => createRatingUpdater<Album>(item),\n                            );\n                            return updatedPages ? { ...prev, pages: updatedPages } : prev;\n                        },\n                    });\n                }\n            });\n\n            // Update infinite loader custom query keys\n            const infiniteLoaderQueryKey = [\n                variables.apiClientProps.serverId,\n                'item-list-infinite-loader',\n                LibraryItem.ALBUM,\n            ];\n\n            const infiniteLoaderQueries = queryClient.getQueriesData({\n                exact: false,\n                queryKey: infiniteLoaderQueryKey,\n            });\n\n            infiniteLoaderQueries.forEach(([queryKey, data]) => {\n                if (data) {\n                    pendingUpdates.push({\n                        previousData: data,\n                        queryKey,\n                        updater: (\n                            prev:\n                                | undefined\n                                | {\n                                      data: unknown[];\n                                      pagesLoaded: Record<string, boolean>;\n                                  },\n                        ) => {\n                            if (prev && prev.data) {\n                                const updatedData = updateItemInArray(\n                                    prev.data as Array<{ id: string; userRating?: null | number }>,\n                                    itemIdSet,\n                                    (item) => createRatingUpdater(item),\n                                );\n                                return updatedData ? { ...prev, data: updatedData } : prev;\n                            }\n                            return prev;\n                        },\n                    });\n                }\n            });\n\n            break;\n        }\n        case LibraryItem.ALBUM_ARTIST: {\n            const detailQueryKey = queryKeys.albumArtists.detail(variables.apiClientProps.serverId);\n            const detailQueries = queryClient.getQueriesData({\n                exact: false,\n                queryKey: detailQueryKey,\n            });\n\n            detailQueries.forEach(([queryKey, data]) => {\n                if (data) {\n                    pendingUpdates.push({\n                        previousData: data,\n                        queryKey,\n                        updater: (prev: AlbumArtistDetailResponse | undefined) => {\n                            if (!prev) return prev;\n\n                            if (itemIdSet.has(prev.id)) {\n                                return { ...prev, userRating: rating };\n                            }\n\n                            return prev;\n                        },\n                    });\n                }\n            });\n\n            const infoQueryKey = queryKeys.albumArtists.info(variables.apiClientProps.serverId);\n            const infoQueries = queryClient.getQueriesData({\n                exact: false,\n                queryKey: infoQueryKey,\n            });\n\n            infoQueries.forEach(([queryKey, data]) => {\n                if (data) {\n                    pendingUpdates.push({\n                        previousData: data,\n                        queryKey,\n                        updater: (prev: AlbumArtistInfoResponse | null | undefined) => {\n                            if (!prev?.similarArtists?.length) return prev;\n\n                            const hasMatching = prev.similarArtists.some((artist) =>\n                                itemIdSet.has(artist.id),\n                            );\n                            if (!hasMatching) return prev;\n\n                            return {\n                                ...prev,\n                                similarArtists: prev.similarArtists.map((artist) =>\n                                    itemIdSet.has(artist.id)\n                                        ? { ...artist, userRating: rating }\n                                        : artist,\n                                ),\n                            };\n                        },\n                    });\n                }\n            });\n\n            const listQueryKey = queryKeys.albumArtists.list(variables.apiClientProps.serverId);\n            const listQueries = queryClient.getQueriesData({\n                exact: false,\n                queryKey: listQueryKey,\n            });\n\n            listQueries.forEach(([queryKey, data]) => {\n                if (data) {\n                    pendingUpdates.push({\n                        previousData: data,\n                        queryKey,\n                        updater: (prev: AlbumArtistListResponse | undefined) => {\n                            if (!prev) return prev;\n                            const updatedItems = updateItemInArray(prev.items, itemIdSet, (item) =>\n                                createRatingUpdater<AlbumArtist>(item),\n                            );\n                            return updatedItems ? { ...prev, items: updatedItems } : prev;\n                        },\n                    });\n                }\n            });\n\n            const infiniteListQueryKey = queryKeys.albumArtists.infiniteList(\n                variables.apiClientProps.serverId,\n            );\n            const infiniteListQueries = queryClient.getQueriesData({\n                exact: false,\n                queryKey: infiniteListQueryKey,\n            });\n\n            infiniteListQueries.forEach(([queryKey, data]) => {\n                if (data) {\n                    pendingUpdates.push({\n                        previousData: data,\n                        queryKey,\n                        updater: (\n                            prev:\n                                | undefined\n                                | { pageParams: string[]; pages: AlbumArtistListResponse[] },\n                        ) => {\n                            if (!prev) return prev;\n                            const updatedPages = updateItemsInPages<\n                                AlbumArtist,\n                                AlbumArtistListResponse\n                            >(\n                                prev.pages.filter((p): p is AlbumArtistListResponse => !!p),\n                                itemIdSet,\n                                (item) => createRatingUpdater<AlbumArtist>(item),\n                            );\n                            return updatedPages ? { ...prev, pages: updatedPages } : prev;\n                        },\n                    });\n                }\n            });\n\n            // Update infinite loader custom query keys\n            const infiniteLoaderQueryKey = [\n                variables.apiClientProps.serverId,\n                'item-list-infinite-loader',\n                LibraryItem.ALBUM_ARTIST,\n            ];\n\n            const infiniteLoaderQueries = queryClient.getQueriesData({\n                exact: false,\n                queryKey: infiniteLoaderQueryKey,\n            });\n\n            infiniteLoaderQueries.forEach(([queryKey, data]) => {\n                if (data) {\n                    pendingUpdates.push({\n                        previousData: data,\n                        queryKey,\n                        updater: (\n                            prev:\n                                | undefined\n                                | {\n                                      data: unknown[];\n                                      pagesLoaded: Record<string, boolean>;\n                                  },\n                        ) => {\n                            if (prev && prev.data) {\n                                const updatedData = updateItemInArray(\n                                    prev.data as Array<{ id: string; userRating?: null | number }>,\n                                    itemIdSet,\n                                    (item) => createRatingUpdater(item),\n                                );\n                                return updatedData ? { ...prev, data: updatedData } : prev;\n                            }\n                            return prev;\n                        },\n                    });\n                }\n            });\n\n            break;\n        }\n        case LibraryItem.ARTIST: {\n            const listQueryKey = queryKeys.artists.list(variables.apiClientProps.serverId);\n            const listQueries = queryClient.getQueriesData({\n                exact: false,\n                queryKey: listQueryKey,\n            });\n\n            listQueries.forEach(([queryKey, data]) => {\n                if (data) {\n                    pendingUpdates.push({\n                        previousData: data,\n                        queryKey,\n                        updater: (prev: ArtistListResponse | undefined) => {\n                            if (!prev) return prev;\n                            const updatedItems = updateItemInArray(prev.items, itemIdSet, (item) =>\n                                createRatingUpdater<AlbumArtist>(item),\n                            );\n                            return updatedItems ? { ...prev, items: updatedItems } : prev;\n                        },\n                    });\n                }\n            });\n\n            const infiniteListQueryKey = queryKeys.artists.infiniteList(\n                variables.apiClientProps.serverId,\n            );\n            const infiniteListQueries = queryClient.getQueriesData({\n                exact: false,\n                queryKey: infiniteListQueryKey,\n            });\n\n            infiniteListQueries.forEach(([queryKey, data]) => {\n                if (data) {\n                    pendingUpdates.push({\n                        previousData: data,\n                        queryKey,\n                        updater: (\n                            prev: undefined | { pageParams: string[]; pages: ArtistListResponse[] },\n                        ) => {\n                            if (!prev) return prev;\n                            const updatedPages = updateItemsInPages<\n                                AlbumArtist,\n                                AlbumArtistListResponse\n                            >(\n                                prev.pages.filter((p): p is AlbumArtistListResponse => !!p),\n                                itemIdSet,\n                                (item) => createRatingUpdater<AlbumArtist>(item),\n                            );\n                            return updatedPages ? { ...prev, pages: updatedPages } : prev;\n                        },\n                    });\n                }\n            });\n\n            // Update infinite loader custom query keys\n            const infiniteLoaderQueryKey = [\n                variables.apiClientProps.serverId,\n                'item-list-infinite-loader',\n                LibraryItem.ARTIST,\n            ];\n\n            const infiniteLoaderQueries = queryClient.getQueriesData({\n                exact: false,\n                queryKey: infiniteLoaderQueryKey,\n            });\n\n            infiniteLoaderQueries.forEach(([queryKey, data]) => {\n                if (data) {\n                    pendingUpdates.push({\n                        previousData: data,\n                        queryKey,\n                        updater: (\n                            prev:\n                                | undefined\n                                | {\n                                      data: unknown[];\n                                      pagesLoaded: Record<string, boolean>;\n                                  },\n                        ) => {\n                            if (prev && prev.data) {\n                                const updatedData = updateItemInArray(\n                                    prev.data as Array<{ id: string; userRating?: null | number }>,\n                                    itemIdSet,\n                                    (item) => createRatingUpdater(item),\n                                );\n                                return updatedData ? { ...prev, data: updatedData } : prev;\n                            }\n                            return prev;\n                        },\n                    });\n                }\n            });\n\n            break;\n        }\n        case LibraryItem.PLAYLIST_SONG:\n        case LibraryItem.QUEUE_SONG:\n        case LibraryItem.SONG: {\n            const albumDetailQueryKey = queryKeys.albums.detail(variables.apiClientProps.serverId);\n            const albumDetailQueries = queryClient.getQueriesData({\n                exact: false,\n                queryKey: albumDetailQueryKey,\n            });\n\n            albumDetailQueries.forEach(([queryKey, data]) => {\n                if (data) {\n                    pendingUpdates.push({\n                        previousData: data,\n                        queryKey,\n                        updater: (prev: AlbumDetailResponse | undefined) => {\n                            if (!prev || !prev.songs) return prev;\n                            const updatedSongs = updateItemInArray(prev.songs, itemIdSet, (item) =>\n                                createRatingUpdater<Song>(item),\n                            );\n                            return updatedSongs ? { ...prev, songs: updatedSongs } : prev;\n                        },\n                    });\n                }\n            });\n\n            const detailQueryKey = queryKeys.songs.detail(variables.apiClientProps.serverId);\n            const detailQueries = queryClient.getQueriesData({\n                exact: false,\n                queryKey: detailQueryKey,\n            });\n\n            detailQueries.forEach(([queryKey, data]) => {\n                if (data) {\n                    pendingUpdates.push({\n                        previousData: data,\n                        queryKey,\n                        updater: (prev: SongDetailResponse | undefined) => {\n                            if (prev && itemIdSet.has(prev.id)) {\n                                return { ...prev, userRating: rating };\n                            }\n                            return prev;\n                        },\n                    });\n                }\n            });\n\n            const playlistSongListQueryKey = queryKeys.playlists.songList(\n                variables.apiClientProps.serverId,\n            );\n            const playlistSongListQueries = queryClient.getQueriesData({\n                exact: false,\n                queryKey: playlistSongListQueryKey,\n            });\n\n            playlistSongListQueries.forEach(([queryKey, data]) => {\n                if (data) {\n                    pendingUpdates.push({\n                        previousData: data,\n                        queryKey,\n                        updater: (prev: PlaylistSongListResponse | undefined) => {\n                            if (!prev) return prev;\n                            const updatedItems = updateItemInArray(prev.items, itemIdSet, (item) =>\n                                createRatingUpdater<Song>(item),\n                            );\n                            return updatedItems ? { ...prev, items: updatedItems } : prev;\n                        },\n                    });\n                }\n            });\n\n            const songListQueryKey = queryKeys.songs.list(variables.apiClientProps.serverId);\n            const songListQueries = queryClient.getQueriesData({\n                exact: false,\n                queryKey: songListQueryKey,\n            });\n\n            songListQueries.forEach(([queryKey, data]) => {\n                if (data) {\n                    pendingUpdates.push({\n                        previousData: data,\n                        queryKey,\n                        updater: (prev: undefined | { items: Song[] }) => {\n                            if (!prev) return prev;\n                            const updatedItems = updateItemInArray(prev.items, itemIdSet, (item) =>\n                                createRatingUpdater<Song>(item),\n                            );\n                            return updatedItems ? { ...prev, items: updatedItems } : prev;\n                        },\n                    });\n                }\n            });\n\n            const topSongsQueryKey = queryKeys.albumArtists.topSongs(\n                variables.apiClientProps.serverId,\n            );\n            const topSongsQueries = queryClient.getQueriesData({\n                exact: false,\n                queryKey: topSongsQueryKey,\n            });\n\n            topSongsQueries.forEach(([queryKey, data]) => {\n                if (data) {\n                    pendingUpdates.push({\n                        previousData: data,\n                        queryKey,\n                        updater: (prev: TopSongListResponse | undefined) => {\n                            if (!prev) return prev;\n                            const updatedItems = updateItemInArray(prev.items, itemIdSet, (item) =>\n                                createRatingUpdater<Song>(item),\n                            );\n                            return updatedItems ? { ...prev, items: updatedItems } : prev;\n                        },\n                    });\n                }\n            });\n\n            break;\n        }\n    }\n\n    return collectAndApplyUpdates(queryClient, pendingUpdates);\n};\n\nexport const applyRatingOptimisticUpdatesDeferred = (\n    queryClient: QueryClient,\n    variables: SetRatingArgs,\n    rating: number,\n): PreviousQueryData[] => {\n    const previousQueries: PreviousQueryData[] = [];\n    const itemIdSet = new Set<string>();\n\n    if (Array.isArray(variables.query.id)) {\n        variables.query.id.forEach((id) => {\n            itemIdSet.add(id);\n        });\n    } else {\n        itemIdSet.add(variables.query.id);\n    }\n\n    const queryKeysToUpdate: Array<{\n        data: unknown;\n        queryKey: readonly unknown[];\n        type: string;\n    }> = [];\n\n    const collectQueries = (baseKey: readonly unknown[], type: string) => {\n        const queries = queryClient.getQueriesData({ exact: false, queryKey: baseKey });\n        queries.forEach(([queryKey, data]) => {\n            if (data) {\n                previousQueries.push({ data, queryKey });\n                queryKeysToUpdate.push({ data, queryKey, type });\n            }\n        });\n    };\n\n    switch (variables.query.type) {\n        case LibraryItem.ALBUM: {\n            collectQueries(\n                queryKeys.albums.detail(variables.apiClientProps.serverId),\n                'album-detail',\n            );\n            collectQueries(queryKeys.albums.list(variables.apiClientProps.serverId), 'album-list');\n            collectQueries(\n                queryKeys.albums.infiniteList(variables.apiClientProps.serverId),\n                'album-infinite-list',\n            );\n            collectQueries(\n                [variables.apiClientProps.serverId, 'item-list-infinite-loader', LibraryItem.ALBUM],\n                'album-infinite-loader',\n            );\n            break;\n        }\n        case LibraryItem.ALBUM_ARTIST: {\n            collectQueries(\n                queryKeys.albumArtists.detail(variables.apiClientProps.serverId),\n                'album-artist-detail',\n            );\n            collectQueries(\n                queryKeys.albumArtists.info(variables.apiClientProps.serverId),\n                'album-artist-info',\n            );\n            collectQueries(\n                queryKeys.albumArtists.list(variables.apiClientProps.serverId),\n                'album-artist-list',\n            );\n            collectQueries(\n                queryKeys.albumArtists.infiniteList(variables.apiClientProps.serverId),\n                'album-artist-infinite-list',\n            );\n            collectQueries(\n                [\n                    variables.apiClientProps.serverId,\n                    'item-list-infinite-loader',\n                    LibraryItem.ALBUM_ARTIST,\n                ],\n                'album-artist-infinite-loader',\n            );\n            break;\n        }\n        case LibraryItem.ARTIST: {\n            collectQueries(\n                queryKeys.artists.list(variables.apiClientProps.serverId),\n                'artist-list',\n            );\n            collectQueries(\n                queryKeys.artists.infiniteList(variables.apiClientProps.serverId),\n                'artist-infinite-list',\n            );\n            collectQueries(\n                [\n                    variables.apiClientProps.serverId,\n                    'item-list-infinite-loader',\n                    LibraryItem.ARTIST,\n                ],\n                'artist-infinite-loader',\n            );\n            break;\n        }\n        case LibraryItem.PLAYLIST_SONG:\n        case LibraryItem.QUEUE_SONG:\n        case LibraryItem.SONG: {\n            collectQueries(\n                queryKeys.albums.detail(variables.apiClientProps.serverId),\n                'album-detail',\n            );\n            collectQueries(\n                queryKeys.songs.detail(variables.apiClientProps.serverId),\n                'song-detail',\n            );\n            collectQueries(queryKeys.songs.list(variables.apiClientProps.serverId), 'song-list');\n            collectQueries(\n                queryKeys.albumArtists.topSongs(variables.apiClientProps.serverId),\n                'top-songs',\n            );\n            break;\n        }\n    }\n\n    queueMicrotask(() => {\n        queryKeysToUpdate.forEach(({ queryKey, type }) => {\n            queryClient.setQueryData(queryKey, (prev: any) => {\n                if (!prev) return prev;\n\n                switch (type) {\n                    case 'album-artist-detail':\n                    case 'album-detail':\n                    case 'song-detail': {\n                        if (itemIdSet.has(prev.id)) {\n                            return { ...prev, userRating: rating };\n                        }\n                        return prev;\n                    }\n                    case 'album-artist-infinite-list':\n                    case 'album-infinite-list':\n                    case 'artist-infinite-list': {\n                        const updatedPages = updateItemsInPages(\n                            prev.pages || [],\n                            itemIdSet,\n                            (item) => ({ ...item, userRating: rating }),\n                        );\n                        return updatedPages ? { ...prev, pages: updatedPages } : prev;\n                    }\n                    case 'album-artist-infinite-loader':\n                    case 'album-infinite-loader':\n                    case 'artist-infinite-loader': {\n                        if (prev.data) {\n                            const updatedData = updateItemInArray(prev.data, itemIdSet, (item) => ({\n                                ...item,\n                                userRating: rating,\n                            }));\n                            return updatedData ? { ...prev, data: updatedData } : prev;\n                        }\n                        return prev;\n                    }\n                    case 'album-artist-info': {\n                        if (!prev?.similarArtists?.length) return prev;\n                        const hasMatch = prev.similarArtists.some((a: any) => itemIdSet.has(a.id));\n                        if (!hasMatch) return prev;\n                        return {\n                            ...prev,\n                            similarArtists: prev.similarArtists.map((a: any) =>\n                                itemIdSet.has(a.id) ? { ...a, userRating: rating } : a,\n                            ),\n                        };\n                    }\n                    case 'album-artist-list':\n                    case 'album-list':\n                    case 'artist-list':\n                    case 'song-list':\n                    case 'top-songs': {\n                        const updatedItems = updateItemInArray(\n                            prev.items || [],\n                            itemIdSet,\n                            (item) => ({ ...item, userRating: rating }),\n                        );\n                        return updatedItems ? { ...prev, items: updatedItems } : prev;\n                    }\n                    default:\n                        return prev;\n                }\n            });\n        });\n    });\n\n    return previousQueries;\n};\n\nexport const restoreRatingQueryData = (\n    queryClient: QueryClient,\n    previousQueries: PreviousQueryData[],\n): void => {\n    previousQueries.forEach(({ data, queryKey }) => {\n        queryClient.setQueryData(queryKey, data);\n    });\n};\n"
  },
  {
    "path": "src/renderer/features/shared/mutations/set-rating-mutation.ts",
    "content": "import { useIsMutating, useMutation, useQueryClient } from '@tanstack/react-query';\nimport { AxiosError } from 'axios';\nimport { useTranslation } from 'react-i18next';\n\nimport { api } from '/@/renderer/api';\nimport { queryKeys } from '/@/renderer/api/query-keys';\nimport { eventEmitter } from '/@/renderer/events/event-emitter';\nimport { PreviousQueryData } from '/@/renderer/features/shared/mutations/favorite-optimistic-updates';\nimport {\n    applyRatingOptimisticUpdates,\n    restoreRatingQueryData,\n} from '/@/renderer/features/shared/mutations/rating-optimistic-updates';\nimport { MutationHookArgs } from '/@/renderer/lib/react-query';\nimport { toast } from '/@/shared/components/toast/toast';\nimport { LibraryItem, RatingResponse, SetRatingArgs } from '/@/shared/types/domain-types';\n\nconst setRatingMutationKey = ['set-rating'];\n\nexport const useSetRatingMutation = (args: MutationHookArgs) => {\n    const { options } = args || {};\n    const queryClient = useQueryClient();\n    const { t } = useTranslation();\n\n    return useMutation<RatingResponse, AxiosError, SetRatingArgs, PreviousQueryData[]>({\n        mutationFn: (args) => {\n            return api.controller.setRating({\n                ...args,\n                apiClientProps: { serverId: args.apiClientProps.serverId },\n            });\n        },\n        mutationKey: setRatingMutationKey,\n        onError: (_error, _variables, context) => {\n            if (context) {\n                restoreRatingQueryData(queryClient, context);\n            }\n\n            toast.show({\n                message: _error.message,\n                title: t('error.genericError', { postProcess: 'sentenceCase' }) as string,\n                type: 'error',\n            });\n\n            eventEmitter.emit('USER_RATING', {\n                id: _variables.query.id,\n                itemType: _variables.query.type,\n                rating: _variables.query.rating,\n                serverId: _variables.apiClientProps.serverId,\n            });\n        },\n        onMutate: (variables) => {\n            eventEmitter.emit('USER_RATING', {\n                id: variables.query.id,\n                itemType: variables.query.type,\n                rating: variables.query.rating,\n                serverId: variables.apiClientProps.serverId,\n            });\n\n            return applyRatingOptimisticUpdates(queryClient, variables, variables.query.rating);\n        },\n        onSuccess: (_data, variables) => {\n            if (\n                variables.query.type === LibraryItem.SONG ||\n                variables.query.type === LibraryItem.PLAYLIST_SONG ||\n                variables.query.type === LibraryItem.QUEUE_SONG\n            ) {\n                queryClient.invalidateQueries({\n                    queryKey: queryKeys.albumArtists.favoriteSongs(\n                        variables.apiClientProps.serverId,\n                    ),\n                });\n            }\n        },\n        ...options,\n    });\n};\n\nexport const useIsMutatingRating = () => {\n    const mutatingCount = useIsMutating({ mutationKey: setRatingMutationKey });\n    return mutatingCount > 0;\n};\n"
  },
  {
    "path": "src/renderer/features/shared/utils.ts",
    "content": "import Fuse from 'fuse.js';\nimport z from 'zod';\n\nimport i18n from '/@/i18n/i18n';\nimport {\n    Album,\n    AlbumArtist,\n    Artist,\n    Genre,\n    InternetRadioStation,\n    LibraryItem,\n    Playlist,\n    QueueSong,\n    Song,\n} from '/@/shared/types/domain-types';\nimport { Play } from '/@/shared/types/types';\n\nexport const PLAY_TYPES = [\n    {\n        label: i18n.t('player.play', { postProcess: 'sentenceCase' }),\n        play: Play.NOW,\n    },\n    {\n        label: i18n.t('player.shuffle', { postProcess: 'sentenceCase' }),\n        play: Play.SHUFFLE,\n    },\n    {\n        label: i18n.t('player.addLast', { postProcess: 'sentenceCase' }),\n        play: Play.LAST,\n    },\n    {\n        label: i18n.t('player.addNext', { postProcess: 'sentenceCase' }),\n        play: Play.NEXT,\n    },\n];\n\nexport const customFiltersSchema = z.record(z.string(), z.any());\n\nenum AlbumFilterKeys {\n    _CUSTOM = '_custom',\n    ARTIST_IDS = 'artistIds',\n    COMPILATION = 'compilation',\n    FAVORITE = 'favorite',\n    GENRE_ID = 'genreIds',\n    HAS_RATING = 'hasRating',\n    MAX_YEAR = 'maxYear',\n    MIN_YEAR = 'minYear',\n    RECENTLY_PLAYED = 'isRecentlyPlayed',\n}\n\nenum ArtistFilterKeys {\n    ROLE = 'role',\n}\n\nenum SharedFilterKeys {\n    MUSIC_FOLDER_ID = 'musicFolderId',\n    SEARCH_TERM = 'searchTerm',\n    SORT_BY = 'sortBy',\n    SORT_ORDER = 'sortOrder',\n}\n\nenum SongFilterKeys {\n    _CUSTOM = '_custom',\n    ALBUM_ARTIST_IDS = 'albumArtistIds',\n    ALBUM_ARTIST_IDS_MODE = 'albumArtistIdsMode',\n    ARTIST_IDS = 'artistIds',\n    ARTIST_IDS_MODE = 'artistIdsMode',\n    FAVORITE = 'favorite',\n    GENRE_ID = 'genreIds',\n    GENRE_ID_MODE = 'genreIdsMode',\n    HAS_RATING = 'hasRating',\n    MAX_YEAR = 'maxYear',\n    MIN_YEAR = 'minYear',\n}\n\nconst PaginationFilterKeys = {\n    CURRENT_PAGE: 'currentPage',\n    SCROLL_OFFSET: 'scrollOffset',\n};\n\nenum FolderFilterKeys {\n    FOLDER_PATH = 'folderPath',\n}\n\nenum PlaylistFilterKeys {\n    CUSTOM = '_custom',\n}\n\nexport const FILTER_KEYS = {\n    ALBUM: AlbumFilterKeys,\n    ARTIST: ArtistFilterKeys,\n    FOLDER: FolderFilterKeys,\n    PAGINATION: PaginationFilterKeys,\n    PLAYLIST: PlaylistFilterKeys,\n    SHARED: SharedFilterKeys,\n    SONG: SongFilterKeys,\n};\n\ninterface CreateFuseOptions {\n    fieldNormWeight?: number;\n    ignoreLocation?: boolean;\n    threshold?: number;\n}\n\ntype FuseSearchableItem =\n    | Album\n    | AlbumArtist\n    | Artist\n    | Genre\n    | InternetRadioStation\n    | Playlist\n    | QueueSong\n    | Song;\n\nexport const createFuseForLibraryItem = <T extends FuseSearchableItem>(\n    items: T[],\n    itemType: LibraryItem,\n    options: CreateFuseOptions = {},\n): Fuse<T> => {\n    const { fieldNormWeight = 1, ignoreLocation = true, threshold = 0.3 } = options;\n\n    if (items.length === 0) {\n        return new Fuse(items, {\n            fieldNormWeight,\n            ignoreLocation,\n            keys: [],\n            threshold,\n        });\n    }\n\n    const stringKeys: string[] = [];\n    const nestedKeys: Array<{ getFn: (item: T) => string; name: string }> = [];\n\n    switch (itemType) {\n        case LibraryItem.ALBUM: {\n            stringKeys.push('name', 'releaseType');\n            nestedKeys.push(\n                {\n                    getFn: (item) => {\n                        const a = item as Album;\n                        return a.artists?.map((artist) => artist.name).join(' ') || '';\n                    },\n                    name: 'artists',\n                },\n                {\n                    getFn: (item) => {\n                        const a = item as Album;\n                        return a.albumArtists?.map((artist) => artist.name).join(' ') || '';\n                    },\n                    name: 'albumArtists',\n                },\n                {\n                    getFn: (item) => {\n                        const a = item as Album;\n                        return a.genres?.map((genre) => genre.name).join(' ') || '';\n                    },\n                    name: 'genres',\n                },\n            );\n            break;\n        }\n\n        case LibraryItem.ALBUM_ARTIST: {\n            stringKeys.push('name');\n            nestedKeys.push({\n                getFn: (item) => {\n                    const aa = item as AlbumArtist;\n                    return aa.genres?.map((genre) => genre.name).join(' ') || '';\n                },\n                name: 'genres',\n            });\n            break;\n        }\n\n        case LibraryItem.ARTIST:\n        case LibraryItem.GENRE:\n        case LibraryItem.RADIO_STATION:\n            stringKeys.push('name');\n            break;\n        case LibraryItem.PLAYLIST: {\n            stringKeys.push('name');\n            nestedKeys.push({\n                getFn: (item) => {\n                    const p = item as Playlist;\n                    return p.genres?.map((genre) => genre.name).join(' ') || '';\n                },\n                name: 'genres',\n            });\n            break;\n        }\n\n        case LibraryItem.PLAYLIST_SONG:\n        case LibraryItem.QUEUE_SONG:\n        case LibraryItem.SONG:\n            stringKeys.push('album', 'name');\n            nestedKeys.push(\n                {\n                    getFn: (item) => {\n                        const s = item as QueueSong | Song;\n                        return s.artists?.map((artist) => artist.name).join(' ') || '';\n                    },\n                    name: 'artists',\n                },\n                {\n                    getFn: (item) => {\n                        const s = item as QueueSong | Song;\n                        return s.albumArtists?.map((artist) => artist.name).join(' ') || '';\n                    },\n                    name: 'albumArtists',\n                },\n            );\n            break;\n    }\n\n    return new Fuse(items, {\n        fieldNormWeight,\n        ignoreLocation,\n        keys: [...stringKeys, ...nestedKeys],\n        threshold,\n    });\n};\n\nexport const searchLibraryItems = <T extends FuseSearchableItem>(\n    items: T[],\n    searchTerm: string | undefined,\n    itemType: LibraryItem,\n    options?: CreateFuseOptions,\n): T[] => {\n    if (!searchTerm?.trim()) {\n        return items;\n    }\n\n    const fuse = createFuseForLibraryItem(items, itemType, options);\n    return fuse.search(searchTerm).map((result) => result.item);\n};\n"
  },
  {
    "path": "src/renderer/features/sharing/components/share-item-context-modal.tsx",
    "content": "import { closeModal, ContextModalProps } from '@mantine/modals';\nimport dayjs from 'dayjs';\nimport { useTranslation } from 'react-i18next';\n\nimport { useShareItem } from '/@/renderer/features/sharing/mutations/share-item-mutation';\nimport { useCurrentServer } from '/@/renderer/store';\nimport { getServerUrl } from '/@/renderer/utils/normalize-server-url';\nimport { DateTimePicker } from '/@/shared/components/date-time-picker/date-time-picker';\nimport { Group } from '/@/shared/components/group/group';\nimport { ModalButton } from '/@/shared/components/modal/model-shared';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { Switch } from '/@/shared/components/switch/switch';\nimport { Textarea } from '/@/shared/components/textarea/textarea';\nimport { toast } from '/@/shared/components/toast/toast';\nimport { useForm } from '/@/shared/hooks/use-form';\n\nexport const ShareItemContextModal = ({\n    id,\n    innerProps,\n}: ContextModalProps<{\n    itemIds: string[];\n    resourceType: string;\n}>) => {\n    const { t } = useTranslation();\n    const { itemIds, resourceType } = innerProps;\n    const server = useCurrentServer();\n\n    const shareItemMutation = useShareItem({});\n\n    // Uses the same default as Navidrome: 1 year\n    const defaultDate = dayjs().add(1, 'year').format('YYYY-MM-DD HH:mm:ss');\n\n    const form = useForm({\n        initialValues: {\n            allowDownloading: false,\n            description: '',\n            expires: defaultDate,\n        },\n        validate: {\n            expires: (value) =>\n                dayjs(value).isAfter(dayjs())\n                    ? null\n                    : t('form.shareItem.expireInvalid', {\n                          postProcess: 'sentenceCase',\n                      }),\n        },\n    });\n\n    const handleSubmit = form.onSubmit(async (values) => {\n        shareItemMutation.mutate(\n            {\n                apiClientProps: { serverId: server?.id || '' },\n                body: {\n                    description: values.description,\n                    downloadable: values.allowDownloading,\n                    expires: dayjs(values.expires).valueOf(),\n                    resourceIds: itemIds.join(),\n                    resourceType,\n                },\n            },\n            {\n                onError: () => {\n                    toast.error({\n                        message: t('form.shareItem.createFailed', {\n                            postProcess: 'sentenceCase',\n                        }),\n                    });\n                },\n                onSuccess: (_data) => {\n                    if (!server) throw new Error('Server not found');\n                    if (!_data?.id) throw new Error('Failed to share item');\n\n                    const serverUrl = getServerUrl(server, true);\n                    if (!serverUrl) throw new Error('Server URL not found');\n                    const shareUrl = `${serverUrl}/share/${_data.id}`;\n\n                    const canUseClipboard = navigator.clipboard && window.isSecureContext;\n                    if (canUseClipboard) {\n                        navigator.clipboard.writeText(shareUrl);\n                    }\n\n                    toast.success({\n                        autoClose: canUseClipboard ? 5000 : 15000,\n                        id: 'share-item-toast',\n                        message: t(\n                            canUseClipboard\n                                ? 'form.shareItem.success'\n                                : 'form.shareItem.successMustClick',\n                            {\n                                postProcess: 'sentenceCase',\n                            },\n                        ),\n                        onClick: (a) => {\n                            if (!(a.target instanceof HTMLElement)) return;\n\n                            // Make sure we weren't clicking close (otherwise clicking close /also/ opens the url)\n                            if (a.target.nodeName !== 'svg') {\n                                window.open(shareUrl);\n                                toast.hide('share-item-toast');\n                            }\n                        },\n                    });\n                },\n            },\n        );\n\n        closeModal(id);\n        return null;\n    });\n\n    return (\n        <form onSubmit={handleSubmit}>\n            <Stack>\n                <DateTimePicker\n                    clearable\n                    label={t('form.shareItem.setExpiration', {\n                        postProcess: 'titleCase',\n                    })}\n                    minDate={new Date()}\n                    placeholder={defaultDate}\n                    popoverProps={{ withinPortal: true }}\n                    valueFormat=\"MM/DD/YYYY HH:mm\"\n                    {...form.getInputProps('expires')}\n                />\n                <Textarea\n                    autosize\n                    label={t('form.shareItem.description', {\n                        postProcess: 'titleCase',\n                    })}\n                    minRows={5}\n                    {...form.getInputProps('description')}\n                />\n                <Switch\n                    defaultChecked={false}\n                    label={t('form.shareItem.allowDownloading', {\n                        postProcess: 'titleCase',\n                    })}\n                    {...form.getInputProps('allowDownloading')}\n                />\n\n                <Group justify=\"flex-end\">\n                    <ModalButton onClick={() => closeModal(id)}>{t('common.cancel')}</ModalButton>\n                    <ModalButton type=\"submit\" variant=\"filled\">\n                        {t('common.share')}\n                    </ModalButton>\n                </Group>\n            </Stack>\n        </form>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/sharing/mutations/share-item-mutation.ts",
    "content": "import { useMutation } from '@tanstack/react-query';\nimport { AxiosError } from 'axios';\n\nimport { api } from '/@/renderer/api';\nimport { MutationHookArgs } from '/@/renderer/lib/react-query';\nimport { AnyLibraryItems, ShareItemArgs, ShareItemResponse } from '/@/shared/types/domain-types';\n\nexport const useShareItem = (args: MutationHookArgs) => {\n    const { options } = args || {};\n\n    return useMutation<\n        ShareItemResponse,\n        AxiosError,\n        ShareItemArgs,\n        { previous: undefined | { items: AnyLibraryItems } }\n    >({\n        mutationFn: (args) => {\n            return api.controller.shareItem({\n                ...args,\n                apiClientProps: { serverId: args.apiClientProps.serverId },\n            });\n        },\n        retry: false,\n        ...options,\n    });\n};\n"
  },
  {
    "path": "src/renderer/features/sidebar/components/action-bar.module.css",
    "content": ".container {\n    display: flex;\n    align-items: center;\n    height: 65px;\n    -webkit-app-region: drag;\n\n    input {\n        -webkit-app-region: no-drag;\n    }\n}\n"
  },
  {
    "path": "src/renderer/features/sidebar/components/action-bar.tsx",
    "content": "import { useTranslation } from 'react-i18next';\nimport { useNavigate } from 'react-router';\n\nimport styles from './action-bar.module.css';\n\nimport { AppMenu } from '/@/renderer/features/titlebar/components/app-menu';\nimport { useCommandPalette } from '/@/renderer/store';\nimport { Button } from '/@/shared/components/button/button';\nimport { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu';\nimport { Grid } from '/@/shared/components/grid/grid';\nimport { Group } from '/@/shared/components/group/group';\nimport { Icon } from '/@/shared/components/icon/icon';\nimport { TextInput } from '/@/shared/components/text-input/text-input';\n\nexport const ActionBar = () => {\n    const { t } = useTranslation();\n    const { open } = useCommandPalette();\n\n    return (\n        <div className={styles.container}>\n            <Grid\n                display=\"flex\"\n                gutter=\"sm\"\n                style={{ padding: '0 var(--theme-spacing-md)' }}\n                w=\"100%\"\n            >\n                <Grid.Col span={7}>\n                    <TextInput\n                        leftSection={<Icon icon=\"search\" />}\n                        onClick={open}\n                        onKeyDown={(e) => {\n                            if (e.key === 'Enter' || e.key === ' ') {\n                                open();\n                            }\n                        }}\n                        placeholder={t('common.search', { postProcess: 'titleCase' })}\n                        readOnly\n                    />\n                </Grid.Col>\n                <Grid.Col span={5}>\n                    <Group gap=\"sm\" grow wrap=\"nowrap\">\n                        <DropdownMenu position=\"bottom-start\">\n                            <DropdownMenu.Target>\n                                <Button p=\"0\">\n                                    <Icon icon=\"menu\" size=\"lg\" />\n                                </Button>\n                            </DropdownMenu.Target>\n                            <DropdownMenu.Dropdown>\n                                <AppMenu />\n                            </DropdownMenu.Dropdown>\n                        </DropdownMenu>\n                        <NavigateButtons />\n                    </Group>\n                </Grid.Col>\n            </Grid>\n        </div>\n    );\n};\n\nconst NavigateButtons = () => {\n    const navigate = useNavigate();\n\n    return (\n        <>\n            <Button onClick={() => navigate(-1)} p=\"0\">\n                <Icon icon=\"arrowLeftS\" size=\"lg\" />\n            </Button>\n            <Button onClick={() => navigate(1)} p=\"0\">\n                <Icon icon=\"arrowRightS\" size=\"lg\" />\n            </Button>\n        </>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/sidebar/components/collapsed-sidebar-button.module.css",
    "content": ".button {\n    width: 100%;\n    height: 100%;\n    padding: 0.9rem 0.3rem;\n}\n"
  },
  {
    "path": "src/renderer/features/sidebar/components/collapsed-sidebar-button.tsx",
    "content": "import { forwardRef } from 'react';\n\nimport styles from './collapsed-sidebar-button.module.css';\n\nimport { ActionIcon, ActionIconProps } from '/@/shared/components/action-icon/action-icon';\n\ninterface CollapsedSidebarButtonProps extends ActionIconProps {}\n\nexport const CollapsedSidebarButton = forwardRef<HTMLButtonElement, CollapsedSidebarButtonProps>(\n    ({ children, ...props }: CollapsedSidebarButtonProps, ref) => {\n        return (\n            <ActionIcon className={styles.button} ref={ref} variant=\"subtle\" {...props}>\n                {children}\n            </ActionIcon>\n        );\n    },\n);\n"
  },
  {
    "path": "src/renderer/features/sidebar/components/collapsed-sidebar-item.module.css",
    "content": ".container {\n    position: relative;\n    width: 100%;\n    padding: 0.9rem 0.3rem;\n    color: var(--theme-colors-foreground-muted);\n    cursor: pointer;\n\n    &:focus-visible {\n        outline: none;\n    }\n\n    svg {\n        fill: var(--theme-colors-foreground-muted);\n    }\n\n    &:hover {\n        color: var(--theme-colors-foreground);\n\n        svg {\n            fill: var(--theme-colors-foreground);\n        }\n    }\n}\n\n.container.active {\n    svg {\n        fill: var(--theme-colors-primary-filled);\n    }\n}\n\n.container.disabled {\n    pointer-events: none;\n    cursor: default;\n    user-select: none;\n    opacity: 0.6;\n\n    &:hover {\n        div {\n            color: var(--theme-colors-foreground) !important;\n        }\n\n        svg {\n            fill: var(--theme-colors-primary-filled);\n        }\n    }\n}\n\n.text-wrapper {\n    width: 100%;\n    overflow: hidden;\n    text-overflow: ellipsis;\n    text-align: center;\n    white-space: pre-line;\n}\n\n.text-wrapper.active {\n    color: var(--theme-colors-foreground);\n}\n\n.active-tab-indicator {\n    position: absolute;\n    inset: 0 0 0 3px;\n    width: 2px;\n    height: 80%;\n    margin-top: auto;\n    margin-bottom: auto;\n    background: var(--theme-colors-primary-filled);\n}\n"
  },
  {
    "path": "src/renderer/features/sidebar/components/collapsed-sidebar-item.tsx",
    "content": "import clsx from 'clsx';\nimport { forwardRef, ReactNode } from 'react';\nimport { useMatch } from 'react-router';\n\nimport styles from './collapsed-sidebar-item.module.css';\n\nimport { Flex } from '/@/shared/components/flex/flex';\nimport { Text } from '/@/shared/components/text/text';\nimport { createPolymorphicComponent } from '/@/shared/utils/create-polymorphic-component';\n\ninterface CollapsedSidebarItemProps {\n    activeIcon: ReactNode;\n    disabled?: boolean;\n    icon: ReactNode;\n    label: string;\n    route?: string;\n}\n\nconst _CollapsedSidebarItem = forwardRef<HTMLDivElement, CollapsedSidebarItemProps>(\n    ({ activeIcon, disabled, icon, label, route, ...props }: CollapsedSidebarItemProps, ref) => {\n        const match = useMatch(route || '/null');\n        const isMatch = Boolean(match);\n\n        return (\n            <Flex\n                align=\"center\"\n                className={clsx({\n                    [styles.active]: isMatch,\n                    [styles.container]: true,\n                    [styles.disabled]: disabled,\n                })}\n                direction=\"column\"\n                ref={ref}\n                tabIndex={0}\n                {...props}\n            >\n                {isMatch ? <div className={styles.activeTabIndicator} /> : null}\n                {isMatch ? activeIcon : icon}\n                <Text\n                    className={clsx({\n                        [styles.active]: isMatch,\n                        [styles.textWrapper]: true,\n                    })}\n                    fw=\"600\"\n                    isMuted={!isMatch}\n                    size=\"xs\"\n                >\n                    {label}\n                </Text>\n            </Flex>\n        );\n    },\n);\n\nexport const CollapsedSidebarItem = createPolymorphicComponent<'button', CollapsedSidebarItemProps>(\n    _CollapsedSidebarItem,\n);\n"
  },
  {
    "path": "src/renderer/features/sidebar/components/collapsed-sidebar.module.css",
    "content": ".sidebar-container {\n    position: relative;\n    display: flex;\n    flex-direction: column;\n    height: 100%;\n    max-height: calc(100vh - 119px);\n    user-select: none;\n}\n\n.sidebar-container.web,\n.sidebar-container.linux {\n    max-height: calc(100vh - 149px);\n}\n\n.server-icon {\n    width: 2rem;\n    height: 2rem;\n    object-fit: cover;\n    border-radius: var(--theme-radius-md);\n}\n"
  },
  {
    "path": "src/renderer/features/sidebar/components/collapsed-sidebar.tsx",
    "content": "import clsx from 'clsx';\nimport { motion } from 'motion/react';\nimport { useMemo } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { Link, NavLink, useNavigate } from 'react-router';\n\nimport styles from './collapsed-sidebar.module.css';\n\nimport JellyfinLogo from '/@/renderer/features/servers/assets/jellyfin.png';\nimport NavidromeLogo from '/@/renderer/features/servers/assets/navidrome.png';\nimport OpenSubsonicLogo from '/@/renderer/features/servers/assets/opensubsonic.png';\nimport { CollapsedSidebarButton } from '/@/renderer/features/sidebar/components/collapsed-sidebar-button';\nimport { CollapsedSidebarItem } from '/@/renderer/features/sidebar/components/collapsed-sidebar-item';\nimport { ServerSelectorItems } from '/@/renderer/features/sidebar/components/server-selector-items';\nimport { getCollectionTo } from '/@/renderer/features/sidebar/components/sidebar-collection-list';\nimport { SidebarIcon } from '/@/renderer/features/sidebar/components/sidebar-icon';\nimport { AppMenu } from '/@/renderer/features/titlebar/components/app-menu';\nimport { AppRoute } from '/@/renderer/router/routes';\nimport {\n    SidebarItemType,\n    useCollections,\n    useCurrentServer,\n    useSidebarCollapsedNavigation,\n    useSidebarItems,\n    useWindowSettings,\n} from '/@/renderer/store';\nimport { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu';\nimport { Flex } from '/@/shared/components/flex/flex';\nimport { Group } from '/@/shared/components/group/group';\nimport { Icon } from '/@/shared/components/icon/icon';\nimport { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { LibraryItem, ServerType } from '/@/shared/types/domain-types';\nimport { Platform } from '/@/shared/types/types';\n\nexport const CollapsedSidebar = () => {\n    const { t } = useTranslation();\n    const navigate = useNavigate();\n    const collections = useCollections();\n    const { windowBarStyle } = useWindowSettings();\n    const sidebarCollapsedNavigation = useSidebarCollapsedNavigation();\n    const sidebarItems = useSidebarItems();\n    const currentServer = useCurrentServer();\n\n    const translatedSidebarItemMap = useMemo(\n        () => ({\n            Albums: t('page.sidebar.albums', { postProcess: 'titleCase' }),\n            Artists: t('page.sidebar.albumArtists', { postProcess: 'titleCase' }).replace(\n                ' ',\n                '\\n',\n            ),\n            'Artists-all': t('page.sidebar.artists', { postProcess: 'titleCase' }),\n            Collections: t('page.sidebar.collections', { postProcess: 'titleCase' }),\n            Favorites: t('page.sidebar.favorites', { postProcess: 'titleCase' }),\n            Folders: t('page.sidebar.folders', { postProcess: 'titleCase' }),\n            Genres: t('page.sidebar.genres', { postProcess: 'titleCase' }),\n            Home: t('page.sidebar.home', { postProcess: 'titleCase' }),\n            'Now Playing': t('page.sidebar.nowPlaying', { postProcess: 'titleCase' }),\n            Playlists: t('page.sidebar.playlists', { postProcess: 'titleCase' }),\n            Radio: t('page.sidebar.radio', { postProcess: 'titleCase' }),\n            Search: t('page.sidebar.search', { postProcess: 'titleCase' }),\n            Settings: t('page.sidebar.settings', { postProcess: 'titleCase' }),\n            Tracks: t('page.sidebar.tracks', { postProcess: 'titleCase' }),\n        }),\n        [t],\n    );\n\n    const sidebarItemsWithRoute: SidebarItemType[] = useMemo(() => {\n        if (!sidebarItems) return [];\n\n        const items = sidebarItems\n            .filter((item) => !item.disabled)\n            .map((item) => ({\n                ...item,\n                label:\n                    translatedSidebarItemMap[item.id as keyof typeof translatedSidebarItemMap] ??\n                    item.label,\n            }));\n\n        return items;\n    }, [sidebarItems, translatedSidebarItemMap]);\n\n    return (\n        <motion.div\n            className={clsx({\n                [styles.linux]: windowBarStyle === Platform.LINUX,\n                [styles.sidebarContainer]: true,\n                [styles.web]: windowBarStyle === Platform.WEB,\n            })}\n        >\n            <ScrollArea>\n                {sidebarCollapsedNavigation && (\n                    <Group gap={0} grow>\n                        <CollapsedSidebarButton onClick={() => navigate(-1)}>\n                            <Icon icon=\"arrowLeftS\" size=\"xl\" />\n                        </CollapsedSidebarButton>\n                        <CollapsedSidebarButton onClick={() => navigate(1)}>\n                            <Icon icon=\"arrowRightS\" size=\"xl\" />\n                        </CollapsedSidebarButton>\n                    </Group>\n                )}\n                <DropdownMenu position=\"right-start\">\n                    <DropdownMenu.Target>\n                        <CollapsedSidebarItem\n                            activeIcon={null}\n                            component={Flex}\n                            icon={<Icon fill=\"muted\" icon=\"menu\" size=\"3xl\" />}\n                            label={t('common.menu', { postProcess: 'titleCase' })}\n                            style={{\n                                cursor: 'pointer',\n                                padding: 'var(--theme-spacing-md) 0',\n                            }}\n                        />\n                    </DropdownMenu.Target>\n                    <DropdownMenu.Dropdown>\n                        <AppMenu />\n                    </DropdownMenu.Dropdown>\n                </DropdownMenu>\n                {sidebarItemsWithRoute.map((item) =>\n                    item.id === 'Collections' ? (\n                        collections && collections.length > 0 ? (\n                            <DropdownMenu key={item.id} offset={0} position=\"right-end\">\n                                <DropdownMenu.Target>\n                                    <CollapsedSidebarItem\n                                        activeIcon={null}\n                                        component={Flex}\n                                        icon={<Icon color=\"muted\" icon=\"collection\" size=\"3xl\" />}\n                                        label={item.label}\n                                        style={{\n                                            cursor: 'pointer',\n                                            padding: 'var(--theme-spacing-md) 0',\n                                        }}\n                                    />\n                                </DropdownMenu.Target>\n                                <DropdownMenu.Dropdown>\n                                    <ScrollArea style={{ maxHeight: '50vh' }}>\n                                        <Stack gap={0} p=\"xs\">\n                                            {collections.map((collection) => {\n                                                const to = getCollectionTo(collection);\n                                                return (\n                                                    <DropdownMenu.Item\n                                                        component={Link}\n                                                        key={collection.id}\n                                                        leftSection={\n                                                            <SidebarIcon\n                                                                route={\n                                                                    collection.type ===\n                                                                    LibraryItem.ALBUM\n                                                                        ? AppRoute.LIBRARY_ALBUMS\n                                                                        : AppRoute.LIBRARY_SONGS\n                                                                }\n                                                            />\n                                                        }\n                                                        to={to}\n                                                    >\n                                                        {collection.name}\n                                                    </DropdownMenu.Item>\n                                                );\n                                            })}\n                                        </Stack>\n                                    </ScrollArea>\n                                </DropdownMenu.Dropdown>\n                            </DropdownMenu>\n                        ) : null\n                    ) : (\n                        <CollapsedSidebarItem\n                            activeIcon={<SidebarIcon active route={item.route} size=\"25\" />}\n                            component={NavLink}\n                            icon={<SidebarIcon route={item.route} size=\"25\" />}\n                            key={item.id}\n                            label={item.label}\n                            route={item.route}\n                            to={item.route}\n                        />\n                    ),\n                )}\n                {currentServer && (\n                    <DropdownMenu offset={0} position=\"right-end\" width={240}>\n                        <DropdownMenu.Target>\n                            <CollapsedSidebarItem\n                                activeIcon={null}\n                                component={Flex}\n                                icon={\n                                    <img\n                                        className={styles.serverIcon}\n                                        src={\n                                            currentServer.type === ServerType.NAVIDROME\n                                                ? NavidromeLogo\n                                                : currentServer.type === ServerType.JELLYFIN\n                                                  ? JellyfinLogo\n                                                  : OpenSubsonicLogo\n                                        }\n                                    />\n                                }\n                                label={''}\n                                py=\"md\"\n                                style={{\n                                    cursor: 'pointer',\n                                }}\n                            />\n                        </DropdownMenu.Target>\n                        <DropdownMenu.Dropdown>\n                            <ScrollArea style={{ maxHeight: '95vh' }}>\n                                <ServerSelectorItems />\n                            </ScrollArea>\n                        </DropdownMenu.Dropdown>\n                    </DropdownMenu>\n                )}\n            </ScrollArea>\n        </motion.div>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/sidebar/components/mobile-sidebar.module.css",
    "content": ".container {\n    display: flex;\n    flex-direction: column;\n    gap: var(--mantine-spacing-xs);\n    width: 100%;\n    height: 100%;\n    background: var(--theme-colors-background-alternate);\n}\n\n.scroll-area {\n    flex: 1;\n    min-height: 0;\n    padding: 0 var(--theme-spacing-md) var(--theme-spacing-md) var(--theme-spacing-md);\n}\n\n.accordion-root {\n    height: 100%;\n}\n\n.accordion-item {\n    border-bottom: none;\n}\n\n.accordion-control {\n    height: 2.5rem;\n    border-radius: var(--theme-radius-md);\n\n    &:hover {\n        background: var(--theme-colors-background);\n    }\n}\n\n.accordion-content {\n    padding: 0;\n    background: var(--theme-colors-background-alternate);\n}\n\n.accordion-content:last-child {\n    padding-bottom: var(--theme-spacing-md);\n}\n\n.server-selector-wrapper {\n    position: relative;\n    z-index: 1;\n    flex-shrink: 0;\n}\n"
  },
  {
    "path": "src/renderer/features/sidebar/components/mobile-sidebar.tsx",
    "content": "import { useMemo } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport styles from './mobile-sidebar.module.css';\n\nimport { ActionBar } from '/@/renderer/features/sidebar/components/action-bar';\nimport { ServerSelector } from '/@/renderer/features/sidebar/components/server-selector';\nimport { SidebarIcon } from '/@/renderer/features/sidebar/components/sidebar-icon';\nimport { SidebarItem } from '/@/renderer/features/sidebar/components/sidebar-item';\nimport {\n    SidebarPlaylistList,\n    SidebarSharedPlaylistList,\n} from '/@/renderer/features/sidebar/components/sidebar-playlist-list';\nimport {\n    SidebarItemType,\n    useSidebarItems,\n    useSidebarPlaylistList,\n} from '/@/renderer/store/settings.store';\nimport { Accordion } from '/@/shared/components/accordion/accordion';\nimport { Group } from '/@/shared/components/group/group';\nimport { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';\nimport { Text } from '/@/shared/components/text/text';\n\nexport const MobileSidebar = () => {\n    const { t } = useTranslation();\n    const sidebarPlaylistList = useSidebarPlaylistList();\n\n    const translatedSidebarItemMap = useMemo(\n        () => ({\n            Albums: t('page.sidebar.albums', { postProcess: 'titleCase' }),\n            Artists: t('page.sidebar.albumArtists', { postProcess: 'titleCase' }),\n            'Artists-all': t('page.sidebar.artists', { postProcess: 'titleCase' }),\n            Favorites: t('page.sidebar.favorites', { postProcess: 'titleCase' }),\n            Genres: t('page.sidebar.genres', { postProcess: 'titleCase' }),\n            Home: t('page.sidebar.home', { postProcess: 'titleCase' }),\n            'Now Playing': t('page.sidebar.nowPlaying', { postProcess: 'titleCase' }),\n            Playlists: t('page.sidebar.playlists', { postProcess: 'titleCase' }),\n            Search: t('page.sidebar.search', { postProcess: 'titleCase' }),\n            Settings: t('page.sidebar.settings', { postProcess: 'titleCase' }),\n            Tracks: t('page.sidebar.tracks', { postProcess: 'titleCase' }),\n        }),\n        [t],\n    );\n\n    const sidebarItems = useSidebarItems();\n\n    const sidebarItemsWithRoute: SidebarItemType[] = useMemo(() => {\n        if (!sidebarItems) return [];\n\n        const items = sidebarItems\n            .filter((item) => !item.disabled)\n            .map((item) => ({\n                ...item,\n                label:\n                    translatedSidebarItemMap[item.id as keyof typeof translatedSidebarItemMap] ??\n                    item.label,\n            }));\n\n        return items;\n    }, [sidebarItems, translatedSidebarItemMap]);\n\n    return (\n        <div className={styles.container} id=\"mobile-sidebar\">\n            <Group grow id=\"global-search-container\" style={{ flexShrink: 0 }}>\n                <ActionBar />\n            </Group>\n            <ScrollArea allowDragScroll className={styles.scrollArea}>\n                <Accordion\n                    classNames={{\n                        content: styles.accordionContent,\n                        control: styles.accordionControl,\n                        item: styles.accordionItem,\n                        root: styles.accordionRoot,\n                    }}\n                    defaultValue={['library', 'playlists']}\n                    multiple\n                >\n                    <Accordion.Item value=\"library\">\n                        <Accordion.Control>\n                            <Text fw={600} variant=\"secondary\">\n                                {t('page.sidebar.myLibrary', {\n                                    postProcess: 'titleCase',\n                                })}\n                            </Text>\n                        </Accordion.Control>\n                        <Accordion.Panel>\n                            {sidebarItemsWithRoute.map((item) => {\n                                return (\n                                    <SidebarItem key={`sidebar-${item.route}`} to={item.route}>\n                                        <Group gap=\"sm\">\n                                            <SidebarIcon route={item.route} />\n                                            {item.label}\n                                        </Group>\n                                    </SidebarItem>\n                                );\n                            })}\n                        </Accordion.Panel>\n                    </Accordion.Item>\n                    {sidebarPlaylistList && (\n                        <>\n                            <SidebarPlaylistList />\n                            <SidebarSharedPlaylistList />\n                        </>\n                    )}\n                </Accordion>\n            </ScrollArea>\n            <div className={styles.serverSelectorWrapper}>\n                <ServerSelector />\n            </div>\n        </div>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/sidebar/components/server-selector-items.tsx",
    "content": "import { openModal } from '@mantine/modals';\nimport { useQuery, useQueryClient } from '@tanstack/react-query';\nimport { useTranslation } from 'react-i18next';\nimport { useNavigate } from 'react-router';\n\nimport { isServerLock } from '/@/renderer/features/action-required/utils/window-properties';\nimport JellyfinLogo from '/@/renderer/features/servers/assets/jellyfin.png';\nimport NavidromeLogo from '/@/renderer/features/servers/assets/navidrome.png';\nimport OpenSubsonicLogo from '/@/renderer/features/servers/assets/opensubsonic.png';\nimport { ServerList } from '/@/renderer/features/servers/components/server-list';\nimport { sharedQueries } from '/@/renderer/features/shared/api/shared-api';\nimport { AppRoute } from '/@/renderer/router/routes';\nimport { useAuthStoreActions, useCurrentServer, useServerList } from '/@/renderer/store';\nimport { hasFeature } from '/@/shared/api/utils';\nimport { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu';\nimport { Icon } from '/@/shared/components/icon/icon';\nimport { ServerListItemWithCredential, ServerType } from '/@/shared/types/domain-types';\nimport { ServerFeature } from '/@/shared/types/features-types';\n\nexport const ServerSelectorItems = () => {\n    const { t } = useTranslation();\n    const navigate = useNavigate();\n    const currentServer = useCurrentServer();\n    const serverList = useServerList();\n    const { setCurrentServer, setMusicFolderId } = useAuthStoreActions();\n\n    const { data: musicFolders } = useQuery(\n        currentServer\n            ? sharedQueries.musicFolders({ query: null, serverId: currentServer.id })\n            : { enabled: false, queryKey: ['disabled'] },\n    );\n\n    const handleSetCurrentServer = (server: ServerListItemWithCredential) => {\n        navigate(AppRoute.HOME);\n        setCurrentServer(server);\n        setMusicFolderId(undefined);\n    };\n\n    const supportsMultiSelect = hasFeature(currentServer, ServerFeature.MUSIC_FOLDER_MULTISELECT);\n\n    const queryClient = useQueryClient();\n\n    const handleToggleMusicFolder = (musicFolderId: string) => {\n        if (supportsMultiSelect) {\n            const currentIds = currentServer.musicFolderId || [];\n            const isSelected = currentIds.includes(musicFolderId);\n\n            if (isSelected) {\n                // Remove from selection\n                const newIds = currentIds.filter((id) => id !== musicFolderId);\n                setMusicFolderId(newIds.length > 0 ? newIds : undefined);\n            } else {\n                // Add to selection\n                setMusicFolderId([...currentIds, musicFolderId]);\n            }\n        } else {\n            const currentId = Array.isArray(currentServer.musicFolderId)\n                ? currentServer.musicFolderId[0]\n                : currentServer.musicFolderId;\n            const isSelected = currentId === musicFolderId;\n\n            if (isSelected) {\n                setMusicFolderId(undefined);\n            } else {\n                setMusicFolderId([musicFolderId]);\n            }\n        }\n\n        queryClient.removeQueries();\n    };\n\n    const handleClearMusicFolders = () => {\n        setMusicFolderId(undefined);\n        queryClient.removeQueries();\n    };\n\n    if (!currentServer) {\n        return null;\n    }\n\n    const selectedMusicFolders =\n        musicFolders?.items.filter((folder) => currentServer.musicFolderId?.includes(folder.id)) ||\n        [];\n\n    const handleManageServersModal = () => {\n        openModal({\n            children: <ServerList />,\n            title: t('page.manageServers.title', { postProcess: 'titleCase' }),\n        });\n    };\n\n    return (\n        <>\n            <DropdownMenu.Label>\n                {t('page.appMenu.selectServer', { postProcess: 'titleCase' })}\n            </DropdownMenu.Label>\n            {Object.values(serverList).map((server) => {\n                const isNavidromeExpired =\n                    server.type === ServerType.NAVIDROME && !server.ndCredential;\n                const isJellyfinExpired = server.type === ServerType.JELLYFIN && !server.credential;\n                const isSessionExpired = isNavidromeExpired || isJellyfinExpired;\n\n                const logo =\n                    server.type === ServerType.NAVIDROME\n                        ? NavidromeLogo\n                        : server.type === ServerType.JELLYFIN\n                          ? JellyfinLogo\n                          : OpenSubsonicLogo;\n\n                return (\n                    <DropdownMenu.Item\n                        isSelected={currentServer?.id === server.id}\n                        key={`server-${server.id}`}\n                        leftSection={<img src={logo} style={{ height: '1rem', width: '1rem' }} />}\n                        onClick={() => {\n                            if (!isSessionExpired) {\n                                handleSetCurrentServer(server);\n                            }\n                        }}\n                    >\n                        {server.name}\n                    </DropdownMenu.Item>\n                );\n            })}\n            {!isServerLock() && (\n                <DropdownMenu.Item\n                    leftSection={<Icon icon=\"edit\" />}\n                    onClick={handleManageServersModal}\n                >\n                    {t('page.appMenu.manageServers', { postProcess: 'sentenceCase' })}\n                </DropdownMenu.Item>\n            )}\n            {musicFolders && musicFolders.items.length > 0 && (\n                <>\n                    <DropdownMenu.Divider />\n                    <DropdownMenu.Label>\n                        {t('page.appMenu.selectMusicFolder', { postProcess: 'sentenceCase' })}\n                    </DropdownMenu.Label>\n                    <DropdownMenu.Item\n                        isSelected={selectedMusicFolders.length === 0}\n                        leftSection={<Icon icon=\"minus\" />}\n                        onClick={handleClearMusicFolders}\n                    >\n                        {t('common.none', { postProcess: 'titleCase' })}\n                    </DropdownMenu.Item>\n                    {musicFolders.items.map((folder) => {\n                        const isSelected = supportsMultiSelect\n                            ? currentServer.musicFolderId?.includes(folder.id) || false\n                            : (Array.isArray(currentServer.musicFolderId)\n                                  ? currentServer.musicFolderId[0]\n                                  : currentServer.musicFolderId) === folder.id;\n                        return (\n                            <DropdownMenu.Item\n                                isSelected={isSelected}\n                                key={`musicFolder-${folder.id}`}\n                                leftSection={<Icon icon={isSelected ? 'check' : 'folder'} />}\n                                onClick={() => handleToggleMusicFolder(folder.id)}\n                            >\n                                {folder.name}\n                            </DropdownMenu.Item>\n                        );\n                    })}\n                </>\n            )}\n        </>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/sidebar/components/server-selector.module.css",
    "content": ".button-container {\n    align-items: center;\n    width: 100%;\n    padding: var(--theme-spacing-md);\n    cursor: pointer;\n}\n\n.button-container-no-bottom-padding {\n    padding-bottom: 0;\n}\n\n.button-group {\n    padding: var(--theme-spacing-sm);\n    background: var(--theme-colors-surface);\n    border-radius: var(--theme-radius-md);\n}\n\n.logo {\n    flex-shrink: 0;\n    width: 2.5rem;\n    height: 2.5rem;\n    object-fit: cover;\n    border-radius: var(--theme-radius-md);\n}\n\n.button-stack {\n    flex: 1;\n    min-width: 0;\n}\n\n.popover-target {\n    width: 100%;\n    user-select: none;\n}\n\n.scroll-area {\n    max-height: calc(100vh - 50px);\n}\n\n.server-logo {\n    width: var(--theme-font-size-md);\n    height: var(--theme-font-size-md);\n}\n\n.server-button {\n    justify-content: flex-start;\n}\n"
  },
  {
    "path": "src/renderer/features/sidebar/components/server-selector.tsx",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { useRef } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport styles from './server-selector.module.css';\n\nimport JellyfinLogo from '/@/renderer/features/servers/assets/jellyfin.png';\nimport NavidromeLogo from '/@/renderer/features/servers/assets/navidrome.png';\nimport OpenSubsonicLogo from '/@/renderer/features/servers/assets/opensubsonic.png';\nimport { sharedQueries } from '/@/renderer/features/shared/api/shared-api';\nimport { ServerSelectorItems } from '/@/renderer/features/sidebar/components/server-selector-items';\nimport { useAppStore, useCurrentServer } from '/@/renderer/store';\nimport { hasFeature } from '/@/shared/api/utils';\nimport { Box } from '/@/shared/components/box/box';\nimport { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu';\nimport { Group } from '/@/shared/components/group/group';\nimport { Icon } from '/@/shared/components/icon/icon';\nimport { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { Text } from '/@/shared/components/text/text';\nimport { ServerType } from '/@/shared/types/domain-types';\nimport { ServerFeature } from '/@/shared/types/features-types';\n\nexport const ServerSelector = () => {\n    const { t } = useTranslation();\n    const currentServer = useCurrentServer();\n    const sidebarImageEnabled = useAppStore((state) => state.sidebar.image);\n    const showImage = sidebarImageEnabled;\n\n    const { data: musicFolders } = useQuery(\n        currentServer\n            ? sharedQueries.musicFolders({ query: null, serverId: currentServer.id })\n            : { enabled: false, queryKey: ['disabled'] },\n    );\n\n    const targetRef = useRef<HTMLDivElement | null>(null);\n    const widthOfTarget = targetRef.current?.getBoundingClientRect().width;\n\n    if (!currentServer) {\n        return null;\n    }\n\n    const supportsMultiSelect = hasFeature(currentServer, ServerFeature.MUSIC_FOLDER_MULTISELECT);\n\n    const selectedMusicFolders =\n        musicFolders?.items.filter((folder) => currentServer.musicFolderId?.includes(folder.id)) ||\n        [];\n\n    const musicFolderDisplayText = (() => {\n        if (selectedMusicFolders.length === 0) {\n            return t('page.appMenu.noMusicFolder', { postProcess: 'sentenceCase' });\n        }\n\n        if (supportsMultiSelect && selectedMusicFolders.length > 1) {\n            return t('page.appMenu.multipleMusicFolders', {\n                count: selectedMusicFolders.length,\n                postProcess: 'sentenceCase',\n            });\n        }\n\n        return selectedMusicFolders[0].name;\n    })();\n\n    const logo =\n        currentServer.type === ServerType.NAVIDROME\n            ? NavidromeLogo\n            : currentServer.type === ServerType.JELLYFIN\n              ? JellyfinLogo\n              : OpenSubsonicLogo;\n\n    return (\n        <DropdownMenu offset={0} position=\"right\">\n            <DropdownMenu.Target>\n                <div className={styles.popoverTarget}>\n                    <Box\n                        className={`${styles.buttonContainer} ${\n                            showImage ? styles.buttonContainerNoBottomPadding : ''\n                        }`}\n                    >\n                        <Group className={styles.buttonGroup} gap=\"sm\" ref={targetRef}>\n                            <img className={styles.logo} src={logo} />\n                            <Stack className={styles.buttonStack} gap={2}>\n                                <Text fw={600} size=\"sm\" truncate>\n                                    {currentServer.name}\n                                </Text>\n                                <Text isMuted size=\"xs\" truncate>\n                                    {musicFolderDisplayText}\n                                </Text>\n                            </Stack>\n                            <Icon icon=\"ellipsisVertical\" size=\"sm\" />\n                        </Group>\n                    </Box>\n                </div>\n            </DropdownMenu.Target>\n            <DropdownMenu.Dropdown style={{ width: `${widthOfTarget}px` }}>\n                <ScrollArea className={styles.scrollArea}>\n                    <ServerSelectorItems />\n                </ScrollArea>\n            </DropdownMenu.Dropdown>\n        </DropdownMenu>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/sidebar/components/sidebar-collection-list.module.css",
    "content": ".row {\n    position: relative;\n    width: 100%;\n    border-radius: var(--theme-radius-md);\n}\n\n.row:hover .more-button {\n    opacity: 1;\n}\n\n.row-active {\n    background-color: var(--theme-colors-surface);\n}\n\n.row-link {\n    display: flex;\n    gap: var(--theme-spacing-xs);\n    align-items: center;\n    width: 100%;\n    min-width: 0;\n    padding: var(--theme-spacing-xs) var(--theme-spacing-md);\n    color: var(--theme-colors-foreground);\n    text-decoration: none;\n    cursor: pointer;\n    border: 1px transparent solid;\n    border-radius: var(--theme-radius-md);\n    transition: color 0.2s ease-in-out;\n\n    &:hover,\n    &:active,\n    &:focus-visible {\n        @mixin dark {\n            background-color: lighten(var(--theme-colors-background), 10%);\n        }\n\n        @mixin light {\n            background-color: darken(var(--theme-colors-background), 5%);\n        }\n    }\n\n    &:focus-visible {\n        border-color: var(--theme-colors-primary-filled);\n    }\n}\n\n.row-active .row-link {\n    color: var(--theme-colors-primary-filled);\n}\n\n.row-active .row-link .name {\n    color: var(--theme-colors-primary-filled);\n}\n\n.row-content {\n    flex: 1;\n    min-width: 0;\n}\n\n.type-icon {\n    flex-shrink: 0;\n}\n\n.name {\n    overflow: hidden;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n}\n\n.more-button {\n    flex-shrink: 0;\n    align-self: center;\n    opacity: 0;\n    transition: opacity 0.15s ease;\n}\n"
  },
  {
    "path": "src/renderer/features/sidebar/components/sidebar-collection-list.tsx",
    "content": "import clsx from 'clsx';\nimport { MouseEvent, useCallback } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { Link, useLocation } from 'react-router';\n\nimport styles from './sidebar-collection-list.module.css';\n\nimport { SidebarIcon } from '/@/renderer/features/sidebar/components/sidebar-icon';\nimport { AppRoute } from '/@/renderer/router/routes';\nimport { useCollections, useSettingsStoreActions } from '/@/renderer/store';\nimport { getFilterQueryStringFromSearchParams } from '/@/renderer/utils/query-params';\nimport { Accordion } from '/@/shared/components/accordion/accordion';\nimport { ActionIcon } from '/@/shared/components/action-icon/action-icon';\nimport { Button } from '/@/shared/components/button/button';\nimport { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu';\nimport { Group } from '/@/shared/components/group/group';\nimport { Popover } from '/@/shared/components/popover/popover';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { TextInput } from '/@/shared/components/text-input/text-input';\nimport { Text } from '/@/shared/components/text/text';\nimport { useDisclosure } from '/@/shared/hooks/use-disclosure';\nimport { useForm } from '/@/shared/hooks/use-form';\nimport { LibraryItem, SavedCollection } from '/@/shared/types/domain-types';\n\nexport const getCollectionTo = (collection: SavedCollection) => {\n    const pathname =\n        collection.type === LibraryItem.ALBUM ? AppRoute.LIBRARY_ALBUMS : AppRoute.LIBRARY_SONGS;\n    const search = collection.filterQueryString ? `?${collection.filterQueryString}` : '';\n    return { pathname, search };\n};\n\nconst CollectionRow = ({\n    collection,\n    onRename,\n}: {\n    collection: SavedCollection;\n    onRename: (id: string, name: string) => void;\n}) => {\n    const { t } = useTranslation();\n    const { removeCollection } = useSettingsStoreActions();\n    const [isRenameOpen, renameHandlers] = useDisclosure(false);\n\n    const form = useForm({\n        initialValues: {\n            name: collection.name,\n        },\n    });\n\n    const location = useLocation();\n    const to = getCollectionTo(collection);\n\n    const currentFilterQuery = getFilterQueryStringFromSearchParams(\n        new URLSearchParams(location.search),\n    );\n    const collectionFilterQuery = collection.filterQueryString ?? '';\n    const isActive =\n        location.pathname === to.pathname && currentFilterQuery === collectionFilterQuery;\n\n    const handleRenameOpen = useCallback(\n        (e: MouseEvent) => {\n            e.preventDefault();\n            e.stopPropagation();\n            form.setValues({ name: collection.name });\n            renameHandlers.open();\n        },\n        [collection.name, form, renameHandlers],\n    );\n\n    const handleRenameSubmit = form.onSubmit((values) => {\n        const trimmed = values.name.trim();\n        if (trimmed) {\n            onRename(collection.id, trimmed);\n            renameHandlers.close();\n        }\n    });\n\n    const handleDelete = useCallback(\n        (e: MouseEvent) => {\n            e.preventDefault();\n            e.stopPropagation();\n            removeCollection(collection.id);\n        },\n        [collection.id, removeCollection],\n    );\n\n    return (\n        <Popover\n            onClose={renameHandlers.close}\n            opened={isRenameOpen}\n            position=\"right-start\"\n            width={280}\n        >\n            <Popover.Target>\n                <div className={clsx(styles.row, { [styles.rowActive]: isActive })}>\n                    <Link className={styles.rowLink} to={to}>\n                        <Group className={styles.rowContent} wrap=\"nowrap\">\n                            <SidebarIcon\n                                active={isActive}\n                                route={\n                                    collection.type === LibraryItem.ALBUM\n                                        ? AppRoute.LIBRARY_ALBUMS\n                                        : AppRoute.LIBRARY_SONGS\n                                }\n                                size=\"1rem\"\n                            />\n                            <Text className={styles.name} fw={500} size=\"md\">\n                                {collection.name}\n                            </Text>\n                        </Group>\n                        <DropdownMenu position=\"right-start\" trigger=\"click\">\n                            <DropdownMenu.Target>\n                                <ActionIcon\n                                    className={styles.moreButton}\n                                    icon=\"ellipsisVertical\"\n                                    iconProps={{ size: 'xs' }}\n                                    onClick={(e) => {\n                                        e.preventDefault();\n                                        e.stopPropagation();\n                                    }}\n                                    size=\"compact-sm\"\n                                    variant=\"transparent\"\n                                />\n                            </DropdownMenu.Target>\n                            <DropdownMenu.Dropdown>\n                                <DropdownMenu.Item onClick={handleRenameOpen}>\n                                    {t('common.rename', { postProcess: 'sentenceCase' })}\n                                </DropdownMenu.Item>\n                                <DropdownMenu.Item color=\"red\" onClick={handleDelete}>\n                                    {t('common.delete', { postProcess: 'sentenceCase' })}\n                                </DropdownMenu.Item>\n                            </DropdownMenu.Dropdown>\n                        </DropdownMenu>\n                    </Link>\n                </div>\n            </Popover.Target>\n            <Popover.Dropdown>\n                <form onSubmit={handleRenameSubmit}>\n                    <Stack gap=\"md\" p=\"xs\">\n                        <TextInput\n                            autoFocus\n                            maxLength={128}\n                            variant=\"filled\"\n                            {...form.getInputProps('name')}\n                        />\n                        <Group gap=\"xs\" justify=\"flex-end\">\n                            <Button onClick={renameHandlers.close} type=\"button\" variant=\"subtle\">\n                                {t('common.cancel', { postProcess: 'sentenceCase' })}\n                            </Button>\n                            <Button type=\"submit\" variant=\"filled\">\n                                {t('common.save', { postProcess: 'sentenceCase' })}\n                            </Button>\n                        </Group>\n                    </Stack>\n                </form>\n            </Popover.Dropdown>\n        </Popover>\n    );\n};\n\nexport const SidebarCollectionList = () => {\n    const { t } = useTranslation();\n    const collections = useCollections();\n    const { updateCollection } = useSettingsStoreActions();\n\n    const handleRename = useCallback(\n        (id: string, name: string) => {\n            updateCollection(id, { name });\n        },\n        [updateCollection],\n    );\n\n    if (!collections || collections.length === 0) {\n        return null;\n    }\n\n    return (\n        <Accordion.Item value=\"collections\">\n            <Accordion.Control component=\"div\" role=\"button\" style={{ userSelect: 'none' }}>\n                <Text fw={500}>{t('page.sidebar.collections', { postProcess: 'titleCase' })}</Text>\n            </Accordion.Control>\n            <Accordion.Panel>\n                {collections.map((collection) => (\n                    <CollectionRow\n                        collection={collection}\n                        key={collection.id}\n                        onRename={handleRename}\n                    />\n                ))}\n            </Accordion.Panel>\n        </Accordion.Item>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/sidebar/components/sidebar-icon.module.css",
    "content": ".wrapper {\n    display: inline-flex;\n    flex-shrink: 0;\n}\n"
  },
  {
    "path": "src/renderer/features/sidebar/components/sidebar-icon.tsx",
    "content": "import {\n    RiAlbumFill,\n    RiAlbumLine,\n    RiFlag2Fill,\n    RiFlag2Line,\n    RiFolder3Fill,\n    RiFolder3Line,\n    RiHeartFill,\n    RiHeartLine,\n    RiHome6Fill,\n    RiHome6Line,\n    RiMusic2Fill,\n    RiMusic2Line,\n    RiPlayFill,\n    RiPlayLine,\n    RiPlayListFill,\n    RiPlayListLine,\n    RiRadioFill,\n    RiRadioLine,\n    RiSearchFill,\n    RiSearchLine,\n    RiSettings2Fill,\n    RiSettings2Line,\n    RiUserVoiceFill,\n    RiUserVoiceLine,\n} from 'react-icons/ri';\nimport { generatePath, useLocation } from 'react-router';\n\nimport styles from './sidebar-icon.module.css';\n\nimport { AppRoute } from '/@/renderer/router/routes';\nimport { LibraryItem } from '/@/shared/types/domain-types';\n\ninterface SidebarIconProps {\n    active?: boolean;\n    route: string;\n    size?: string;\n}\n\nexport const SidebarIcon = ({ active, route, size }: SidebarIconProps) => {\n    const location = useLocation();\n    const isActive = active !== undefined ? active : location.pathname === route;\n    const renderIcon = () => {\n        switch (route) {\n            case AppRoute.HOME:\n                if (isActive) return <RiHome6Fill size={size} />;\n                return <RiHome6Line size={size} />;\n            case AppRoute.LIBRARY_ALBUM_ARTISTS:\n                if (isActive) return <RiUserVoiceFill size={size} />;\n                return <RiUserVoiceLine size={size} />;\n            case AppRoute.LIBRARY_ALBUMS:\n                if (isActive) return <RiAlbumFill size={size} />;\n                return <RiAlbumLine size={size} />;\n            case AppRoute.LIBRARY_ARTISTS:\n                if (isActive) return <RiUserVoiceFill size={size} />;\n                return <RiUserVoiceLine size={size} />;\n            case AppRoute.LIBRARY_FOLDERS:\n                if (isActive) return <RiFolder3Fill size={size} />;\n                return <RiFolder3Line size={size} />;\n            case AppRoute.LIBRARY_GENRES:\n                if (isActive) return <RiFlag2Fill size={size} />;\n                return <RiFlag2Line size={size} />;\n            case AppRoute.LIBRARY_SONGS:\n                if (isActive) return <RiMusic2Fill size={size} />;\n                return <RiMusic2Line size={size} />;\n            case AppRoute.NOW_PLAYING:\n                if (isActive) return <RiPlayFill size={size} />;\n                return <RiPlayLine size={size} />;\n            case AppRoute.PLAYLISTS:\n                if (isActive) return <RiPlayListFill size={size} />;\n                return <RiPlayListLine size={size} />;\n            case AppRoute.RADIO:\n                if (isActive) return <RiRadioFill size={size} />;\n                return <RiRadioLine size={size} />;\n            case AppRoute.SETTINGS:\n                if (isActive) return <RiSettings2Fill size={size} />;\n                return <RiSettings2Line size={size} />;\n            case generatePath(AppRoute.SEARCH, { itemType: LibraryItem.SONG }):\n                if (isActive) return <RiSearchFill size={size} />;\n                return <RiSearchLine size={size} />;\n            default:\n                if (route.startsWith(AppRoute.FAVORITES)) {\n                    if (isActive) return <RiHeartFill size={size} />;\n                    return <RiHeartLine size={size} />;\n                }\n                return <RiHome6Line size={size} />;\n        }\n    };\n    return <span className={styles.wrapper}>{renderIcon()}</span>;\n};\n"
  },
  {
    "path": "src/renderer/features/sidebar/components/sidebar-item.module.css",
    "content": ".item {\n    width: 100%;\n    font-family: var(--theme-content-font-family);\n    font-size: var(--theme-font-size-md);\n    font-weight: 600;\n\n    &:focus-visible {\n        border: 1px solid var(--theme-colors-primary-filled);\n    }\n}\n\n.root {\n    padding-right: var(--theme-spacing-md);\n    padding-left: var(--theme-spacing-md);\n    cursor: default;\n}\n\n.inner {\n    display: flex;\n    justify-content: flex-start;\n    width: 100%;\n}\n\n.label {\n    display: block;\n    align-content: center;\n    overflow: hidden;\n    text-overflow: ellipsis;\n    font-weight: 500;\n    white-space: nowrap;\n}\n\n.link {\n    display: flex;\n    width: 100%;\n    font-size: var(--theme-font-size-md);\n    color: var(--theme-colors-foreground);\n    border: 1px transparent solid;\n    transition: color 0.2s ease-in-out;\n\n    &:focus-visible {\n        border: 1px solid var(--theme-colors-primary-filled);\n    }\n}\n\n.link.active {\n    color: var(--theme-colors-primary-filled);\n}\n\n.link.disabled {\n    pointer-events: none;\n    opacity: 0.6;\n}\n"
  },
  {
    "path": "src/renderer/features/sidebar/components/sidebar-item.tsx",
    "content": "import clsx from 'clsx';\nimport { memo } from 'react';\nimport { Link, LinkProps, useLocation } from 'react-router';\n\nimport styles from './sidebar-item.module.css';\n\nimport { Button, ButtonProps } from '/@/shared/components/button/button';\n\ninterface SidebarItemProps extends ButtonProps {\n    to: LinkProps['to'];\n}\n\nexport const SidebarItem = ({ children, className, to, ...props }: SidebarItemProps) => {\n    const location = useLocation();\n    const toPath = typeof to === 'string' ? to : to.pathname || '';\n    const isActive = location.pathname === toPath;\n\n    const handleLinkDragStart = (e: React.DragEvent<HTMLButtonElement>) => {\n        e.preventDefault();\n        e.stopPropagation();\n    };\n\n    return (\n        <Button\n            className={clsx(\n                {\n                    [styles.active]: isActive,\n                    [styles.disabled]: props.disabled,\n                    [styles.link]: true,\n                    [styles.root]: true,\n                },\n                className,\n            )}\n            classNames={{\n                inner: styles.inner,\n                label: styles.label,\n            }}\n            component={Link}\n            draggable={false}\n            onDragStart={handleLinkDragStart}\n            to={to}\n            variant=\"subtle\"\n            {...props}\n        >\n            {children}\n        </Button>\n    );\n};\n\nexport const MemoizedSidebarItem = memo(SidebarItem);\n"
  },
  {
    "path": "src/renderer/features/sidebar/components/sidebar-playlist-list.module.css",
    "content": "@value label from './sidebar-item.module.css';\n\n.list {\n    padding: var(--theme-spacing-sm) var(--theme-spacing-md);\n}\n\n.row {\n    position: relative;\n    display: flex;\n    width: 100%;\n    cursor: default;\n    border-radius: var(--theme-radius-md);\n}\n\n.row-hover {\n    .metadata {\n        margin-right: 100px;\n    }\n\n    background-color: var(--theme-colors-surface);\n}\n\n.controls {\n    position: absolute;\n    top: 50%;\n    right: var(--theme-spacing-xs);\n    padding: var(--theme-spacing-md);\n    background: var(--theme-colors-surface);\n    transform: translateY(-50%);\n}\n\n.row-dragged-over {\n    border-radius: var(--mantine-radius-sm);\n    box-shadow: 0 0 0 2px var(--theme-colors-primary);\n    opacity: 0.8;\n}\n\n.row-group {\n    display: flex;\n    gap: var(--theme-spacing-md);\n    align-items: center;\n    width: 100%;\n    height: 100%;\n    padding: var(--theme-spacing-xs) var(--theme-spacing-md);\n}\n\n.metadata {\n    display: flex;\n    flex: 1;\n    flex-direction: column;\n    gap: var(--theme-spacing-xs);\n    min-width: 0;\n    overflow: hidden;\n}\n\n.metadata-group {\n    display: flex;\n    flex-wrap: nowrap;\n    gap: var(--theme-spacing-md);\n    align-items: center;\n    min-width: 0;\n    overflow: hidden;\n    text-overflow: ellipsis;\n}\n\n.metadata-group-item {\n    display: flex;\n    flex-shrink: 1;\n    flex-wrap: nowrap;\n    gap: var(--theme-spacing-xs);\n    align-items: center;\n    min-width: 0;\n    overflow: hidden;\n    white-space: nowrap;\n}\n\n.metadata-group-item-no-shrink {\n    flex-shrink: 0;\n}\n\n.metadata-group-item > * {\n    flex-shrink: 0;\n}\n\n.metadata-group-item > *:last-child {\n    flex-shrink: 1;\n    min-width: 0;\n    overflow: hidden;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n}\n\n.metadata-group-item-no-shrink > *:last-child {\n    flex-shrink: 0;\n    overflow: visible;\n    text-overflow: clip;\n}\n\n.name {\n    overflow: hidden;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n}\n\n.image-container {\n    flex-shrink: 0;\n    width: 3rem;\n    min-width: 3rem;\n    height: 3rem;\n}\n"
  },
  {
    "path": "src/renderer/features/sidebar/components/sidebar-playlist-list.tsx",
    "content": "import { openContextModal } from '@mantine/modals';\nimport { useQuery } from '@tanstack/react-query';\nimport clsx from 'clsx';\nimport { memo, MouseEvent, useCallback, useMemo, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { generatePath, Link } from 'react-router';\n\nimport styles from './sidebar-playlist-list.module.css';\n\nimport { useItemImageUrl } from '/@/renderer/components/item-image/item-image';\nimport { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';\nimport { usePlayer } from '/@/renderer/features/player/context/player-context';\nimport { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';\nimport { openCreatePlaylistModal } from '/@/renderer/features/playlists/components/create-playlist-form';\nimport {\n    LONG_PRESS_PLAY_BEHAVIOR,\n    PlayTooltip,\n} from '/@/renderer/features/shared/components/play-button-group';\nimport { usePlayButtonClick } from '/@/renderer/features/shared/hooks/use-play-button-click';\nimport { useDragDrop } from '/@/renderer/hooks/use-drag-drop';\nimport { AppRoute } from '/@/renderer/router/routes';\nimport {\n    useCurrentServer,\n    useCurrentServerId,\n    usePermissions,\n    useSidebarPlaylistListFilterRegex,\n    useSidebarPlaylistSorting,\n} from '/@/renderer/store';\nimport { formatDurationString } from '/@/renderer/utils';\nimport { Accordion } from '/@/shared/components/accordion/accordion';\nimport { ActionIcon, ActionIconGroup } from '/@/shared/components/action-icon/action-icon';\nimport { ButtonProps } from '/@/shared/components/button/button';\nimport { Group } from '/@/shared/components/group/group';\nimport { Icon } from '/@/shared/components/icon/icon';\nimport { Image } from '/@/shared/components/image/image';\nimport { Text } from '/@/shared/components/text/text';\nimport { useLocalStorage } from '/@/shared/hooks/use-local-storage';\nimport {\n    LibraryItem,\n    Playlist,\n    PlaylistListSort,\n    Song,\n    SortOrder,\n} from '/@/shared/types/domain-types';\nimport { DragOperation, DragTarget } from '/@/shared/types/drag-and-drop';\nimport { Play } from '/@/shared/types/types';\n\nconst getPlaylistOrderKey = (serverId: string | undefined, scope: 'owned' | 'shared') => {\n    const sid = serverId || 'local';\n    return `playlist_order:${sid}:${scope}`;\n};\n\ninterface PlaylistRowButtonProps extends Omit<ButtonProps, 'onContextMenu' | 'onPlay'> {\n    item: Playlist;\n    name: string;\n    onContextMenu: (e: MouseEvent<HTMLAnchorElement>, item: Playlist) => void;\n    onReorder?: (sourceIds: string[], targetId: string, edge: 'bottom' | 'top' | null) => void;\n    to: string;\n}\n\nconst PlaylistRowButton = memo(\n    ({ item, name, onContextMenu, onReorder, to }: PlaylistRowButtonProps) => {\n        const url = {\n            pathname: generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId: to }),\n            state: { item },\n        };\n        const { t } = useTranslation();\n        const sidebarPlaylistSorting = useSidebarPlaylistSorting();\n\n        const [isHovered, setIsHovered] = useState(false);\n\n        const { isDraggedOver, isDragging, ref } = useDragDrop<HTMLAnchorElement>({\n            drag: {\n                getId: () => {\n                    return item && item.id ? [item.id] : [];\n                },\n                getItem: () => {\n                    return item ? [item] : [];\n                },\n                itemType: LibraryItem.PLAYLIST,\n                operation: [DragOperation.ADD, DragOperation.REORDER],\n                target: DragTarget.PLAYLIST,\n            },\n            drop: {\n                canDrop: (args) => {\n                    // Allow dropping items into a playlist (ADD)\n                    const canAdd =\n                        args.source.itemType !== undefined &&\n                        args.source.type !== DragTarget.PLAYLIST &&\n                        (args.source.operation?.includes(DragOperation.ADD) ?? false);\n\n                    // Allow reordering playlists when source is playlist and operation includes REORDER\n                    // do not allow cross-scope reorders\n                    const canReorder =\n                        args.source.itemType === LibraryItem.PLAYLIST &&\n                        args.source.type === DragTarget.PLAYLIST &&\n                        (args.source.operation?.includes(DragOperation.REORDER) ?? false);\n                    return canAdd || (canReorder && sidebarPlaylistSorting);\n                },\n                getData: () => {\n                    return {\n                        id: [to],\n                        item: [],\n                        itemType: LibraryItem.PLAYLIST,\n                        type: DragTarget.PLAYLIST,\n                    };\n                },\n                onDrag: () => {\n                    return;\n                },\n                onDragLeave: () => {\n                    return;\n                },\n                onDrop: (args) => {\n                    const sourceItemType = args.source.itemType as LibraryItem;\n                    const sourceIds = args.source.id;\n\n                    // Handle playlist reordering locally\n                    if (\n                        sourceItemType === LibraryItem.PLAYLIST &&\n                        (args.source.operation?.includes(DragOperation.REORDER) ?? false) &&\n                        args.edge &&\n                        (args.edge === 'top' || args.edge === 'bottom') &&\n                        onReorder\n                    ) {\n                        const sourceItems = Array.isArray(args.source.item)\n                            ? (args.source.item as Playlist[])\n                            : undefined;\n\n                        // Prevent cross-scope reorders (owned <-> shared)\n                        if (sourceItems && sourceItems.length > 0) {\n                            if (sourceItems.some((si) => si.ownerId !== item.ownerId)) {\n                                return;\n                            }\n                        }\n\n                        onReorder(sourceIds, to, args.edge);\n                        return;\n                    }\n\n                    const modalProps: {\n                        albumId?: string[];\n                        artistId?: string[];\n                        folderId?: string[];\n                        genreId?: string[];\n                        initialSelectedIds?: string[];\n                        playlistId?: string[];\n                        songId?: string[];\n                    } = {\n                        initialSelectedIds: [to],\n                    };\n\n                    switch (sourceItemType) {\n                        case LibraryItem.ALBUM:\n                            modalProps.albumId = sourceIds;\n                            break;\n                        case LibraryItem.ALBUM_ARTIST:\n                        case LibraryItem.ARTIST:\n                            modalProps.artistId = sourceIds;\n                            break;\n                        case LibraryItem.FOLDER:\n                            modalProps.folderId = sourceIds;\n                            break;\n                        case LibraryItem.GENRE:\n                            modalProps.genreId = sourceIds;\n                            break;\n                        case LibraryItem.PLAYLIST:\n                            modalProps.playlistId = sourceIds;\n                            break;\n                        case LibraryItem.PLAYLIST_SONG:\n                        case LibraryItem.QUEUE_SONG:\n                        case LibraryItem.SONG:\n                            if (args.source.item && Array.isArray(args.source.item)) {\n                                const songs = args.source.item as Song[];\n                                modalProps.songId = songs.map((song) => song.id);\n                            } else {\n                                modalProps.songId = sourceIds;\n                            }\n                            break;\n                        default:\n                            return;\n                    }\n\n                    openContextModal({\n                        innerProps: modalProps,\n                        modalKey: 'addToPlaylist',\n                        size: 'lg',\n                        title: t('form.addToPlaylist.title', { postProcess: 'titleCase' }),\n                    });\n                },\n            },\n            isEnabled: true,\n        });\n\n        const player = usePlayer();\n        const serverId = useCurrentServerId();\n\n        const permissions = usePermissions();\n\n        const handlePlay = useCallback(\n            (id: string, type: Play) => {\n                player.addToQueueByFetch(serverId, [id], LibraryItem.PLAYLIST, type);\n            },\n            [player, serverId],\n        );\n\n        const imageUrl = useItemImageUrl({\n            id: item.imageId || undefined,\n            itemType: LibraryItem.PLAYLIST,\n            type: 'table',\n        });\n\n        return (\n            <Link\n                className={clsx(styles.row, {\n                    [styles.rowDraggedOver]: isDraggedOver,\n                    [styles.rowHover]: isHovered,\n                })}\n                onContextMenu={(e: MouseEvent<HTMLAnchorElement>) => {\n                    e.preventDefault();\n                    onContextMenu(e, item);\n                }}\n                onMouseEnter={() => setIsHovered(true)}\n                onMouseLeave={() => setIsHovered(false)}\n                ref={ref}\n                style={{\n                    opacity: isDragging ? 0.5 : 1,\n                }}\n                to={url}\n            >\n                <div className={styles.rowGroup}>\n                    <Image containerClassName={styles.imageContainer} src={imageUrl} />\n                    <div className={styles.metadata}>\n                        <Text className={styles.name} fw={500} size=\"md\">\n                            {name}\n                        </Text>\n                        <div className={styles.metadataGroup}>\n                            <div\n                                className={clsx(\n                                    styles.metadataGroupItem,\n                                    styles.metadataGroupItemNoShrink,\n                                )}\n                            >\n                                <Icon color=\"muted\" icon=\"itemSong\" size=\"sm\" />\n                                <Text isMuted size=\"sm\">\n                                    {item.songCount || 0}\n                                </Text>\n                            </div>\n                            <div className={styles.metadataGroupItem}>\n                                <Icon color=\"muted\" icon=\"duration\" size=\"sm\" />\n                                <Text isMuted size=\"sm\">\n                                    {formatDurationString(item.duration ?? 0)}\n                                </Text>\n                            </div>\n                            {item.ownerId === permissions.userId && Boolean(item.public) && (\n                                <div className={styles.metadataGroupItem}>\n                                    <Text isMuted size=\"sm\">\n                                        {t('common.public', { postProcess: 'titleCase' })}\n                                    </Text>\n                                </div>\n                            )}\n                            {item.ownerId !== permissions.userId && (\n                                <div className={styles.metadataGroupItem}>\n                                    <Icon color=\"muted\" icon=\"user\" size=\"sm\" />\n                                    <Text isMuted size=\"sm\">\n                                        {item.owner}\n                                    </Text>\n                                </div>\n                            )}\n                        </div>\n                    </div>\n                </div>\n\n                {isHovered && <RowControls id={to} onPlay={handlePlay} />}\n            </Link>\n        );\n    },\n);\n\nconst RowControls = ({\n    id,\n    onPlay,\n}: {\n    id: string;\n    onPlay: (id: string, playType: Play) => void;\n}) => {\n    const handlePlayNext = usePlayButtonClick({\n        onClick: () => {\n            onPlay(id, Play.NEXT);\n        },\n        onLongPress: () => {\n            onPlay(id, LONG_PRESS_PLAY_BEHAVIOR[Play.NEXT]);\n        },\n    });\n\n    const handlePlayNow = usePlayButtonClick({\n        onClick: () => {\n            onPlay(id, Play.NOW);\n        },\n        onLongPress: () => {\n            onPlay(id, LONG_PRESS_PLAY_BEHAVIOR[Play.NOW]);\n        },\n    });\n\n    const handlePlayLast = usePlayButtonClick({\n        onClick: () => {\n            onPlay(id, Play.LAST);\n        },\n        onLongPress: () => {\n            onPlay(id, LONG_PRESS_PLAY_BEHAVIOR[Play.LAST]);\n        },\n    });\n\n    return (\n        <ActionIconGroup className={styles.controls}>\n            <PlayTooltip type={Play.NOW}>\n                <ActionIcon\n                    icon=\"mediaPlay\"\n                    iconProps={{\n                        size: 'md',\n                    }}\n                    size=\"xs\"\n                    variant=\"subtle\"\n                    {...handlePlayNow.handlers}\n                    {...handlePlayNow.props}\n                />\n            </PlayTooltip>\n            <PlayTooltip type={Play.NEXT}>\n                <ActionIcon\n                    icon=\"mediaPlayNext\"\n                    iconProps={{\n                        size: 'md',\n                    }}\n                    size=\"xs\"\n                    variant=\"subtle\"\n                    {...handlePlayNext.handlers}\n                    {...handlePlayNext.props}\n                />\n            </PlayTooltip>\n            <PlayTooltip type={Play.LAST}>\n                <ActionIcon\n                    icon=\"mediaPlayLast\"\n                    iconProps={{\n                        size: 'md',\n                    }}\n                    size=\"xs\"\n                    variant=\"subtle\"\n                    {...handlePlayLast.handlers}\n                    {...handlePlayLast.props}\n                />\n            </PlayTooltip>\n        </ActionIconGroup>\n    );\n};\n\nexport const SidebarPlaylistList = () => {\n    const player = usePlayer();\n    const { t } = useTranslation();\n    const server = useCurrentServer();\n    const sidebarPlaylistSorting = useSidebarPlaylistSorting();\n    const filterRegex = useSidebarPlaylistListFilterRegex();\n\n    const playlistsQuery = useQuery(\n        playlistsQueries.list({\n            query: {\n                sortBy: PlaylistListSort.NAME,\n                sortOrder: SortOrder.ASC,\n                startIndex: 0,\n            },\n            serverId: server?.id,\n        }),\n    );\n\n    const handlePlayPlaylist = useCallback(\n        (id: string, playType: Play) => {\n            player.addToQueueByFetch(server.id, [id], LibraryItem.PLAYLIST, playType);\n        },\n        [player, server.id],\n    );\n\n    const handleContextMenu = useCallback(\n        (e: MouseEvent<HTMLAnchorElement>, playlist: Playlist) => {\n            e.preventDefault();\n            e.stopPropagation();\n            ContextMenuController.call({\n                cmd: { items: [playlist], type: LibraryItem.PLAYLIST },\n                event: e,\n            });\n        },\n        [],\n    );\n\n    const [playlistOrder, setPlaylistOrder] = useLocalStorage<string[]>({\n        defaultValue: [],\n        key: getPlaylistOrderKey(server.id, 'owned'),\n    });\n\n    const playlistItems = useMemo(() => {\n        const base = { handlePlay: handlePlayPlaylist };\n\n        if (!server?.type || !server?.username || !playlistsQuery.data?.items) {\n            return { ...base, items: playlistsQuery.data?.items };\n        }\n\n        let regex: null | RegExp = null;\n        if (filterRegex) {\n            try {\n                regex = new RegExp(filterRegex, 'i');\n            } catch {\n                // Invalid regex, ignore filtering\n            }\n        }\n\n        const ownedPlaylistItems: Array<Playlist> = [];\n\n        for (const playlist of playlistsQuery.data?.items ?? []) {\n            if (!playlist.owner || playlist.owner === server.username) {\n                // Filter out playlists that match the regex\n                if (regex && regex.test(playlist.name)) {\n                    continue;\n                }\n                ownedPlaylistItems.push(playlist);\n            }\n        }\n\n        if (!ownedPlaylistItems || !sidebarPlaylistSorting || !playlistOrder) {\n            return { ...base, items: ownedPlaylistItems };\n        }\n\n        // Apply saved order, include only playlists that still exist\n        const idMap = new Map(ownedPlaylistItems.map((it) => [it.id, it]));\n        const ordered = playlistOrder\n            .map((id) => idMap.get(id))\n            .filter((it): it is Playlist => it !== undefined);\n\n        // Append any new items that weren't in saved order\n        const remaining = ownedPlaylistItems.filter((it) => !playlistOrder.includes(it.id));\n        const newPlaylistItems = [...ordered, ...remaining];\n        return { ...base, items: newPlaylistItems };\n    }, [\n        handlePlayPlaylist,\n        playlistsQuery.data?.items,\n        server.type,\n        server.username,\n        sidebarPlaylistSorting,\n        playlistOrder,\n        filterRegex,\n    ]);\n\n    const handleReorder = (\n        sourceIds: string[],\n        targetId: string,\n        edge: 'bottom' | 'top' | null,\n    ) => {\n        if (!playlistItems?.items || !edge) return;\n\n        const currentIds = playlistItems.items.map((p) => p.id);\n        const targetIndex = currentIds.indexOf(targetId);\n        if (targetIndex === -1) return;\n\n        const idsWithoutSources = currentIds.filter((id) => !sourceIds.includes(id));\n\n        const sourcesBeforeTarget = sourceIds.filter((id) => {\n            const sourceIndex = currentIds.indexOf(id);\n            return sourceIndex !== -1 && sourceIndex < targetIndex;\n        }).length;\n\n        const insertIndexInFiltered =\n            edge === 'top'\n                ? targetIndex - sourcesBeforeTarget\n                : targetIndex - sourcesBeforeTarget + 1;\n\n        const insertIndex = Math.max(0, Math.min(insertIndexInFiltered, idsWithoutSources.length));\n\n        const reorderedIds = [\n            ...idsWithoutSources.slice(0, insertIndex),\n            ...sourceIds,\n            ...idsWithoutSources.slice(insertIndex),\n        ];\n\n        setPlaylistOrder(reorderedIds);\n    };\n\n    const handleCreatePlaylistModal = (e: MouseEvent<HTMLButtonElement>) => {\n        openCreatePlaylistModal(server, e);\n    };\n\n    return (\n        <Accordion.Item value=\"playlists\">\n            <Accordion.Control component=\"div\" role=\"button\" style={{ userSelect: 'none' }}>\n                <Group justify=\"space-between\" pr=\"var(--theme-spacing-md)\">\n                    <Text fw={500}>\n                        {t('page.sidebar.playlists', {\n                            postProcess: 'titleCase',\n                        })}\n                    </Text>\n                    <Group gap=\"xs\">\n                        <ActionIcon\n                            icon=\"add\"\n                            iconProps={{\n                                size: 'lg',\n                            }}\n                            onClick={handleCreatePlaylistModal}\n                            size=\"xs\"\n                            tooltip={{\n                                label: t('action.createPlaylist', {\n                                    postProcess: 'sentenceCase',\n                                }),\n                            }}\n                            variant=\"subtle\"\n                        />\n                        <ActionIcon\n                            component={Link}\n                            icon=\"list\"\n                            iconProps={{\n                                size: 'lg',\n                            }}\n                            onClick={(e) => e.stopPropagation()}\n                            size=\"xs\"\n                            to={AppRoute.PLAYLISTS}\n                            tooltip={{\n                                label: t('action.viewPlaylists', {\n                                    postProcess: 'sentenceCase',\n                                }),\n                            }}\n                            variant=\"subtle\"\n                        />\n                    </Group>\n                </Group>\n            </Accordion.Control>\n            <Accordion.Panel>\n                {playlistItems?.items?.map((item, index) => (\n                    <PlaylistRowButton\n                        item={item}\n                        key={index}\n                        name={item.name}\n                        onContextMenu={handleContextMenu}\n                        onReorder={handleReorder}\n                        to={item.id}\n                    />\n                ))}\n            </Accordion.Panel>\n        </Accordion.Item>\n    );\n};\n\nexport const SidebarSharedPlaylistList = () => {\n    const player = usePlayer();\n    const { t } = useTranslation();\n    const server = useCurrentServer();\n    const sidebarPlaylistSorting = useSidebarPlaylistSorting();\n    const filterRegex = useSidebarPlaylistListFilterRegex();\n\n    const playlistsQuery = useQuery(\n        playlistsQueries.list({\n            query: {\n                sortBy: PlaylistListSort.NAME,\n                sortOrder: SortOrder.ASC,\n                startIndex: 0,\n            },\n            serverId: server?.id,\n        }),\n    );\n\n    const handlePlayPlaylist = useCallback(\n        (id: string, playType: Play) => {\n            if (!server?.id) return;\n            player.addToQueueByFetch(server.id, [id], LibraryItem.PLAYLIST, playType);\n        },\n        [player, server.id],\n    );\n\n    const handleContextMenu = useCallback(\n        (e: MouseEvent<HTMLAnchorElement>, playlist: Playlist) => {\n            e.preventDefault();\n            e.stopPropagation();\n            ContextMenuController.call({\n                cmd: {\n                    items: [playlist],\n                    type: LibraryItem.PLAYLIST,\n                },\n                event: e,\n            });\n        },\n        [],\n    );\n\n    const [playlistOrder, setPlaylistOrder] = useLocalStorage<string[]>({\n        defaultValue: [],\n        key: getPlaylistOrderKey(server.id, 'shared'),\n    });\n\n    const playlistItems = useMemo(() => {\n        const base = { handlePlay: handlePlayPlaylist };\n\n        if (!server?.type || !server?.username || !playlistsQuery.data?.items) {\n            return { ...base, items: playlistsQuery.data?.items };\n        }\n\n        let regex: null | RegExp = null;\n        if (filterRegex) {\n            try {\n                regex = new RegExp(filterRegex, 'i');\n            } catch {\n                // Invalid regex, ignore filtering\n            }\n        }\n\n        const sharedPlaylistItems: Array<Playlist> = [];\n\n        for (const playlist of playlistsQuery.data?.items ?? []) {\n            if (playlist.owner && playlist.owner !== server.username) {\n                // Filter out playlists that match the regex\n                if (regex && regex.test(playlist.name)) {\n                    continue;\n                }\n                sharedPlaylistItems.push(playlist);\n            }\n        }\n\n        if (!sharedPlaylistItems || !sidebarPlaylistSorting || !playlistOrder) {\n            return { ...base, items: sharedPlaylistItems };\n        }\n\n        // Apply saved order, include only playlists that still exist\n        const idMap = new Map(sharedPlaylistItems.map((it) => [it.id, it]));\n        const ordered = playlistOrder\n            .map((id) => idMap.get(id))\n            .filter((it): it is Playlist => it !== undefined);\n\n        // Append any new items that weren't in saved order\n        const remaining = sharedPlaylistItems.filter((it) => !playlistOrder.includes(it.id));\n        const newPlaylistItems = [...ordered, ...remaining];\n        return { ...base, items: newPlaylistItems };\n    }, [\n        handlePlayPlaylist,\n        playlistsQuery.data?.items,\n        server.type,\n        server.username,\n        sidebarPlaylistSorting,\n        playlistOrder,\n        filterRegex,\n    ]);\n\n    const handleReorder = (\n        sourceIds: string[],\n        targetId: string,\n        edge: 'bottom' | 'top' | null,\n    ) => {\n        if (!playlistItems?.items || !edge) return;\n\n        const currentIds = playlistItems.items.map((p) => p.id);\n        const targetIndex = currentIds.indexOf(targetId);\n        if (targetIndex === -1) return;\n\n        const idsWithoutSources = currentIds.filter((id) => !sourceIds.includes(id));\n\n        const sourcesBeforeTarget = sourceIds.filter((id) => {\n            const sourceIndex = currentIds.indexOf(id);\n            return sourceIndex !== -1 && sourceIndex < targetIndex;\n        }).length;\n\n        const insertIndexInFiltered =\n            edge === 'top'\n                ? targetIndex - sourcesBeforeTarget\n                : targetIndex - sourcesBeforeTarget + 1;\n\n        const insertIndex = Math.max(0, Math.min(insertIndexInFiltered, idsWithoutSources.length));\n\n        const reorderedIds = [\n            ...idsWithoutSources.slice(0, insertIndex),\n            ...sourceIds,\n            ...idsWithoutSources.slice(insertIndex),\n        ];\n\n        setPlaylistOrder(reorderedIds);\n    };\n\n    if (playlistItems?.items?.length === 0) {\n        return null;\n    }\n\n    return (\n        <Accordion.Item value=\"shared-playlists\">\n            <Accordion.Control>\n                <Text fw={500} variant=\"secondary\">\n                    {t('page.sidebar.shared', {\n                        postProcess: 'titleCase',\n                    })}\n                </Text>\n            </Accordion.Control>\n            <Accordion.Panel>\n                {playlistItems?.items?.map((item, index) => (\n                    <PlaylistRowButton\n                        item={item}\n                        key={index}\n                        name={item.name}\n                        onContextMenu={handleContextMenu}\n                        onReorder={handleReorder}\n                        to={item.id}\n                    />\n                ))}\n            </Accordion.Panel>\n        </Accordion.Item>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/sidebar/components/sidebar.module.css",
    "content": ".container {\n    display: flex;\n    flex-direction: column;\n    gap: var(--mantine-spacing-xs);\n    width: 100%;\n    height: 100%;\n    max-height: calc(100vh - 90px);\n    background: var(--theme-colors-background-alternate);\n}\n\n.container.custom-bar {\n    max-height: calc(100vh - 120px);\n}\n\n.scroll-area {\n    flex: 1;\n    min-height: 0;\n    padding: 0 var(--theme-spacing-md) var(--theme-spacing-md) var(--theme-spacing-md);\n}\n\n@keyframes fade-in {\n    from {\n        opacity: 0;\n    }\n\n    to {\n        opacity: 1;\n    }\n}\n\n.image-container {\n    position: relative;\n    flex-shrink: 0;\n    width: var(--sidebar-image-height);\n    height: var(--sidebar-image-height);\n    padding: var(--theme-spacing-md);\n    cursor: pointer;\n    animation: fade-in 0.2s ease-in-out;\n\n    button {\n        display: none;\n    }\n\n    &:hover button {\n        display: block;\n    }\n}\n\n.sidebar-image {\n    width: 100%;\n    height: 100%;\n    object-fit: var(--theme-image-fit);\n    border-radius: var(--theme-radius-md);\n}\n\n.censored.sidebar-image {\n    filter: blur(20px);\n}\n\n.accordion-root {\n    height: 100%;\n}\n\n.accordion-item {\n    border-bottom: none;\n}\n\n.accordion-control {\n    height: 2.5rem;\n    border-radius: var(--theme-radius-md);\n\n    &:hover {\n        background: var(--theme-colors-background);\n    }\n}\n\n.accordion-content {\n    padding: 0;\n    background: var(--theme-colors-background-alternate);\n}\n\n.accordion-content:last-child {\n    padding-bottom: var(--theme-spacing-md);\n}\n\n.server-selector-wrapper {\n    position: relative;\n    z-index: 1;\n    flex-shrink: 0;\n}\n"
  },
  {
    "path": "src/renderer/features/sidebar/components/sidebar.tsx",
    "content": "import clsx from 'clsx';\nimport { AnimatePresence, motion } from 'motion/react';\nimport { CSSProperties, MouseEvent, useMemo } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport styles from './sidebar.module.css';\n\nimport { useItemImageUrl } from '/@/renderer/components/item-image/item-image';\nimport { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';\nimport {\n    useIsRadioActive,\n    useRadioPlayer,\n} from '/@/renderer/features/radio/hooks/use-radio-player';\nimport { ActionBar } from '/@/renderer/features/sidebar/components/action-bar';\nimport { ServerSelector } from '/@/renderer/features/sidebar/components/server-selector';\nimport { SidebarCollectionList } from '/@/renderer/features/sidebar/components/sidebar-collection-list';\nimport { SidebarIcon } from '/@/renderer/features/sidebar/components/sidebar-icon';\nimport { SidebarItem } from '/@/renderer/features/sidebar/components/sidebar-item';\nimport {\n    SidebarPlaylistList,\n    SidebarSharedPlaylistList,\n} from '/@/renderer/features/sidebar/components/sidebar-playlist-list';\nimport {\n    useAppStore,\n    useAppStoreActions,\n    useFullScreenPlayerStore,\n    useGeneralSettings,\n    usePlayerSong,\n    useSetFullScreenPlayerStore,\n} from '/@/renderer/store';\nimport {\n    SidebarItemType,\n    useSidebarItems,\n    useSidebarPlaylistList,\n    useWindowSettings,\n} from '/@/renderer/store/settings.store';\nimport { Accordion } from '/@/shared/components/accordion/accordion';\nimport { ActionIcon } from '/@/shared/components/action-icon/action-icon';\nimport { Center } from '/@/shared/components/center/center';\nimport { Group } from '/@/shared/components/group/group';\nimport { Icon } from '/@/shared/components/icon/icon';\nimport { ImageUnloader } from '/@/shared/components/image/image';\nimport { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';\nimport { Text } from '/@/shared/components/text/text';\nimport { Tooltip } from '/@/shared/components/tooltip/tooltip';\nimport { ExplicitStatus, LibraryItem } from '/@/shared/types/domain-types';\nimport { Platform } from '/@/shared/types/types';\n\nexport const Sidebar = () => {\n    const { t } = useTranslation();\n\n    const sidebarPlaylistList = useSidebarPlaylistList();\n\n    const translatedSidebarItemMap = useMemo(\n        () => ({\n            Albums: t('page.sidebar.albums', { postProcess: 'titleCase' }),\n            Artists: t('page.sidebar.albumArtists', { postProcess: 'titleCase' }),\n            'Artists-all': t('page.sidebar.artists', { postProcess: 'titleCase' }),\n            Collections: t('page.sidebar.collections', { postProcess: 'titleCase' }),\n            Favorites: t('page.sidebar.favorites', { postProcess: 'titleCase' }),\n            Folders: t('page.sidebar.folders', { postProcess: 'titleCase' }),\n            Genres: t('page.sidebar.genres', { postProcess: 'titleCase' }),\n            Home: t('page.sidebar.home', { postProcess: 'titleCase' }),\n            'Now Playing': t('page.sidebar.nowPlaying', { postProcess: 'titleCase' }),\n            Playlists: t('page.sidebar.playlists', { postProcess: 'titleCase' }),\n            Radio: t('page.sidebar.radio', { postProcess: 'titleCase' }),\n            Search: t('page.sidebar.search', { postProcess: 'titleCase' }),\n            Settings: t('page.sidebar.settings', { postProcess: 'titleCase' }),\n            Tracks: t('page.sidebar.tracks', { postProcess: 'titleCase' }),\n        }),\n        [t],\n    );\n\n    const sidebarItems = useSidebarItems();\n    const { windowBarStyle } = useWindowSettings();\n    const sidebarImageEnabled = useAppStore((state) => state.sidebar.image);\n    const showImage = sidebarImageEnabled;\n\n    const sidebarItemsWithRoute: SidebarItemType[] = useMemo(() => {\n        if (!sidebarItems) return [];\n\n        const items = sidebarItems\n            .filter((item) => !item.disabled)\n            .map((item) => ({\n                ...item,\n                label:\n                    translatedSidebarItemMap[item.id as keyof typeof translatedSidebarItemMap] ??\n                    item.label,\n            }));\n\n        return items;\n    }, [sidebarItems, translatedSidebarItemMap]);\n\n    /* Library accordion: only items with a route (exclude Collections section) */\n    const libraryItemsWithRoute = useMemo(\n        () => sidebarItemsWithRoute.filter((item) => item.id !== 'Collections' && item.route),\n        [sidebarItemsWithRoute],\n    );\n\n    const isCustomWindowBar =\n        windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS;\n\n    return (\n        <div\n            className={clsx(styles.container, {\n                [styles.customBar]: isCustomWindowBar,\n            })}\n            id=\"left-sidebar\"\n        >\n            <Group grow id=\"global-search-container\" style={{ flexShrink: 0 }}>\n                <ActionBar />\n            </Group>\n            <ScrollArea allowDragScroll className={styles.scrollArea}>\n                <Accordion\n                    classNames={{\n                        content: styles.accordionContent,\n                        control: styles.accordionControl,\n                        item: styles.accordionItem,\n                        root: styles.accordionRoot,\n                    }}\n                    defaultValue={['library', 'collections', 'playlists']}\n                    multiple\n                >\n                    <Accordion.Item value=\"library\">\n                        <Accordion.Control>\n                            <Text fw={500} variant=\"secondary\">\n                                {t('page.sidebar.myLibrary', {\n                                    postProcess: 'titleCase',\n                                })}\n                            </Text>\n                        </Accordion.Control>\n                        <Accordion.Panel>\n                            {libraryItemsWithRoute.map((item) => {\n                                return (\n                                    <SidebarItem key={`sidebar-${item.route}`} to={item.route}>\n                                        <Group gap=\"md\">\n                                            <SidebarIcon route={item.route} />\n                                            {item.label}\n                                        </Group>\n                                    </SidebarItem>\n                                );\n                            })}\n                        </Accordion.Panel>\n                    </Accordion.Item>\n                    <SidebarCollectionList />\n                    {sidebarPlaylistList && (\n                        <>\n                            <SidebarPlaylistList />\n                            <SidebarSharedPlaylistList />\n                        </>\n                    )}\n                </Accordion>\n            </ScrollArea>\n            <AnimatePresence initial={false} mode=\"popLayout\">\n                <motion.div className={styles.serverSelectorWrapper} key=\"server-selector\" layout>\n                    <ServerSelector />\n                </motion.div>\n                {showImage && <SidebarImage />}\n            </AnimatePresence>\n        </div>\n    );\n};\n\nconst SidebarImage = () => {\n    const { t } = useTranslation();\n    const leftWidth = useAppStore((state) => state.sidebar.leftWidth);\n    const { setSideBar } = useAppStoreActions();\n    const currentSong = usePlayerSong();\n    const isRadioActive = useIsRadioActive();\n    const { isPlaying: isRadioPlaying } = useRadioPlayer();\n    const { blurExplicitImages } = useGeneralSettings();\n\n    const imageUrl = useItemImageUrl({\n        id: currentSong?.imageId || undefined,\n        itemType: LibraryItem.SONG,\n        serverId: currentSong?._serverId,\n        type: 'sidebar',\n    });\n\n    const isPlayingRadio = isRadioActive && isRadioPlaying;\n    const isSongDefined = Boolean(currentSong?.id);\n\n    const setFullScreenPlayerStore = useSetFullScreenPlayerStore();\n    const { expanded: isFullScreenPlayerExpanded } = useFullScreenPlayerStore();\n    const expandFullScreenPlayer = () => {\n        setFullScreenPlayerStore({ expanded: !isFullScreenPlayerExpanded });\n    };\n\n    const handleToggleContextMenu = (e: MouseEvent<HTMLDivElement>) => {\n        e.preventDefault();\n        e.stopPropagation();\n\n        if (!currentSong || isPlayingRadio) {\n            return;\n        }\n\n        if (isSongDefined && !isFullScreenPlayerExpanded) {\n            ContextMenuController.call({\n                cmd: { items: [currentSong!], type: LibraryItem.SONG },\n                event: e,\n            });\n        }\n    };\n\n    return (\n        <motion.div\n            animate={{ opacity: 1, y: 0 }}\n            className={styles.imageContainer}\n            exit={{ opacity: 0, y: 200 }}\n            initial={{ opacity: 0, y: 200 }}\n            key=\"sidebar-image\"\n            onClick={expandFullScreenPlayer}\n            onContextMenu={handleToggleContextMenu}\n            role=\"button\"\n            style={\n                {\n                    '--sidebar-image-height': leftWidth,\n                } as CSSProperties\n            }\n            transition={{ duration: 0.3, ease: 'easeInOut' }}\n        >\n            <Tooltip\n                label={t('player.toggleFullscreenPlayer', {\n                    postProcess: 'sentenceCase',\n                })}\n            >\n                {isPlayingRadio ? (\n                    <Center\n                        className={styles.sidebarImage}\n                        style={{\n                            background: 'var(--theme-colors-surface)',\n                            borderRadius: 'var(--theme-card-default-radius)',\n                            height: '100%',\n                            width: '100%',\n                        }}\n                    >\n                        <Icon color=\"muted\" icon=\"radio\" size=\"40%\" />\n                    </Center>\n                ) : imageUrl ? (\n                    <img\n                        className={clsx(styles.sidebarImage, {\n                            [styles.censored]:\n                                currentSong?.explicitStatus === ExplicitStatus.EXPLICIT &&\n                                blurExplicitImages,\n                        })}\n                        loading=\"eager\"\n                        src={imageUrl}\n                    />\n                ) : (\n                    <ImageUnloader icon=\"emptySongImage\" />\n                )}\n            </Tooltip>\n            <ActionIcon\n                icon=\"arrowDownS\"\n                iconProps={{\n                    size: 'lg',\n                }}\n                onClick={(e) => {\n                    e.stopPropagation();\n                    setSideBar({ image: false });\n                }}\n                opacity={0.8}\n                radius=\"md\"\n                style={{\n                    cursor: 'default',\n                    position: 'absolute',\n                    right: '1rem',\n                    top: '1rem',\n                }}\n                tooltip={{\n                    label: t('common.collapse', {\n                        postProcess: 'titleCase',\n                    }),\n                    openDelay: 500,\n                }}\n            />\n        </motion.div>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/similar-songs/components/similar-songs-list.tsx",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { useMemo } from 'react';\nimport { ErrorBoundary } from 'react-error-boundary';\n\nimport { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder';\nimport { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize';\nimport { ItemTableList } from '/@/renderer/components/item-list/item-table-list/item-table-list';\nimport { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';\nimport { ErrorFallback } from '/@/renderer/features/action-required/components/error-fallback';\nimport { songsQueries } from '/@/renderer/features/songs/api/songs-api';\nimport { useListSettings } from '/@/renderer/store';\nimport { Spinner } from '/@/shared/components/spinner/spinner';\nimport { LibraryItem, Song } from '/@/shared/types/domain-types';\nimport { ItemListKey } from '/@/shared/types/types';\n\nexport type SimilarSongsListProps = {\n    count?: number;\n    fullScreen?: boolean;\n    song: Song;\n};\n\nexport const SimilarSongsList = ({ count, song }: SimilarSongsListProps) => {\n    const songQuery = useQuery(\n        songsQueries.similar({\n            options: {\n                gcTime: 1000 * 60 * 2,\n            },\n            query: {\n                count,\n                songId: song.id,\n            },\n            serverId: song?._serverId,\n        }),\n    );\n\n    const { table } = useListSettings(ItemListKey.FULL_SCREEN);\n    const { table: fullScreenTable } = useListSettings(ItemListKey.FULL_SCREEN);\n\n    const { handleColumnReordered } = useItemListColumnReorder({\n        itemListKey: ItemListKey.FULL_SCREEN,\n    });\n\n    const { handleColumnResized } = useItemListColumnResize({\n        itemListKey: ItemListKey.FULL_SCREEN,\n    });\n\n    const tableData = useMemo(() => {\n        return songQuery.data || [];\n    }, [songQuery.data]);\n\n    if (songQuery.isLoading || songQuery.isRefetching) {\n        return <Spinner container size={25} />;\n    }\n\n    return (\n        <ErrorBoundary FallbackComponent={ErrorFallback}>\n            <ItemTableList\n                autoFitColumns={table?.autoFitColumns}\n                CellComponent={ItemTableListColumn}\n                columns={table?.columns || []}\n                data={tableData}\n                enableAlternateRowColors={fullScreenTable?.enableAlternateRowColors}\n                enableExpansion={false}\n                enableHeader={fullScreenTable?.enableHeader}\n                enableHorizontalBorders={fullScreenTable?.enableHorizontalBorders}\n                enableRowHoverHighlight={fullScreenTable?.enableRowHoverHighlight}\n                enableScrollShadow={false}\n                enableSelection\n                enableSelectionDialog={false}\n                enableVerticalBorders={fullScreenTable?.enableVerticalBorders}\n                itemType={LibraryItem.SONG}\n                onColumnReordered={handleColumnReordered}\n                onColumnResized={handleColumnResized}\n                size={table?.size}\n            />\n        </ErrorBoundary>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/songs/api/songs-api.ts",
    "content": "import { queryOptions } from '@tanstack/react-query';\n\nimport { api } from '/@/renderer/api';\nimport { controller } from '/@/renderer/api/controller';\nimport { queryKeys } from '/@/renderer/api/query-keys';\nimport { getOptimizedListCount } from '/@/renderer/api/utils-list-count';\nimport { QueryHookArgs } from '/@/renderer/lib/react-query';\nimport {\n    AlbumRadioQuery,\n    ArtistRadioQuery,\n    GetQueueQuery,\n    ListCountQuery,\n    RandomSongListQuery,\n    SimilarSongsQuery,\n    SongListQuery,\n} from '/@/shared/types/domain-types';\n\nexport const songsQueries = {\n    albumRadio: (args: QueryHookArgs<AlbumRadioQuery>) => {\n        return queryOptions({\n            queryFn: ({ signal }) => {\n                return api.controller.getAlbumRadio({\n                    apiClientProps: { serverId: args.serverId, signal },\n                    query: {\n                        albumId: args.query.albumId,\n                        count: args.query.count ?? 20,\n                    },\n                });\n            },\n            queryKey: queryKeys.songs.albumRadio(args.serverId, args.query),\n            ...args.options,\n        });\n    },\n    artistRadio: (args: QueryHookArgs<ArtistRadioQuery>) => {\n        return queryOptions({\n            queryFn: ({ signal }) => {\n                return api.controller.getArtistRadio({\n                    apiClientProps: { serverId: args.serverId, signal },\n                    query: {\n                        artistId: args.query.artistId,\n                        count: args.query.count ?? 20,\n                    },\n                });\n            },\n            queryKey: queryKeys.songs.artistRadio(args.serverId, args.query),\n            ...args.options,\n        });\n    },\n    getQueue: (args: QueryHookArgs<GetQueueQuery>) => {\n        return queryOptions({\n            queryFn: ({ signal }) => {\n                return api.controller.getPlayQueue({\n                    apiClientProps: { serverId: args.serverId, signal },\n                });\n            },\n            queryKey: queryKeys.player.fetch({ type: 'queue' }),\n        });\n    },\n    list: (args: QueryHookArgs<SongListQuery>, imageSize?: number) => {\n        return queryOptions({\n            queryFn: ({ signal }) => {\n                return controller.getSongList({\n                    apiClientProps: { serverId: args.serverId, signal },\n                    query: { ...args.query, imageSize },\n                });\n            },\n            queryKey: queryKeys.songs.list(args.serverId, { ...args.query, imageSize }),\n            ...args.options,\n        });\n    },\n    listCount: (args: QueryHookArgs<ListCountQuery<SongListQuery>>) => {\n        return queryOptions({\n            gcTime: 1000 * 60 * 60,\n            queryFn: async ({ client, signal }) => {\n                const optimizedCount = await getOptimizedListCount<\n                    ListCountQuery<SongListQuery>,\n                    SongListQuery,\n                    { totalRecordCount: null | number }\n                >({\n                    client,\n                    listQueryFn: controller.getSongList,\n                    listQueryKeyFn: queryKeys.songs.list,\n                    query: args.query,\n                    serverId: args.serverId,\n                    signal,\n                });\n\n                if (optimizedCount !== null) {\n                    return optimizedCount;\n                }\n\n                return api.controller.getSongListCount({\n                    apiClientProps: { serverId: args.serverId, signal },\n                    query: args.query,\n                });\n            },\n            queryKey: queryKeys.songs.count(\n                args.serverId,\n                Object.keys(args.query).length === 0 ? undefined : args.query,\n            ),\n            staleTime: 1000 * 60 * 60,\n            ...args.options,\n        });\n    },\n    random: (args: QueryHookArgs<RandomSongListQuery>) => {\n        return queryOptions({\n            queryFn: ({ signal }) => {\n                return api.controller.getRandomSongList({\n                    apiClientProps: { serverId: args.serverId, signal },\n                    query: args.query,\n                });\n            },\n            queryKey: queryKeys.songs.randomSongList(args.serverId, args.query),\n            ...args.options,\n        });\n    },\n    similar: (args: QueryHookArgs<SimilarSongsQuery>) => {\n        return queryOptions({\n            queryFn: ({ signal }) => {\n                return api.controller.getSimilarSongs({\n                    apiClientProps: { serverId: args.serverId, signal },\n                    query: {\n                        count: args.query.count ?? 50,\n                        songId: args.query.songId,\n                    },\n                });\n            },\n            queryKey: queryKeys.songs.similar(args.serverId, args.query),\n            ...args.options,\n        });\n    },\n};\n"
  },
  {
    "path": "src/renderer/features/songs/components/jellyfin-song-filters.tsx",
    "content": "import { useQuery, useSuspenseQuery } from '@tanstack/react-query';\nimport { useCallback, useMemo } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport { getItemImageUrl } from '/@/renderer/components/item-image/item-image';\nimport { artistsQueries } from '/@/renderer/features/artists/api/artists-api';\nimport { genresQueries } from '/@/renderer/features/genres/api/genres-api';\nimport {\n    ArtistMultiSelectRow,\n    GenreMultiSelectRow,\n} from '/@/renderer/features/shared/components/multi-select-rows';\nimport { TagFilters } from '/@/renderer/features/shared/components/tag-filter';\nimport { useSongListFilters } from '/@/renderer/features/songs/hooks/use-song-list-filters';\nimport { useCurrentServer } from '/@/renderer/store';\nimport { useAppStore, useAppStoreActions } from '/@/renderer/store/app.store';\nimport { Divider } from '/@/shared/components/divider/divider';\nimport { Group } from '/@/shared/components/group/group';\nimport { VirtualMultiSelect } from '/@/shared/components/multi-select/virtual-multi-select';\nimport { NumberInput } from '/@/shared/components/number-input/number-input';\nimport { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { Text } from '/@/shared/components/text/text';\nimport { YesNoSelect } from '/@/shared/components/yes-no-select/yes-no-select';\nimport { useDebouncedCallback } from '/@/shared/hooks/use-debounced-callback';\nimport {\n    AlbumArtistListSort,\n    GenreListSort,\n    LibraryItem,\n    SortOrder,\n} from '/@/shared/types/domain-types';\n\ninterface JellyfinSongFiltersProps {\n    disableArtistFilter?: boolean;\n    disableGenreFilter?: boolean;\n}\n\nexport const JellyfinSongFilters = ({\n    disableArtistFilter,\n    disableGenreFilter,\n}: JellyfinSongFiltersProps) => {\n    const server = useCurrentServer();\n    const serverId = server.id;\n    const { t } = useTranslation();\n    const { query, setArtistIds, setCustom, setFavorite, setMaxYear, setMinYear } =\n        useSongListFilters();\n\n    // Despite the fact that getTags returns genres, it only returns genre names.\n    // We prefer using IDs, hence the double query\n    const genreListQuery = useQuery(\n        genresQueries.list({\n            options: {\n                gcTime: 1000 * 60 * 2,\n                staleTime: 1000 * 60 * 1,\n            },\n            query: {\n                sortBy: GenreListSort.NAME,\n                sortOrder: SortOrder.ASC,\n                startIndex: 0,\n            },\n            serverId,\n        }),\n    );\n\n    const genreList = useMemo(() => {\n        if (!genreListQuery.data) return [];\n        return genreListQuery.data.items.map((genre) => ({\n            albumCount: genre.albumCount,\n            label: genre.name,\n            songCount: genre.songCount,\n            value: genre.id,\n        }));\n    }, [genreListQuery.data]);\n\n    const albumArtistListQuery = useSuspenseQuery(\n        artistsQueries.albumArtistList({\n            options: {\n                gcTime: 1000 * 60 * 2,\n                staleTime: 1000 * 60 * 1,\n            },\n            query: {\n                sortBy: AlbumArtistListSort.NAME,\n                sortOrder: SortOrder.ASC,\n                startIndex: 0,\n            },\n            serverId,\n        }),\n    );\n\n    const selectableAlbumArtists = useMemo(() => {\n        if (!albumArtistListQuery?.data?.items) return [];\n\n        return albumArtistListQuery?.data?.items?.map((artist) => ({\n            albumCount: artist.albumCount,\n            imageUrl: getItemImageUrl({\n                id: artist.id,\n                itemType: LibraryItem.ARTIST,\n                type: 'table',\n            }),\n            label: artist.name,\n            songCount: artist.songCount,\n            value: artist.id,\n        }));\n    }, [albumArtistListQuery.data?.items]);\n\n    const selectedArtistIds = useMemo(() => query.artistIds || [], [query.artistIds]);\n\n    const selectedGenres = useMemo(() => {\n        return query._custom?.GenreIds?.split(',') || [];\n    }, [query._custom?.GenreIds]);\n\n    const yesNoFilters = [\n        {\n            label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),\n            onChange: (favorite: boolean | undefined) => {\n                setFavorite(favorite ?? null);\n            },\n            value: query.favorite,\n        },\n    ];\n\n    const handleMinYearFilter = useMemo(\n        () => (e: number | string) => {\n            // Handle empty string, null, undefined, or invalid numbers as clearing\n            if (e === '' || e === null || e === undefined || isNaN(Number(e))) {\n                setMinYear(null);\n                return;\n            }\n\n            const year = typeof e === 'number' ? e : Number(e);\n            // If it's a valid number within range, set it; otherwise clear\n            if (!isNaN(year) && isFinite(year) && year >= 1700 && year <= 2300) {\n                setMinYear(year);\n            } else {\n                setMinYear(null);\n            }\n        },\n        [setMinYear],\n    );\n\n    const handleMaxYearFilter = useMemo(\n        () => (e: number | string) => {\n            // Handle empty string, null, undefined, or invalid numbers as clearing\n            if (e === '' || e === null || e === undefined || isNaN(Number(e))) {\n                setMaxYear(null);\n                return;\n            }\n\n            const year = typeof e === 'number' ? e : Number(e);\n            // If it's a valid number within range, set it; otherwise clear\n            if (!isNaN(year) && isFinite(year) && year >= 1700 && year <= 2300) {\n                setMaxYear(year);\n            } else {\n                setMaxYear(null);\n            }\n        },\n        [setMaxYear],\n    );\n\n    const debouncedHandleMinYearFilter = useDebouncedCallback(handleMinYearFilter, 300);\n    const debouncedHandleMaxYearFilter = useDebouncedCallback(handleMaxYearFilter, 300);\n\n    const handleGenresFilter = useCallback(\n        (e: null | string[]) => {\n            setCustom((prev) => {\n                const current = prev ?? {};\n\n                if (!e || e.length === 0) {\n                    // Remove GenreIds and IncludeItemTypes if genres are cleared\n                    const rest = { ...current };\n                    delete rest.GenreIds;\n                    delete rest.IncludeItemTypes;\n                    // Return null if object is empty, otherwise return the rest\n                    return Object.keys(rest).length === 0 ? null : rest;\n                }\n\n                return {\n                    ...current,\n                    GenreIds: e.join(','),\n                    IncludeItemTypes: 'Audio',\n                };\n            });\n        },\n        [setCustom],\n    );\n\n    const artistSelectMode = useAppStore((state) => state.artistSelectMode);\n    const genreSelectMode = useAppStore((state) => state.genreSelectMode);\n    const { setArtistSelectMode, setGenreSelectMode } = useAppStoreActions();\n\n    const handleArtistSelectModeChange = useCallback(\n        (value: string) => {\n            const newMode = value as 'multi' | 'single';\n            setArtistSelectMode(newMode);\n\n            if (newMode === 'single' && selectedArtistIds.length > 1) {\n                setArtistIds([selectedArtistIds[0]]);\n            }\n        },\n        [selectedArtistIds, setArtistIds, setArtistSelectMode],\n    );\n\n    const artistFilterLabel = useMemo(() => {\n        return (\n            <Group gap=\"xs\" justify=\"space-between\" w=\"100%\">\n                <Text fw={500} size=\"sm\">\n                    {t('entity.artist', { count: 2, postProcess: 'sentenceCase' })}\n                </Text>\n                <SegmentedControl\n                    data={[\n                        {\n                            label: t('common.filter_single', { postProcess: 'titleCase' }),\n                            value: 'single',\n                        },\n                        {\n                            label: t('common.filter_multiple', { postProcess: 'titleCase' }),\n                            value: 'multi',\n                        },\n                    ]}\n                    onChange={handleArtistSelectModeChange}\n                    size=\"xs\"\n                    value={artistSelectMode}\n                />\n            </Group>\n        );\n    }, [artistSelectMode, handleArtistSelectModeChange, t]);\n\n    const handleArtistChange = useCallback(\n        (e: null | string[]) => {\n            if (e && e.length > 0) {\n                setArtistIds(e);\n            } else {\n                setArtistIds(null);\n            }\n        },\n        [setArtistIds],\n    );\n\n    const handleGenreSelectModeChange = useCallback(\n        (value: string) => {\n            const newMode = value as 'multi' | 'single';\n            setGenreSelectMode(newMode);\n\n            if (newMode === 'single' && selectedGenres.length > 1) {\n                handleGenresFilter([selectedGenres[0]]);\n            }\n        },\n        [selectedGenres, handleGenresFilter, setGenreSelectMode],\n    );\n\n    const genreFilterLabel = useMemo(() => {\n        return (\n            <Group gap=\"xs\" justify=\"space-between\" w=\"100%\">\n                <Text fw={500} size=\"sm\">\n                    {t('entity.genre', { count: 2, postProcess: 'sentenceCase' })}\n                </Text>\n                <SegmentedControl\n                    data={[\n                        {\n                            label: t('common.filter_single', { postProcess: 'titleCase' }),\n                            value: 'single',\n                        },\n                        {\n                            label: t('common.filter_multiple', { postProcess: 'titleCase' }),\n                            value: 'multi',\n                        },\n                    ]}\n                    onChange={handleGenreSelectModeChange}\n                    size=\"xs\"\n                    value={genreSelectMode}\n                />\n            </Group>\n        );\n    }, [genreSelectMode, handleGenreSelectModeChange, t]);\n\n    return (\n        <Stack px=\"md\" py=\"md\">\n            {yesNoFilters.map((filter) => (\n                <YesNoSelect\n                    key={`jf-filter-${filter.label}`}\n                    label={filter.label}\n                    onChange={(e) => filter.onChange(e ? e === 'true' : undefined)}\n                    value={filter.value ? filter.value.toString() : undefined}\n                />\n            ))}\n            {!disableArtistFilter && (\n                <>\n                    <Divider my=\"md\" />\n                    <VirtualMultiSelect\n                        displayCountType=\"song\"\n                        height={300}\n                        label={artistFilterLabel}\n                        onChange={handleArtistChange}\n                        options={selectableAlbumArtists}\n                        RowComponent={ArtistMultiSelectRow}\n                        singleSelect={artistSelectMode === 'single'}\n                        value={selectedArtistIds}\n                    />\n                </>\n            )}\n            {!disableGenreFilter && (\n                <>\n                    <Divider my=\"md\" />\n                    <VirtualMultiSelect\n                        displayCountType=\"song\"\n                        height={220}\n                        isLoading={genreListQuery.isFetching}\n                        label={genreFilterLabel}\n                        onChange={handleGenresFilter}\n                        options={genreList}\n                        RowComponent={GenreMultiSelectRow}\n                        singleSelect={genreSelectMode === 'single'}\n                        value={selectedGenres}\n                    />\n                </>\n            )}\n            <Divider my=\"md\" />\n            <Group grow>\n                <NumberInput\n                    hideControls={false}\n                    label={t('filter.fromYear', { postProcess: 'sentenceCase' })}\n                    max={2300}\n                    min={1700}\n                    onChange={(e) => debouncedHandleMinYearFilter(e)}\n                    required={!!query.minYear}\n                    value={query.minYear ?? undefined}\n                />\n                <NumberInput\n                    hideControls={false}\n                    label={t('filter.toYear', { postProcess: 'sentenceCase' })}\n                    max={2300}\n                    min={1700}\n                    onChange={(e) => debouncedHandleMaxYearFilter(e)}\n                    required={!!query.minYear}\n                    value={query.maxYear ?? undefined}\n                />\n            </Group>\n            <TagFilters query={query} setCustom={setCustom} type={LibraryItem.SONG} />\n        </Stack>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/songs/components/navidrome-song-filters.tsx",
    "content": "import { useQuery, useSuspenseQuery } from '@tanstack/react-query';\nimport { useCallback, useMemo } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport { getItemImageUrl } from '/@/renderer/components/item-image/item-image';\nimport { artistsQueries } from '/@/renderer/features/artists/api/artists-api';\nimport { genresQueries } from '/@/renderer/features/genres/api/genres-api';\nimport {\n    ArtistMultiSelectRow,\n    GenreMultiSelectRow,\n} from '/@/renderer/features/shared/components/multi-select-rows';\nimport { TagFilters } from '/@/renderer/features/shared/components/tag-filter';\nimport { useSongListFilters } from '/@/renderer/features/songs/hooks/use-song-list-filters';\nimport { useCurrentServer } from '/@/renderer/store';\nimport { useAppStore, useAppStoreActions } from '/@/renderer/store/app.store';\nimport { hasFeature } from '/@/shared/api/utils';\nimport { Divider } from '/@/shared/components/divider/divider';\nimport { Group } from '/@/shared/components/group/group';\nimport { VirtualMultiSelect } from '/@/shared/components/multi-select/virtual-multi-select';\nimport { NumberInput } from '/@/shared/components/number-input/number-input';\nimport { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { Text } from '/@/shared/components/text/text';\nimport { useDebouncedCallback } from '/@/shared/hooks/use-debounced-callback';\nimport {\n    AlbumArtistListSort,\n    GenreListSort,\n    LibraryItem,\n    SortOrder,\n} from '/@/shared/types/domain-types';\nimport { ServerFeature } from '/@/shared/types/features-types';\n\ninterface NavidromeSongFiltersProps {\n    disableArtistFilter?: boolean;\n    disableGenreFilter?: boolean;\n}\n\nexport const NavidromeSongFilters = ({\n    disableArtistFilter,\n    disableGenreFilter,\n}: NavidromeSongFiltersProps) => {\n    const { t } = useTranslation();\n    const server = useCurrentServer();\n    const serverId = server.id;\n    const {\n        query,\n        setArtistIds,\n        setCustom,\n        setFavorite,\n        setGenreId,\n        setHasRating,\n        setMaxYear,\n        setMinYear,\n    } = useSongListFilters();\n\n    const showRatingFilter = hasFeature(server, ServerFeature.TRACK_YES_NO_RATING_FILTER);\n\n    const genreListQuery = useQuery(\n        genresQueries.list({\n            options: {\n                gcTime: 1000 * 60 * 2,\n                staleTime: 1000 * 60 * 1,\n            },\n            query: {\n                sortBy: GenreListSort.NAME,\n                sortOrder: SortOrder.ASC,\n                startIndex: 0,\n            },\n            serverId,\n        }),\n    );\n\n    const genreList = useMemo(() => {\n        if (!genreListQuery?.data) return [];\n        return genreListQuery.data.items.map((genre) => ({\n            albumCount: genre.albumCount,\n            label: genre.name,\n            songCount: genre.songCount,\n            value: genre.id,\n        }));\n    }, [genreListQuery.data]);\n\n    const albumArtistListQuery = useSuspenseQuery(\n        artistsQueries.albumArtistList({\n            options: {\n                gcTime: 1000 * 60 * 2,\n                staleTime: 1000 * 60 * 1,\n            },\n            query: {\n                sortBy: AlbumArtistListSort.NAME,\n                sortOrder: SortOrder.ASC,\n                startIndex: 0,\n            },\n            serverId,\n        }),\n    );\n\n    const selectableAlbumArtists = useMemo(() => {\n        if (!albumArtistListQuery?.data?.items) return [];\n\n        return albumArtistListQuery?.data?.items?.map((artist) => ({\n            albumCount: artist.albumCount,\n            imageUrl: getItemImageUrl({\n                id: artist.id,\n                itemType: LibraryItem.ARTIST,\n                type: 'table',\n            }),\n            label: artist.name,\n            songCount: artist.songCount,\n            value: artist.id,\n        }));\n    }, [albumArtistListQuery.data?.items]);\n\n    // Helper function to convert boolean/null to segment value\n    const booleanToSegmentValue = (value: boolean | null | undefined): string => {\n        if (value === true) return 'true';\n        if (value === false) return 'false';\n        return 'none';\n    };\n\n    // Helper function to convert segment value to boolean/null\n    const segmentValueToBoolean = (value: string): boolean | null => {\n        if (value === 'true') return true;\n        if (value === 'false') return false;\n        return null;\n    };\n\n    const segmentedControlData = useMemo(\n        () => [\n            {\n                label: t('common.none', { postProcess: 'titleCase' }),\n                value: 'none',\n            },\n            {\n                label: t('common.yes', { postProcess: 'titleCase' }),\n                value: 'true',\n            },\n            {\n                label: t('common.no', { postProcess: 'titleCase' }),\n                value: 'false',\n            },\n        ],\n        [t],\n    );\n\n    const handleYearFilter = useMemo(\n        () => (e: number | string) => {\n            // Handle empty string, null, undefined, or invalid numbers as clearing\n\n            if (e === '' || e === null || e === undefined) {\n                setMinYear(null);\n                setMaxYear(null);\n                return;\n            }\n\n            const year = typeof e === 'number' ? e : Number(e);\n            // If it's a valid number, set it; otherwise clear\n            if (!isNaN(year) && isFinite(year) && year > 0) {\n                setMinYear(year);\n                setMaxYear(year);\n            } else {\n                setMinYear(null);\n                setMaxYear(null);\n            }\n        },\n        [setMinYear, setMaxYear],\n    );\n\n    const debouncedHandleYearFilter = useDebouncedCallback(handleYearFilter, 300);\n\n    const genreSelectMode = useAppStore((state) => state.genreSelectMode);\n    const { setGenreSelectMode } = useAppStoreActions();\n\n    const selectedGenreIds = useMemo(() => query.genreIds || [], [query.genreIds]);\n\n    const handleGenreSelectModeChange = useCallback(\n        (value: string) => {\n            const newMode = value as 'multi' | 'single';\n            setGenreSelectMode(newMode);\n\n            if (newMode === 'single' && selectedGenreIds.length > 1) {\n                setGenreId([selectedGenreIds[0]]);\n            }\n        },\n        [selectedGenreIds, setGenreId, setGenreSelectMode],\n    );\n\n    const genreFilterLabel = useMemo(() => {\n        return (\n            <Group gap=\"xs\" justify=\"space-between\" w=\"100%\">\n                <Text fw={500} size=\"sm\">\n                    {t('entity.genre', { count: 2, postProcess: 'sentenceCase' })}\n                </Text>\n                <SegmentedControl\n                    data={[\n                        {\n                            label: t('common.filter_single', { postProcess: 'titleCase' }),\n                            value: 'single',\n                        },\n                        {\n                            label: t('common.filter_multiple', { postProcess: 'titleCase' }),\n                            value: 'multi',\n                        },\n                    ]}\n                    onChange={handleGenreSelectModeChange}\n                    size=\"xs\"\n                    value={genreSelectMode}\n                />\n            </Group>\n        );\n    }, [genreSelectMode, handleGenreSelectModeChange, t]);\n\n    const handleGenreChange = useCallback(\n        (e: null | string[]) => {\n            if (e && e.length > 0) {\n                setGenreId(e);\n            } else {\n                setGenreId(null);\n            }\n        },\n        [setGenreId],\n    );\n\n    const artistSelectMode = useAppStore((state) => state.artistSelectMode);\n    const { setArtistSelectMode } = useAppStoreActions();\n\n    const selectedArtistIds = useMemo(() => query.artistIds || [], [query.artistIds]);\n\n    const handleArtistSelectModeChange = useCallback(\n        (value: string) => {\n            const newMode = value as 'multi' | 'single';\n            setArtistSelectMode(newMode);\n\n            if (newMode === 'single' && selectedArtistIds.length > 1) {\n                setArtistIds([selectedArtistIds[0]]);\n            }\n        },\n        [selectedArtistIds, setArtistIds, setArtistSelectMode],\n    );\n\n    const artistFilterLabel = useMemo(() => {\n        return (\n            <Group gap=\"xs\" justify=\"space-between\" w=\"100%\">\n                <Text fw={500} size=\"sm\">\n                    {t('entity.artist', { count: 2, postProcess: 'sentenceCase' })}\n                </Text>\n                <SegmentedControl\n                    data={[\n                        {\n                            label: t('common.filter_single', { postProcess: 'titleCase' }),\n                            value: 'single',\n                        },\n                        {\n                            label: t('common.filter_multiple', { postProcess: 'titleCase' }),\n                            value: 'multi',\n                        },\n                    ]}\n                    onChange={handleArtistSelectModeChange}\n                    size=\"xs\"\n                    value={artistSelectMode}\n                />\n            </Group>\n        );\n    }, [artistSelectMode, handleArtistSelectModeChange, t]);\n\n    const handleArtistChange = useCallback(\n        (e: null | string[]) => {\n            if (e && e.length > 0) {\n                setArtistIds(e);\n            } else {\n                setArtistIds(null);\n            }\n        },\n        [setArtistIds],\n    );\n\n    return (\n        <Stack px=\"md\" py=\"md\">\n            <Stack gap=\"xs\">\n                <Text size=\"sm\" weight={500}>\n                    {t('filter.isFavorited', { postProcess: 'sentenceCase' })}\n                </Text>\n                <SegmentedControl\n                    data={segmentedControlData}\n                    onChange={(value) => {\n                        setFavorite(segmentValueToBoolean(value));\n                    }}\n                    size=\"sm\"\n                    value={booleanToSegmentValue(query.favorite)}\n                    w=\"100%\"\n                />\n            </Stack>\n            {showRatingFilter && (\n                <>\n                    <Divider my=\"md\" />\n                    <Stack gap=\"xs\">\n                        <Text size=\"sm\" weight={500}>\n                            {t('filter.isRated', { postProcess: 'sentenceCase' })}\n                        </Text>\n                        <SegmentedControl\n                            data={segmentedControlData}\n                            onChange={(value) => {\n                                setHasRating(segmentValueToBoolean(value));\n                            }}\n                            size=\"sm\"\n                            value={booleanToSegmentValue(query.hasRating)}\n                            w=\"100%\"\n                        />\n                    </Stack>\n                </>\n            )}\n            {!disableArtistFilter && (\n                <>\n                    <Divider my=\"md\" />\n                    <VirtualMultiSelect\n                        displayCountType=\"song\"\n                        height={300}\n                        label={artistFilterLabel}\n                        onChange={handleArtistChange}\n                        options={selectableAlbumArtists}\n                        RowComponent={ArtistMultiSelectRow}\n                        singleSelect={artistSelectMode === 'single'}\n                        value={selectedArtistIds}\n                    />\n                </>\n            )}\n            {!disableGenreFilter && (\n                <VirtualMultiSelect\n                    displayCountType=\"song\"\n                    height={220}\n                    isLoading={genreListQuery.isFetching}\n                    label={genreFilterLabel}\n                    onChange={handleGenreChange}\n                    options={genreList}\n                    RowComponent={GenreMultiSelectRow}\n                    singleSelect={genreSelectMode === 'single'}\n                    value={selectedGenreIds}\n                />\n            )}\n            <NumberInput\n                hideControls={false}\n                label={t('common.year', { postProcess: 'titleCase' })}\n                max={5000}\n                min={0}\n                onChange={(e) => debouncedHandleYearFilter(e)}\n                value={query.minYear ?? undefined}\n            />\n            <TagFilters query={query} setCustom={setCustom} type={LibraryItem.SONG} />\n        </Stack>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/songs/components/song-infinite-carousel.tsx",
    "content": "import { QueryFunctionContext, useSuspenseInfiniteQuery } from '@tanstack/react-query';\nimport { Suspense, useCallback, useMemo } from 'react';\n\nimport { api } from '/@/renderer/api';\nimport { queryKeys } from '/@/renderer/api/query-keys';\nimport {\n    GridCarousel,\n    GridCarouselSkeletonFallback,\n    useGridCarouselContainerQuery,\n} from '/@/renderer/components/grid-carousel/grid-carousel-v2';\nimport { DataRow, MemoizedItemCard } from '/@/renderer/components/item-card/item-card';\nimport { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls';\nimport { useGridRows } from '/@/renderer/components/item-list/helpers/use-grid-rows';\nimport { DefaultItemControlProps } from '/@/renderer/components/item-list/types';\nimport { usePlayer } from '/@/renderer/features/player/context/player-context';\nimport { useCurrentServerId } from '/@/renderer/store';\nimport {\n    LibraryItem,\n    Song,\n    SongListQuery,\n    SongListResponse,\n    SongListSort,\n    SortOrder,\n} from '/@/shared/types/domain-types';\nimport { ItemListKey, Play } from '/@/shared/types/types';\n\ninterface SongCarouselProps {\n    containerQuery?: ReturnType<typeof useGridCarouselContainerQuery>;\n    enableRefresh?: boolean;\n    excludeIds?: string[];\n    query?: Partial<Omit<SongListQuery, 'startIndex'>>;\n    queryKey?: QueryFunctionContext['queryKey'];\n    rowCount?: number;\n    sortBy: SongListSort;\n    sortOrder: SortOrder;\n    title: React.ReactNode | string;\n}\n\nconst BaseSongInfiniteCarousel = (props: SongCarouselProps & { rows: DataRow[] }) => {\n    const {\n        containerQuery,\n        enableRefresh,\n        excludeIds,\n        query: additionalQuery,\n        queryKey,\n        rowCount = 1,\n        rows,\n        sortBy,\n        sortOrder,\n        title,\n    } = props;\n    const {\n        data: songs,\n        fetchNextPage,\n        hasNextPage,\n        isFetchingNextPage,\n        refetch,\n    } = useSongListInfinite(sortBy, sortOrder, 20, additionalQuery, queryKey);\n\n    const player = usePlayer();\n    const baseControls = useDefaultItemListControls();\n\n    const controls = useMemo(() => {\n        return {\n            ...baseControls,\n            onPlay: ({ item, playType }: DefaultItemControlProps & { playType: Play }) => {\n                if (!item) {\n                    return;\n                }\n\n                player.addToQueueByData([item as Song], playType);\n            },\n        };\n    }, [baseControls, player]);\n\n    const cards = useMemo(() => {\n        // Flatten all pages and filter excluded IDs\n        const allItems = songs?.pages.flatMap((page: SongListResponse) => page.items) || [];\n        const filteredItems = excludeIds\n            ? allItems.filter((song) => !excludeIds.includes(song.id))\n            : allItems;\n\n        return filteredItems.map((song: Song) => ({\n            content: (\n                <MemoizedItemCard\n                    controls={controls}\n                    data={song}\n                    enableDrag\n                    imageFetchPriority=\"low\"\n                    itemType={LibraryItem.SONG}\n                    rows={rows}\n                    type=\"poster\"\n                    withControls\n                />\n            ),\n            id: song.id,\n        }));\n    }, [songs, controls, excludeIds, rows]);\n\n    const handleNextPage = useCallback(() => {}, []);\n\n    const handlePrevPage = useCallback(() => {}, []);\n\n    const handleRefresh = useCallback(() => {\n        refetch();\n    }, [refetch]);\n\n    const firstPageItems = excludeIds\n        ? songs?.pages[0]?.items.filter((song) => !excludeIds.includes(song.id)) || []\n        : songs?.pages[0]?.items || [];\n\n    if (firstPageItems.length === 0) {\n        return null;\n    }\n\n    return (\n        <GridCarousel\n            cards={cards}\n            containerQuery={containerQuery}\n            enableRefresh={enableRefresh}\n            hasNextPage={hasNextPage}\n            isFetchingNextPage={isFetchingNextPage}\n            loadNextPage={fetchNextPage}\n            onNextPage={handleNextPage}\n            onPrevPage={handlePrevPage}\n            onRefresh={handleRefresh}\n            placeholderItemType={LibraryItem.SONG}\n            placeholderRows={rows}\n            rowCount={rowCount}\n            title={title}\n        />\n    );\n};\n\nexport const SongInfiniteCarousel = (props: SongCarouselProps) => {\n    const rows = useGridRows(LibraryItem.SONG, ItemListKey.SONG);\n\n    return (\n        <Suspense\n            fallback={\n                <GridCarouselSkeletonFallback\n                    containerQuery={props.containerQuery}\n                    placeholderItemType={LibraryItem.SONG}\n                    placeholderRows={rows}\n                    title={props.title}\n                />\n            }\n        >\n            <BaseSongInfiniteCarousel {...props} rows={rows} />\n        </Suspense>\n    );\n};\n\nfunction useSongListInfinite(\n    sortBy: SongListSort,\n    sortOrder: SortOrder,\n    itemLimit: number,\n    additionalQuery?: Partial<Omit<SongListQuery, 'startIndex'>>,\n    overrideQueryKey?: QueryFunctionContext['queryKey'],\n) {\n    const serverId = useCurrentServerId();\n\n    const defaultQueryKey = queryKeys.songs.infiniteList(serverId, {\n        sortBy,\n        sortOrder,\n        ...additionalQuery,\n    });\n\n    const query = useSuspenseInfiniteQuery<SongListResponse>({\n        getNextPageParam: (lastPage, _allPages, lastPageParam) => {\n            if (lastPage.items.length < itemLimit) {\n                return undefined;\n            }\n\n            const nextPageParam = Number(lastPageParam) + itemLimit;\n\n            return String(nextPageParam);\n        },\n        initialPageParam: '0',\n        queryFn: ({ pageParam, signal }) => {\n            return api.controller.getSongList({\n                apiClientProps: { serverId, signal },\n                query: {\n                    limit: itemLimit,\n                    sortBy,\n                    sortOrder,\n                    startIndex: Number(pageParam),\n                    ...additionalQuery,\n                },\n            });\n        },\n        queryKey: overrideQueryKey || defaultQueryKey,\n    });\n\n    return query;\n}\n"
  },
  {
    "path": "src/renderer/features/songs/components/song-list-content.tsx",
    "content": "import { lazy, Suspense, useMemo } from 'react';\n\nimport { useListContext } from '/@/renderer/context/list-context';\nimport { ListFilters, ListFiltersTitle } from '/@/renderer/features/shared/components/list-filters';\nimport { ListWithSidebarContainer } from '/@/renderer/features/shared/components/list-with-sidebar-container';\nimport { SaveAsCollectionButton } from '/@/renderer/features/shared/components/save-as-collection-button';\nimport { useSongListFilters } from '/@/renderer/features/songs/hooks/use-song-list-filters';\nimport { ItemListSettings, useCurrentServer, useListSettings } from '/@/renderer/store';\nimport { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';\nimport { Spinner } from '/@/shared/components/spinner/spinner';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { LibraryItem, SongListQuery } from '/@/shared/types/domain-types';\nimport { ItemListKey, ListDisplayType, ListPaginationType } from '/@/shared/types/types';\n\nconst SongListInfiniteGrid = lazy(() =>\n    import('/@/renderer/features/songs/components/song-list-infinite-grid').then((module) => ({\n        default: module.SongListInfiniteGrid,\n    })),\n);\nconst SongListPaginatedGrid = lazy(() =>\n    import('/@/renderer/features/songs/components/song-list-paginated-grid').then((module) => ({\n        default: module.SongListPaginatedGrid,\n    })),\n);\nconst SongListInfiniteTable = lazy(() =>\n    import('/@/renderer/features/songs/components/song-list-infinite-table').then((module) => ({\n        default: module.SongListInfiniteTable,\n    })),\n);\nconst SongListPaginatedTable = lazy(() =>\n    import('/@/renderer/features/songs/components/song-list-paginated-table').then((module) => ({\n        default: module.SongListPaginatedTable,\n    })),\n);\n\nexport const SongListContent = () => {\n    return (\n        <>\n            <SongListFilters />\n            <SongListSuspenseContainer />\n        </>\n    );\n};\n\nconst SongListFilters = () => {\n    return (\n        <ListWithSidebarContainer.SidebarPortal>\n            <Stack h=\"100%\" style={{ minHeight: 0 }}>\n                <ListFiltersTitle itemType={LibraryItem.SONG} />\n                <ScrollArea style={{ flex: 1, minHeight: 0 }}>\n                    <ListFilters itemType={LibraryItem.SONG} />\n                </ScrollArea>\n                <Stack p=\"sm\">\n                    <SaveAsCollectionButton fullWidth itemType={LibraryItem.SONG} />\n                </Stack>\n            </Stack>\n        </ListWithSidebarContainer.SidebarPortal>\n    );\n};\n\nconst SongListSuspenseContainer = () => {\n    const { display, grid, itemsPerPage, pagination, table } = useListSettings(ItemListKey.SONG);\n\n    const { customFilters } = useListContext();\n\n    return (\n        <Suspense fallback={<Spinner container />}>\n            <SongListView\n                display={display}\n                grid={grid}\n                itemsPerPage={itemsPerPage}\n                overrideQuery={customFilters}\n                pagination={pagination}\n                table={table}\n            />\n        </Suspense>\n    );\n};\n\nexport type OverrideSongListQuery = Omit<Partial<SongListQuery>, 'limit' | 'startIndex'>;\n\nexport const SongListView = ({\n    display,\n    grid,\n    itemsPerPage,\n    overrideQuery,\n    pagination,\n    table,\n}: ItemListSettings & { overrideQuery?: OverrideSongListQuery }) => {\n    const server = useCurrentServer();\n    const { pageKey } = useListContext();\n\n    const { query } = useSongListFilters(pageKey as ItemListKey);\n\n    const mergedQuery = useMemo(() => {\n        if (!overrideQuery) {\n            return query;\n        }\n\n        return {\n            ...query,\n            ...overrideQuery,\n            sortBy: overrideQuery.sortBy || query.sortBy,\n            sortOrder: overrideQuery.sortOrder || query.sortOrder,\n        };\n    }, [query, overrideQuery]);\n\n    switch (display) {\n        case ListDisplayType.GRID: {\n            switch (pagination) {\n                case ListPaginationType.INFINITE:\n                    return (\n                        <SongListInfiniteGrid\n                            gap={grid.itemGap}\n                            itemsPerPage={itemsPerPage}\n                            itemsPerRow={grid.itemsPerRowEnabled ? grid.itemsPerRow : undefined}\n                            query={mergedQuery}\n                            serverId={server.id}\n                            size={grid.size}\n                        />\n                    );\n                case ListPaginationType.PAGINATED:\n                    return (\n                        <SongListPaginatedGrid\n                            gap={grid.itemGap}\n                            itemsPerPage={itemsPerPage}\n                            itemsPerRow={grid.itemsPerRowEnabled ? grid.itemsPerRow : undefined}\n                            query={mergedQuery}\n                            serverId={server.id}\n                            size={grid.size}\n                        />\n                    );\n                default:\n                    return null;\n            }\n        }\n        case ListDisplayType.TABLE: {\n            switch (pagination) {\n                case ListPaginationType.INFINITE:\n                    return (\n                        <SongListInfiniteTable\n                            autoFitColumns={table.autoFitColumns}\n                            columns={table.columns}\n                            enableAlternateRowColors={table.enableAlternateRowColors}\n                            enableHeader={table.enableHeader}\n                            enableHorizontalBorders={table.enableHorizontalBorders}\n                            enableRowHoverHighlight={table.enableRowHoverHighlight}\n                            enableVerticalBorders={table.enableVerticalBorders}\n                            itemsPerPage={itemsPerPage}\n                            query={mergedQuery}\n                            serverId={server.id}\n                            size={table.size}\n                        />\n                    );\n                case ListPaginationType.PAGINATED:\n                    return (\n                        <SongListPaginatedTable\n                            autoFitColumns={table.autoFitColumns}\n                            columns={table.columns}\n                            enableAlternateRowColors={table.enableAlternateRowColors}\n                            enableHeader={table.enableHeader}\n                            enableHorizontalBorders={table.enableHorizontalBorders}\n                            enableRowHoverHighlight={table.enableRowHoverHighlight}\n                            enableVerticalBorders={table.enableVerticalBorders}\n                            itemsPerPage={itemsPerPage}\n                            query={mergedQuery}\n                            serverId={server.id}\n                            size={table.size}\n                        />\n                    );\n                default:\n                    return null;\n            }\n        }\n    }\n\n    return null;\n};\n"
  },
  {
    "path": "src/renderer/features/songs/components/song-list-header-filters.tsx",
    "content": "import { useCallback, useMemo } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport { SONG_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns';\nimport { useListContext } from '/@/renderer/context/list-context';\nimport { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album-list-filters';\nimport {\n    ListConfigMenu,\n    SONG_DISPLAY_TYPES,\n} from '/@/renderer/features/shared/components/list-config-menu';\nimport { ListDisplayTypeToggleButton } from '/@/renderer/features/shared/components/list-display-type-toggle-button';\nimport {\n    isFilterValueSet,\n    ListFiltersModal,\n} from '/@/renderer/features/shared/components/list-filters';\nimport { ListRefreshButton } from '/@/renderer/features/shared/components/list-refresh-button';\nimport { ListSortByDropdown } from '/@/renderer/features/shared/components/list-sort-by-dropdown';\nimport { ListSortOrderToggleButton } from '/@/renderer/features/shared/components/list-sort-order-toggle-button';\nimport { FILTER_KEYS } from '/@/renderer/features/shared/utils';\nimport { useSongListFilters } from '/@/renderer/features/songs/hooks/use-song-list-filters';\nimport { GenreTarget, useGenreTarget, useSettingsStoreActions } from '/@/renderer/store';\nimport { Button } from '/@/shared/components/button/button';\nimport { Divider } from '/@/shared/components/divider/divider';\nimport { Flex } from '/@/shared/components/flex/flex';\nimport { Group } from '/@/shared/components/group/group';\nimport { Icon } from '/@/shared/components/icon/icon';\nimport { LibraryItem, SongListSort, SortOrder } from '/@/shared/types/domain-types';\nimport { ItemListKey } from '/@/shared/types/types';\n\nexport const SongListHeaderFilters = ({ toggleGenreTarget }: { toggleGenreTarget?: boolean }) => {\n    const { t } = useTranslation();\n    const target = useGenreTarget();\n    const { setGenreBehavior } = useSettingsStoreActions();\n    const albumFilters = useAlbumListFilters();\n    const songFilters = useSongListFilters();\n\n    const { pageKey } = useListContext();\n\n    const handleToggleGenreTarget = useCallback(() => {\n        // Clear all filter query states\n        albumFilters.clear();\n        songFilters.clear();\n\n        // Toggle the genre target\n        setGenreBehavior(target === GenreTarget.ALBUM ? GenreTarget.TRACK : GenreTarget.ALBUM);\n    }, [target, setGenreBehavior, albumFilters, songFilters]);\n\n    const choice = useMemo(() => {\n        return target === GenreTarget.ALBUM\n            ? t('entity.album', { count: 2, postProcess: 'titleCase' })\n            : t('entity.track', { count: 2, postProcess: 'titleCase' });\n    }, [target, t]);\n\n    const hasActiveFilters = useMemo(() => {\n        const query = songFilters.query;\n        return Boolean(\n            isFilterValueSet(query[FILTER_KEYS.SONG._CUSTOM]) ||\n                isFilterValueSet(query[FILTER_KEYS.SONG.ARTIST_IDS]) ||\n                query[FILTER_KEYS.SONG.FAVORITE] !== undefined ||\n                isFilterValueSet(query[FILTER_KEYS.SONG.GENRE_ID]) ||\n                isFilterValueSet(query[FILTER_KEYS.SONG.MAX_YEAR]) ||\n                isFilterValueSet(query[FILTER_KEYS.SONG.MIN_YEAR]) ||\n                isFilterValueSet(query[FILTER_KEYS.SHARED.SEARCH_TERM]),\n        );\n    }, [songFilters.query]);\n\n    return (\n        <Flex justify=\"space-between\">\n            <Group gap=\"sm\" w=\"100%\">\n                {toggleGenreTarget && (\n                    <>\n                        <Button\n                            leftSection={<Icon icon=\"arrowLeftRight\" />}\n                            onClick={handleToggleGenreTarget}\n                            variant=\"subtle\"\n                        >\n                            {choice}\n                        </Button>\n                        <Divider orientation=\"vertical\" />\n                    </>\n                )}\n                <ListSortByDropdown\n                    defaultSortByValue={SongListSort.NAME}\n                    itemType={LibraryItem.SONG}\n                    listKey={pageKey as ItemListKey}\n                />\n                <Divider orientation=\"vertical\" />\n                <ListSortOrderToggleButton\n                    defaultSortOrder={SortOrder.ASC}\n                    listKey={pageKey as ItemListKey}\n                />\n                <ListFiltersModal isActive={hasActiveFilters} itemType={LibraryItem.SONG} />\n                <ListRefreshButton listKey={pageKey as ItemListKey} />\n            </Group>\n            <Group gap=\"sm\" wrap=\"nowrap\">\n                <ListDisplayTypeToggleButton listKey={ItemListKey.SONG} />\n                <ListConfigMenu\n                    displayTypes={SONG_DISPLAY_TYPES}\n                    listKey={ItemListKey.SONG}\n                    tableColumnsData={SONG_TABLE_COLUMNS}\n                />\n            </Group>\n        </Flex>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/songs/components/song-list-header.tsx",
    "content": "import { useSuspenseQuery } from '@tanstack/react-query';\nimport { useMemo } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport { useIsFetchingItemListCount } from '/@/renderer/components/item-list/helpers/use-is-fetching-item-list';\nimport { PageHeader } from '/@/renderer/components/page-header/page-header';\nimport { useListContext } from '/@/renderer/context/list-context';\nimport { artistsQueries } from '/@/renderer/features/artists/api/artists-api';\nimport { useGenreList } from '/@/renderer/features/genres/api/genres-api';\nimport { FilterBar } from '/@/renderer/features/shared/components/filter-bar';\nimport { LibraryHeaderBar } from '/@/renderer/features/shared/components/library-header-bar';\nimport { ListSearchInput } from '/@/renderer/features/shared/components/list-search-input';\nimport { SongListHeaderFilters } from '/@/renderer/features/songs/components/song-list-header-filters';\nimport { useSongListFilters } from '/@/renderer/features/songs/hooks/use-song-list-filters';\nimport { useCurrentServerId } from '/@/renderer/store';\nimport { Flex } from '/@/shared/components/flex/flex';\nimport { Group } from '/@/shared/components/group/group';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { LibraryItem } from '/@/shared/types/domain-types';\nimport { ItemListKey } from '/@/shared/types/types';\n\ninterface SongListHeaderProps {\n    genreId?: string;\n    title?: string;\n}\n\nexport const SongListHeader = ({ title }: SongListHeaderProps) => {\n    return (\n        <Stack gap={0}>\n            <PageHeader>\n                <Flex justify=\"space-between\" w=\"100%\">\n                    <LibraryHeaderBar ignoreMaxWidth>\n                        <PlayButton />\n                        <PageTitle title={title} />\n                        <SongListHeaderBadge />\n                    </LibraryHeaderBar>\n                    <Group>\n                        <ListSearchInput />\n                    </Group>\n                </Flex>\n            </PageHeader>\n            <FilterBar>\n                <SongListHeaderFilters />\n            </FilterBar>\n        </Stack>\n    );\n};\n\nconst SongListHeaderBadge = () => {\n    const { itemCount } = useListContext();\n\n    const isFetching = useIsFetchingItemListCount({\n        itemType: LibraryItem.SONG,\n    });\n\n    return <LibraryHeaderBar.Badge isLoading={isFetching}>{itemCount}</LibraryHeaderBar.Badge>;\n};\n\nconst PlayButton = () => {\n    const { customFilters } = useListContext();\n    const { query } = useSongListFilters();\n\n    const mergedQuery = useMemo(() => {\n        return {\n            ...query,\n            ...(customFilters ?? {}),\n        };\n    }, [query, customFilters]);\n\n    return <LibraryHeaderBar.PlayButton itemType={LibraryItem.SONG} listQuery={mergedQuery} />;\n};\n\nconst PageTitle = ({ title }: { title?: string }) => {\n    const { t } = useTranslation();\n    const { pageKey } = useListContext();\n    const pageTitle = title || t('page.trackList.title', { postProcess: 'titleCase' });\n\n    switch (pageKey) {\n        case ItemListKey.ALBUM_ARTIST_SONG:\n            return <AlbumArtistTitle />;\n        case ItemListKey.GENRE_SONG:\n            return <GenreTitle />;\n    }\n\n    return <LibraryHeaderBar.Title>{pageTitle}</LibraryHeaderBar.Title>;\n};\n\nconst AlbumArtistTitle = () => {\n    const { id } = useListContext();\n    const serverId = useCurrentServerId();\n\n    const { data: albumArtist } = useSuspenseQuery(\n        artistsQueries.albumArtistDetail({\n            query: { id: id! },\n            serverId: serverId,\n        }),\n    );\n\n    return <LibraryHeaderBar.Title>{albumArtist?.name || '—'}</LibraryHeaderBar.Title>;\n};\n\nconst GenreTitle = () => {\n    const { id } = useListContext();\n\n    const { data: genre } = useGenreList();\n\n    const name = useMemo(() => {\n        return genre?.items.find((g) => g.id === id)?.name || '—';\n    }, [id, genre]);\n\n    return <LibraryHeaderBar.Title>{name || '—'}</LibraryHeaderBar.Title>;\n};\n"
  },
  {
    "path": "src/renderer/features/songs/components/song-list-infinite-grid.tsx",
    "content": "import { UseSuspenseQueryOptions } from '@tanstack/react-query';\n\nimport { api } from '/@/renderer/api';\nimport { useItemListInfiniteLoader } from '/@/renderer/components/item-list/helpers/item-list-infinite-loader';\nimport { useGridRows } from '/@/renderer/components/item-list/helpers/use-grid-rows';\nimport { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist';\nimport { ItemGridList } from '/@/renderer/components/item-list/item-grid-list/item-grid-list';\nimport { ItemListGridComponentProps } from '/@/renderer/components/item-list/types';\nimport { useListContext } from '/@/renderer/context/list-context';\nimport { songsQueries } from '/@/renderer/features/songs/api/songs-api';\nimport { useGeneralSettings } from '/@/renderer/store';\nimport { LibraryItem, SongListQuery, SongListSort, SortOrder } from '/@/shared/types/domain-types';\nimport { ItemListKey } from '/@/shared/types/types';\n\ninterface SongListInfiniteGridProps extends ItemListGridComponentProps<SongListQuery> {}\n\nexport const SongListInfiniteGrid = ({\n    gap = 'md',\n    itemsPerPage = 100,\n    itemsPerRow,\n    query = {\n        sortBy: SongListSort.NAME,\n        sortOrder: SortOrder.ASC,\n    },\n    saveScrollOffset = true,\n    serverId,\n    size,\n}: SongListInfiniteGridProps) => {\n    const listCountQuery = songsQueries.listCount({\n        query: { ...query, limit: itemsPerPage },\n        serverId: serverId,\n    }) as UseSuspenseQueryOptions<number, Error, number, readonly unknown[]>;\n\n    const listQueryFn = api.controller.getSongList;\n    const { pageKey } = useListContext();\n\n    const { dataVersion, getItem, getItemIndex, itemCount, loadedItems, onRangeChanged } =\n        useItemListInfiniteLoader({\n            eventKey: pageKey || ItemListKey.SONG,\n            itemsPerPage,\n            itemType: LibraryItem.SONG,\n            listCountQuery,\n            listQueryFn,\n            query,\n            serverId,\n        });\n\n    const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({\n        enabled: saveScrollOffset,\n    });\n\n    const rows = useGridRows(LibraryItem.SONG, ItemListKey.SONG, size);\n    const { enableGridMultiSelect } = useGeneralSettings();\n\n    return (\n        <ItemGridList\n            data={loadedItems}\n            dataVersion={dataVersion}\n            enableMultiSelect={enableGridMultiSelect}\n            gap={gap}\n            getItem={getItem}\n            getItemIndex={getItemIndex}\n            initialTop={{\n                to: scrollOffset ?? 0,\n                type: 'offset',\n            }}\n            itemCount={itemCount}\n            itemsPerRow={itemsPerRow}\n            itemType={LibraryItem.SONG}\n            onRangeChanged={onRangeChanged}\n            onScrollEnd={handleOnScrollEnd}\n            rows={rows}\n            size={size}\n        />\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/songs/components/song-list-infinite-table.tsx",
    "content": "import { UseSuspenseQueryOptions } from '@tanstack/react-query';\n\nimport { api } from '/@/renderer/api';\nimport { useItemListInfiniteLoader } from '/@/renderer/components/item-list/helpers/item-list-infinite-loader';\nimport { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder';\nimport { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize';\nimport { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist';\nimport { ItemTableList } from '/@/renderer/components/item-list/item-table-list/item-table-list';\nimport { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';\nimport { ItemListTableComponentProps } from '/@/renderer/components/item-list/types';\nimport { useListContext } from '/@/renderer/context/list-context';\nimport { songsQueries } from '/@/renderer/features/songs/api/songs-api';\nimport { usePlayerSong } from '/@/renderer/store';\nimport { LibraryItem, SongListQuery, SongListSort, SortOrder } from '/@/shared/types/domain-types';\nimport { ItemListKey } from '/@/shared/types/types';\n\ninterface SongListInfiniteTableProps extends ItemListTableComponentProps<SongListQuery> {}\n\nexport const SongListInfiniteTable = ({\n    autoFitColumns = false,\n    columns,\n    enableAlternateRowColors = false,\n    enableHeader = true,\n    enableHorizontalBorders = false,\n    enableRowHoverHighlight = true,\n    enableSelection = true,\n    enableVerticalBorders = false,\n    itemsPerPage = 100,\n    query = {\n        sortBy: SongListSort.NAME,\n        sortOrder: SortOrder.ASC,\n    },\n    saveScrollOffset = true,\n    serverId,\n    size = 'default',\n}: SongListInfiniteTableProps) => {\n    const listCountQuery = songsQueries.listCount({\n        query: { ...query, limit: itemsPerPage },\n        serverId: serverId,\n    }) as UseSuspenseQueryOptions<number, Error, number, readonly unknown[]>;\n\n    const listQueryFn = api.controller.getSongList;\n    const { pageKey } = useListContext();\n\n    const { getItem, getItemIndex, itemCount, loadedItems, onRangeChanged } =\n        useItemListInfiniteLoader({\n            eventKey: pageKey || ItemListKey.SONG,\n            itemsPerPage,\n            itemType: LibraryItem.SONG,\n            listCountQuery,\n            listQueryFn,\n            query,\n            serverId,\n        });\n\n    const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({\n        enabled: saveScrollOffset,\n    });\n\n    const { handleColumnReordered } = useItemListColumnReorder({\n        itemListKey: ItemListKey.SONG,\n    });\n\n    const { handleColumnResized } = useItemListColumnResize({\n        itemListKey: ItemListKey.SONG,\n    });\n\n    const currentSong = usePlayerSong();\n\n    return (\n        <ItemTableList\n            activeRowId={currentSong?.id}\n            autoFitColumns={autoFitColumns}\n            CellComponent={ItemTableListColumn}\n            columns={columns}\n            data={loadedItems}\n            enableAlternateRowColors={enableAlternateRowColors}\n            enableExpansion={false}\n            enableHeader={enableHeader}\n            enableHorizontalBorders={enableHorizontalBorders}\n            enableRowHoverHighlight={enableRowHoverHighlight}\n            enableSelection={enableSelection}\n            enableVerticalBorders={enableVerticalBorders}\n            getItem={getItem}\n            getItemIndex={getItemIndex}\n            initialTop={{\n                to: scrollOffset ?? 0,\n                type: 'offset',\n            }}\n            itemCount={itemCount}\n            itemType={LibraryItem.SONG}\n            onColumnReordered={handleColumnReordered}\n            onColumnResized={handleColumnResized}\n            onRangeChanged={onRangeChanged}\n            onScrollEnd={handleOnScrollEnd}\n            size={size}\n        />\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/songs/components/song-list-paginated-grid.tsx",
    "content": "import { UseSuspenseQueryOptions } from '@tanstack/react-query';\n\nimport { api } from '/@/renderer/api';\nimport { useItemListPaginatedLoader } from '/@/renderer/components/item-list/helpers/item-list-paginated-loader';\nimport { useGridRows } from '/@/renderer/components/item-list/helpers/use-grid-rows';\nimport { ItemGridList } from '/@/renderer/components/item-list/item-grid-list/item-grid-list';\nimport { ItemListWithPagination } from '/@/renderer/components/item-list/item-list-pagination/item-list-pagination';\nimport { useItemListPagination } from '/@/renderer/components/item-list/item-list-pagination/use-item-list-pagination';\nimport { ItemListGridComponentProps } from '/@/renderer/components/item-list/types';\nimport { useListContext } from '/@/renderer/context/list-context';\nimport { songsQueries } from '/@/renderer/features/songs/api/songs-api';\nimport { useGeneralSettings } from '/@/renderer/store';\nimport { LibraryItem, SongListQuery, SongListSort, SortOrder } from '/@/shared/types/domain-types';\nimport { ItemListKey } from '/@/shared/types/types';\n\ninterface SongListPaginatedGridProps extends ItemListGridComponentProps<SongListQuery> {}\n\nexport const SongListPaginatedGrid = ({\n    gap = 'md',\n    itemsPerPage = 100,\n    itemsPerRow,\n    query = {\n        sortBy: SongListSort.NAME,\n        sortOrder: SortOrder.ASC,\n    },\n    serverId,\n    size,\n}: SongListPaginatedGridProps) => {\n    const { pageKey } = useListContext();\n    const { currentPage, onChange } = useItemListPagination();\n\n    const listCountQuery = songsQueries.listCount({\n        query: { ...query, limit: itemsPerPage },\n        serverId: serverId,\n    }) as UseSuspenseQueryOptions<number, Error, number, readonly unknown[]>;\n\n    const listQueryFn = api.controller.getSongList;\n\n    const { data, pageCount, totalItemCount } = useItemListPaginatedLoader({\n        currentPage,\n        eventKey: pageKey || ItemListKey.SONG,\n        itemsPerPage,\n        itemType: LibraryItem.SONG,\n        listCountQuery,\n        listQueryFn,\n        query,\n        serverId,\n    });\n\n    const rows = useGridRows(LibraryItem.SONG, ItemListKey.SONG, size);\n    const { enableGridMultiSelect } = useGeneralSettings();\n\n    return (\n        <ItemListWithPagination\n            currentPage={currentPage}\n            itemsPerPage={itemsPerPage}\n            onChange={onChange}\n            pageCount={pageCount}\n            totalItemCount={totalItemCount}\n        >\n            <ItemGridList\n                currentPage={currentPage}\n                data={data || []}\n                enableMultiSelect={enableGridMultiSelect}\n                gap={gap}\n                itemsPerRow={itemsPerRow}\n                itemType={LibraryItem.SONG}\n                rows={rows}\n                size={size}\n            />\n        </ItemListWithPagination>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/songs/components/song-list-paginated-table.tsx",
    "content": "import { UseSuspenseQueryOptions } from '@tanstack/react-query';\n\nimport { api } from '/@/renderer/api';\nimport { useItemListPaginatedLoader } from '/@/renderer/components/item-list/helpers/item-list-paginated-loader';\nimport { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder';\nimport { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize';\nimport { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist';\nimport { ItemListWithPagination } from '/@/renderer/components/item-list/item-list-pagination/item-list-pagination';\nimport { useItemListPagination } from '/@/renderer/components/item-list/item-list-pagination/use-item-list-pagination';\nimport { ItemTableList } from '/@/renderer/components/item-list/item-table-list/item-table-list';\nimport { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';\nimport { ItemListTableComponentProps } from '/@/renderer/components/item-list/types';\nimport { useListContext } from '/@/renderer/context/list-context';\nimport { songsQueries } from '/@/renderer/features/songs/api/songs-api';\nimport { usePlayerSong } from '/@/renderer/store';\nimport { LibraryItem, SongListQuery, SongListSort, SortOrder } from '/@/shared/types/domain-types';\nimport { ItemListKey } from '/@/shared/types/types';\n\ninterface SongListPaginatedTableProps extends ItemListTableComponentProps<SongListQuery> {}\n\nexport const SongListPaginatedTable = ({\n    autoFitColumns = false,\n    columns,\n    enableAlternateRowColors = false,\n    enableHeader = true,\n    enableHorizontalBorders = false,\n    enableRowHoverHighlight = true,\n    enableSelection = true,\n    enableVerticalBorders = false,\n    itemsPerPage = 100,\n    query = {\n        sortBy: SongListSort.NAME,\n        sortOrder: SortOrder.ASC,\n    },\n    saveScrollOffset = true,\n    serverId,\n    size = 'default',\n}: SongListPaginatedTableProps) => {\n    const { pageKey } = useListContext();\n    const { currentPage, onChange } = useItemListPagination();\n\n    const listCountQuery = songsQueries.listCount({\n        query: { ...query, limit: itemsPerPage },\n        serverId: serverId,\n    }) as UseSuspenseQueryOptions<number, Error, number, readonly unknown[]>;\n\n    const listQueryFn = api.controller.getSongList;\n\n    const { data, pageCount, totalItemCount } = useItemListPaginatedLoader({\n        currentPage,\n        eventKey: pageKey || ItemListKey.SONG,\n        itemsPerPage,\n        itemType: LibraryItem.SONG,\n        listCountQuery,\n        listQueryFn,\n        query,\n        serverId,\n    });\n\n    const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({\n        enabled: saveScrollOffset,\n    });\n\n    const { handleColumnReordered } = useItemListColumnReorder({\n        itemListKey: ItemListKey.SONG,\n    });\n\n    const { handleColumnResized } = useItemListColumnResize({\n        itemListKey: ItemListKey.SONG,\n    });\n\n    const startRowIndex = currentPage * itemsPerPage;\n\n    const currentSong = usePlayerSong();\n\n    return (\n        <ItemListWithPagination\n            currentPage={currentPage}\n            itemsPerPage={itemsPerPage}\n            onChange={onChange}\n            pageCount={pageCount}\n            totalItemCount={totalItemCount}\n        >\n            <ItemTableList\n                activeRowId={currentSong?.id}\n                autoFitColumns={autoFitColumns}\n                CellComponent={ItemTableListColumn}\n                columns={columns}\n                data={data || []}\n                enableAlternateRowColors={enableAlternateRowColors}\n                enableExpansion={false}\n                enableHeader={enableHeader}\n                enableHorizontalBorders={enableHorizontalBorders}\n                enableRowHoverHighlight={enableRowHoverHighlight}\n                enableSelection={enableSelection}\n                enableVerticalBorders={enableVerticalBorders}\n                initialTop={{\n                    to: scrollOffset ?? 0,\n                    type: 'offset',\n                }}\n                itemType={LibraryItem.SONG}\n                onColumnReordered={handleColumnReordered}\n                onColumnResized={handleColumnResized}\n                onScrollEnd={handleOnScrollEnd}\n                size={size}\n                startRowIndex={startRowIndex}\n            />\n        </ItemListWithPagination>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/songs/components/subsonic-song-filters.tsx",
    "content": "import { useSuspenseQuery } from '@tanstack/react-query';\nimport { ChangeEvent, useCallback, useMemo } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport { getItemImageUrl } from '/@/renderer/components/item-image/item-image';\nimport { artistsQueries } from '/@/renderer/features/artists/api/artists-api';\nimport { useGenreList } from '/@/renderer/features/genres/api/genres-api';\nimport {\n    ArtistMultiSelectRow,\n    GenreMultiSelectRow,\n} from '/@/renderer/features/shared/components/multi-select-rows';\nimport { useSongListFilters } from '/@/renderer/features/songs/hooks/use-song-list-filters';\nimport { useCurrentServerId } from '/@/renderer/store';\nimport { Divider } from '/@/shared/components/divider/divider';\nimport { Group } from '/@/shared/components/group/group';\nimport { VirtualMultiSelect } from '/@/shared/components/multi-select/virtual-multi-select';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { Switch } from '/@/shared/components/switch/switch';\nimport { Text } from '/@/shared/components/text/text';\nimport { AlbumArtistListSort, LibraryItem, SortOrder } from '/@/shared/types/domain-types';\n\ninterface SubsonicSongFiltersProps {\n    disableArtistFilter?: boolean;\n    disableGenreFilter?: boolean;\n}\n\nexport const SubsonicSongFilters = ({\n    disableArtistFilter,\n    disableGenreFilter,\n}: SubsonicSongFiltersProps) => {\n    const { t } = useTranslation();\n    const serverId = useCurrentServerId();\n    const { query, setArtistIds, setFavorite, setGenreId } = useSongListFilters();\n\n    const genreListQuery = useGenreList();\n\n    const genreList = useMemo(() => {\n        if (!genreListQuery.data) return [];\n        return genreListQuery.data.items.map((genre) => ({\n            albumCount: genre.albumCount,\n            label: genre.name,\n            songCount: genre.songCount,\n            value: genre.id,\n        }));\n    }, [genreListQuery.data]);\n\n    const selectedGenreIds = useMemo(() => query.genreIds || [], [query.genreIds]);\n\n    const albumArtistListQuery = useSuspenseQuery(\n        artistsQueries.albumArtistList({\n            options: {\n                gcTime: 1000 * 60 * 2,\n                staleTime: 1000 * 60 * 1,\n            },\n            query: {\n                sortBy: AlbumArtistListSort.NAME,\n                sortOrder: SortOrder.ASC,\n                startIndex: 0,\n            },\n            serverId,\n        }),\n    );\n\n    const items = albumArtistListQuery?.data?.items;\n\n    const selectableAlbumArtists = useMemo(() => {\n        if (!items) return [];\n\n        return items.map((artist) => ({\n            albumCount: artist.albumCount,\n            imageUrl: getItemImageUrl({\n                id: artist.id,\n                itemType: LibraryItem.ARTIST,\n                type: 'table',\n            }),\n            label: artist.name,\n            songCount: artist.songCount,\n            value: artist.id,\n        }));\n    }, [items]);\n\n    const selectedArtistIds = useMemo(() => query.artistIds || [], [query.artistIds]);\n\n    const hasFavorite = query.favorite === true;\n    const hasArtist = query.artistIds && query.artistIds.length > 0;\n    const hasGenre = query.genreIds && query.genreIds.length > 0;\n\n    const isFavoriteDisabled = hasArtist || hasGenre;\n    const isArtistDisabled = hasFavorite || hasGenre;\n    const isGenreDisabled = hasFavorite || hasArtist;\n\n    const handleArtistFilter = useCallback(\n        (e: null | string[]) => {\n            if (isArtistDisabled && e !== null) return;\n            setArtistIds(e ?? null);\n        },\n        [isArtistDisabled, setArtistIds],\n    );\n\n    const artistFilterLabel = useMemo(() => {\n        return (\n            <Text fw={500} size=\"sm\">\n                {t('entity.artist', { count: 2, postProcess: 'sentenceCase' })}\n            </Text>\n        );\n    }, [t]);\n\n    const handleGenresFilter = useCallback(\n        (e: null | string[]) => {\n            if (isGenreDisabled && e !== null && e.length > 0) return;\n            if (e && e.length > 0) {\n                setGenreId([e[0]]);\n            } else {\n                setGenreId(null);\n            }\n        },\n        [isGenreDisabled, setGenreId],\n    );\n\n    const genreFilterLabel = useMemo(() => {\n        return (\n            <Text fw={500} size=\"sm\">\n                {t('entity.genre', { count: 1, postProcess: 'sentenceCase' })}\n            </Text>\n        );\n    }, [t]);\n\n    const toggleFilters = useMemo(\n        () => [\n            {\n                label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),\n                onChange: (e: ChangeEvent<HTMLInputElement>) => {\n                    if (isFavoriteDisabled && e.target.checked) return;\n                    const favoriteValue = e.target.checked ? true : undefined;\n                    setFavorite(favoriteValue ?? null);\n                },\n                value: query.favorite,\n            },\n        ],\n        [isFavoriteDisabled, query.favorite, setFavorite, t],\n    );\n\n    return (\n        <Stack px=\"md\" py=\"md\">\n            {toggleFilters.map((filter) => (\n                <Group justify=\"space-between\" key={`ss-filter-${filter.label}`}>\n                    <Text>{filter.label}</Text>\n                    <Switch\n                        checked={filter.value ?? false}\n                        disabled={isFavoriteDisabled}\n                        onChange={filter.onChange}\n                    />\n                </Group>\n            ))}\n            {!disableArtistFilter && (\n                <>\n                    <Divider my=\"md\" />\n                    <VirtualMultiSelect\n                        disabled={isArtistDisabled}\n                        displayCountType=\"song\"\n                        height={300}\n                        isLoading={albumArtistListQuery.isFetching}\n                        label={artistFilterLabel}\n                        onChange={handleArtistFilter}\n                        options={selectableAlbumArtists}\n                        RowComponent={ArtistMultiSelectRow}\n                        singleSelect={true}\n                        value={selectedArtistIds}\n                    />\n                </>\n            )}\n            {!disableGenreFilter && (\n                <>\n                    <Divider my=\"md\" />\n                    <VirtualMultiSelect\n                        disabled={isGenreDisabled}\n                        displayCountType=\"song\"\n                        height={220}\n                        isLoading={genreListQuery.isFetching}\n                        label={genreFilterLabel}\n                        onChange={handleGenresFilter}\n                        options={genreList}\n                        RowComponent={GenreMultiSelectRow}\n                        singleSelect={true}\n                        value={selectedGenreIds}\n                    />\n                </>\n            )}\n        </Stack>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/songs/hooks/use-song-list-filters.ts",
    "content": "import { useCallback, useMemo } from 'react';\nimport { useSearchParams } from 'react-router';\n\nimport { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-search-term-filter';\nimport { useSortByFilter } from '/@/renderer/features/shared/hooks/use-sort-by-filter';\nimport { useSortOrderFilter } from '/@/renderer/features/shared/hooks/use-sort-order-filter';\nimport { FILTER_KEYS } from '/@/renderer/features/shared/utils';\nimport {\n    parseArrayParam,\n    parseBooleanParam,\n    parseCustomFiltersParam,\n    parseIntParam,\n    setJsonSearchParam,\n    setMultipleSearchParams,\n    setSearchParam,\n} from '/@/renderer/utils/query-params';\nimport { SongListSort, SortOrder } from '/@/shared/types/domain-types';\nimport { ItemListKey } from '/@/shared/types/types';\n\nexport const useSongListFilters = (listKey?: ItemListKey) => {\n    const resolvedListKey = listKey ?? ItemListKey.SONG;\n\n    const { sortBy } = useSortByFilter<SongListSort>(SongListSort.NAME, resolvedListKey);\n\n    const { sortOrder } = useSortOrderFilter(SortOrder.ASC, resolvedListKey);\n\n    const { searchTerm, setSearchTerm } = useSearchTermFilter('');\n\n    const [searchParams, setSearchParams] = useSearchParams();\n\n    const genreId = useMemo(\n        () => parseArrayParam(searchParams, FILTER_KEYS.SONG.GENRE_ID),\n        [searchParams],\n    );\n\n    const artistIds = useMemo(\n        () => parseArrayParam(searchParams, FILTER_KEYS.SONG.ARTIST_IDS),\n        [searchParams],\n    );\n\n    const minYear = useMemo(\n        () => parseIntParam(searchParams, FILTER_KEYS.SONG.MIN_YEAR),\n        [searchParams],\n    );\n\n    const maxYear = useMemo(\n        () => parseIntParam(searchParams, FILTER_KEYS.SONG.MAX_YEAR),\n        [searchParams],\n    );\n\n    const favorite = useMemo(\n        () => parseBooleanParam(searchParams, FILTER_KEYS.SONG.FAVORITE),\n        [searchParams],\n    );\n\n    const hasRating = useMemo(\n        () => parseBooleanParam(searchParams, FILTER_KEYS.SONG.HAS_RATING),\n        [searchParams],\n    );\n\n    const custom = useMemo(\n        () => parseCustomFiltersParam(searchParams, FILTER_KEYS.SONG._CUSTOM),\n        [searchParams],\n    );\n\n    const setGenreId = useCallback(\n        (value: null | string[]) => {\n            setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.GENRE_ID, value), {\n                replace: true,\n            });\n        },\n        [setSearchParams],\n    );\n\n    const setArtistIds = useCallback(\n        (value: null | string[]) => {\n            setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.ARTIST_IDS, value), {\n                replace: true,\n            });\n        },\n        [setSearchParams],\n    );\n\n    const setMinYear = useCallback(\n        (value: null | number) => {\n            setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.MIN_YEAR, value), {\n                replace: true,\n            });\n        },\n        [setSearchParams],\n    );\n\n    const setMaxYear = useCallback(\n        (value: null | number) => {\n            setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.MAX_YEAR, value), {\n                replace: true,\n            });\n        },\n        [setSearchParams],\n    );\n\n    const setFavorite = useCallback(\n        (value: boolean | null) => {\n            setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.FAVORITE, value), {\n                replace: true,\n            });\n        },\n        [setSearchParams],\n    );\n\n    const setHasRating = useCallback(\n        (value: boolean | null) => {\n            setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.HAS_RATING, value), {\n                replace: true,\n            });\n        },\n        [setSearchParams],\n    );\n\n    const setCustom = useCallback(\n        (\n            value:\n                | ((prev: null | Record<string, any>) => null | Record<string, any>)\n                | null\n                | Record<string, any>,\n        ) => {\n            setSearchParams(\n                (prev) => {\n                    const currentCustom = parseCustomFiltersParam(prev, FILTER_KEYS.SONG._CUSTOM);\n                    let newValue =\n                        typeof value === 'function' ? value(currentCustom ?? null) : value;\n                    // Convert empty objects to null to clear them from URL\n                    if (\n                        newValue &&\n                        typeof newValue === 'object' &&\n                        Object.keys(newValue).length === 0\n                    ) {\n                        newValue = null;\n                    }\n                    return setJsonSearchParam(prev, FILTER_KEYS.SONG._CUSTOM, newValue);\n                },\n                { replace: true },\n            );\n        },\n        [setSearchParams],\n    );\n\n    const clear = useCallback(() => {\n        setSearchParams(\n            (prev) =>\n                setMultipleSearchParams(\n                    prev,\n                    {\n                        [FILTER_KEYS.SHARED.SEARCH_TERM]: null,\n                        [FILTER_KEYS.SONG._CUSTOM]: null,\n                        [FILTER_KEYS.SONG.ARTIST_IDS]: null,\n                        [FILTER_KEYS.SONG.FAVORITE]: null,\n                        [FILTER_KEYS.SONG.GENRE_ID]: null,\n                        [FILTER_KEYS.SONG.HAS_RATING]: null,\n                        [FILTER_KEYS.SONG.MAX_YEAR]: null,\n                        [FILTER_KEYS.SONG.MIN_YEAR]: null,\n                    },\n                    new Set([FILTER_KEYS.SONG._CUSTOM]),\n                ),\n            { replace: true },\n        );\n    }, [setSearchParams]);\n\n    const query = useMemo(\n        () => ({\n            [FILTER_KEYS.SHARED.SEARCH_TERM]: searchTerm ?? undefined,\n            [FILTER_KEYS.SHARED.SORT_BY]: sortBy ?? undefined,\n            [FILTER_KEYS.SHARED.SORT_ORDER]: sortOrder ?? undefined,\n            [FILTER_KEYS.SONG._CUSTOM]: custom ?? undefined,\n            [FILTER_KEYS.SONG.ARTIST_IDS]: artistIds ?? undefined,\n            [FILTER_KEYS.SONG.FAVORITE]: favorite ?? undefined,\n            [FILTER_KEYS.SONG.GENRE_ID]: genreId ?? undefined,\n            [FILTER_KEYS.SONG.HAS_RATING]: hasRating ?? undefined,\n            [FILTER_KEYS.SONG.MAX_YEAR]: maxYear ?? undefined,\n            [FILTER_KEYS.SONG.MIN_YEAR]: minYear ?? undefined,\n        }),\n        [\n            searchTerm,\n            sortBy,\n            sortOrder,\n            custom,\n            artistIds,\n            favorite,\n            genreId,\n            hasRating,\n            maxYear,\n            minYear,\n        ],\n    );\n\n    return {\n        clear,\n        query,\n        setArtistIds,\n        setCustom,\n        setFavorite,\n        setGenreId,\n        setHasRating,\n        setMaxYear,\n        setMinYear,\n        setSearchTerm,\n    };\n};\n"
  },
  {
    "path": "src/renderer/features/songs/routes/song-list-route.tsx",
    "content": "import { useMemo, useState } from 'react';\nimport { useParams } from 'react-router';\n\nimport { ListContext } from '/@/renderer/context/list-context';\nimport { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';\nimport { ListWithSidebarContainer } from '/@/renderer/features/shared/components/list-with-sidebar-container';\nimport { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';\nimport { SongListContent } from '/@/renderer/features/songs/components/song-list-content';\nimport { SongListHeader } from '/@/renderer/features/songs/components/song-list-header';\nimport { usePageSidebar } from '/@/renderer/store/app.store';\nimport { SongListQuery } from '/@/shared/types/domain-types';\nimport { ItemListKey } from '/@/shared/types/types';\n\nconst getPageKey = (options: { albumArtistId?: string; genreId?: string }) => {\n    if (options.albumArtistId) {\n        return ItemListKey.ALBUM_ARTIST_SONG;\n    }\n\n    if (options.genreId) {\n        return ItemListKey.GENRE_SONG;\n    }\n\n    return ItemListKey.SONG;\n};\n\nconst SongListRoute = () => {\n    const { albumArtistId, genreId } = useParams();\n    const pageKey = getPageKey({ albumArtistId, genreId });\n\n    const [itemCount, setItemCount] = useState<number | undefined>(undefined);\n    const [isSidebarOpen, setIsSidebarOpen] = usePageSidebar(pageKey);\n\n    const customFilters: Partial<SongListQuery> = useMemo(() => {\n        if (albumArtistId) {\n            return {\n                artistIds: [albumArtistId],\n            };\n        }\n\n        if (genreId) {\n            return {\n                genreIds: [genreId],\n            };\n        }\n\n        return {};\n    }, [albumArtistId, genreId]);\n\n    const providerValue = useMemo(() => {\n        return {\n            customFilters,\n            id: albumArtistId ?? genreId,\n            isSidebarOpen,\n            itemCount,\n            pageKey,\n            setIsSidebarOpen,\n            setItemCount,\n        };\n    }, [\n        albumArtistId,\n        customFilters,\n        genreId,\n        isSidebarOpen,\n        itemCount,\n        pageKey,\n        setIsSidebarOpen,\n    ]);\n\n    return (\n        <AnimatedPage>\n            <ListContext.Provider value={providerValue}>\n                <SongListHeader />\n                <ListWithSidebarContainer>\n                    <SongListContent />\n                </ListWithSidebarContainer>\n            </ListContext.Provider>\n        </AnimatedPage>\n    );\n};\n\nconst SongListRouteWithBoundary = () => {\n    return (\n        <PageErrorBoundary>\n            <SongListRoute />\n        </PageErrorBoundary>\n    );\n};\n\nexport default SongListRouteWithBoundary;\n"
  },
  {
    "path": "src/renderer/features/titlebar/components/app-menu.tsx",
    "content": "import { openModal } from '@mantine/modals';\nimport isElectron from 'is-electron';\nimport { Fragment, ReactNode } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { Link, useNavigate } from 'react-router';\n\nimport packageJson from '../../../../../package.json';\n\nimport { isServerLock } from '/@/renderer/features/action-required/utils/window-properties';\nimport { ServerList } from '/@/renderer/features/servers/components/server-list';\nimport { openSettingsModal } from '/@/renderer/features/settings/utils/open-settings-modal';\nimport { openReleaseNotesModal } from '/@/renderer/release-notes-modal';\nimport {\n    useAppStore,\n    useAppStoreActions,\n    useCommandPalette,\n    useGeneralSettings,\n    useSettingsStoreActions,\n} from '/@/renderer/store';\nimport { ActionIcon } from '/@/shared/components/action-icon/action-icon';\nimport { DropdownMenu, MenuItemProps } from '/@/shared/components/dropdown-menu/dropdown-menu';\nimport { Group } from '/@/shared/components/group/group';\nimport { Icon } from '/@/shared/components/icon/icon';\nimport { toast } from '/@/shared/components/toast/toast';\n\nconst browser = isElectron() ? window.api.browser : null;\n\ninterface BaseMenuItem {\n    id: string;\n    type: 'conditional-group' | 'conditional-item' | 'custom' | 'divider' | 'item';\n}\n\ninterface ConditionalGroupItem extends BaseMenuItem {\n    condition: boolean;\n    items: MenuItem[];\n    type: 'conditional-group';\n}\n\ninterface ConditionalItem extends BaseMenuItem {\n    condition: boolean;\n    item: Omit<MenuItem, 'id' | 'type'>;\n    type: 'conditional-item';\n}\n\ninterface CustomItem extends BaseMenuItem {\n    component: ReactNode;\n    type: 'custom';\n}\n\ninterface DividerItem extends BaseMenuItem {\n    type: 'divider';\n}\n\ntype MenuItem = ConditionalGroupItem | ConditionalItem | CustomItem | DividerItem | RegularMenuItem;\n\ninterface RegularMenuItem extends BaseMenuItem {\n    component?: 'a' | typeof Link;\n    href?: string;\n    icon?: keyof typeof import('/@/shared/components/icon/icon').AppIcon;\n    iconColor?:\n        | 'contrast'\n        | 'default'\n        | 'error'\n        | 'info'\n        | 'inherit'\n        | 'muted'\n        | 'primary'\n        | 'success'\n        | 'warn';\n    label: string;\n    leftSection?: ReactNode;\n    onClick?: () => void;\n    rightSection?: ReactNode;\n    target?: string;\n    to?: string;\n    type: 'item';\n}\n\nexport const AppMenu = () => {\n    const { t } = useTranslation();\n    const navigate = useNavigate();\n    const collapsed = useAppStore((state) => state.sidebar.collapsed);\n    const privateMode = useAppStore((state) => state.privateMode);\n    const { setPrivateMode, setSideBar } = useAppStoreActions();\n    const { setSettings } = useSettingsStoreActions();\n    const settings = useGeneralSettings();\n    const { open: openCommandPalette } = useCommandPalette();\n\n    const handleBrowserDevTools = () => {\n        browser?.devtools();\n    };\n\n    const handleCollapseSidebar = () => {\n        setSideBar({ collapsed: true });\n    };\n\n    const handleExpandSidebar = () => {\n        setSideBar({ collapsed: false });\n    };\n\n    const handlePrivateModeOff = () => {\n        setPrivateMode(false);\n        toast.info({\n            message: t('form.privateMode.disabled', { postProcess: 'sentenceCase' }),\n            title: t('form.privateMode.title', { postProcess: 'sentenceCase' }),\n        });\n    };\n\n    const handlePrivateModeOn = () => {\n        setPrivateMode(true);\n        toast.info({\n            message: t('form.privateMode.enabled', { postProcess: 'sentenceCase' }),\n            title: t('form.privateMode.title', { postProcess: 'sentenceCase' }),\n        });\n    };\n\n    const handleManageServersModal = () => {\n        openModal({\n            children: <ServerList />,\n            title: t('page.manageServers.title', { postProcess: 'titleCase' }),\n        });\n    };\n\n    const handleQuit = () => {\n        browser?.quit();\n    };\n\n    const handleSetSideQueueLayout = (sideQueueLayout: 'horizontal' | 'vertical') => {\n        setSettings({\n            general: {\n                ...settings,\n                sideQueueLayout,\n            },\n        });\n    };\n\n    const menuConfig: MenuItem[] = [\n        {\n            icon: 'search',\n            id: 'command-palette',\n            label: t('page.appMenu.commandPalette', { postProcess: 'sentenceCase' }),\n            onClick: openCommandPalette,\n            type: 'item',\n        },\n        {\n            id: 'divider-1',\n            type: 'divider',\n        },\n        {\n            condition: collapsed,\n            id: 'navigation-group',\n            items: [\n                {\n                    icon: 'arrowLeftS',\n                    id: 'go-back',\n                    label: t('page.appMenu.goBack', { postProcess: 'sentenceCase' }),\n                    onClick: () => navigate(-1),\n                    type: 'item',\n                },\n                {\n                    icon: 'arrowRightS',\n                    id: 'go-forward',\n                    label: t('page.appMenu.goForward', { postProcess: 'sentenceCase' }),\n                    onClick: () => navigate(1),\n                    type: 'item',\n                },\n            ],\n            type: 'conditional-group',\n        },\n        {\n            condition: collapsed,\n            id: 'sidebar-expand',\n            item: {\n                icon: 'panelRightOpen',\n                id: 'expand-sidebar',\n                label: t('page.appMenu.expandSidebar', { postProcess: 'sentenceCase' }),\n                onClick: handleExpandSidebar,\n                type: 'item',\n            },\n            type: 'conditional-item',\n        },\n        {\n            condition: !collapsed,\n            id: 'sidebar-collapse',\n            item: {\n                icon: 'panelRightClose',\n                id: 'collapse-sidebar',\n                label: t('page.appMenu.collapseSidebar', { postProcess: 'sentenceCase' }),\n                onClick: handleCollapseSidebar,\n                type: 'item',\n            },\n            type: 'conditional-item',\n        },\n        {\n            id: 'divider-2',\n            type: 'divider',\n        },\n        {\n            condition: !isServerLock(),\n            id: 'manage-servers',\n            item: {\n                label: t('page.appMenu.manageServers', { postProcess: 'sentenceCase' }),\n                leftSection: <Icon icon=\"edit\" />,\n                onClick: handleManageServersModal,\n                type: 'item',\n            },\n            type: 'conditional-item',\n        },\n        {\n            id: 'divider-3',\n            type: 'divider',\n        },\n        {\n            icon: 'settings',\n            id: 'settings',\n            label: t('page.appMenu.settings', { postProcess: 'sentenceCase' }),\n            onClick: () => openSettingsModal(),\n            type: 'item',\n        },\n        {\n            condition: privateMode,\n            id: 'private-mode-off',\n            item: {\n                icon: 'lock',\n                iconColor: 'error',\n                label: t('page.appMenu.privateModeOff', { postProcess: 'sentenceCase' }),\n                onClick: handlePrivateModeOff,\n                type: 'item',\n            },\n            type: 'conditional-item',\n        },\n        {\n            condition: !privateMode,\n            id: 'private-mode-on',\n            item: {\n                icon: 'lockOpen',\n                label: t('page.appMenu.privateModeOn', { postProcess: 'sentenceCase' }),\n                onClick: handlePrivateModeOn,\n                type: 'item',\n            },\n            type: 'conditional-item',\n        },\n        {\n            id: 'divider-4',\n            type: 'divider',\n        },\n        {\n            icon: 'brandGitHub',\n            id: 'version',\n            label: t('page.appMenu.version', {\n                postProcess: 'sentenceCase',\n                version: packageJson.version,\n            }),\n            onClick: () =>\n                openReleaseNotesModal(\n                    t('common.newVersion', {\n                        postProcess: 'sentenceCase',\n                        version: packageJson.version,\n                    }) as string,\n                ),\n            type: 'item',\n        },\n        {\n            condition: isElectron(),\n            id: 'devtools',\n            item: {\n                icon: 'appWindow',\n                id: 'open-devtools',\n                label: t('page.appMenu.openBrowserDevtools', { postProcess: 'sentenceCase' }),\n                onClick: handleBrowserDevTools,\n                type: 'item',\n            },\n            type: 'conditional-item',\n        },\n        {\n            condition: isElectron(),\n            id: 'quit',\n            item: {\n                icon: 'x',\n                id: 'quit-app',\n                label: t('page.appMenu.quit', { postProcess: 'sentenceCase' }),\n                onClick: handleQuit,\n                type: 'item',\n            },\n            type: 'conditional-item',\n        },\n        {\n            id: 'divider-5',\n            type: 'divider',\n        },\n        {\n            condition: settings.sideQueueType === 'sideQueue',\n            id: 'layout-toggle-group',\n            items: [\n                {\n                    component: (\n                        <Group gap=\"xs\" grow pb=\"xs\" pt=\"sm\" px=\"xs\" w=\"100%\">\n                            <ActionIcon\n                                icon=\"layoutPanelRight\"\n                                iconProps={{\n                                    size: 'xl',\n                                }}\n                                onClick={() => handleSetSideQueueLayout('horizontal')}\n                                tooltip={{\n                                    label: t('setting.sidePlayQueueLayout', {\n                                        context: 'optionHorizontal',\n                                        postProcess: 'sentenceCase',\n                                    }),\n                                    openDelay: 0,\n                                    position: 'bottom',\n                                }}\n                                variant={\n                                    settings.sideQueueLayout === 'horizontal'\n                                        ? 'default'\n                                        : 'transparent'\n                                }\n                            />\n                            <ActionIcon\n                                icon=\"layoutPanelBottom\"\n                                iconProps={{\n                                    size: 'xl',\n                                }}\n                                onClick={() => handleSetSideQueueLayout('vertical')}\n                                tooltip={{\n                                    label: t('setting.sidePlayQueueLayout', {\n                                        context: 'optionVertical',\n                                        postProcess: 'sentenceCase',\n                                    }),\n                                    openDelay: 0,\n                                    position: 'bottom',\n                                }}\n                                variant={\n                                    settings.sideQueueLayout === 'vertical'\n                                        ? 'default'\n                                        : 'transparent'\n                                }\n                            />\n                        </Group>\n                    ),\n                    id: 'layout-toggle',\n                    type: 'custom',\n                },\n            ],\n            type: 'conditional-group',\n        },\n    ];\n\n    const renderMenuItem = (item: MenuItem): ReactNode => {\n        switch (item.type) {\n            case 'conditional-group':\n                if (!item.condition) return null;\n                return (\n                    <div key={item.id}>\n                        {item.items.map((subItem) => {\n                            return <Fragment key={subItem.id}>{renderMenuItem(subItem)}</Fragment>;\n                        })}\n                    </div>\n                );\n\n            case 'conditional-item':\n                if (!item.condition) return null;\n                return <Fragment key={item.id}>{renderMenuItem(item.item as MenuItem)}</Fragment>;\n\n            case 'custom':\n                return <div key={item.id}>{item.component}</div>;\n\n            case 'divider':\n                return <DropdownMenu.Divider key={item.id} />;\n\n            case 'item': {\n                const leftSection =\n                    item.leftSection ||\n                    (item.icon && <Icon color={item.iconColor} icon={item.icon} />);\n\n                const props = {\n                    leftSection,\n                    ...(item.rightSection && { rightSection: item.rightSection }),\n                    ...(item.onClick && { onClick: item.onClick }),\n                    ...(item.component && { component: item.component }),\n                    ...(item.to && { to: item.to }),\n                    ...(item.href && { href: item.href }),\n                    ...(item.target && { target: item.target }),\n                } as MenuItemProps;\n\n                return (\n                    <DropdownMenu.Item key={item.id} {...props}>\n                        {item.label}\n                    </DropdownMenu.Item>\n                );\n            }\n\n            default:\n                return null;\n        }\n    };\n\n    return <>{menuConfig.map((item) => renderMenuItem(item))}</>;\n};\n"
  },
  {
    "path": "src/renderer/features/titlebar/components/titlebar.module.css",
    "content": ".titlebar-container {\n    display: flex;\n    flex-direction: row;\n    align-items: center;\n    justify-content: space-between;\n\n    button {\n        -webkit-app-region: no-drag;\n    }\n}\n\n.right {\n    display: flex;\n    flex: 1/3;\n    justify-content: center;\n    height: 100%;\n}\n"
  },
  {
    "path": "src/renderer/features/titlebar/components/titlebar.tsx",
    "content": "import type { ReactNode } from 'react';\n\nimport styles from './titlebar.module.css';\n\nimport { WindowControls } from '/@/renderer/features/window-controls/components/window-controls';\nimport { Group } from '/@/shared/components/group/group';\n\ninterface TitlebarProps {\n    children?: ReactNode;\n}\n\nexport const Titlebar = ({ children }: TitlebarProps) => {\n    return (\n        <>\n            <div className={styles.titlebarContainer}>\n                <div className={styles.right}>\n                    {children}\n                    <Group gap=\"xs\">\n                        <WindowControls />\n                    </Group>\n                </div>\n            </div>\n        </>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/visualizer/components/audiomotionanalyzer/presets.ts",
    "content": "import { nanoid } from 'nanoid';\n\nexport const audiomotionanalyzerPresets = [\n    {\n        id: nanoid(),\n        name: 'Preset 1',\n        value: {\n            alphaBars: false,\n            ansiBands: false,\n            barSpace: 0.7,\n            channelLayout: 'single',\n            colorMode: 'gradient',\n            fadePeaks: true,\n            fftSize: 16384,\n            fillAlpha: 0,\n            frequencyScale: 'log',\n            gradient: 'prism',\n            gravity: 11,\n            ledBars: false,\n            linearAmplitude: false,\n            linearBoost: 4,\n            lineWidth: 1.9,\n            loRes: false,\n            lumiBars: false,\n            maxDecibels: -25,\n            maxFPS: 0,\n            maxFreq: 22050,\n            minDecibels: -85,\n            minFreq: 20,\n            mirror: 0,\n            mode: 10,\n            noteLabels: false,\n            outlineBars: false,\n            peakFadeTime: 900,\n            peakHoldTime: 500,\n            peakLine: true,\n            radial: false,\n            radialInvert: false,\n            radius: 0.7,\n            reflexAlpha: 0.1,\n            reflexBright: 1,\n            reflexFit: false,\n            reflexRatio: 0.5,\n            roundBars: false,\n            showFPS: false,\n            showPeaks: false,\n            showScaleX: false,\n            showScaleY: false,\n            smoothing: 0.6,\n            spinSpeed: 0,\n            splitGradient: false,\n            trueLeds: false,\n            volume: 1,\n            weightingFilter: '',\n        },\n    },\n    {\n        id: nanoid(),\n        name: 'Preset 2',\n        value: {\n            alphaBars: false,\n            ansiBands: false,\n            barSpace: 0.7,\n            channelLayout: 'single',\n            colorMode: 'gradient',\n            fadePeaks: true,\n            fftSize: 8192,\n            fillAlpha: 1,\n            frequencyScale: 'log',\n            gradient: 'prism',\n            gravity: 11,\n            ledBars: true,\n            linearAmplitude: false,\n            linearBoost: 4,\n            lineWidth: 0,\n            loRes: false,\n            lumiBars: false,\n            maxDecibels: -25,\n            maxFPS: 0,\n            maxFreq: 8000,\n            minDecibels: -85,\n            minFreq: 20,\n            mirror: 0,\n            mode: 4,\n            noteLabels: false,\n            outlineBars: false,\n            peakFadeTime: 900,\n            peakHoldTime: 500,\n            peakLine: true,\n            radial: false,\n            radialInvert: false,\n            radius: 0.7,\n            reflexAlpha: 0.5,\n            reflexBright: 1,\n            reflexFit: false,\n            reflexRatio: 0.5,\n            roundBars: false,\n            showFPS: false,\n            showPeaks: false,\n            showScaleX: false,\n            showScaleY: false,\n            smoothing: 0.7,\n            spinSpeed: 0.5,\n            splitGradient: false,\n            trueLeds: false,\n            volume: 1,\n            weightingFilter: '',\n        },\n    },\n    {\n        id: nanoid(),\n        name: 'Preset 3',\n        value: {\n            alphaBars: false,\n            ansiBands: false,\n            barSpace: 0,\n            channelLayout: 'single',\n            colorMode: 'gradient',\n            fadePeaks: true,\n            fftSize: 4096,\n            fillAlpha: 0,\n            frequencyScale: 'log',\n            gradient: 'prism',\n            gradientLeft: 'rainbow',\n            gradientRight: 'prism',\n            gravity: 11,\n            ledBars: true,\n            linearAmplitude: false,\n            linearBoost: 4,\n            lineWidth: 1.9,\n            loRes: false,\n            lumiBars: false,\n            maxDecibels: -25,\n            maxFPS: 0,\n            maxFreq: 15000,\n            minDecibels: -85,\n            minFreq: 20,\n            mirror: 0,\n            mode: 8,\n            noteLabels: false,\n            outlineBars: false,\n            peakFadeTime: 900,\n            peakHoldTime: 500,\n            peakLine: true,\n            radial: false,\n            radialInvert: false,\n            radius: 0.7,\n            reflexAlpha: 0.45,\n            reflexBright: 1,\n            reflexFit: false,\n            reflexRatio: 0,\n            roundBars: false,\n            showFPS: false,\n            showPeaks: false,\n            showScaleX: false,\n            showScaleY: false,\n            smoothing: 0.8,\n            spinSpeed: 0.5,\n            splitGradient: false,\n            trueLeds: false,\n            volume: 1,\n            weightingFilter: '',\n        },\n    },\n    {\n        id: nanoid(),\n        name: 'Preset 4',\n        value: {\n            alphaBars: false,\n            ansiBands: false,\n            barSpace: 0,\n            channelLayout: 'dual-combined',\n            colorMode: 'gradient',\n            fadePeaks: true,\n            fftSize: 16384,\n            fillAlpha: 0.2,\n            frequencyScale: 'log',\n            gradient: 'prism',\n            gradientLeft: 'prism',\n            gradientRight: 'rainbow',\n            gravity: 11,\n            ledBars: true,\n            linearAmplitude: false,\n            linearBoost: 4,\n            lineWidth: 1.9,\n            loRes: false,\n            lumiBars: false,\n            maxDecibels: -25,\n            maxFPS: 0,\n            maxFreq: 22050,\n            minDecibels: -85,\n            minFreq: 50,\n            mirror: 0,\n            mode: 10,\n            noteLabels: false,\n            outlineBars: false,\n            peakFadeTime: 900,\n            peakHoldTime: 500,\n            peakLine: true,\n            radial: false,\n            radialInvert: false,\n            radius: 0.7,\n            reflexAlpha: 0.45,\n            reflexBright: 1,\n            reflexFit: true,\n            reflexRatio: 0.2,\n            roundBars: false,\n            showFPS: false,\n            showPeaks: false,\n            showScaleX: false,\n            showScaleY: false,\n            smoothing: 0.8,\n            spinSpeed: 0.5,\n            splitGradient: false,\n            trueLeds: false,\n            volume: 1,\n            weightingFilter: 'D',\n        },\n    },\n];\n"
  },
  {
    "path": "src/renderer/features/visualizer/components/audiomotionanalyzer/visualizer-settings-form.module.css",
    "content": ".container {\n    display: flex;\n    flex-direction: column;\n    gap: var(--theme-spacing-md);\n    width: 100%;\n    margin: 0 auto;\n}\n\n.select-label {\n    text-align: center;\n}\n"
  },
  {
    "path": "src/renderer/features/visualizer/components/audiomotionanalyzer/visualizer-settings-form.tsx",
    "content": "import { nanoid } from 'nanoid';\nimport { useEffect, useMemo, useRef, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport styles from './visualizer-settings-form.module.css';\n\nimport i18n from '/@/i18n/i18n';\nimport { getButterchurnPresetOptions } from '/@/renderer/features/visualizer/components/butternchurn/visualizer';\nimport { useSettingsStoreActions, useVisualizerSettings } from '/@/renderer/store/settings.store';\nimport { ActionIcon } from '/@/shared/components/action-icon/action-icon';\nimport { Button } from '/@/shared/components/button/button';\nimport { Checkbox } from '/@/shared/components/checkbox/checkbox';\nimport { ColorInput } from '/@/shared/components/color-input/color-input';\nimport { Divider } from '/@/shared/components/divider/divider';\nimport { Fieldset } from '/@/shared/components/fieldset/fieldset';\nimport { Group } from '/@/shared/components/group/group';\nimport { MultiSelect } from '/@/shared/components/multi-select/multi-select';\nimport { NumberInput } from '/@/shared/components/number-input/number-input';\nimport { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control';\nimport { Select, SelectProps } from '/@/shared/components/select/select';\nimport { Slider, SliderProps } from '/@/shared/components/slider/slider';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { TextInput } from '/@/shared/components/text-input/text-input';\nimport { Text } from '/@/shared/components/text/text';\nimport { Textarea } from '/@/shared/components/textarea/textarea';\nimport { toast } from '/@/shared/components/toast/toast';\n\ntype ButterchurnPresetOption = { label: string; value: string };\n\nlet butterchurnPresetOptionsCache: ButterchurnPresetOption[] | null = null;\n\nconst loadButterchurnPresetOptions = async (): Promise<ButterchurnPresetOption[]> => {\n    if (butterchurnPresetOptionsCache) return butterchurnPresetOptionsCache;\n\n    const mod = await import('butterchurn-presets');\n    const presets = getButterchurnPresetOptions((mod as any).default ?? mod);\n    const presetNames = Object.keys(presets);\n\n    butterchurnPresetOptionsCache = presetNames.map((presetName) => ({\n        label: presetName,\n        value: presetName,\n    }));\n\n    return butterchurnPresetOptionsCache;\n};\n\nconst useButterchurnPresetOptions = () => {\n    const [options, setOptions] = useState<ButterchurnPresetOption[]>(\n        butterchurnPresetOptionsCache ?? [],\n    );\n\n    useEffect(() => {\n        if (butterchurnPresetOptionsCache) return;\n        void loadButterchurnPresetOptions().then(setOptions);\n    }, []);\n\n    return options;\n};\n\nconst modeOptions: { label: string; value: string }[] = [\n    { label: i18n.t('visualizer.options.mode.0') as string, value: '0' },\n    { label: i18n.t('visualizer.options.mode.1') as string, value: '1' },\n    { label: i18n.t('visualizer.options.mode.2') as string, value: '2' },\n    { label: i18n.t('visualizer.options.mode.3') as string, value: '3' },\n    { label: i18n.t('visualizer.options.mode.4') as string, value: '4' },\n    { label: i18n.t('visualizer.options.mode.5') as string, value: '5' },\n    { label: i18n.t('visualizer.options.mode.6') as string, value: '6' },\n    { label: i18n.t('visualizer.options.mode.7') as string, value: '7' },\n    { label: i18n.t('visualizer.options.mode.8') as string, value: '8' },\n    { label: i18n.t('visualizer.options.mode.10') as string, value: '10' },\n];\n\nconst colorModeOptions: { label: string; value: string }[] = [\n    { label: i18n.t('visualizer.options.colorMode.gradient') as string, value: 'gradient' },\n    { label: i18n.t('visualizer.options.colorMode.barIndex') as string, value: 'bar-index' },\n    { label: i18n.t('visualizer.options.colorMode.barLevel') as string, value: 'bar-level' },\n];\n\nconst gradientOptions: { label: string; value: string }[] = [\n    { label: i18n.t('visualizer.options.gradient.classic') as string, value: 'classic' },\n    { label: i18n.t('visualizer.options.gradient.prism') as string, value: 'prism' },\n    { label: i18n.t('visualizer.options.gradient.rainbow') as string, value: 'rainbow' },\n    { label: i18n.t('visualizer.options.gradient.steelblue') as string, value: 'steelblue' },\n    { label: i18n.t('visualizer.options.gradient.orangered') as string, value: 'orangered' },\n];\n\nconst channelLayoutOptions: { label: string; value: string }[] = [\n    { label: i18n.t('visualizer.options.channelLayout.single') as string, value: 'single' },\n    {\n        label: i18n.t('visualizer.options.channelLayout.dualCombined') as string,\n        value: 'dual-combined',\n    },\n    {\n        label: i18n.t('visualizer.options.channelLayout.dualHorizontal') as string,\n        value: 'dual-horizontal',\n    },\n    {\n        label: i18n.t('visualizer.options.channelLayout.dualVertical') as string,\n        value: 'dual-vertical',\n    },\n];\n\nconst fftSizeOptions: { label: string; value: string }[] = [\n    { label: '1024', value: '1024' },\n    { label: '2048', value: '2048' },\n    { label: '4096', value: '4096' },\n    { label: '8192', value: '8192' },\n    { label: '16384', value: '16384' },\n    { label: '32768', value: '32768' },\n];\n\nconst frequencyScaleOptions: { label: string; value: string }[] = [\n    { label: i18n.t('visualizer.options.frequencyScale.bark') as string, value: 'bark' },\n    { label: i18n.t('visualizer.options.frequencyScale.linear') as string, value: 'linear' },\n    { label: i18n.t('visualizer.options.frequencyScale.log') as string, value: 'log' },\n    { label: i18n.t('visualizer.options.frequencyScale.mel') as string, value: 'mel' },\n];\n\nconst weightingFilterOptions = [\n    { label: i18n.t('visualizer.options.weightingFilter.none') as string, value: '' },\n    { label: i18n.t('visualizer.options.weightingFilter.a') as string, value: 'A' },\n    { label: i18n.t('visualizer.options.weightingFilter.b') as string, value: 'B' },\n    { label: i18n.t('visualizer.options.weightingFilter.C') as string, value: 'C' },\n    { label: i18n.t('visualizer.options.weightingFilter.D') as string, value: 'D' },\n    { label: i18n.t('visualizer.options.weightingFilter.z') as string, value: 'Z' },\n];\n\nconst minFreqOptions = [\n    { label: '20', value: '20' },\n    { label: '30', value: '30' },\n    { label: '40', value: '40' },\n    { label: '50', value: '50' },\n];\n\nconst maxFreqOptions = [\n    { label: '8000', value: '8000' },\n    { label: '10000', value: '10000' },\n    { label: '15000', value: '15000' },\n    { label: '20000', value: '20000' },\n    { label: '22050', value: '22050' },\n];\n\nconst barSpaceOptions = [\n    { label: '0', value: '0' },\n    { label: '0.1', value: '0.1' },\n    { label: '0.25', value: '0.2' },\n    { label: '0.4', value: '0.4' },\n    { label: '0.5', value: '0.5' },\n    { label: '0.75', value: '0.7' },\n    { label: '1.0', value: '1.0' },\n];\n\nconst useUpdateAudioMotionAnalyzer = () => {\n    const visualizer = useVisualizerSettings();\n    const { setSettings } = useSettingsStoreActions();\n\n    const updateProperty = <K extends keyof typeof visualizer.audiomotionanalyzer>(\n        property: K,\n        value: (typeof visualizer.audiomotionanalyzer)[K],\n    ) => {\n        setSettings({\n            visualizer: {\n                audiomotionanalyzer: {\n                    [property]: value,\n                },\n            },\n        });\n    };\n\n    return { updateProperty, visualizer };\n};\n\nconst useUpdateButterchurn = () => {\n    const visualizer = useVisualizerSettings();\n    const { setSettings } = useSettingsStoreActions();\n\n    const updateProperty = <K extends keyof typeof visualizer.butterchurn>(\n        property: K,\n        value: (typeof visualizer.butterchurn)[K],\n    ) => {\n        setSettings({\n            visualizer: {\n                butterchurn: {\n                    [property]: value,\n                },\n            },\n        });\n    };\n\n    return { updateProperty, visualizer };\n};\n\nexport const VisualizerSettingsForm = () => {\n    const { t } = useTranslation();\n    const visualizer = useVisualizerSettings();\n    const { setSettings } = useSettingsStoreActions();\n\n    const visualizerTypeOptions = useMemo(\n        () => [\n            { label: 'AudioMotion Analyzer', value: 'audiomotionanalyzer' },\n            { label: 'Butterchurn', value: 'butterchurn' },\n        ],\n        [],\n    );\n\n    const handleTypeChange = (value: string) => {\n        setSettings({\n            visualizer: {\n                type: value as 'audiomotionanalyzer' | 'butterchurn',\n            },\n        });\n    };\n\n    return (\n        <div className={styles.container}>\n            <Fieldset legend={t('visualizer.visualizerType')}>\n                <Stack>\n                    <SegmentedControl\n                        data={visualizerTypeOptions}\n                        onChange={handleTypeChange}\n                        value={visualizer.type}\n                    />\n                </Stack>\n            </Fieldset>\n            {visualizer.type === 'audiomotionanalyzer' && (\n                <>\n                    <PresetSettings />\n                    <GeneralSettings />\n                    <ColorSettings />\n                    <FFTSettings />\n                    <FrequencySettings />\n                    <SensitivitySettings />\n                    <LinearAmplitudeSettings />\n                    <PeakBehaviorSettings />\n                    <RadialSpectrumSettings />\n                    <ReflexMirrorSettings />\n                    <ToggleSettings />\n                </>\n            )}\n            {visualizer.type === 'butterchurn' && (\n                <>\n                    <ButterchurnGeneralSettings />\n                    <ButterChurnCycleSettings />\n                </>\n            )}\n        </div>\n    );\n};\n\nconst VisualizerSelect = (props: SelectProps) => {\n    return (\n        <Select\n            searchable\n            styles={{ label: { display: 'flex', justifyContent: 'center' } }}\n            {...props}\n        />\n    );\n};\n\nconst VisualizerSlider = (props: SliderProps & { label?: React.ReactNode }) => {\n    const { defaultValue, label, max, min, onChange, onChangeEnd, step, ...rest } = props;\n\n    const sliderRef = useRef<HTMLDivElement>(null);\n    const inputRef = useRef<HTMLInputElement>(null);\n    const [value, setValue] = useState<number>((defaultValue as number) ?? 0);\n    const [isEditing, setIsEditing] = useState(false);\n    const [editValue, setEditValue] = useState<number>((defaultValue as number) ?? 0);\n\n    // Update local state when defaultValue changes externally\n    useEffect(() => {\n        if (defaultValue !== undefined) {\n            setValue(defaultValue as number);\n            setEditValue(defaultValue as number);\n        }\n    }, [defaultValue]);\n\n    // Auto-focus input when entering edit mode\n    useEffect(() => {\n        if (isEditing && inputRef.current) {\n            inputRef.current.focus();\n            inputRef.current.select();\n        }\n    }, [isEditing]);\n\n    const handleChange = (val: number) => {\n        setValue(val);\n        onChange?.(val);\n    };\n\n    const handleTextClick = () => {\n        setEditValue(value);\n        setIsEditing(true);\n    };\n\n    const handleInputChange = (val: number | string) => {\n        const numVal = typeof val === 'number' ? val : parseFloat(val) || 0;\n        setEditValue(numVal);\n\n        // Update slider value in real-time as user types (clamped to bounds)\n        let clampedValue = numVal;\n        if (min !== undefined && clampedValue < min) {\n            clampedValue = min;\n        }\n        if (max !== undefined && clampedValue > max) {\n            clampedValue = max;\n        }\n        setValue(clampedValue);\n        onChange?.(clampedValue);\n    };\n\n    const handleInputBlur = () => {\n        applyEditValue();\n    };\n\n    const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {\n        if (e.key === 'Enter') {\n            e.preventDefault();\n            applyEditValue();\n        } else if (e.key === 'Escape') {\n            setIsEditing(false);\n            setEditValue(value);\n        }\n    };\n\n    const applyEditValue = () => {\n        let finalValue = editValue;\n\n        // Clamp value to min/max bounds\n        if (min !== undefined && finalValue < min) {\n            finalValue = min;\n        }\n        if (max !== undefined && finalValue > max) {\n            finalValue = max;\n        }\n\n        setValue(finalValue);\n        setEditValue(finalValue);\n        setIsEditing(false);\n\n        // Update slider and trigger onChangeEnd to save\n        onChange?.(finalValue);\n        onChangeEnd?.(finalValue);\n    };\n\n    return (\n        <Stack gap=\"sm\">\n            {label && (\n                <div style={{ display: 'flex', justifyContent: 'center' }}>\n                    {typeof label === 'string' ? (\n                        <Text fw=\"500\" size=\"sm\" ta=\"center\">\n                            {label}\n                        </Text>\n                    ) : (\n                        label\n                    )}\n                </div>\n            )}\n            <Slider\n                label={null}\n                max={max}\n                min={min}\n                onChange={handleChange}\n                onChangeEnd={onChangeEnd}\n                ref={sliderRef}\n                step={step}\n                styles={{\n                    root: { alignSelf: 'center', display: 'flex' },\n                }}\n                value={value}\n                w=\"100px\"\n                {...rest}\n            />\n            {isEditing ? (\n                <NumberInput\n                    max={max}\n                    min={min}\n                    onBlur={handleInputBlur}\n                    onChange={handleInputChange}\n                    onKeyDown={handleInputKeyDown}\n                    ref={inputRef}\n                    size=\"xs\"\n                    step={step}\n                    style={{ alignSelf: 'center', width: '80px' }}\n                    styles={{ input: { textAlign: 'center' } }}\n                    value={editValue}\n                />\n            ) : (\n                <Text\n                    fw=\"500\"\n                    onClick={handleTextClick}\n                    size=\"sm\"\n                    style={{ cursor: 'pointer', userSelect: 'none' }}\n                    ta=\"center\"\n                >\n                    {value.toFixed(step && step < 1 ? 1 : 0)}\n                </Text>\n            )}\n        </Stack>\n    );\n};\n\nconst VisualizerToggle = (props: {\n    disabled?: boolean;\n    label: string;\n    onChange: (value: boolean) => void;\n    value: boolean;\n}) => {\n    const { disabled, label, onChange, value } = props;\n\n    return (\n        <Button\n            disabled={disabled}\n            onClick={() => onChange(!value)}\n            variant={value ? 'filled' : 'default'}\n        >\n            {label}\n        </Button>\n    );\n};\n\nconst PresetSettings = () => {\n    const { t } = useTranslation();\n    const visualizer = useVisualizerSettings();\n    const { setSettings } = useSettingsStoreActions();\n    const [selectedPreset, setSelectedPreset] = useState<null | string>(null);\n    const [isSaving, setIsSaving] = useState(false);\n    const [isRenaming, setIsRenaming] = useState(false);\n    const [newPresetName, setNewPresetName] = useState('');\n    const [isPasting, setIsPasting] = useState(false);\n    const [pasteValue, setPasteValue] = useState('');\n\n    const applyPreset = (presetId: null | string) => {\n        if (!presetId) return;\n\n        const preset = visualizer.audiomotionanalyzer.presets.find((p) => p.id === presetId);\n\n        if (!preset) return;\n\n        const initialDefaults = {\n            alphaBars: false,\n            ansiBands: false,\n            barSpace: 0.1,\n            channelLayout: 'single' as const,\n            colorMode: 'gradient' as const,\n            customGradients: [],\n            fadePeaks: false,\n            fftSize: 8192,\n            fillAlpha: 1,\n            frequencyScale: 'log' as const,\n            gradient: 'classic',\n            gradientLeft: undefined,\n            gradientRight: undefined,\n            gravity: 3.8,\n            ledBars: true,\n            linearAmplitude: false,\n            linearBoost: 1.0,\n            lineWidth: 0,\n            loRes: false,\n            lumiBars: false,\n            maxDecibels: -25,\n            maxFPS: 0,\n            maxFreq: 22000,\n            minDecibels: -85,\n            minFreq: 20,\n            mirror: 0.0,\n            mode: 0,\n            noteLabels: false,\n            opacity: 1,\n            outlineBars: false,\n            peakFadeTime: 750,\n            peakHoldTime: 500,\n            peakLine: false,\n            radial: false,\n            radialInvert: false,\n            radius: 0.3,\n            reflexAlpha: 0.15,\n            reflexBright: 1.0,\n            reflexFit: true,\n            reflexRatio: 0,\n            roundBars: false,\n            showFPS: false,\n            showPeaks: true,\n            showScaleX: false,\n            showScaleY: false,\n            smoothing: 0.5,\n            spinSpeed: 0.0,\n            splitGradient: false,\n            trueLeds: false,\n            volume: 1.0,\n            weightingFilter: '' as const,\n        };\n\n        // Merge preset values with initial defaults to ensure all properties are included\n        const presetValue = {\n            ...initialDefaults,\n            ...preset.value,\n        };\n\n        setSettings({\n            visualizer: {\n                audiomotionanalyzer: {\n                    ...presetValue,\n                },\n            },\n        });\n    };\n\n    const handlePresetChange = (value: null | string) => {\n        setSelectedPreset(value);\n        if (value) {\n            applyPreset(value);\n        }\n    };\n\n    const handleSavePreset = () => {\n        if (!newPresetName.trim()) return;\n\n        // Check if preset name already exists\n        const existingPreset = visualizer.audiomotionanalyzer.presets.find(\n            (p) => p.name === newPresetName.trim(),\n        );\n\n        if (existingPreset) {\n            // Update existing preset\n            const updatedPresets = visualizer.audiomotionanalyzer.presets.map((p) =>\n                p.id === existingPreset.id\n                    ? {\n                          ...p,\n                          value: getCurrentSettingsAsPresetValue(),\n                      }\n                    : p,\n            );\n\n            setSettings({\n                visualizer: {\n                    audiomotionanalyzer: {\n                        presets: updatedPresets,\n                    },\n                },\n            });\n\n            setSelectedPreset(existingPreset.id);\n        } else {\n            // Add new preset\n            const newPreset = {\n                id: nanoid(),\n                name: newPresetName.trim(),\n                value: getCurrentSettingsAsPresetValue(),\n            };\n\n            setSettings({\n                visualizer: {\n                    audiomotionanalyzer: {\n                        presets: [...visualizer.audiomotionanalyzer.presets, newPreset],\n                    },\n                },\n            });\n\n            setSelectedPreset(newPreset.id);\n        }\n\n        setNewPresetName('');\n        setIsSaving(false);\n    };\n\n    const getCurrentSettingsAsPresetValue = () => {\n        return {\n            alphaBars: visualizer.audiomotionanalyzer.alphaBars,\n            ansiBands: visualizer.audiomotionanalyzer.ansiBands,\n            barSpace: visualizer.audiomotionanalyzer.barSpace,\n            channelLayout: visualizer.audiomotionanalyzer.channelLayout,\n            colorMode: visualizer.audiomotionanalyzer.colorMode,\n            customGradients: visualizer.audiomotionanalyzer.customGradients,\n            fadePeaks: visualizer.audiomotionanalyzer.fadePeaks,\n            fftSize: visualizer.audiomotionanalyzer.fftSize,\n            fillAlpha: visualizer.audiomotionanalyzer.fillAlpha,\n            frequencyScale: visualizer.audiomotionanalyzer.frequencyScale,\n            gradient: visualizer.audiomotionanalyzer.gradient,\n            gradientLeft: visualizer.audiomotionanalyzer.gradientLeft,\n            gradientRight: visualizer.audiomotionanalyzer.gradientRight,\n            gravity: visualizer.audiomotionanalyzer.gravity,\n            ledBars: visualizer.audiomotionanalyzer.ledBars,\n            linearAmplitude: visualizer.audiomotionanalyzer.linearAmplitude,\n            linearBoost: visualizer.audiomotionanalyzer.linearBoost,\n            lineWidth: visualizer.audiomotionanalyzer.lineWidth,\n            loRes: visualizer.audiomotionanalyzer.loRes,\n            lumiBars: visualizer.audiomotionanalyzer.lumiBars,\n            maxDecibels: visualizer.audiomotionanalyzer.maxDecibels,\n            maxFPS: visualizer.audiomotionanalyzer.maxFPS,\n            maxFreq: visualizer.audiomotionanalyzer.maxFreq,\n            minDecibels: visualizer.audiomotionanalyzer.minDecibels,\n            minFreq: visualizer.audiomotionanalyzer.minFreq,\n            mirror: visualizer.audiomotionanalyzer.mirror,\n            mode: visualizer.audiomotionanalyzer.mode,\n            noteLabels: visualizer.audiomotionanalyzer.noteLabels,\n            opacity: visualizer.audiomotionanalyzer.opacity,\n            outlineBars: visualizer.audiomotionanalyzer.outlineBars,\n            peakFadeTime: visualizer.audiomotionanalyzer.peakFadeTime,\n            peakHoldTime: visualizer.audiomotionanalyzer.peakHoldTime,\n            peakLine: visualizer.audiomotionanalyzer.peakLine,\n            radial: visualizer.audiomotionanalyzer.radial,\n            radialInvert: visualizer.audiomotionanalyzer.radialInvert,\n            radius: visualizer.audiomotionanalyzer.radius,\n            reflexAlpha: visualizer.audiomotionanalyzer.reflexAlpha,\n            reflexBright: visualizer.audiomotionanalyzer.reflexBright,\n            reflexFit: visualizer.audiomotionanalyzer.reflexFit,\n            reflexRatio: visualizer.audiomotionanalyzer.reflexRatio,\n            roundBars: visualizer.audiomotionanalyzer.roundBars,\n            showFPS: visualizer.audiomotionanalyzer.showFPS,\n            showPeaks: visualizer.audiomotionanalyzer.showPeaks,\n            showScaleX: visualizer.audiomotionanalyzer.showScaleX,\n            showScaleY: visualizer.audiomotionanalyzer.showScaleY,\n            smoothing: visualizer.audiomotionanalyzer.smoothing,\n            spinSpeed: visualizer.audiomotionanalyzer.spinSpeed,\n            splitGradient: visualizer.audiomotionanalyzer.splitGradient,\n            trueLeds: visualizer.audiomotionanalyzer.trueLeds,\n            volume: visualizer.audiomotionanalyzer.volume,\n            weightingFilter: visualizer.audiomotionanalyzer.weightingFilter,\n        };\n    };\n\n    const handleUpdatePreset = () => {\n        if (!selectedPreset || !newPresetName.trim()) return;\n\n        const selectedPresetObj = visualizer.audiomotionanalyzer.presets.find(\n            (p) => p.id === selectedPreset,\n        );\n        if (!selectedPresetObj) return;\n\n        let trimmedName = newPresetName.trim();\n        const isRenaming = trimmedName !== selectedPresetObj.name;\n\n        if (isRenaming) {\n            const existingNames = visualizer.audiomotionanalyzer.presets\n                .filter((p) => p.id !== selectedPreset)\n                .map((p) => p.name);\n\n            if (existingNames.includes(trimmedName)) {\n                const pattern = /^(.+?)(\\s+\\((\\d+)\\))?$/;\n                const match = trimmedName.match(pattern);\n                const baseName = match ? match[1] : trimmedName;\n                let counter = 1;\n                while (existingNames.includes(`${baseName} (${counter})`)) {\n                    counter++;\n                }\n                trimmedName = `${baseName} (${counter})`;\n            }\n        }\n\n        const updatedPresets = visualizer.audiomotionanalyzer.presets.map((p) =>\n            p.id === selectedPreset\n                ? {\n                      ...p,\n                      name: trimmedName,\n                      value: getCurrentSettingsAsPresetValue(),\n                  }\n                : p,\n        );\n\n        setSettings({\n            visualizer: {\n                ...visualizer,\n                audiomotionanalyzer: {\n                    ...visualizer.audiomotionanalyzer,\n                    presets: updatedPresets,\n                },\n            },\n        });\n\n        setNewPresetName('');\n        setIsRenaming(false);\n    };\n\n    const handleDeletePreset = () => {\n        if (!selectedPreset) return;\n\n        const updatedPresets = visualizer.audiomotionanalyzer.presets.filter(\n            (p) => p.id !== selectedPreset,\n        );\n\n        setSettings({\n            visualizer: {\n                audiomotionanalyzer: {\n                    presets: updatedPresets,\n                },\n            },\n        });\n\n        setSelectedPreset(null);\n    };\n\n    const handleCopyConfiguration = async () => {\n        try {\n            const config = getCurrentSettingsAsPresetValue();\n            const configJson = JSON.stringify(config, null, 2);\n            await navigator.clipboard.writeText(configJson);\n            toast.success({\n                message: t('visualizer.configCopied', { postProcess: 'sentenceCase' }),\n            });\n        } catch {\n            toast.error({\n                message: t('visualizer.configCopyFailed', { postProcess: 'sentenceCase' }),\n            });\n        }\n    };\n\n    const handlePasteConfiguration = () => {\n        if (!pasteValue.trim()) return;\n\n        try {\n            const parsed = JSON.parse(pasteValue.trim());\n\n            // Validate that it's an object with expected properties\n            if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {\n                throw new Error('Invalid configuration format');\n            }\n\n            // Merge with initial defaults to ensure all properties are set\n            const initialDefaults = {\n                alphaBars: false,\n                ansiBands: false,\n                barSpace: 0.1,\n                channelLayout: 'single' as const,\n                colorMode: 'gradient' as const,\n                customGradients: [],\n                fadePeaks: false,\n                fftSize: 8192,\n                fillAlpha: 1,\n                frequencyScale: 'log' as const,\n                gradient: 'classic',\n                gradientLeft: undefined,\n                gradientRight: undefined,\n                gravity: 3.8,\n                ledBars: true,\n                linearAmplitude: false,\n                linearBoost: 1.0,\n                lineWidth: 0,\n                loRes: false,\n                lumiBars: false,\n                maxDecibels: -25,\n                maxFPS: 0,\n                maxFreq: 22000,\n                minDecibels: -85,\n                minFreq: 20,\n                mirror: 0.0,\n                mode: 0,\n                noteLabels: false,\n                opacity: 1,\n                outlineBars: false,\n                peakFadeTime: 750,\n                peakHoldTime: 500,\n                peakLine: false,\n                radial: false,\n                radialInvert: false,\n                radius: 0.3,\n                reflexAlpha: 0.15,\n                reflexBright: 1.0,\n                reflexFit: true,\n                reflexRatio: 0,\n                roundBars: false,\n                showFPS: false,\n                showPeaks: true,\n                showScaleX: false,\n                showScaleY: false,\n                smoothing: 0.5,\n                spinSpeed: 0.0,\n                splitGradient: false,\n                trueLeds: false,\n                volume: 1.0,\n                weightingFilter: '' as const,\n            };\n\n            const pastedCustomGradients = Array.isArray(parsed.customGradients)\n                ? parsed.customGradients\n                : [];\n\n            const parsedWithoutCustomGradients = { ...parsed };\n            delete parsedWithoutCustomGradients.customGradients;\n\n            // Determine the channel layout from the pasted config (or use default)\n            const pastedChannelLayout = parsed.channelLayout || initialDefaults.channelLayout;\n\n            // Get the gradient values that would be used based on channel layout\n            const gradientNamesToCheck: (string | undefined)[] = [];\n            if (pastedChannelLayout === 'single') {\n                gradientNamesToCheck.push(parsed.gradient);\n            } else {\n                gradientNamesToCheck.push(parsed.gradientLeft, parsed.gradientRight);\n            }\n\n            // Check if any of the gradient names match custom gradients in the pasted config\n            const pastedCustomGradientNames = pastedCustomGradients.map((g) => g.name);\n            const isUsingCustomGradient = gradientNamesToCheck.some(\n                (gradientName) => gradientName && pastedCustomGradientNames.includes(gradientName),\n            );\n\n            // Only append custom gradients if they're actually being used in the configuration\n            const customGradientsToUse = isUsingCustomGradient\n                ? [\n                      ...(visualizer.audiomotionanalyzer.customGradients || []),\n                      ...pastedCustomGradients,\n                  ]\n                : pastedCustomGradients;\n\n            const configValue = {\n                ...initialDefaults,\n                ...parsedWithoutCustomGradients,\n                customGradients: customGradientsToUse,\n            };\n\n            setSettings({\n                visualizer: {\n                    audiomotionanalyzer: {\n                        ...configValue,\n                    },\n                },\n            });\n\n            toast.success({\n                message: t('visualizer.configPasted', { postProcess: 'sentenceCase' }),\n            });\n\n            setPasteValue('');\n            setIsPasting(false);\n        } catch {\n            toast.error({\n                message: t('visualizer.configPasteFailed', { postProcess: 'sentenceCase' }),\n            });\n        }\n    };\n\n    const handlePasteFromClipboard = async () => {\n        try {\n            const text = await navigator.clipboard.readText();\n            setPasteValue(text);\n            setIsPasting(true);\n        } catch {\n            toast.error({\n                message: t('visualizer.configPasteReadFailed', { postProcess: 'sentenceCase' }),\n            });\n        }\n    };\n\n    const presetOptions = useMemo(() => {\n        return visualizer.audiomotionanalyzer.presets.map((preset) => ({\n            label: preset.name,\n            value: preset.id,\n        }));\n    }, [visualizer.audiomotionanalyzer.presets]);\n\n    return (\n        <Fieldset legend={t('visualizer.presets')}>\n            <Stack>\n                <VisualizerSelect\n                    data={presetOptions}\n                    label={t('visualizer.selectPreset')}\n                    onChange={handlePresetChange}\n                    value={selectedPreset || undefined}\n                />\n                {isSaving ? (\n                    <Group grow>\n                        <TextInput\n                            autoFocus\n                            label={t('visualizer.presetName')}\n                            onChange={(e) => setNewPresetName(e.currentTarget.value)}\n                            onKeyDown={(e) => {\n                                if (e.key === 'Enter') {\n                                    handleSavePreset();\n                                } else if (e.key === 'Escape') {\n                                    setIsSaving(false);\n                                    setNewPresetName('');\n                                }\n                            }}\n                            placeholder={t('visualizer.presetNamePlaceholder')}\n                            value={newPresetName}\n                        />\n                        <Group style={{ alignSelf: 'flex-end' }}>\n                            <Button onClick={() => setIsSaving(false)} variant=\"subtle\">\n                                {t('common.cancel', { postProcess: 'titleCase' })}\n                            </Button>\n                            <Button\n                                disabled={!newPresetName.trim()}\n                                onClick={handleSavePreset}\n                                variant=\"filled\"\n                            >\n                                {t('common.save', { postProcess: 'titleCase' })}\n                            </Button>\n                        </Group>\n                    </Group>\n                ) : isRenaming ? (\n                    <Group grow>\n                        <TextInput\n                            autoFocus\n                            label={t('visualizer.presetName')}\n                            onChange={(e) => setNewPresetName(e.currentTarget.value)}\n                            onKeyDown={(e) => {\n                                if (e.key === 'Enter') {\n                                    handleUpdatePreset();\n                                } else if (e.key === 'Escape') {\n                                    setIsRenaming(false);\n                                    setNewPresetName('');\n                                }\n                            }}\n                            placeholder={t('visualizer.presetNamePlaceholder')}\n                            value={newPresetName}\n                        />\n                        <Group style={{ alignSelf: 'flex-end' }}>\n                            <Button onClick={() => setIsRenaming(false)} variant=\"subtle\">\n                                {t('common.cancel', { postProcess: 'titleCase' })}\n                            </Button>\n                            <Button\n                                disabled={!newPresetName.trim()}\n                                onClick={handleUpdatePreset}\n                                variant=\"filled\"\n                            >\n                                {t('common.save', { postProcess: 'titleCase' })}\n                            </Button>\n                        </Group>\n                    </Group>\n                ) : isPasting ? (\n                    <Stack>\n                        <Textarea\n                            autosize\n                            label={t('visualizer.pasteConfiguration')}\n                            maxRows={10}\n                            minRows={5}\n                            onChange={(e) => setPasteValue(e.currentTarget.value)}\n                            placeholder={t('visualizer.pasteConfigurationPlaceholder')}\n                            spellCheck={false}\n                            value={pasteValue}\n                        />\n                        <Group>\n                            <Button onClick={handlePasteFromClipboard} variant=\"subtle\">\n                                {t('visualizer.pasteFromClipboard')}\n                            </Button>\n                            <Button onClick={() => setIsPasting(false)} variant=\"subtle\">\n                                {t('common.cancel', { postProcess: 'titleCase' })}\n                            </Button>\n                            <Button\n                                disabled={!pasteValue.trim()}\n                                onClick={handlePasteConfiguration}\n                                variant=\"filled\"\n                            >\n                                {t('visualizer.applyConfiguration')}\n                            </Button>\n                        </Group>\n                    </Stack>\n                ) : (\n                    <Group>\n                        <Button onClick={() => setIsSaving(true)} variant=\"default\">\n                            {t('visualizer.saveAsPreset')}\n                        </Button>\n                        {selectedPreset && (\n                            <>\n                                <Button\n                                    onClick={() => {\n                                        const preset = visualizer.audiomotionanalyzer.presets.find(\n                                            (p) => p.id === selectedPreset,\n                                        );\n                                        if (preset) {\n                                            setNewPresetName(preset.name);\n                                            setIsRenaming(true);\n                                        }\n                                    }}\n                                    variant=\"default\"\n                                >\n                                    {t('visualizer.updatePreset')}\n                                </Button>\n                                <Button onClick={handleDeletePreset} variant=\"subtle\">\n                                    {t('common.delete', { postProcess: 'titleCase' })}\n                                </Button>\n                            </>\n                        )}\n                        <Button onClick={handleCopyConfiguration} variant=\"default\">\n                            {t('visualizer.copyConfiguration')}\n                        </Button>\n                        <Button onClick={() => setIsPasting(true)} variant=\"default\">\n                            {t('visualizer.pasteConfiguration')}\n                        </Button>\n                    </Group>\n                )}\n            </Stack>\n        </Fieldset>\n    );\n};\n\nconst GeneralSettings = () => {\n    const { t } = useTranslation();\n    const { updateProperty, visualizer } = useUpdateAudioMotionAnalyzer();\n\n    const isMode18Disabled = visualizer.audiomotionanalyzer.mode > 8;\n    const isMode10Disabled = visualizer.audiomotionanalyzer.mode !== 10;\n\n    const getChannelLayoutKey = (value: string) => {\n        const layoutMap: Record<string, string> = {\n            'dual-combined': 'dualCombined',\n            'dual-horizontal': 'dualHorizontal',\n            'dual-vertical': 'dualVertical',\n            single: 'single',\n        };\n        return layoutMap[value] || 'single';\n    };\n\n    const translatedChannelLayoutOptions = useMemo(\n        () =>\n            channelLayoutOptions.map((option) => {\n                const value = option.value || 'single';\n                return {\n                    label: t(`visualizer.options.channelLayout.${getChannelLayoutKey(value)}`),\n                    value: value as string,\n                };\n            }),\n        [t],\n    );\n\n    return (\n        <Fieldset\n            legend={\n                <Group gap=\"xs\">\n                    {t('visualizer.general')}\n                    <ActionIcon\n                        component=\"a\"\n                        href=\"https://audiomotion.dev/#/?id=constructor-specific-options\"\n                        icon=\"externalLink\"\n                        iconProps={{ color: 'info' }}\n                        size=\"xs\"\n                        target=\"_blank\"\n                        variant=\"transparent\"\n                    />\n                </Group>\n            }\n        >\n            <Stack>\n                <Group grow>\n                    <VisualizerSelect\n                        data={modeOptions}\n                        defaultValue={visualizer.audiomotionanalyzer.mode.toString()}\n                        label={t('visualizer.mode')}\n                        onChange={(e) => updateProperty('mode', Number(e))}\n                    />\n                </Group>\n                <div\n                    style={{\n                        display: 'flex',\n                        gap: 'var(--theme-spacing-md)',\n                    }}\n                >\n                    <Fieldset legend={t('visualizer.mode1To8')} style={{ flex: 1, flexGrow: 1 }}>\n                        <Group grow>\n                            <VisualizerSelect\n                                data={barSpaceOptions.map((option) => ({\n                                    label: option.label,\n                                    value: option.value,\n                                }))}\n                                defaultValue={visualizer.audiomotionanalyzer.barSpace.toString()}\n                                disabled={isMode18Disabled}\n                                label={t('visualizer.barSpace')}\n                                onChange={(e) => updateProperty('barSpace', Number(e))}\n                            />\n                        </Group>\n                    </Fieldset>\n                    <Fieldset legend={t('visualizer.mode10')} style={{ flex: 1, flexGrow: 1 }}>\n                        <Group grow>\n                            <VisualizerSlider\n                                defaultValue={visualizer.audiomotionanalyzer.lineWidth}\n                                disabled={isMode10Disabled}\n                                label={t('visualizer.lineWidth')}\n                                max={4}\n                                min={0}\n                                onChangeEnd={(e) => updateProperty('lineWidth', e)}\n                                step={0.1}\n                            />\n                            <VisualizerSlider\n                                defaultValue={visualizer.audiomotionanalyzer.fillAlpha}\n                                disabled={isMode10Disabled}\n                                label={t('visualizer.fillAlpha')}\n                                max={1}\n                                min={0}\n                                onChangeEnd={(e) => updateProperty('fillAlpha', e)}\n                                step={0.1}\n                            />\n                        </Group>\n                    </Fieldset>\n                </div>\n\n                <Group grow>\n                    <VisualizerSelect\n                        data={translatedChannelLayoutOptions}\n                        defaultValue={visualizer.audiomotionanalyzer.channelLayout}\n                        label={t('visualizer.channelLayout')}\n                        onChange={(e) =>\n                            updateProperty(\n                                'channelLayout',\n                                e as\n                                    | 'dual-combined'\n                                    | 'dual-horizontal'\n                                    | 'dual-vertical'\n                                    | 'single',\n                            )\n                        }\n                    />\n                    <VisualizerSlider\n                        defaultValue={visualizer.audiomotionanalyzer.maxFPS}\n                        label={t('visualizer.maxFPS')}\n                        max={144}\n                        min={0}\n                        onChangeEnd={(e) => updateProperty('maxFPS', e)}\n                    />\n                    <VisualizerSlider\n                        defaultValue={visualizer.audiomotionanalyzer.opacity}\n                        label={t('visualizer.opacity')}\n                        max={1}\n                        min={0}\n                        onChangeEnd={(e) => updateProperty('opacity', e)}\n                        step={0.01}\n                    />\n                </Group>\n            </Stack>\n        </Fieldset>\n    );\n};\n\ntype CustomGradient = {\n    colorStops: StoredColorStop[];\n    dir?: string;\n    name: string;\n};\n\ntype StoredColorStop = {\n    color: string;\n    level?: number;\n    levelEnabled?: boolean;\n    pos?: number;\n    positionEnabled?: boolean;\n};\n\nconst CustomGradientsManager = () => {\n    const { t } = useTranslation();\n    const { updateProperty, visualizer } = useUpdateAudioMotionAnalyzer();\n    const [isAdding, setIsAdding] = useState(false);\n    const [editingIndex, setEditingIndex] = useState<null | number>(null);\n    const [isPasting, setIsPasting] = useState(false);\n    const [pasteValue, setPasteValue] = useState('');\n    const [newGradient, setNewGradient] = useState<CustomGradient>({\n        colorStops: [{ color: '#ff0000', levelEnabled: false, positionEnabled: false }],\n        dir: 'v',\n        name: '',\n    });\n\n    const customGradients = visualizer.audiomotionanalyzer.customGradients || [];\n\n    const generateDefaultName = () => {\n        const existingNames = customGradients.map((g) => g.name);\n        const pattern = /^Custom Gradient (\\d+)$/i;\n        const numbers = existingNames\n            .map((name) => {\n                const match = name.match(pattern);\n                return match ? parseInt(match[1], 10) : null;\n            })\n            .filter((num): num is number => num !== null);\n\n        if (numbers.length === 0) {\n            return 'Custom Gradient 1';\n        }\n\n        const maxNumber = Math.max(...numbers);\n        return `Custom Gradient ${maxNumber + 1}`;\n    };\n\n    const handleStartAdding = () => {\n        setNewGradient({\n            colorStops: [{ color: '#ff0000', levelEnabled: false, positionEnabled: false }],\n            dir: 'v',\n            name: generateDefaultName(),\n        });\n        setIsAdding(true);\n    };\n\n    const handleAddGradient = () => {\n        if (!newGradient.name.trim()) return;\n\n        const updatedGradients = [...customGradients, newGradient];\n        updateProperty('customGradients', updatedGradients);\n        setNewGradient({\n            colorStops: [\n                {\n                    color: '#ff0000',\n                    level: 0,\n                    levelEnabled: false,\n                    pos: 0,\n                    positionEnabled: false,\n                },\n            ],\n            dir: 'v',\n            name: '',\n        });\n        setIsAdding(false);\n    };\n\n    const handleDeleteGradient = (index: number) => {\n        const updatedGradients = customGradients.filter((_, i) => i !== index);\n        updateProperty('customGradients', updatedGradients);\n    };\n\n    const handleEditGradient = (index: number) => {\n        const gradient = customGradients[index];\n        setNewGradient(gradient);\n        setEditingIndex(index);\n        setIsAdding(true);\n    };\n\n    const handleSaveEdit = () => {\n        if (!newGradient.name.trim() || editingIndex === null) return;\n\n        const updatedGradients = [...customGradients];\n        updatedGradients[editingIndex] = newGradient;\n        updateProperty('customGradients', updatedGradients);\n        setNewGradient({\n            colorStops: [{ color: '#ff0000', levelEnabled: false, positionEnabled: false }],\n            dir: 'v',\n            name: '',\n        });\n        setEditingIndex(null);\n        setIsAdding(false);\n    };\n\n    const handleCancel = () => {\n        setNewGradient({\n            colorStops: [{ color: '#ff0000', levelEnabled: false, positionEnabled: false }],\n            dir: 'v',\n            name: '',\n        });\n        setEditingIndex(null);\n        setIsAdding(false);\n    };\n\n    const handleAddColorStop = () => {\n        setNewGradient({\n            ...newGradient,\n            colorStops: [\n                ...newGradient.colorStops,\n                { color: '#00ff00', levelEnabled: false, positionEnabled: false },\n            ],\n        });\n    };\n\n    const handleRemoveColorStop = (index: number) => {\n        if (newGradient.colorStops.length <= 1) return;\n        setNewGradient({\n            ...newGradient,\n            colorStops: newGradient.colorStops.filter((_, i) => i !== index),\n        });\n    };\n\n    const handleColorStopChange = (index: number, color: string) => {\n        const updatedColorStops = [...newGradient.colorStops];\n        const currentStop = updatedColorStops[index];\n\n        updatedColorStops[index] = {\n            ...currentStop,\n            color,\n        };\n\n        setNewGradient({ ...newGradient, colorStops: updatedColorStops });\n    };\n\n    const handleColorStopPosChange = (index: number, pos: number | string) => {\n        const updatedColorStops = [...newGradient.colorStops];\n        const currentStop = updatedColorStops[index];\n        const posValue = typeof pos === 'number' ? pos : parseFloat(pos) || undefined;\n\n        updatedColorStops[index] = {\n            ...currentStop,\n            ...(currentStop.positionEnabled && posValue !== undefined ? { pos: posValue } : {}),\n        };\n\n        setNewGradient({ ...newGradient, colorStops: updatedColorStops });\n    };\n\n    const handleColorStopLevelChange = (index: number, level: number | string) => {\n        const updatedColorStops = [...newGradient.colorStops];\n        const currentStop = updatedColorStops[index];\n        const levelValue = typeof level === 'number' ? level : parseFloat(level) || undefined;\n\n        updatedColorStops[index] = {\n            ...currentStop,\n            ...(currentStop.levelEnabled && levelValue !== undefined ? { level: levelValue } : {}),\n        };\n\n        setNewGradient({ ...newGradient, colorStops: updatedColorStops });\n    };\n\n    const handleTogglePos = (index: number, enabled: boolean) => {\n        const updatedColorStops = [...newGradient.colorStops];\n        const currentStop = updatedColorStops[index];\n\n        updatedColorStops[index] = {\n            ...currentStop,\n            positionEnabled: enabled,\n            // Remove pos if disabling\n            ...(enabled && currentStop.pos !== undefined ? { pos: currentStop.pos } : {}),\n            ...(!enabled ? { pos: undefined } : {}),\n        };\n\n        setNewGradient({ ...newGradient, colorStops: updatedColorStops });\n    };\n\n    const handleToggleLevel = (index: number, enabled: boolean) => {\n        const updatedColorStops = [...newGradient.colorStops];\n        const currentStop = updatedColorStops[index];\n\n        updatedColorStops[index] = {\n            ...currentStop,\n            levelEnabled: enabled,\n            // Remove level if disabling\n            ...(enabled && currentStop.level !== undefined ? { level: currentStop.level } : {}),\n            ...(!enabled ? { level: undefined } : {}),\n        };\n\n        setNewGradient({ ...newGradient, colorStops: updatedColorStops });\n    };\n\n    const handleCopyGradient = async (gradient: CustomGradient) => {\n        try {\n            const gradientJson = JSON.stringify(gradient, null, 2);\n            await navigator.clipboard.writeText(gradientJson);\n            toast.success({\n                message: t('visualizer.configCopied'),\n            });\n        } catch {\n            toast.error({\n                message: t('visualizer.configCopyFailed'),\n            });\n        }\n    };\n\n    const handlePasteGradient = () => {\n        if (!pasteValue.trim()) return;\n\n        try {\n            const parsed = JSON.parse(pasteValue.trim());\n\n            // Validate that it's a valid gradient object\n            if (\n                typeof parsed !== 'object' ||\n                parsed === null ||\n                Array.isArray(parsed) ||\n                !parsed.colorStops ||\n                !Array.isArray(parsed.colorStops) ||\n                parsed.colorStops.length === 0\n            ) {\n                throw new Error('Invalid gradient format');\n            }\n\n            // Generate a unique name if the pasted gradient has a name that already exists\n            let gradientName = parsed.name || generateDefaultName();\n            const existingNames = customGradients.map((g) => g.name);\n            if (existingNames.includes(gradientName)) {\n                const pattern = /^(.+?)(\\s+\\((\\d+)\\))?$/;\n                const match = gradientName.match(pattern);\n                const baseName = match ? match[1] : gradientName;\n                let counter = 1;\n                while (existingNames.includes(`${baseName} (${counter})`)) {\n                    counter++;\n                }\n                gradientName = `${baseName} (${counter})`;\n            }\n\n            const pastedGradient: CustomGradient = {\n                colorStops: parsed.colorStops.map((stop: any) => ({\n                    color: stop.color || '#ff0000',\n                    level: stop.level,\n                    levelEnabled: stop.levelEnabled || false,\n                    pos: stop.pos,\n                    positionEnabled: stop.positionEnabled || false,\n                })),\n                dir: parsed.dir || 'v',\n                name: gradientName,\n            };\n\n            setNewGradient(pastedGradient);\n            setPasteValue('');\n            setIsPasting(false);\n            setIsAdding(true);\n            setEditingIndex(null);\n        } catch {\n            toast.error({\n                message: t('visualizer.configPasteFailed'),\n            });\n        }\n    };\n\n    return (\n        <Fieldset\n            legend={\n                <Group gap=\"xs\">\n                    {t('visualizer.customGradients')}\n                    <ActionIcon\n                        component=\"a\"\n                        href=\"https://audiomotion.dev/#/?id=registergradient-name-options-\"\n                        icon=\"externalLink\"\n                        iconProps={{ color: 'info' }}\n                        size=\"xs\"\n                        target=\"_blank\"\n                        variant=\"transparent\"\n                    />\n                </Group>\n            }\n        >\n            <Stack gap=\"md\">\n                {customGradients.length > 0 && (\n                    <Stack gap=\"sm\">\n                        {customGradients.map((gradient, index) => (\n                            <Group grow key={index}>\n                                <Group grow>\n                                    <Text size=\"sm\">{gradient.name}</Text>\n                                </Group>\n                                <Group justify=\"flex-end\">\n                                    <Button\n                                        onClick={() => handleCopyGradient(gradient)}\n                                        size=\"xs\"\n                                        variant=\"subtle\"\n                                    >\n                                        {t('visualizer.copyConfiguration')}\n                                    </Button>\n                                    <Button\n                                        onClick={() => handleEditGradient(index)}\n                                        size=\"xs\"\n                                        variant=\"default\"\n                                    >\n                                        {t('common.edit', { postProcess: 'titleCase' })}\n                                    </Button>\n                                    <Button\n                                        onClick={() => handleDeleteGradient(index)}\n                                        size=\"xs\"\n                                        variant=\"state-error\"\n                                    >\n                                        {t('common.delete', { postProcess: 'titleCase' })}\n                                    </Button>\n                                </Group>\n                            </Group>\n                        ))}\n                    </Stack>\n                )}\n\n                {!isAdding && !isPasting ? (\n                    <Group>\n                        <Button onClick={handleStartAdding} size=\"sm\" variant=\"outline\">\n                            {t('visualizer.addCustomGradient')}\n                        </Button>\n                        <Button onClick={() => setIsPasting(true)} size=\"sm\" variant=\"outline\">\n                            {t('visualizer.pasteGradient', { postProcess: 'titleCase' })}\n                        </Button>\n                    </Group>\n                ) : isPasting ? (\n                    <Stack>\n                        <Textarea\n                            autosize\n                            label={t('visualizer.pasteGradient', { postProcess: 'titleCase' })}\n                            maxRows={10}\n                            minRows={5}\n                            onChange={(e) => setPasteValue(e.currentTarget.value)}\n                            placeholder={t('visualizer.pasteGradientPlaceholder')}\n                            spellCheck={false}\n                            value={pasteValue}\n                        />\n                        <Group>\n                            <Button onClick={() => setIsPasting(false)} variant=\"subtle\">\n                                {t('common.cancel', { postProcess: 'titleCase' })}\n                            </Button>\n                            <Button\n                                disabled={!pasteValue.trim()}\n                                onClick={handlePasteGradient}\n                                variant=\"filled\"\n                            >\n                                {t('common.add', { postProcess: 'titleCase' })}\n                            </Button>\n                        </Group>\n                    </Stack>\n                ) : (\n                    <>\n                        <Divider />\n                        <Stack gap=\"sm\">\n                            <TextInput\n                                onChange={(e) =>\n                                    setNewGradient({ ...newGradient, name: e.currentTarget.value })\n                                }\n                                placeholder={t('visualizer.gradientNamePlaceholder')}\n                                size=\"sm\"\n                                value={newGradient.name}\n                            />\n                            <SegmentedControl\n                                data={[\n                                    { label: t('visualizer.vertical'), value: 'v' },\n                                    { label: t('visualizer.horizontal'), value: 'h' },\n                                ]}\n                                onChange={(value) =>\n                                    setNewGradient({\n                                        ...newGradient,\n                                        dir: value,\n                                    })\n                                }\n                                size=\"sm\"\n                                value={newGradient.dir || 'v'}\n                            />\n                            <Stack gap=\"xl\">\n                                <Group justify=\"space-between\">\n                                    <Text>{t('visualizer.colorStops')}</Text>\n                                    <Button\n                                        onClick={handleAddColorStop}\n                                        size=\"xs\"\n                                        variant=\"outline\"\n                                    >\n                                        {t('visualizer.addColor')}\n                                    </Button>\n                                </Group>\n                                {newGradient.colorStops.map((stop, index) => {\n                                    return (\n                                        <Group grow key={index}>\n                                            <ColorInput\n                                                format=\"hex\"\n                                                onChangeEnd={(color) =>\n                                                    handleColorStopChange(index, color)\n                                                }\n                                                size=\"sm\"\n                                                value={stop.color}\n                                            />\n                                            <VisualizerSlider\n                                                defaultValue={stop.pos}\n                                                disabled={!stop.positionEnabled}\n                                                label={\n                                                    <Group\n                                                        gap=\"xs\"\n                                                        style={{ alignItems: 'center' }}\n                                                    >\n                                                        <Checkbox\n                                                            checked={stop.positionEnabled || false}\n                                                            onChange={(e) =>\n                                                                handleTogglePos(\n                                                                    index,\n                                                                    e.currentTarget.checked,\n                                                                )\n                                                            }\n                                                            size=\"xs\"\n                                                        />\n                                                        <Text fw={500} size=\"sm\">\n                                                            {t('visualizer.position')}\n                                                        </Text>\n                                                    </Group>\n                                                }\n                                                max={1}\n                                                min={0}\n                                                onChangeEnd={(e) =>\n                                                    handleColorStopPosChange(index, e)\n                                                }\n                                                step={0.1}\n                                            />\n                                            <VisualizerSlider\n                                                defaultValue={stop.level}\n                                                disabled={!stop.levelEnabled}\n                                                label={\n                                                    <Group\n                                                        gap=\"xs\"\n                                                        style={{ alignItems: 'center' }}\n                                                    >\n                                                        <Checkbox\n                                                            checked={stop.levelEnabled || false}\n                                                            onChange={(e) =>\n                                                                handleToggleLevel(\n                                                                    index,\n                                                                    e.currentTarget.checked,\n                                                                )\n                                                            }\n                                                            size=\"xs\"\n                                                        />\n                                                        <Text fw={500} size=\"sm\">\n                                                            {t('visualizer.level')}\n                                                        </Text>\n                                                    </Group>\n                                                }\n                                                max={1}\n                                                min={0}\n                                                onChangeEnd={(e) =>\n                                                    handleColorStopLevelChange(index, e)\n                                                }\n                                                step={0.1}\n                                            />\n                                            {newGradient.colorStops.length > 1 && (\n                                                <Button\n                                                    onClick={() => handleRemoveColorStop(index)}\n                                                    size=\"xs\"\n                                                    variant=\"subtle\"\n                                                >\n                                                    {t('visualizer.remove')}\n                                                </Button>\n                                            )}\n                                        </Group>\n                                    );\n                                })}\n                            </Stack>\n                            <Group grow>\n                                <Button onClick={handleCancel} size=\"sm\" variant=\"subtle\">\n                                    {t('common.cancel', { postProcess: 'titleCase' })}\n                                </Button>\n                                <Button\n                                    disabled={!newGradient.name.trim()}\n                                    onClick={\n                                        editingIndex !== null ? handleSaveEdit : handleAddGradient\n                                    }\n                                    size=\"sm\"\n                                    variant=\"filled\"\n                                >\n                                    {editingIndex !== null\n                                        ? t('common.save', { postProcess: 'titleCase' })\n                                        : t('common.add', { postProcess: 'titleCase' })}\n                                </Button>\n                            </Group>\n                        </Stack>\n                    </>\n                )}\n            </Stack>\n        </Fieldset>\n    );\n};\n\nconst ColorSettings = () => {\n    const { t } = useTranslation();\n    const { updateProperty, visualizer } = useUpdateAudioMotionAnalyzer();\n\n    const isGradientDisabled = visualizer.audiomotionanalyzer.channelLayout !== 'single';\n    const isGradientLeftDisabled = visualizer.audiomotionanalyzer.channelLayout === 'single';\n    const isGradientRightDisabled = visualizer.audiomotionanalyzer.channelLayout === 'single';\n\n    const allGradientOptions = useMemo(\n        () => [\n            {\n                group: t('visualizer.custom'),\n                items: (visualizer.audiomotionanalyzer.customGradients || []).map((gradient) => ({\n                    label: gradient.name,\n                    value: gradient.name,\n                })),\n            },\n            {\n                group: t('visualizer.builtIn'),\n                items: gradientOptions,\n            },\n        ],\n        [t, visualizer.audiomotionanalyzer.customGradients],\n    );\n\n    return (\n        <Fieldset legend={t('visualizer.colors')}>\n            <Stack>\n                <Group grow>\n                    <VisualizerSelect\n                        data={colorModeOptions}\n                        defaultValue={visualizer.audiomotionanalyzer.colorMode}\n                        label={t('visualizer.colorMode')}\n                        onChange={(e) =>\n                            updateProperty(\n                                'colorMode',\n                                (e || 'gradient') as 'bar-index' | 'bar-level' | 'gradient',\n                            )\n                        }\n                    />\n                    <VisualizerSelect\n                        data={allGradientOptions}\n                        defaultValue={visualizer.audiomotionanalyzer.gradient}\n                        disabled={isGradientDisabled}\n                        label={t('visualizer.gradient')}\n                        onChange={(e) =>\n                            updateProperty(\n                                'gradient',\n                                (e || 'classic') as typeof visualizer.audiomotionanalyzer.gradient,\n                            )\n                        }\n                    />\n                </Group>\n                <Group grow>\n                    <VisualizerSelect\n                        data={allGradientOptions}\n                        defaultValue={visualizer.audiomotionanalyzer.gradientLeft}\n                        disabled={isGradientLeftDisabled}\n                        label={t('visualizer.gradientLeft')}\n                        onChange={(e) =>\n                            updateProperty(\n                                'gradientLeft',\n                                (e ||\n                                    'classic') as typeof visualizer.audiomotionanalyzer.gradientLeft,\n                            )\n                        }\n                    />\n                    <VisualizerSelect\n                        data={allGradientOptions}\n                        defaultValue={visualizer.audiomotionanalyzer.gradientRight}\n                        disabled={isGradientRightDisabled}\n                        label={t('visualizer.gradientRight')}\n                        onChange={(e) =>\n                            updateProperty(\n                                'gradientRight',\n                                (e ||\n                                    'classic') as typeof visualizer.audiomotionanalyzer.gradientRight,\n                            )\n                        }\n                    />\n                </Group>\n                <CustomGradientsManager />\n            </Stack>\n        </Fieldset>\n    );\n};\n\nconst FFTSettings = () => {\n    const { t } = useTranslation();\n    const { updateProperty, visualizer } = useUpdateAudioMotionAnalyzer();\n\n    return (\n        <Fieldset legend={t('visualizer.fft')}>\n            <Group grow>\n                <VisualizerSelect\n                    data={fftSizeOptions.map((option) => ({\n                        label: option.label,\n                        value: option.value as string,\n                    }))}\n                    defaultValue={visualizer.audiomotionanalyzer.fftSize.toString()}\n                    label={t('visualizer.fftSize')}\n                    onChange={(e) => updateProperty('fftSize', Number(e))}\n                />\n                <VisualizerSlider\n                    defaultValue={visualizer.audiomotionanalyzer.smoothing}\n                    label={t('visualizer.smoothing')}\n                    max={1}\n                    min={0}\n                    onChangeEnd={(e) => updateProperty('smoothing', e)}\n                    step={0.1}\n                />\n            </Group>\n        </Fieldset>\n    );\n};\n\nconst FrequencySettings = () => {\n    const { t } = useTranslation();\n    const { updateProperty, visualizer } = useUpdateAudioMotionAnalyzer();\n\n    const translatedFrequencyScaleOptions = useMemo(\n        () =>\n            frequencyScaleOptions.map((option) => ({\n                label: t(`visualizer.options.frequencyScale.${option.value}`),\n                value: option.value as string,\n            })),\n        [t],\n    );\n\n    return (\n        <Fieldset legend={t('visualizer.frequencyRangeAndScaling')}>\n            <Group grow wrap=\"nowrap\">\n                <VisualizerSelect\n                    data={minFreqOptions.map((option) => ({\n                        label: option.label,\n                        value: option.value as string,\n                    }))}\n                    defaultValue={visualizer.audiomotionanalyzer.minFreq.toString()}\n                    label={t('visualizer.minimumFrequency')}\n                    onChange={(e) => updateProperty('minFreq', Number(e))}\n                />\n                <VisualizerSelect\n                    data={maxFreqOptions.map((option) => ({\n                        label: option.label,\n                        value: option.value as string,\n                    }))}\n                    defaultValue={visualizer.audiomotionanalyzer.maxFreq.toString()}\n                    label={t('visualizer.maximumFrequency')}\n                    onChange={(e) => updateProperty('maxFreq', Number(e))}\n                />\n                <VisualizerSelect\n                    data={translatedFrequencyScaleOptions}\n                    defaultValue={visualizer.audiomotionanalyzer.frequencyScale}\n                    label={t('visualizer.frequencyScale')}\n                    onChange={(e) =>\n                        updateProperty(\n                            'frequencyScale',\n                            (e || 'log') as 'bark' | 'linear' | 'log' | 'mel',\n                        )\n                    }\n                />\n            </Group>\n        </Fieldset>\n    );\n};\n\nconst SensitivitySettings = () => {\n    const { t } = useTranslation();\n    const { updateProperty, visualizer } = useUpdateAudioMotionAnalyzer();\n\n    const getWeightingFilterKey = (value: string) => {\n        return value === '' ? 'none' : value.toLowerCase();\n    };\n\n    const translatedWeightingFilterOptions = useMemo(\n        () =>\n            weightingFilterOptions.map((option) => ({\n                label: t(\n                    `visualizer.options.weightingFilter.${getWeightingFilterKey(option.value)}`,\n                ),\n                value: option.value as string,\n            })),\n        [t],\n    );\n\n    return (\n        <Fieldset legend={t('visualizer.sensitivity')}>\n            <Group grow>\n                <VisualizerSelect\n                    data={translatedWeightingFilterOptions}\n                    defaultValue={visualizer.audiomotionanalyzer.weightingFilter}\n                    label={t('visualizer.weightingFilter')}\n                    onChange={(e) =>\n                        updateProperty('weightingFilter', e as 'A' | 'B' | 'C' | 'D' | 'Z')\n                    }\n                />\n                <VisualizerSlider\n                    defaultValue={visualizer.audiomotionanalyzer.minDecibels}\n                    label={t('visualizer.minimumDecibels')}\n                    max={-60}\n                    min={-120}\n                    onChangeEnd={(e) => updateProperty('minDecibels', e)}\n                    step={1}\n                />\n                <VisualizerSlider\n                    defaultValue={visualizer.audiomotionanalyzer.maxDecibels}\n                    label={t('visualizer.maximumDecibels')}\n                    max={0}\n                    min={-40}\n                    onChangeEnd={(e) => updateProperty('maxDecibels', e)}\n                    step={1}\n                />\n            </Group>\n        </Fieldset>\n    );\n};\n\nconst LinearAmplitudeSettings = () => {\n    const { t } = useTranslation();\n    const { updateProperty, visualizer } = useUpdateAudioMotionAnalyzer();\n\n    const isLinearBoostDisabled = !visualizer.audiomotionanalyzer.linearAmplitude;\n\n    return (\n        <Fieldset legend={t('visualizer.linearAmplitude')}>\n            <Group grow>\n                <VisualizerToggle\n                    label={t('visualizer.linearAmplitude')}\n                    onChange={(value) => updateProperty('linearAmplitude', value)}\n                    value={visualizer.audiomotionanalyzer.linearAmplitude}\n                />\n                <VisualizerSlider\n                    defaultValue={visualizer.audiomotionanalyzer.linearBoost}\n                    disabled={isLinearBoostDisabled}\n                    label={t('visualizer.linearBoost')}\n                    max={4}\n                    min={1}\n                    onChangeEnd={(e) => updateProperty('linearBoost', e)}\n                    step={0.1}\n                />\n            </Group>\n        </Fieldset>\n    );\n};\n\nconst PeakBehaviorSettings = () => {\n    const { t } = useTranslation();\n    const { updateProperty, visualizer } = useUpdateAudioMotionAnalyzer();\n\n    const peakToggles = useMemo(\n        () => [\n            { label: t('visualizer.showPeaks'), value: 'showPeaks' },\n            { label: t('visualizer.fadePeaks'), value: 'fadePeaks' },\n            { label: t('visualizer.peakLine'), value: 'peakLine' },\n        ],\n        [t],\n    );\n\n    const isFadePeaksDisabled = !visualizer.audiomotionanalyzer.showPeaks;\n    const isPeakLineDisabled = !visualizer.audiomotionanalyzer.showPeaks;\n    const isGravityDisabled = !visualizer.audiomotionanalyzer.showPeaks;\n    const isPeakFadeTimeDisabled =\n        !visualizer.audiomotionanalyzer.showPeaks || !visualizer.audiomotionanalyzer.fadePeaks;\n    const isPeakHoldTimeDisabled = !visualizer.audiomotionanalyzer.showPeaks;\n\n    const isToggleDisabled = (toggle: (typeof peakToggles)[number]) => {\n        if (toggle.value === 'fadePeaks') return isFadePeaksDisabled;\n        if (toggle.value === 'peakLine') return isPeakLineDisabled;\n        return false;\n    };\n\n    return (\n        <Fieldset legend={t('visualizer.peakBehavior')}>\n            <Stack>\n                <Group grow>\n                    {peakToggles.map((toggle) => (\n                        <VisualizerToggle\n                            disabled={isToggleDisabled(toggle)}\n                            key={toggle.value}\n                            label={toggle.label}\n                            onChange={(value) =>\n                                updateProperty(\n                                    toggle.value as keyof typeof visualizer.audiomotionanalyzer,\n                                    value,\n                                )\n                            }\n                            value={visualizer.audiomotionanalyzer[toggle.value]}\n                        />\n                    ))}\n                </Group>\n                <Group grow>\n                    <VisualizerSlider\n                        defaultValue={visualizer.audiomotionanalyzer.gravity}\n                        disabled={isGravityDisabled}\n                        label={t('visualizer.gravity')}\n                        max={20}\n                        min={0.1}\n                        onChangeEnd={(e) => updateProperty('gravity', e)}\n                    />\n                    <VisualizerSlider\n                        defaultValue={visualizer.audiomotionanalyzer.peakFadeTime}\n                        disabled={isPeakFadeTimeDisabled}\n                        label={t('visualizer.peakFadeTime')}\n                        max={2000}\n                        min={0}\n                        onChangeEnd={(e) => updateProperty('peakFadeTime', e)}\n                        step={1}\n                    />\n                    <VisualizerSlider\n                        defaultValue={visualizer.audiomotionanalyzer.peakHoldTime}\n                        disabled={isPeakHoldTimeDisabled}\n                        label={t('visualizer.peakHoldTime')}\n                        max={1000}\n                        min={0}\n                        onChangeEnd={(e) => updateProperty('peakHoldTime', e)}\n                        step={1}\n                    />\n                </Group>\n            </Stack>\n        </Fieldset>\n    );\n};\n\nconst RadialSpectrumSettings = () => {\n    const { t } = useTranslation();\n    const { updateProperty, visualizer } = useUpdateAudioMotionAnalyzer();\n\n    const isRadialInvertDisabled = !visualizer.audiomotionanalyzer.radial;\n    const isRadiusDisabled = !visualizer.audiomotionanalyzer.radial;\n    const isSpinSpeedDisabled = !visualizer.audiomotionanalyzer.radial;\n\n    return (\n        <Fieldset legend={t('visualizer.radialSpectrum')}>\n            <Group grow>\n                <VisualizerToggle\n                    label={t('visualizer.radial')}\n                    onChange={(value) => updateProperty('radial', value)}\n                    value={visualizer.audiomotionanalyzer.radial}\n                />\n                <VisualizerToggle\n                    disabled={isRadialInvertDisabled}\n                    label={t('visualizer.radialInvert')}\n                    onChange={(value) => updateProperty('radialInvert', value)}\n                    value={visualizer.audiomotionanalyzer.radialInvert}\n                />\n                <VisualizerSlider\n                    defaultValue={visualizer.audiomotionanalyzer.radius}\n                    disabled={isRadiusDisabled}\n                    label={t('visualizer.radius')}\n                    max={1}\n                    min={0}\n                    onChangeEnd={(e) => updateProperty('radius', e)}\n                    step={0.05}\n                />\n                <VisualizerSlider\n                    defaultValue={visualizer.audiomotionanalyzer.spinSpeed}\n                    disabled={isSpinSpeedDisabled}\n                    label={t('visualizer.spinSpeed')}\n                    max={5}\n                    min={-5}\n                    onChangeEnd={(e) => updateProperty('spinSpeed', e)}\n                    step={0.1}\n                />\n            </Group>\n        </Fieldset>\n    );\n};\n\nconst ReflexMirrorSettings = () => {\n    const { t } = useTranslation();\n    const { updateProperty, visualizer } = useUpdateAudioMotionAnalyzer();\n\n    return (\n        <Fieldset legend={t('visualizer.reflexMirror')}>\n            <Group grow>\n                <VisualizerToggle\n                    label={t('visualizer.reflexFit')}\n                    onChange={(value) => updateProperty('reflexFit', value)}\n                    value={visualizer.audiomotionanalyzer.reflexFit}\n                />\n                <VisualizerSlider\n                    defaultValue={visualizer.audiomotionanalyzer.reflexRatio}\n                    label={t('visualizer.reflexRatio')}\n                    max={1}\n                    min={0}\n                    onChangeEnd={(e) => updateProperty('reflexRatio', e)}\n                    step={0.1}\n                />\n                <VisualizerSlider\n                    defaultValue={visualizer.audiomotionanalyzer.reflexAlpha}\n                    label={t('visualizer.reflexAlpha')}\n                    max={1}\n                    min={0}\n                    onChangeEnd={(e) => updateProperty('reflexAlpha', e)}\n                    step={0.05}\n                />\n                <VisualizerSlider\n                    defaultValue={visualizer.audiomotionanalyzer.reflexBright}\n                    label={t('visualizer.reflexBrightness')}\n                    max={2}\n                    min={0}\n                    onChangeEnd={(e) => updateProperty('reflexBright', e)}\n                    step={0.1}\n                />\n                <VisualizerSlider\n                    defaultValue={visualizer.audiomotionanalyzer.mirror}\n                    label={t('visualizer.mirror')}\n                    max={1}\n                    min={-1}\n                    onChangeEnd={(e) => updateProperty('mirror', e)}\n                    step={1}\n                />\n            </Group>\n        </Fieldset>\n    );\n};\n\nconst ToggleSettings = () => {\n    const { t } = useTranslation();\n    const { updateProperty, visualizer } = useUpdateAudioMotionAnalyzer();\n\n    const AMA_TOGGLES = useMemo(\n        () => [\n            { label: t('visualizer.alphaBars'), value: 'alphaBars' },\n            { label: t('visualizer.ansiBands'), value: 'ansiBands' },\n            { label: t('visualizer.ledBars'), value: 'ledBars' },\n            { label: t('visualizer.trueLeds'), value: 'trueLeds' },\n            { label: t('visualizer.lumiBars'), value: 'lumiBars' },\n            { label: t('visualizer.outlineBars'), value: 'outlineBars' },\n            { label: t('visualizer.roundBars'), value: 'roundBars' },\n            { label: t('visualizer.lowResolution'), value: 'loRes' },\n            { label: t('visualizer.splitGradient'), value: 'splitGradient' },\n            { label: t('visualizer.showFPS'), value: 'showFPS' },\n            { label: t('visualizer.showScaleX'), value: 'showScaleX' },\n            { label: t('visualizer.noteLabels'), value: 'noteLabels' },\n            { label: t('visualizer.showScaleY'), value: 'showScaleY' },\n        ],\n        [t],\n    );\n\n    const isToggleDisabled = (toggle: (typeof AMA_TOGGLES)[number]) => {\n        if (toggle.value === 'ledBars') return visualizer.audiomotionanalyzer.radial;\n        if (toggle.value === 'trueLeds') return visualizer.audiomotionanalyzer.radial;\n        if (toggle.value === 'lumiBars') return visualizer.audiomotionanalyzer.radial;\n        if (toggle.value === 'noteLabels') return !visualizer.audiomotionanalyzer.showScaleX;\n        if (toggle.value === 'outlineBars') return visualizer.audiomotionanalyzer.radial;\n        if (toggle.value === 'roundBars') return visualizer.audiomotionanalyzer.radial;\n        if (toggle.value === 'loRes') return visualizer.audiomotionanalyzer.radial;\n        if (toggle.value === 'splitGradient') return visualizer.audiomotionanalyzer.radial;\n        if (toggle.value === 'showFPS') return visualizer.audiomotionanalyzer.radial;\n        return false;\n    };\n\n    return (\n        <Fieldset legend={t('visualizer.miscellaneousSettings')}>\n            <Group>\n                {AMA_TOGGLES.map((toggle) => (\n                    <VisualizerToggle\n                        disabled={isToggleDisabled(toggle)}\n                        key={toggle.value}\n                        label={toggle.label}\n                        onChange={(value) =>\n                            updateProperty(\n                                toggle.value as keyof typeof visualizer.audiomotionanalyzer,\n                                value,\n                            )\n                        }\n                        value={\n                            visualizer.audiomotionanalyzer[\n                                toggle.value as keyof typeof visualizer.audiomotionanalyzer\n                            ] as boolean\n                        }\n                    />\n                ))}\n            </Group>\n        </Fieldset>\n    );\n};\n\nconst ButterchurnGeneralSettings = () => {\n    const { t } = useTranslation();\n    const { updateProperty, visualizer } = useUpdateButterchurn();\n\n    const presetOptions = useButterchurnPresetOptions();\n\n    return (\n        <Fieldset legend={t('visualizer.general')}>\n            <Stack>\n                <Group grow>\n                    <VisualizerSelect\n                        data={presetOptions}\n                        label={t('visualizer.selectPreset')}\n                        onChange={(value) => {\n                            updateProperty('currentPreset', value || undefined);\n                        }}\n                        value={visualizer.butterchurn.currentPreset}\n                    />\n                </Group>\n                <Group grow>\n                    <VisualizerSlider\n                        defaultValue={visualizer.butterchurn.blendTime}\n                        label={t('visualizer.blendTime')}\n                        max={10}\n                        min={0}\n                        onChangeEnd={(e) => updateProperty('blendTime', e)}\n                        step={0.1}\n                    />\n                    <VisualizerSlider\n                        defaultValue={visualizer.butterchurn.maxFPS}\n                        label={t('visualizer.maxFPS')}\n                        max={144}\n                        min={0}\n                        onChangeEnd={(e) => updateProperty('maxFPS', e)}\n                        step={1}\n                    />\n                    <VisualizerSlider\n                        defaultValue={visualizer.butterchurn.opacity}\n                        label={t('visualizer.opacity')}\n                        max={1}\n                        min={0}\n                        onChangeEnd={(e) => updateProperty('opacity', e)}\n                        step={0.01}\n                    />\n                </Group>\n            </Stack>\n        </Fieldset>\n    );\n};\n\nconst ButterChurnCycleSettings = () => {\n    const { t } = useTranslation();\n    const { updateProperty, visualizer } = useUpdateButterchurn();\n\n    const presetOptions = useButterchurnPresetOptions();\n\n    return (\n        <Fieldset legend={t('visualizer.cyclePresets')}>\n            <Stack>\n                <Group grow>\n                    <VisualizerToggle\n                        label={t('visualizer.cyclePresets')}\n                        onChange={(checked) => updateProperty('cyclePresets', checked)}\n                        value={visualizer.butterchurn.cyclePresets}\n                    />\n                    <VisualizerToggle\n                        disabled={!visualizer.butterchurn.cyclePresets}\n                        label={t('visualizer.includeAllPresets')}\n                        onChange={(checked) => updateProperty('includeAllPresets', checked)}\n                        value={visualizer.butterchurn.includeAllPresets}\n                    />\n                    <VisualizerToggle\n                        disabled={!visualizer.butterchurn.cyclePresets}\n                        label={t('visualizer.randomizeNextPreset')}\n                        onChange={(checked) => updateProperty('randomizeNextPreset', checked)}\n                        value={visualizer.butterchurn.randomizeNextPreset}\n                    />\n                </Group>\n                <MultiSelect\n                    data={presetOptions}\n                    disabled={\n                        !visualizer.butterchurn.cyclePresets ||\n                        visualizer.butterchurn.includeAllPresets\n                    }\n                    label={t('visualizer.selectedPresets')}\n                    onChange={(values) => updateProperty('selectedPresets', values)}\n                    value={visualizer.butterchurn.selectedPresets}\n                />\n                <MultiSelect\n                    data={presetOptions}\n                    disabled={!visualizer.butterchurn.cyclePresets}\n                    label={t('visualizer.ignoredPresets')}\n                    onChange={(values) => updateProperty('ignoredPresets', values)}\n                    value={visualizer.butterchurn.ignoredPresets}\n                />\n\n                <Group grow>\n                    <VisualizerSlider\n                        defaultValue={visualizer.butterchurn.cycleTime}\n                        disabled={!visualizer.butterchurn.cyclePresets}\n                        label={t('visualizer.cycleTime')}\n                        max={300}\n                        min={1}\n                        onChangeEnd={(e) => updateProperty('cycleTime', e)}\n                        step={1}\n                    />\n                </Group>\n            </Stack>\n        </Fieldset>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/visualizer/components/audiomotionanalyzer/visualizer-settings-modal.tsx",
    "content": "import { VisualizerSettingsForm } from './visualizer-settings-form';\n\nexport const VisualizerSettingsContextModal = () => {\n    return <VisualizerSettingsForm />;\n};\n"
  },
  {
    "path": "src/renderer/features/visualizer/components/audiomotionanalyzer/visualizer.module.css",
    "content": ".container {\n    position: relative;\n    z-index: 50;\n    width: 100%;\n    height: 100%;\n    max-height: 100%;\n    margin: auto;\n    overflow: hidden;\n\n    canvas {\n        width: 100%;\n        max-width: 100%;\n        max-height: 100%;\n        margin: auto;\n    }\n\n}\n\n.icon-group {\n    z-index: 100;\n}\n\n.icon-group > * {\n    opacity: 0;\n    transition: opacity 0.2s ease-in-out;\n}\n\n.container:hover .icon-group > * {\n    opacity: 1;\n}\n\n.visualizer {\n    width: 100%;\n    max-width: 100%;\n    height: 100%;\n    max-height: 100%;\n    overflow: hidden;\n}\n"
  },
  {
    "path": "src/renderer/features/visualizer/components/audiomotionanalyzer/visualizer.tsx",
    "content": "import { createRef, useCallback, useEffect, useMemo, useRef, useState } from 'react';\n\nimport styles from './visualizer.module.css';\n\nimport { useWebAudio } from '/@/renderer/features/player/hooks/use-webaudio';\nimport { openVisualizerSettingsModal } from '/@/renderer/features/player/utils/open-visualizer-settings-modal';\nimport { ComponentErrorBoundary } from '/@/renderer/features/shared/components/component-error-boundary';\nimport { useAccent, useSettingsStore } from '/@/renderer/store';\nimport {\n    useFullScreenPlayerStore,\n    useFullScreenPlayerStoreActions,\n} from '/@/renderer/store/full-screen-player.store';\nimport { ActionIcon } from '/@/shared/components/action-icon/action-icon';\nimport { Group } from '/@/shared/components/group/group';\n\nconst VisualizerInner = () => {\n    const { webAudio } = useWebAudio();\n    const canvasRef = createRef<HTMLDivElement>();\n    const accent = useAccent();\n    const visualizer = useSettingsStore((store) => store.visualizer);\n    const opacity = useSettingsStore((store) => store.visualizer.audiomotionanalyzer.opacity);\n    const [motion, setMotion] = useState<any>();\n    const [libraryLoaded, setLibraryLoaded] = useState(false);\n    const AudioMotionAnalyzerRef = useRef<any>(null);\n\n    useEffect(() => {\n        let isMounted = true;\n\n        const loadLibrary = async () => {\n            try {\n                const module = await import('audiomotion-analyzer');\n                if (isMounted) {\n                    AudioMotionAnalyzerRef.current = module.default;\n                    setLibraryLoaded(true);\n                }\n            } catch (error) {\n                console.error('Failed to load AudioMotionAnalyzer library:', error);\n            }\n        };\n\n        loadLibrary();\n\n        return () => {\n            isMounted = false;\n        };\n    }, []);\n\n    // Check if a gradient name is a custom gradient\n    const isCustomGradient = useCallback(\n        (gradientName: string | undefined): boolean => {\n            if (!gradientName || visualizer.type !== 'audiomotionanalyzer') {\n                return false;\n            }\n\n            const customGradients = visualizer.audiomotionanalyzer.customGradients || [];\n            return customGradients.some((gradient) => gradient.name === gradientName);\n        },\n        [visualizer],\n    );\n\n    const [gradientsRegistered, setGradientsRegistered] = useState(false);\n\n    const options = useMemo(() => {\n        if (visualizer.type !== 'audiomotionanalyzer') {\n            return {};\n        }\n\n        const ama = visualizer.audiomotionanalyzer;\n\n        const defaults = {\n            bgAlpha: 0,\n            showBgColor: false,\n        };\n\n        const gradients: { gradient?: string; gradientLeft?: string; gradientRight?: string } = {};\n\n        // Use default gradient if custom gradient is selected but not yet registered\n        const getSafeGradient = (gradientName: string | undefined): string => {\n            if (!gradientName) return 'classic';\n            if (isCustomGradient(gradientName)) {\n                // Use default until custom gradients are registered\n                return gradientsRegistered ? gradientName : 'classic';\n            }\n            return gradientName;\n        };\n\n        if (ama.channelLayout === 'single') {\n            gradients.gradient = getSafeGradient(ama.gradient);\n        } else {\n            gradients.gradientLeft = getSafeGradient(ama.gradientLeft);\n            gradients.gradientRight = getSafeGradient(ama.gradientRight);\n        }\n\n        return {\n            ...defaults,\n            ...gradients,\n            alphaBars: ama.alphaBars,\n            ansiBands: ama.ansiBands,\n            barSpace: ama.barSpace,\n            channelLayout: ama.channelLayout,\n            colorMode: ama.colorMode,\n            connectSpeakers: false,\n            fadePeaks: ama.fadePeaks,\n            fftSize: ama.fftSize,\n            fillAlpha: ama.fillAlpha,\n            frequencyScale: ama.frequencyScale,\n            gravity: ama.gravity,\n            ledBars: ama.ledBars,\n            linearAmplitude: ama.linearAmplitude,\n            linearBoost: ama.linearBoost,\n            lineWidth: ama.lineWidth,\n            loRes: ama.loRes,\n            lumiBars: ama.lumiBars,\n            maxDecibels: ama.maxDecibels,\n            maxFPS: ama.maxFPS,\n            maxFreq: ama.maxFreq,\n            minDecibels: ama.minDecibels,\n            minFreq: ama.minFreq,\n            mirror: ama.mirror,\n            mode: ama.mode,\n            noteLabels: ama.noteLabels,\n            outlineBars: ama.outlineBars,\n            overlay: true,\n            peakFadeTime: ama.peakFadeTime,\n            peakHoldTime: ama.peakHoldTime,\n            peakLine: ama.peakLine,\n            radial: ama.radial,\n            radialInvert: ama.radialInvert,\n            radius: ama.radius,\n            reflexAlpha: ama.reflexAlpha,\n            reflexBright: ama.reflexBright,\n            reflexFit: ama.reflexFit,\n            reflexRatio: ama.reflexRatio,\n            roundBars: ama.roundBars,\n            showFPS: ama.showFPS,\n            showPeaks: ama.showPeaks,\n            showScaleX: ama.showScaleX,\n            showScaleY: ama.showScaleY,\n            smoothing: ama.smoothing,\n            spinSpeed: ama.spinSpeed,\n            splitGradient: ama.splitGradient,\n            trueLeds: ama.trueLeds,\n            volume: ama.volume,\n            weightingFilter: (ama.weightingFilter || '') as any,\n        };\n    }, [visualizer, gradientsRegistered, isCustomGradient]);\n\n    const transformGradientForVisualizer = useCallback(\n        (gradient: {\n            colorStops: Array<{\n                color: string;\n                level?: number;\n                levelEnabled?: boolean;\n                pos?: number;\n                positionEnabled?: boolean;\n            }>;\n            dir?: string;\n        }): {\n            colorStops: (string | { color: string; level?: number; pos?: number })[];\n            dir?: string;\n        } => {\n            const transformedColorStops = gradient.colorStops.map((stop) => {\n                // If neither position nor level is enabled, return just the color string\n                if (!stop.positionEnabled && !stop.levelEnabled) {\n                    return stop.color;\n                }\n\n                // Otherwise, return an object with only enabled properties\n                const transformedStop: { color: string; level?: number; pos?: number } = {\n                    color: stop.color,\n                };\n\n                if (stop.positionEnabled && stop.pos !== undefined) {\n                    transformedStop.pos = stop.pos;\n                }\n\n                if (stop.levelEnabled && stop.level !== undefined) {\n                    transformedStop.level = stop.level;\n                }\n\n                return transformedStop;\n            });\n\n            return {\n                colorStops: transformedColorStops,\n                ...(gradient.dir ? { dir: gradient.dir } : {}),\n            };\n        },\n        [],\n    );\n\n    const registerCustomGradients = useCallback(\n        (audioMotionInstance: any) => {\n            if (visualizer.type !== 'audiomotionanalyzer') {\n                return;\n            }\n\n            const customGradients = visualizer.audiomotionanalyzer.customGradients || [];\n\n            customGradients.forEach((gradient) => {\n                try {\n                    const gradientConfig = transformGradientForVisualizer(gradient);\n\n                    audioMotionInstance.registerGradient(gradient.name, gradientConfig as any);\n                } catch (error) {\n                    console.error(`Failed to register gradient \"${gradient.name}\":`, error);\n                }\n            });\n\n            // Mark gradients as registered\n            setGradientsRegistered(true);\n        },\n        [visualizer, transformGradientForVisualizer],\n    );\n\n    useEffect(() => {\n        const { context, gains } = webAudio || {};\n        let audioMotion: any | undefined;\n        if (gains && context && canvasRef.current && !motion && libraryLoaded) {\n            const AudioMotionAnalyzer = AudioMotionAnalyzerRef.current;\n            if (!AudioMotionAnalyzer) return;\n\n            // Reset gradients registered flag on new instance\n            setGradientsRegistered(false);\n\n            // Create options without custom gradients on first init\n            const initOptions: any = { ...options };\n\n            // Replace custom gradients with default 'classic' for initial setup\n            if (visualizer.type === 'audiomotionanalyzer') {\n                const ama = visualizer.audiomotionanalyzer;\n                if (isCustomGradient(ama.gradient)) {\n                    initOptions.gradient = 'classic';\n                }\n                if (isCustomGradient(ama.gradientLeft)) {\n                    initOptions.gradientLeft = 'classic';\n                }\n                if (isCustomGradient(ama.gradientRight)) {\n                    initOptions.gradientRight = 'classic';\n                }\n            }\n\n            audioMotion = new AudioMotionAnalyzer(canvasRef.current, {\n                ...initOptions,\n                audioCtx: context,\n            });\n\n            // Register custom gradients (this will set gradientsRegistered to true)\n            registerCustomGradients(audioMotion);\n\n            setMotion(audioMotion);\n            for (const gain of gains) audioMotion.connectInput(gain);\n        }\n\n        return () => {\n            if (motion) {\n                motion.destroy();\n                setMotion(undefined);\n            }\n        };\n    }, [\n        accent,\n        canvasRef,\n        registerCustomGradients,\n        webAudio,\n        visualizer,\n        options,\n        isCustomGradient,\n        motion,\n        libraryLoaded,\n    ]);\n\n    // Re-register custom gradients when they change\n    useEffect(() => {\n        if (motion && visualizer.type === 'audiomotionanalyzer') {\n            setGradientsRegistered(false);\n            registerCustomGradients(motion);\n        }\n    }, [\n        motion,\n        registerCustomGradients,\n        visualizer.audiomotionanalyzer.customGradients,\n        visualizer.type,\n    ]);\n\n    // Update visualizer settings when they change\n    useEffect(() => {\n        if (motion) {\n            motion.setOptions(options);\n        }\n    }, [motion, options]);\n\n    return <div className={styles.visualizer} ref={canvasRef} style={{ opacity }} />;\n};\n\nexport const Visualizer = () => {\n    const { visualizerExpanded } = useFullScreenPlayerStore();\n    const { setStore } = useFullScreenPlayerStoreActions();\n\n    const handleToggleFullscreen = () => {\n        setStore({ expanded: false, visualizerExpanded: !visualizerExpanded });\n    };\n\n    return (\n        <div className={styles.container}>\n            <Group\n                className={styles.iconGroup}\n                gap=\"xs\"\n                pos=\"absolute\"\n                right=\"var(--theme-spacing-sm)\"\n                top=\"var(--theme-spacing-sm)\"\n            >\n                <ActionIcon\n                    icon=\"expand\"\n                    iconProps={{ size: 'lg' }}\n                    onClick={handleToggleFullscreen}\n                    variant=\"subtle\"\n                />\n                <ActionIcon\n                    icon=\"settings2\"\n                    iconProps={{ size: 'lg' }}\n                    onClick={openVisualizerSettingsModal}\n                    variant=\"subtle\"\n                />\n            </Group>\n            <ComponentErrorBoundary>\n                <VisualizerInner />\n            </ComponentErrorBoundary>\n        </div>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/visualizer/components/butternchurn/butterchurn.d.ts",
    "content": "declare module 'butterchurn' {\n    export default butterchurn;\n}\n\ndeclare module 'butterchurn-presets' {\n    export default butterchurnPresets;\n}\n"
  },
  {
    "path": "src/renderer/features/visualizer/components/butternchurn/visualizer.module.css",
    "content": ".container {\n    position: relative;\n    z-index: 50;\n    width: 100%;\n    height: 100%;\n    max-height: 100%;\n    margin: auto;\n    overflow: hidden;\n}\n\n.icon-group {\n    z-index: 100;\n}\n\n.icon-group > * {\n    opacity: 0;\n}\n\n.container:hover .icon-group > * {\n    opacity: 1;\n}\n\n.canvas {\n    display: block;\n    width: 100%;\n    max-width: 100%;\n    height: 100%;\n    max-height: 100%;\n}\n\n.preset-overlay {\n    position: absolute;\n    bottom: 0;\n    left: 0;\n    z-index: 10;\n    padding: var(--theme-spacing-xs) var(--theme-spacing-sm);\n    font-weight: 500;\n    color: #ddd;\n    pointer-events: none;\n    background-color: rgb(0 0 0 / 50%);\n    border-radius: 0 var(--theme-radius-md) 0 0;\n    opacity: 0;\n    transition: opacity 0.2s ease-in-out;\n}\n\n.container:hover .preset-overlay {\n    opacity: 1;\n}\n"
  },
  {
    "path": "src/renderer/features/visualizer/components/butternchurn/visualizer.tsx",
    "content": "import { createRef, useCallback, useEffect, useRef, useState } from 'react';\n\nimport styles from './visualizer.module.css';\n\nimport { useWebAudio } from '/@/renderer/features/player/hooks/use-webaudio';\nimport { openVisualizerSettingsModal } from '/@/renderer/features/player/utils/open-visualizer-settings-modal';\nimport { ComponentErrorBoundary } from '/@/renderer/features/shared/components/component-error-boundary';\nimport {\n    subscribeButterchurnPreset,\n    useButterchurnSettings,\n    useSettingsStore,\n    useSettingsStoreActions,\n} from '/@/renderer/store';\nimport {\n    useFullScreenPlayerStore,\n    useFullScreenPlayerStoreActions,\n} from '/@/renderer/store/full-screen-player.store';\nimport { usePlayerStatus } from '/@/renderer/store/player.store';\nimport { ActionIcon } from '/@/shared/components/action-icon/action-icon';\nimport { Group } from '/@/shared/components/group/group';\nimport { Text } from '/@/shared/components/text/text';\nimport { PlayerStatus } from '/@/shared/types/types';\n\n// Ignore presets that are erroring out\nconst IGNORED_PRESETS = ['Flexi + Martin - astral projection'];\n\ntype ButterchurnVisualizer = {\n    connectAudio: (audioNode: AudioNode) => void;\n    loadPreset: (preset: any, blendTime: number) => void;\n    render: () => void;\n    setRendererSize: (width: number, height: number) => void;\n};\n\nexport function getButterchurnPresetOptions(presets: Record<string, string>) {\n    if (!presets) return [];\n    return Object.fromEntries(\n        Object.entries(presets).filter(([preset]) => !IGNORED_PRESETS.includes(preset)),\n    );\n}\n\nconst VisualizerInner = () => {\n    const { webAudio } = useWebAudio();\n    const canvasRef = createRef<HTMLCanvasElement>();\n    const containerRef = createRef<HTMLDivElement>();\n    const visualizerRef = useRef<ButterchurnVisualizer | undefined>(undefined);\n    const isInitializedRef = useRef(false);\n    const [isVisualizerReady, setIsVisualizerReady] = useState(false);\n    const [librariesLoaded, setLibrariesLoaded] = useState(false);\n    const butterchurnRef = useRef<any>(null);\n    const butterchurnPresetsRef = useRef<any>(null);\n    const animationFrameRef = useRef<number | undefined>(undefined);\n    const resizeObserverRef = useRef<ResizeObserver | undefined>(undefined);\n    const cycleTimerRef = useRef<NodeJS.Timeout | undefined>(undefined);\n    const cycleStartTimeRef = useRef<number | undefined>(undefined);\n    const pauseTimerRef = useRef<NodeJS.Timeout | undefined>(undefined);\n    const initialPresetLoadedRef = useRef(false);\n    const butterchurnSettings = useButterchurnSettings();\n    const opacity = useSettingsStore((store) => store.visualizer.butterchurn.opacity);\n    const { setSettings } = useSettingsStoreActions();\n    const playerStatus = usePlayerStatus();\n    const isPlaying = playerStatus === PlayerStatus.PLAYING;\n\n    useEffect(() => {\n        let isMounted = true;\n\n        const loadLibraries = async () => {\n            try {\n                const [butterchurnModule, presetsModule] = await Promise.all([\n                    import('butterchurn'),\n                    import('butterchurn-presets'),\n                ]);\n\n                if (isMounted) {\n                    butterchurnRef.current = butterchurnModule.default;\n                    butterchurnPresetsRef.current = butterchurnPresetsRef.current =\n                        getButterchurnPresetOptions(presetsModule.default);\n\n                    setLibrariesLoaded(true);\n                }\n            } catch (error) {\n                console.error('Failed to load butterchurn libraries:', error);\n            }\n        };\n\n        loadLibraries();\n\n        return () => {\n            isMounted = false;\n        };\n    }, []);\n\n    const cleanupVisualizer = () => {\n        if (animationFrameRef.current) {\n            cancelAnimationFrame(animationFrameRef.current);\n            animationFrameRef.current = undefined;\n        }\n\n        if (cycleTimerRef.current) {\n            clearInterval(cycleTimerRef.current);\n            cycleTimerRef.current = undefined;\n        }\n\n        if (pauseTimerRef.current) {\n            clearTimeout(pauseTimerRef.current);\n            pauseTimerRef.current = undefined;\n        }\n\n        if (resizeObserverRef.current) {\n            resizeObserverRef.current.disconnect();\n            resizeObserverRef.current = undefined;\n        }\n\n        visualizerRef.current = undefined;\n        isInitializedRef.current = false;\n        initialPresetLoadedRef.current = false;\n        setIsVisualizerReady(false);\n    };\n\n    // Initialize butterchurn instance\n    useEffect(() => {\n        const { context, gains } = webAudio || {};\n        const canvas = canvasRef.current;\n        const container = containerRef.current;\n\n        const needsInitialization =\n            context &&\n            gains &&\n            gains.length > 0 &&\n            canvas &&\n            container &&\n            isPlaying &&\n            librariesLoaded &&\n            (!isInitializedRef.current || !visualizerRef.current);\n\n        if (!needsInitialization) {\n            return;\n        }\n\n        const getDimensions = () => {\n            const rect = container.getBoundingClientRect();\n            return {\n                height: rect.height || 600,\n                width: rect.width || 800,\n            };\n        };\n\n        let dimensions = getDimensions();\n\n        // If dimensions are 0, wait for next frame\n        if (dimensions.width === 0 || dimensions.height === 0) {\n            requestAnimationFrame(() => {\n                dimensions = getDimensions();\n                if (dimensions.width > 0 && dimensions.height > 0) {\n                    initializeVisualizer(dimensions.width, dimensions.height);\n                }\n            });\n        } else {\n            initializeVisualizer(dimensions.width, dimensions.height);\n        }\n\n        async function initializeVisualizer(width: number, height: number) {\n            if (!gains || gains.length === 0 || !canvas || !context || !librariesLoaded) return;\n\n            canvas.width = width;\n            canvas.height = height;\n\n            try {\n                const butterchurn = butterchurnRef.current;\n                if (!butterchurn) return;\n\n                const butterchurnInstance = butterchurn.createVisualizer(context, canvas, {\n                    height,\n                    width,\n                }) as ButterchurnVisualizer;\n\n                for (const gain of gains) {\n                    butterchurnInstance.connectAudio(gain);\n                }\n\n                visualizerRef.current = butterchurnInstance;\n                isInitializedRef.current = true;\n                setIsVisualizerReady(true);\n            } catch (error) {\n                console.error('Failed to create butterchurn visualizer:', error);\n                isInitializedRef.current = false;\n                visualizerRef.current = undefined;\n            }\n        }\n\n        return () => {\n            // Cleanup on unmount or when webAudio changes\n            cleanupVisualizer();\n        };\n        // eslint-disable-next-line react-hooks/exhaustive-deps\n    }, [webAudio, isPlaying, librariesLoaded]);\n\n    // Kill visualizer after 5 seconds of pause\n    useEffect(() => {\n        if (isPlaying) {\n            // Clear pause timer if player resumes\n            if (pauseTimerRef.current) {\n                clearTimeout(pauseTimerRef.current);\n                pauseTimerRef.current = undefined;\n            }\n            return;\n        }\n\n        // Player is paused\n        if (!visualizerRef.current) return;\n\n        // Start 5-second timer\n        pauseTimerRef.current = setTimeout(() => {\n            cleanupVisualizer();\n            pauseTimerRef.current = undefined;\n        }, 5000);\n\n        return () => {\n            if (pauseTimerRef.current) {\n                clearTimeout(pauseTimerRef.current);\n                pauseTimerRef.current = undefined;\n            }\n        };\n    }, [isPlaying]);\n\n    // Handle resize\n    useEffect(() => {\n        const container = containerRef.current;\n        const visualizer = visualizerRef.current;\n        if (!container || !visualizer) return;\n\n        const handleResize = () => {\n            const rect = container.getBoundingClientRect();\n            const width = rect.width;\n            const height = rect.height;\n\n            if (canvasRef.current) {\n                canvasRef.current.width = width;\n                canvasRef.current.height = height;\n            }\n\n            visualizer.setRendererSize(width, height);\n        };\n\n        resizeObserverRef.current = new ResizeObserver(handleResize);\n        resizeObserverRef.current.observe(container);\n\n        window.addEventListener('resize', handleResize);\n\n        return () => {\n            window.removeEventListener('resize', handleResize);\n            if (resizeObserverRef.current) {\n                resizeObserverRef.current.disconnect();\n                resizeObserverRef.current = undefined;\n            }\n        };\n    }, [isVisualizerReady, canvasRef, containerRef]);\n\n    // Load initial preset when visualizer is ready\n    useEffect(() => {\n        const visualizer = visualizerRef.current;\n        if (!visualizer || !isVisualizerReady || initialPresetLoadedRef.current || !librariesLoaded)\n            return;\n\n        const presets = butterchurnPresetsRef.current;\n        if (!presets) return;\n        const presetNames = Object.keys(presets);\n\n        if (presetNames.length > 0) {\n            const currentPreset = useSettingsStore.getState().visualizer.butterchurn.currentPreset;\n            const presetName =\n                currentPreset && presets[currentPreset] ? currentPreset : presetNames[0];\n            const preset = presets[presetName];\n\n            if (preset) {\n                visualizer.loadPreset(preset, butterchurnSettings.blendTime || 0.0);\n                cycleStartTimeRef.current = Date.now();\n                initialPresetLoadedRef.current = true;\n            }\n        }\n    }, [isVisualizerReady, butterchurnSettings.blendTime, librariesLoaded]);\n\n    const isCyclingRef = useRef(false);\n\n    // Handle preset cycling\n    useEffect(() => {\n        const visualizer = visualizerRef.current;\n        if (!visualizer || !butterchurnSettings.cyclePresets || !librariesLoaded) {\n            if (cycleTimerRef.current) {\n                clearInterval(cycleTimerRef.current);\n                cycleTimerRef.current = undefined;\n            }\n            return;\n        }\n\n        const presets = butterchurnPresetsRef.current;\n\n        if (!presets) return;\n        const allPresetNames = Object.keys(presets);\n\n        // Get the list of presets to cycle through\n        let presetList = butterchurnSettings.includeAllPresets\n            ? allPresetNames\n            : butterchurnSettings.selectedPresets.length > 0\n              ? butterchurnSettings.selectedPresets.filter((name) => presets[name])\n              : allPresetNames;\n\n        // Filter out ignored presets\n        if (butterchurnSettings.ignoredPresets && butterchurnSettings.ignoredPresets.length > 0) {\n            presetList = presetList.filter(\n                (name) => !butterchurnSettings.ignoredPresets.includes(name),\n            );\n        }\n\n        if (presetList.length === 0) return;\n\n        // Reset cycle timer when settings change\n        cycleStartTimeRef.current = Date.now();\n\n        const cycleToNextPreset = () => {\n            const currentVisualizer = visualizerRef.current;\n            if (!currentVisualizer) return;\n\n            const currentPresetName =\n                useSettingsStore.getState().visualizer.butterchurn.currentPreset;\n            let nextPresetName: string;\n\n            if (butterchurnSettings.randomizeNextPreset) {\n                // Randomly select a preset (excluding current if there are multiple)\n                const availablePresets =\n                    presetList.length > 1\n                        ? presetList.filter((name) => name !== currentPresetName)\n                        : presetList;\n                const randomIndex = Math.floor(Math.random() * availablePresets.length);\n                nextPresetName = availablePresets[randomIndex];\n            } else {\n                // Cycle to next preset in order\n                const currentIndex = currentPresetName ? presetList.indexOf(currentPresetName) : -1;\n                const nextIndex =\n                    currentIndex >= 0 && currentIndex < presetList.length - 1\n                        ? currentIndex + 1\n                        : 0;\n                nextPresetName = presetList[nextIndex];\n            }\n\n            const nextPreset = presets[nextPresetName];\n            if (nextPreset) {\n                const currentSettings = useSettingsStore.getState().visualizer.butterchurn;\n\n                isCyclingRef.current = true;\n\n                currentVisualizer.loadPreset(nextPreset, currentSettings.blendTime || 0.0);\n\n                setSettings({\n                    visualizer: {\n                        butterchurn: {\n                            currentPreset: nextPresetName,\n                        },\n                    },\n                });\n\n                cycleStartTimeRef.current = Date.now();\n            }\n        };\n\n        cycleTimerRef.current = setInterval(() => {\n            if (cycleStartTimeRef.current === undefined) {\n                cycleStartTimeRef.current = Date.now();\n                return;\n            }\n            const elapsed = (Date.now() - cycleStartTimeRef.current) / 1000; // Convert to seconds\n            if (elapsed >= butterchurnSettings.cycleTime) {\n                cycleToNextPreset();\n            }\n        }, 1000);\n\n        return () => {\n            if (cycleTimerRef.current) {\n                clearInterval(cycleTimerRef.current);\n                cycleTimerRef.current = undefined;\n            }\n        };\n    }, [\n        isVisualizerReady,\n        butterchurnSettings.cyclePresets,\n        butterchurnSettings.cycleTime,\n        butterchurnSettings.includeAllPresets,\n        butterchurnSettings.selectedPresets,\n        butterchurnSettings.ignoredPresets,\n        butterchurnSettings.randomizeNextPreset,\n        setSettings,\n        librariesLoaded,\n    ]);\n\n    useEffect(() => {\n        const visualizer = visualizerRef.current;\n        if (!visualizer || !isVisualizerReady) return;\n\n        let lastFrameTime = 0;\n        const maxFPS = butterchurnSettings.maxFPS;\n        const minFrameInterval = maxFPS > 0 ? 1000 / maxFPS : 0;\n\n        const render = (currentTime: number) => {\n            const currentVisualizer = visualizerRef.current;\n            if (!currentVisualizer) {\n                if (animationFrameRef.current) {\n                    cancelAnimationFrame(animationFrameRef.current);\n                    animationFrameRef.current = undefined;\n                }\n                return;\n            }\n\n            if (maxFPS === 0 || currentTime - lastFrameTime >= minFrameInterval) {\n                currentVisualizer.render();\n                lastFrameTime = currentTime;\n            }\n            animationFrameRef.current = requestAnimationFrame(render);\n        };\n\n        animationFrameRef.current = requestAnimationFrame(render);\n\n        return () => {\n            if (animationFrameRef.current) {\n                cancelAnimationFrame(animationFrameRef.current);\n                animationFrameRef.current = undefined;\n            }\n        };\n    }, [isVisualizerReady, butterchurnSettings.maxFPS]);\n\n    // Handle preset changes via subscriber\n    useEffect(() => {\n        const unsubscribe = subscribeButterchurnPreset((presetName) => {\n            const visualizer = visualizerRef.current;\n            if (\n                !visualizer ||\n                !isVisualizerReady ||\n                !librariesLoaded ||\n                !presetName ||\n                !initialPresetLoadedRef.current\n            ) {\n                return;\n            }\n\n            if (isCyclingRef.current) {\n                isCyclingRef.current = false;\n                return;\n            }\n\n            const presets = butterchurnPresetsRef.current;\n            if (!presets) return;\n\n            const preset = presets[presetName];\n            if (preset && typeof preset === 'object') {\n                visualizer.loadPreset(preset, butterchurnSettings.blendTime || 0.0);\n                cycleStartTimeRef.current = Date.now();\n            }\n        });\n\n        return () => {\n            unsubscribe();\n        };\n    }, [isVisualizerReady, librariesLoaded, butterchurnSettings.blendTime]);\n\n    const shouldRenderContainer = isPlaying || isVisualizerReady;\n\n    if (!shouldRenderContainer) {\n        return null;\n    }\n\n    return (\n        <div\n            className={styles.container}\n            ref={containerRef}\n            style={{ opacity: isVisualizerReady ? opacity : 0 }}\n        >\n            <canvas className={styles.canvas} ref={canvasRef} />\n            {isVisualizerReady && <CurrentPresetDisplay />}\n        </div>\n    );\n};\n\nconst CurrentPresetDisplay = () => {\n    const currentPreset = useSettingsStore((store) => store.visualizer.butterchurn.currentPreset);\n\n    return (\n        <Text className={styles['preset-overlay']} isNoSelect size=\"sm\">\n            {currentPreset}\n        </Text>\n    );\n};\n\nexport const Visualizer = () => {\n    const { visualizerExpanded } = useFullScreenPlayerStore();\n    const { setStore } = useFullScreenPlayerStoreActions();\n    const { setSettings } = useSettingsStoreActions();\n    const butterchurnSettings = useButterchurnSettings();\n    const [presetsLoaded, setPresetsLoaded] = useState(false);\n    const butterchurnPresetsRef = useRef<any>(null);\n\n    useEffect(() => {\n        let isMounted = true;\n\n        const loadPresets = async () => {\n            try {\n                const presetsModule = await import('butterchurn-presets');\n                if (isMounted) {\n                    butterchurnPresetsRef.current = getButterchurnPresetOptions(\n                        presetsModule.default,\n                    );\n                    setPresetsLoaded(true);\n                }\n            } catch (error) {\n                console.error('Failed to load butterchurn presets:', error);\n            }\n        };\n\n        loadPresets();\n\n        return () => {\n            isMounted = false;\n        };\n    }, []);\n\n    const getPresetList = useCallback(() => {\n        const presets = butterchurnPresetsRef.current;\n        if (!presets) return [];\n\n        const allPresetNames = Object.keys(presets);\n\n        let presetList = butterchurnSettings.includeAllPresets\n            ? allPresetNames\n            : butterchurnSettings.selectedPresets.length > 0\n              ? butterchurnSettings.selectedPresets.filter((name) => presets[name])\n              : allPresetNames;\n\n        if (butterchurnSettings.ignoredPresets && butterchurnSettings.ignoredPresets.length > 0) {\n            presetList = presetList.filter(\n                (name) => !butterchurnSettings.ignoredPresets.includes(name),\n            );\n        }\n\n        return presetList;\n    }, [\n        butterchurnSettings.includeAllPresets,\n        butterchurnSettings.selectedPresets,\n        butterchurnSettings.ignoredPresets,\n    ]);\n\n    const handleToggleFullscreen = () => {\n        setStore({ expanded: false, visualizerExpanded: !visualizerExpanded });\n    };\n\n    const handleNextPreset = () => {\n        if (!presetsLoaded) return;\n\n        const presetList = getPresetList();\n        if (presetList.length === 0) return;\n\n        const currentPresetName = useSettingsStore.getState().visualizer.butterchurn.currentPreset;\n        const currentIndex = currentPresetName ? presetList.indexOf(currentPresetName) : -1;\n        const nextIndex =\n            currentIndex >= 0 && currentIndex < presetList.length - 1 ? currentIndex + 1 : 0;\n        const nextPresetName = presetList[nextIndex];\n\n        setSettings({\n            visualizer: {\n                butterchurn: {\n                    currentPreset: nextPresetName,\n                },\n            },\n        });\n    };\n\n    const handlePreviousPreset = () => {\n        if (!presetsLoaded) return;\n\n        const presetList = getPresetList();\n        if (presetList.length === 0) return;\n\n        const currentPresetName = useSettingsStore.getState().visualizer.butterchurn.currentPreset;\n        const currentIndex = currentPresetName ? presetList.indexOf(currentPresetName) : -1;\n        const prevIndex = currentIndex > 0 ? currentIndex - 1 : presetList.length - 1;\n        const prevPresetName = presetList[prevIndex];\n\n        setSettings({\n            visualizer: {\n                butterchurn: {\n                    currentPreset: prevPresetName,\n                },\n            },\n        });\n    };\n\n    return (\n        <div className={styles.container}>\n            <Group\n                className={styles.iconGroup}\n                gap=\"xs\"\n                pos=\"absolute\"\n                right=\"var(--theme-spacing-sm)\"\n                top=\"var(--theme-spacing-sm)\"\n            >\n                <ActionIcon\n                    icon=\"expand\"\n                    iconProps={{ size: 'lg' }}\n                    onClick={handleToggleFullscreen}\n                    variant=\"subtle\"\n                />\n                <ActionIcon\n                    icon=\"settings2\"\n                    iconProps={{ size: 'lg' }}\n                    onClick={openVisualizerSettingsModal}\n                    variant=\"subtle\"\n                />\n            </Group>\n            <Group\n                className={styles.iconGroup}\n                gap=\"xs\"\n                pos=\"absolute\"\n                right=\"var(--theme-spacing-sm)\"\n                style={{ bottom: 'var(--theme-spacing-sm)' }}\n            >\n                <ActionIcon\n                    icon=\"arrowLeftS\"\n                    iconProps={{ size: 'lg' }}\n                    onClick={handlePreviousPreset}\n                    variant=\"subtle\"\n                />\n                <ActionIcon\n                    icon=\"arrowRightS\"\n                    iconProps={{ size: 'lg' }}\n                    onClick={handleNextPreset}\n                    variant=\"subtle\"\n                />\n            </Group>\n            <ComponentErrorBoundary>\n                <VisualizerInner />\n            </ComponentErrorBoundary>\n        </div>\n    );\n};\n"
  },
  {
    "path": "src/renderer/features/window-controls/components/window-controls.module.css",
    "content": ".windows-button-group {\n    display: flex;\n    width: 130px;\n    height: 100%;\n    -webkit-app-region: no-drag;\n    background-color: var(--theme-colors-background);\n}\n\n.windows-button {\n    display: flex;\n    flex: 1;\n    align-items: center;\n    justify-content: center;\n    -webkit-app-region: no-drag;\n    width: 50px;\n    height: 65px;\n\n    img {\n        width: 35%;\n        height: 50%;\n    }\n\n    &:hover {\n        background: rgb(125 125 125 / 30%);\n    }\n}\n\n.windows-button.exit-button {\n    &:hover {\n        background: var(--theme-colors-state-error);\n    }\n}\n"
  },
  {
    "path": "src/renderer/features/window-controls/components/window-controls.tsx",
    "content": "import clsx from 'clsx';\nimport isElectron from 'is-electron';\nimport { useState } from 'react';\nimport { RiCheckboxBlankLine, RiCloseLine, RiSubtractLine } from 'react-icons/ri';\n\nimport styles from './window-controls.module.css';\n\nconst browser = isElectron() ? window.api.browser : null;\n\nconst close = () => browser?.exit();\n\nconst minimize = () => browser?.minimize();\n\nconst maximize = () => browser?.maximize();\n\nconst unmaximize = () => browser?.unmaximize();\n\nexport const WindowControls = () => {\n    const [max, setMax] = useState(false);\n\n    const handleMinimize = () => minimize();\n\n    const handleMaximize = () => {\n        if (max) {\n            unmaximize();\n        } else {\n            maximize();\n        }\n        setMax(!max);\n    };\n\n    const handleClose = () => close();\n\n    return (\n        <>\n            {isElectron() && (\n                <>\n                    <div className={styles.windowsButtonGroup}>\n                        <div\n                            className={styles.windowsButton}\n                            onClick={handleMinimize}\n                            role=\"button\"\n                        >\n                            <RiSubtractLine size={19} />\n                        </div>\n                        <div\n                            className={styles.windowsButton}\n                            onClick={handleMaximize}\n                            role=\"button\"\n                        >\n                            <RiCheckboxBlankLine size={13} />\n                        </div>\n                        <div\n                            className={clsx(styles.windowsButton, styles.exitButton)}\n                            onClick={handleClose}\n                            role=\"button\"\n                        >\n                            <RiCloseLine size={19} />\n                        </div>\n                    </div>\n                </>\n            )}\n        </>\n    );\n};\n"
  },
  {
    "path": "src/renderer/global.d.ts",
    "content": "declare global {\n    interface Window {\n        ANALYTICS_DISABLED?: boolean | string;\n        FS_AUTO_DJ_ENABLED?: string;\n        FS_AUTO_DJ_ITEM_COUNT?: string;\n        FS_AUTO_DJ_TIMING?: string;\n        FS_CSS_CONTENT?: string;\n        FS_CSS_ENABLED?: string;\n        FS_DISCORD_CLIENT_ID?: string;\n        FS_DISCORD_DISPLAY_TYPE?: string;\n        FS_DISCORD_ENABLED?: string;\n        FS_DISCORD_LINK_TYPE?: string;\n        FS_DISCORD_SHOW_AS_LISTENING?: string;\n        FS_DISCORD_SHOW_PAUSED?: string;\n        FS_DISCORD_SHOW_SERVER_IMAGE?: string;\n        FS_DISCORD_SHOW_STATE_ICON?: string;\n        FS_FONT_BUILT_IN?: string;\n        FS_FONT_SYSTEM?: string;\n        FS_FONT_TYPE?: string;\n        FS_GENERAL_ACCENT?: string;\n        FS_GENERAL_ALBUM_BACKGROUND?: string;\n        FS_GENERAL_ALBUM_BACKGROUND_BLUR?: string;\n        FS_GENERAL_ARTIST_BACKGROUND?: string;\n        FS_GENERAL_ARTIST_BACKGROUND_BLUR?: string;\n        FS_GENERAL_BLUR_EXPLICIT_IMAGES?: string;\n        FS_GENERAL_COMBINED_LYRICS_AND_VISUALIZER?: string;\n        FS_GENERAL_ENABLE_GRID_MULTI_SELECT?: string;\n        FS_GENERAL_EXTERNAL_LINKS?: string;\n        FS_GENERAL_FOLLOW_CURRENT_SONG?: string;\n        FS_GENERAL_FOLLOW_SYSTEM_THEME?: string;\n        FS_GENERAL_HOME_FEATURE?: string;\n        FS_GENERAL_HOME_FEATURE_STYLE?: string;\n        FS_GENERAL_LANGUAGE?: string;\n        FS_GENERAL_LAST_FM?: string;\n        FS_GENERAL_LASTFM_API_KEY?: string;\n        FS_GENERAL_MUSIC_BRAINZ?: string;\n        FS_GENERAL_NATIVE_ASPECT_RATIO?: string;\n        FS_GENERAL_PATH_REPLACE?: string;\n        FS_GENERAL_PATH_REPLACE_WITH?: string;\n        FS_GENERAL_PLAYERBAR_OPEN_DRAWER?: string;\n        FS_GENERAL_PRIMARY_SHADE?: string;\n        FS_GENERAL_RESUME?: string;\n        FS_GENERAL_SHOW_LYRICS_IN_SIDEBAR?: string;\n        FS_GENERAL_SHOW_RATINGS?: string;\n        FS_GENERAL_SHOW_VISUALIZER_IN_SIDEBAR?: string;\n        FS_GENERAL_SIDE_QUEUE_TYPE?: string;\n        FS_GENERAL_SIDEBAR_COLLAPSE_SHARED?: string;\n        FS_GENERAL_SIDEBAR_COLLAPSED_NAVIGATION?: string;\n        FS_GENERAL_SIDEBAR_PLAYLIST_LIST?: string;\n        FS_GENERAL_SIDEBAR_PLAYLIST_SORTING?: string;\n        FS_GENERAL_THEME?: string;\n        FS_GENERAL_THEME_DARK?: string;\n        FS_GENERAL_THEME_LIGHT?: string;\n        FS_GENERAL_USE_THEME_ACCENT_COLOR?: string;\n        FS_GENERAL_USE_THEME_PRIMARY_SHADE?: string;\n        FS_GENERAL_ZOOM_FACTOR?: string;\n        FS_LYRICS_ALIGNMENT?: string;\n        FS_LYRICS_DELAY_MS?: string;\n        FS_LYRICS_ENABLE_AUTO_TRANSLATION?: string;\n        FS_LYRICS_FETCH?: string;\n        FS_LYRICS_FOLLOW?: string;\n        FS_LYRICS_PREFER_LOCAL?: string;\n        FS_LYRICS_SHOW_MATCH?: string;\n        FS_LYRICS_SHOW_PROVIDER?: string;\n        FS_LYRICS_TRANSLATION_API_KEY?: string;\n        FS_LYRICS_TRANSLATION_TARGET_LANGUAGE?: string;\n        FS_PLAYBACK_AUDIO_FADE_ON_STATUS_CHANGE?: string;\n        FS_PLAYBACK_MEDIA_SESSION?: string;\n        FS_PLAYBACK_PRESERVE_PITCH?: string;\n        FS_PLAYBACK_SCROBBLE_AT_DURATION?: string;\n        FS_PLAYBACK_SCROBBLE_AT_PERCENTAGE?: string;\n        FS_PLAYBACK_SCROBBLE_ENABLED?: string;\n        FS_PLAYBACK_SCROBBLE_NOTIFY?: string;\n        FS_PLAYBACK_TRANSCODE_ENABLED?: string;\n        FS_PLAYBACK_WEB_AUDIO?: string;\n        LEGACY_AUTHENTICATION?: boolean | string;\n        REMOTE_URL?: string;\n        SERVER_LOCK?: boolean | string;\n        SERVER_NAME?: string;\n        SERVER_TYPE?: string;\n        SERVER_URL?: string;\n        umami?: {\n            identify(unique_id: string): void;\n            identify(unique_id: string, data: object): void;\n            identify(data: object): void;\n            track(): void;\n            track(event_name: string, data: object): void;\n            track(\n                callback: (props: {\n                    hostname: string;\n                    language: string;\n                    referrer: string;\n                    screen: string;\n                    title: string;\n                    url: string;\n                    website: string;\n                }) => object,\n            ): void;\n        };\n    }\n}\n\nexport {};\n"
  },
  {
    "path": "src/renderer/hooks/index.ts",
    "content": "export * from './use-app-focus';\nexport * from './use-check-for-updates';\nexport * from './use-container-query';\nexport * from './use-fast-average-color';\nexport * from './use-hide-scrollbar';\nexport * from './use-is-mounted';\nexport * from './use-should-pad-titlebar';\n"
  },
  {
    "path": "src/renderer/hooks/use-app-focus.ts",
    "content": "// From https://learnersbucket.com/examples/interview/usehasfocus-hook-in-react/\n\nimport { useEffect, useState } from 'react';\n\nexport const useAppFocus = () => {\n    const [focus, setFocus] = useState(document.hasFocus());\n\n    useEffect(() => {\n        const onFocus = () => setFocus(true);\n        const onBlur = () => setFocus(false);\n\n        window.addEventListener('focus', onFocus);\n        window.addEventListener('blur', onBlur);\n\n        return () => {\n            window.removeEventListener('focus', onFocus);\n            window.removeEventListener('blur', onBlur);\n        };\n    }, []);\n\n    return focus;\n};\n"
  },
  {
    "path": "src/renderer/hooks/use-check-for-updates.ts",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport isElectron from 'is-electron';\nimport { useEffect, useState } from 'react';\n\nconst CHECK_FOR_UPDATES_INTERVAL_MS = 6 * 60 * 60 * 1000;\n\nconst utils = isElectron() ? window.api?.utils : null;\n\nexport const useCheckForUpdates = () => {\n    const [enablePeriodicCheck, setEnablePeriodicCheck] = useState(false);\n\n    // We want to skip the first check since it's already checked in the main process when the app is started\n    useEffect(() => {\n        const timer = setTimeout(() => setEnablePeriodicCheck(true), CHECK_FOR_UPDATES_INTERVAL_MS);\n        return () => clearTimeout(timer);\n    }, []);\n\n    const isEnabled =\n        enablePeriodicCheck &&\n        Boolean(isElectron() && utils?.checkForUpdates && !utils?.disableAutoUpdates?.());\n\n    return useQuery({\n        enabled: isEnabled,\n        queryFn: () => utils?.checkForUpdates?.(),\n        queryKey: ['app-check-for-updates'],\n        refetchInterval: CHECK_FOR_UPDATES_INTERVAL_MS,\n        refetchIntervalInBackground: true,\n    });\n};\n"
  },
  {
    "path": "src/renderer/hooks/use-container-query.ts",
    "content": "import { useElementSize } from '/@/shared/hooks/use-element-size';\n\ninterface UseContainerQueryProps {\n    '2xl'?: number;\n    '3xl'?: number;\n    '4xl'?: number;\n    '5xl'?: number;\n    lg?: number;\n    md?: number;\n    sm?: number;\n    xl?: number;\n    xs?: number;\n}\n\nexport const useContainerQuery = (props?: UseContainerQueryProps) => {\n    const {\n        '2xl': xxl,\n        '3xl': xxxl,\n        '4xl': xxxxl,\n        '5xl': xxxxxl,\n        lg,\n        md,\n        sm,\n        xl,\n        xs,\n    } = props || {};\n    const { height, ref, width } = useElementSize();\n\n    const isXs = width >= (xs || 360);\n    const isSm = width >= (sm || 480);\n    const isMd = width >= (md || 600);\n    const isLg = width >= (lg || 768);\n    const isXl = width >= (xl || 960);\n    const is2xl = width >= (xxl || 1152);\n    const is3xl = width >= (xxxl || 1280);\n    const is4xl = width >= (xxxxl || 1440);\n    const is5xl = width >= (xxxxxl || 1600);\n\n    const isCalculated = width !== 0;\n\n    return {\n        height,\n        is2xl,\n        is3xl,\n        is4xl,\n        is5xl,\n        isCalculated,\n        isLg,\n        isMd,\n        isSm,\n        isXl,\n        isXs,\n        ref,\n        width,\n    };\n};\n"
  },
  {
    "path": "src/renderer/hooks/use-drag-drop.tsx",
    "content": "import {\n    attachClosestEdge,\n    type Edge,\n    extractClosestEdge,\n} from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge';\nimport { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';\nimport {\n    BaseEventPayload,\n    CleanupFn,\n    ElementDragType,\n} from '@atlaskit/pragmatic-drag-and-drop/dist/types/internal-types';\nimport {\n    draggable,\n    dropTargetForElements,\n} from '@atlaskit/pragmatic-drag-and-drop/element/adapter';\nimport { disableNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/disable-native-drag-preview';\nimport { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview';\nimport { useEffect, useRef, useState } from 'react';\nimport { createRoot } from 'react-dom/client';\n\nimport { DragPreview } from '/@/renderer/components/drag-preview/drag-preview';\nimport { LibraryItem } from '/@/shared/types/domain-types';\nimport { dndUtils, DragData, DragOperation, DragTarget } from '/@/shared/types/drag-and-drop';\n\ninterface UseDraggableProps {\n    drag?: {\n        getId: () => string[];\n        getItem: () => unknown[];\n        itemType?: LibraryItem;\n        metadata?: Record<string, unknown>;\n        onDragStart?: () => void;\n        onDrop?: () => void;\n        onGenerateDragPreview?: (data: BaseEventPayload<ElementDragType>) => void;\n        operation: DragOperation[];\n        target: DragTarget | string;\n    };\n    drop?: {\n        canDrop: (args: { source: DragData }) => boolean;\n        getData: () => DragData;\n        onDrag: (args: { edge: Edge | null }) => void;\n        onDragLeave: () => void;\n        onDrop: (args: { edge: Edge | null; self: DragData; source: DragData }) => void;\n    };\n    isEnabled: boolean;\n}\n\nexport const useDragDrop = <TElement extends HTMLElement>({\n    drag,\n    drop,\n    isEnabled,\n}: UseDraggableProps) => {\n    const ref = useRef<null | TElement>(null);\n\n    const [isDragging, setIsDragging] = useState(false);\n    const [isDraggedOver, setIsDraggedOver] = useState<Edge | null>(null);\n\n    useEffect(() => {\n        if (!ref.current || !isEnabled) return;\n\n        const functions: CleanupFn[] = [];\n\n        if (drag) {\n            functions.push(\n                draggable({\n                    element: ref.current,\n                    getInitialData: () => {\n                        const id = drag.getId();\n                        const item = drag.getItem();\n\n                        const data = dndUtils.generateDragData(\n                            {\n                                id,\n                                item,\n                                itemType: drag.itemType,\n                                operation: drag.operation,\n                                type: drag.target,\n                            },\n                            drag.metadata,\n                        );\n                        return data;\n                    },\n                    onDragStart: () => {\n                        setIsDragging(true);\n                        drag.onDragStart?.();\n                    },\n                    onDrop: () => {\n                        setIsDragging(false);\n                        drag.onDrop?.();\n                    },\n                    onGenerateDragPreview: (data) => {\n                        if (drag.onGenerateDragPreview) {\n                            return drag.onGenerateDragPreview(data);\n                        }\n\n                        const dragData = dndUtils.generateDragData(\n                            {\n                                id: drag.getId(),\n                                item: drag.getItem(),\n                                itemType: drag.itemType,\n                                operation: drag.operation,\n                                type: drag.target,\n                            },\n                            drag.metadata,\n                        ) as DragData;\n\n                        disableNativeDragPreview({ nativeSetDragImage: data.nativeSetDragImage });\n                        setCustomNativeDragPreview({\n                            nativeSetDragImage: data.nativeSetDragImage,\n                            render: ({ container }) => {\n                                const root = createRoot(container);\n                                root.render(<DragPreview data={dragData} />);\n                            },\n                        });\n                    },\n                }),\n            );\n        }\n\n        if (drop) {\n            functions.push(\n                dropTargetForElements({\n                    canDrop: (args) => {\n                        return (\n                            drop.canDrop?.({ source: args.source.data as unknown as DragData }) ||\n                            false\n                        );\n                    },\n                    element: ref.current,\n                    getData: (args) => {\n                        const dropData = drop.getData();\n\n                        const data = dndUtils.generateDragData(dropData);\n\n                        return attachClosestEdge(data, {\n                            allowedEdges: ['top', 'bottom'],\n                            element: args.element,\n                            input: args.input,\n                        });\n                    },\n                    onDrag: (args) => {\n                        const closestEdgeOfTarget: Edge | null = extractClosestEdge(args.self.data);\n                        drop.onDrag?.({ edge: closestEdgeOfTarget });\n                        setIsDraggedOver(closestEdgeOfTarget);\n                    },\n                    onDragLeave: () => {\n                        setIsDraggedOver(null);\n                    },\n                    onDrop: (args) => {\n                        const closestEdgeOfTarget: Edge | null = extractClosestEdge(args.self.data);\n                        drop.onDrop?.({\n                            edge: closestEdgeOfTarget,\n                            self: args.self.data as unknown as DragData,\n                            source: args.source.data as unknown as DragData,\n                        });\n                        setIsDraggedOver(null);\n                    },\n                }),\n            );\n        }\n\n        return combine(...functions);\n    }, [drag, drop, isDragging, isDraggedOver, isEnabled]);\n\n    return {\n        isDraggedOver,\n        isDragging,\n        ref,\n    };\n};\n"
  },
  {
    "path": "src/renderer/hooks/use-fast-average-color.tsx",
    "content": "import { FastAverageColor, FastAverageColorIgnoredColor } from 'fast-average-color';\nimport { useEffect, useRef, useState } from 'react';\n\nconst ignoredColors: FastAverageColorIgnoredColor = [\n    [255, 255, 255, 255, 90], // White\n    [255, 255, 255, 255, 50], // Light gray\n    [255, 255, 255, 255, 30], // Very light gray\n    [255, 255, 255, 255, 10], // Very very light gray\n    [0, 0, 0, 255, 30], // Black\n    [0, 0, 0, 0, 40], // Transparent\n];\n\nexport const getFastAverageColor = async (args: {\n    algorithm?: 'dominant' | 'simple' | 'sqrt';\n    src: string;\n}) => {\n    return new Promise<string>((resolve, reject) => {\n        setTimeout(() => {\n            const fac = new FastAverageColor();\n            fac.getColorAsync(args.src, {\n                algorithm: args.algorithm || 'dominant',\n                ignoredColor: ignoredColors,\n                mode: 'speed',\n            })\n                .then((background) => {\n                    resolve(background.rgb);\n                    fac.destroy();\n                })\n                .catch((error) => {\n                    fac.destroy();\n                    reject(error);\n                });\n        });\n    });\n};\n\nexport const useFastAverageColor = (args: {\n    algorithm?: 'dominant' | 'simple' | 'sqrt';\n    default?: string;\n    id?: string;\n    src?: null | string;\n    srcLoaded?: boolean;\n}) => {\n    const { algorithm, default: defaultColor, id, src, srcLoaded } = args;\n    const idRef = useRef<string | undefined>(id);\n    const processingSrcRef = useRef<null | string | undefined>(null);\n\n    const [isLoading, setIsLoading] = useState(false);\n\n    const [background, setBackground] = useState<{\n        background: string | undefined;\n        isDark: boolean;\n        isLight: boolean;\n    }>({\n        background: defaultColor,\n        isDark: true,\n        isLight: false,\n    });\n\n    useEffect(() => {\n        let isMounted = true;\n        let fac: FastAverageColor | null = null;\n        let timeoutId: NodeJS.Timeout | null = null;\n\n        // Reset loading state when src changes or srcLoaded becomes false\n        if (!src || !srcLoaded) {\n            setIsLoading(false);\n            processingSrcRef.current = null;\n        }\n\n        if (src && srcLoaded) {\n            processingSrcRef.current = src;\n            setIsLoading(true);\n\n            timeoutId = setTimeout(() => {\n                // Check if src has changed since we started processing\n                if (!isMounted || processingSrcRef.current !== src) {\n                    return;\n                }\n\n                fac = new FastAverageColor();\n                fac.getColorAsync(src, {\n                    algorithm: algorithm || 'dominant',\n                    ignoredColor: ignoredColors,\n                    mode: 'speed',\n                })\n                    .then((color) => {\n                        // Only update if this is still the current src being processed\n                        if (isMounted && processingSrcRef.current === src) {\n                            idRef.current = id;\n                            setBackground({\n                                background: color.rgb,\n                                isDark: color.isDark,\n                                isLight: color.isLight,\n                            });\n                            setIsLoading(false);\n                            processingSrcRef.current = null;\n                        }\n                        if (fac) {\n                            fac.destroy();\n                            fac = null;\n                        }\n                    })\n                    .catch((e) => {\n                        // Only update if this is still the current src being processed\n                        if (isMounted && processingSrcRef.current === src) {\n                            console.error('Error fetching average color', e);\n                            idRef.current = id;\n                            setBackground({\n                                background: 'rgba(0, 0, 0, 0)',\n                                isDark: true,\n                                isLight: false,\n                            });\n                            setIsLoading(false);\n                            processingSrcRef.current = null;\n                        }\n                        if (fac) {\n                            fac.destroy();\n                            fac = null;\n                        }\n                    });\n            });\n        } else if (srcLoaded) {\n            if (isMounted) {\n                idRef.current = id;\n                setBackground({\n                    background: 'var(--theme-colors-foreground-muted)',\n                    isDark: true,\n                    isLight: false,\n                });\n                setIsLoading(false);\n                processingSrcRef.current = null;\n            }\n        }\n\n        return () => {\n            isMounted = false;\n            processingSrcRef.current = null;\n            if (timeoutId) {\n                clearTimeout(timeoutId);\n                timeoutId = null;\n            }\n            if (fac) {\n                fac.destroy();\n                fac = null;\n            }\n        };\n    }, [algorithm, srcLoaded, src, id]);\n\n    return {\n        background: background.background,\n        colorId: idRef.current,\n        isDark: background.isDark,\n        isLight: background.isLight,\n        isLoading,\n    };\n};\n\nexport const useWaitForColorCalculation = (args: {\n    hasImage: boolean;\n    isLoading: boolean;\n    routeId: string;\n    showBlurredImage: boolean;\n    timeoutMs?: number;\n}) => {\n    const { hasImage, isLoading, routeId, showBlurredImage, timeoutMs = 1000 } = args;\n    const [timeoutReached, setTimeoutReached] = useState(false);\n\n    const shouldWaitForColor = hasImage && !showBlurredImage;\n\n    useEffect(() => {\n        setTimeoutReached(false);\n\n        if (!shouldWaitForColor) {\n            return;\n        }\n\n        const timeoutId = setTimeout(() => {\n            setTimeoutReached(true);\n        }, timeoutMs);\n\n        return () => {\n            clearTimeout(timeoutId);\n        };\n    }, [shouldWaitForColor, routeId, timeoutMs]);\n\n    const isReady = !shouldWaitForColor || !isLoading || timeoutReached;\n\n    return { isReady };\n};\n"
  },
  {
    "path": "src/renderer/hooks/use-garbage-collection.ts",
    "content": "import isElectron from 'is-electron';\nimport { useEffect, useRef } from 'react';\nimport { useLocation } from 'react-router';\n\nconst GARBAGE_COLLECTION_INTERVAL = 1000 * 60 * 5;\n\nexport const useGarbageCollection = () => {\n    const intervalIdRef = useRef<NodeJS.Timeout | null>(null);\n\n    // Clear the cache on an interval\n    useEffect(() => {\n        if (!isElectron()) {\n            return;\n        }\n\n        intervalIdRef.current = setInterval(() => {\n            window.api?.utils?.forceGarbageCollection?.();\n        }, GARBAGE_COLLECTION_INTERVAL);\n\n        return () => {\n            if (intervalIdRef.current) {\n                clearInterval(intervalIdRef.current);\n            }\n        };\n    }, []);\n\n    const location = useLocation();\n\n    // Clear the cache when the location changes\n    useEffect(() => {\n        if (!isElectron()) {\n            return;\n        }\n\n        // Clear the interval when the location changes\n        if (intervalIdRef.current) {\n            clearInterval(intervalIdRef.current);\n        }\n\n        window.api?.utils?.forceGarbageCollection?.();\n    }, [location]);\n};\n"
  },
  {
    "path": "src/renderer/hooks/use-genre-route.ts",
    "content": "import { useMemo } from 'react';\nimport { useLocation } from 'react-router';\n\nimport { AppRoute } from '/@/renderer/router/routes';\n\nconst ALBUM_REGEX = /albums$/;\nconst SONG_REGEX = /songs$/;\n\nexport const useGenreRoute = () => {\n    const { pathname } = useLocation();\n    const matchAlbum = ALBUM_REGEX.test(pathname);\n    const matchSongs = SONG_REGEX.test(pathname);\n\n    const baseState = AppRoute.LIBRARY_GENRES_DETAIL;\n\n    return useMemo(() => {\n        if (matchAlbum) {\n            return AppRoute.LIBRARY_GENRES_DETAIL;\n        }\n        if (matchSongs) {\n            return AppRoute.LIBRARY_GENRES_DETAIL;\n        }\n        return baseState;\n    }, [baseState, matchAlbum, matchSongs]);\n};\n"
  },
  {
    "path": "src/renderer/hooks/use-hide-scrollbar.ts",
    "content": "import { useEffect, useState } from 'react';\n\nimport { useTimeout } from '/@/shared/hooks/use-timeout';\n\nexport const useHideScrollbar = (timeout: number) => {\n    const [hideScrollbar, setHideScrollbar] = useState(false);\n    const { clear, start } = useTimeout(() => setHideScrollbar(true), timeout);\n\n    // Automatically hide the scrollbar after the timeout duration\n    useEffect(() => {\n        start();\n        // eslint-disable-next-line react-hooks/exhaustive-deps\n    }, []);\n\n    const hideScrollbarElementProps = {\n        onMouseEnter: () => {\n            setHideScrollbar(false);\n            clear();\n        },\n        onMouseLeave: () => {\n            start();\n        },\n    };\n\n    return { hideScrollbarElementProps, isScrollbarHidden: hideScrollbar };\n};\n"
  },
  {
    "path": "src/renderer/hooks/use-is-mobile.ts",
    "content": "import { useMediaQuery } from '@mantine/hooks';\n\nexport const useIsMobile = () => {\n    const isMobile = useMediaQuery('(max-width: 768px)');\n    return isMobile;\n};\n"
  },
  {
    "path": "src/renderer/hooks/use-is-mounted.ts",
    "content": "import { useEffect, useState } from 'react';\n\nexport const useIsMounted = () => {\n    const [isMounted, setIsMounted] = useState(false);\n\n    useEffect(() => {\n        setIsMounted(true);\n    }, []);\n\n    return isMounted;\n};\n"
  },
  {
    "path": "src/renderer/hooks/use-server-authenticated.ts",
    "content": "import { isAxiosError } from 'axios';\nimport isElectron from 'is-electron';\nimport debounce from 'lodash/debounce';\nimport isEqual from 'lodash/isEqual';\nimport { useCallback, useEffect, useRef, useState } from 'react';\nimport { useNavigate } from 'react-router';\n\nimport { api } from '/@/renderer/api';\nimport { controller } from '/@/renderer/api/controller';\nimport { AppRoute } from '/@/renderer/router/routes';\nimport { getServerById, useAuthStoreActions, useCurrentServer } from '/@/renderer/store';\nimport { LogCategory, logFn } from '/@/renderer/utils/logger';\nimport { logMsg } from '/@/renderer/utils/logger-message';\nimport { toast } from '/@/shared/components/toast/toast';\nimport { AuthState } from '/@/shared/types/types';\n\nconst localSettings = isElectron() ? window.api.localSettings : null;\n\nconst MIN_AUTH_DELAY_MS = 1000;\nconst MAX_NETWORK_RETRIES = 1;\nconst NETWORK_RETRY_DELAY_MS = 500;\n\nconst isNetworkError = (error: any): boolean => {\n    const message =\n        error.message && typeof error.message === 'string' ? (error.message as string) : null;\n    const messageLower = message?.toLowerCase();\n\n    if (messageLower?.includes('network') || messageLower?.includes('timeout')) {\n        return true;\n    }\n\n    return (\n        isAxiosError(error) &&\n        (error.code === 'ERR_NETWORK' ||\n            error.code === 'ECONNABORTED' ||\n            error.code === 'ETIMEDOUT' ||\n            !navigator.onLine)\n    );\n};\n\nexport const useServerAuthenticated = () => {\n    const priorServerId = useRef<string | undefined>(undefined);\n    const server = useCurrentServer();\n    const [ready, setReady] = useState(AuthState.LOADING);\n    const navigate = useNavigate();\n    const retryCountRef = useRef<number>(0);\n\n    const { setCurrentServer, updateServer } = useAuthStoreActions();\n\n    const authenticateServer = useCallback(\n        async (serverWithAuth: NonNullable<ReturnType<typeof getServerById>>, retryAttempt = 0) => {\n            const authStartTime = Date.now();\n\n            try {\n                setReady(AuthState.LOADING);\n\n                // Use userId if available, otherwise fall back to username (for Subsonic/Navidrome)\n                const userId = serverWithAuth.userId || serverWithAuth.username;\n\n                if (!userId) {\n                    throw new Error('No user ID or username available');\n                }\n\n                // First, try getUserInfo to check if current credentials are still valid\n                logFn.info(logMsg[LogCategory.SYSTEM].authenticatingServer, {\n                    category: LogCategory.SYSTEM,\n                    meta: {\n                        method: 'getUserInfo',\n                        serverId: serverWithAuth.id,\n                        serverName: serverWithAuth.name,\n                        serverType: serverWithAuth.type,\n                    },\n                });\n\n                try {\n                    const userInfo = await api.controller.getUserInfo({\n                        apiClientProps: {\n                            serverId: serverWithAuth.id,\n                        },\n                        query: {\n                            id: userId,\n                            username: serverWithAuth.username,\n                        },\n                    });\n\n                    if (!userInfo) {\n                        throw new Error('Failed to get user info');\n                    }\n\n                    // Update server with user info (in case isAdmin changed)\n                    updateServer(serverWithAuth.id, {\n                        isAdmin: userInfo.isAdmin,\n                    });\n\n                    // Fetch and update server version and features\n                    try {\n                        const serverInfo = await controller.getServerInfo({\n                            apiClientProps: {\n                                serverId: serverWithAuth.id,\n                            },\n                        });\n\n                        if (serverInfo && serverInfo.id === serverWithAuth.id) {\n                            const { features, version } = serverInfo;\n                            const currentServer = getServerById(serverWithAuth.id);\n\n                            if (\n                                currentServer &&\n                                (version !== currentServer.version ||\n                                    !isEqual(features, currentServer.features))\n                            ) {\n                                updateServer(serverWithAuth.id, {\n                                    features,\n                                    version,\n                                });\n                            }\n                        }\n                    } catch (serverInfoError) {\n                        // Log but don't fail authentication if server info fetch fails\n                        logFn.warn(logMsg[LogCategory.SYSTEM].serverAuthenticationSuccess, {\n                            category: LogCategory.SYSTEM,\n                            meta: {\n                                action: 'server_info_fetch_failed',\n                                error: (serverInfoError as Error).message,\n                                serverId: serverWithAuth.id,\n                                serverName: serverWithAuth.name,\n                            },\n                        });\n                    }\n\n                    logFn.info(logMsg[LogCategory.SYSTEM].serverAuthenticationSuccess, {\n                        category: LogCategory.SYSTEM,\n                        meta: {\n                            isAdmin: userInfo.isAdmin,\n                            method: 'getUserInfo',\n                            serverId: serverWithAuth.id,\n                            serverName: serverWithAuth.name,\n                            serverType: serverWithAuth.type,\n                            userId: userInfo.id,\n                        },\n                    });\n\n                    const elapsedTime = Date.now() - authStartTime;\n                    const remainingDelay = Math.max(0, MIN_AUTH_DELAY_MS - elapsedTime);\n\n                    if (remainingDelay > 0) {\n                        await new Promise((resolve) => setTimeout(resolve, remainingDelay));\n                    }\n\n                    setReady(AuthState.VALID);\n                    return;\n                } catch (getUserInfoError: any) {\n                    // Check if it's a forbidden/authentication error (401 or 403)\n                    const isForbiddenError =\n                        getUserInfoError?.response?.status === 401 ||\n                        getUserInfoError?.response?.status === 403 ||\n                        getUserInfoError?.message?.toLowerCase().includes('forbidden') ||\n                        getUserInfoError?.message?.toLowerCase().includes('unauthorized');\n\n                    // Only reauthenticate if it's a forbidden error AND password is saved\n                    if (isForbiddenError && serverWithAuth.savePassword && localSettings) {\n                        const password = await localSettings.passwordGet(serverWithAuth.id);\n\n                        if (password) {\n                            logFn.info(logMsg[LogCategory.SYSTEM].authenticatingServer, {\n                                category: LogCategory.SYSTEM,\n                                meta: {\n                                    method: 'authenticate',\n                                    reason: 'getUserInfo failed with forbidden error',\n                                    serverId: serverWithAuth.id,\n                                    serverName: serverWithAuth.name,\n                                    serverType: serverWithAuth.type,\n                                    url: serverWithAuth.url,\n                                },\n                            });\n\n                            // Authenticate using the API controller\n                            const authData = await api.controller.authenticate(\n                                serverWithAuth.url,\n                                {\n                                    legacy: false,\n                                    password,\n                                    username: serverWithAuth.username,\n                                },\n                                serverWithAuth.type,\n                            );\n\n                            if (!authData) {\n                                throw new Error('Authentication failed: No data returned');\n                            }\n\n                            // Update server with new credentials\n                            const updatedServer = {\n                                credential: authData.credential,\n                                isAdmin: authData.isAdmin,\n                                userId: authData.userId,\n                                username: authData.username,\n                                ...(authData.ndCredential !== undefined && {\n                                    ndCredential: authData.ndCredential,\n                                }),\n                            };\n\n                            updateServer(serverWithAuth.id, updatedServer);\n\n                            // Fetch and update server version and features\n                            try {\n                                const serverInfo = await controller.getServerInfo({\n                                    apiClientProps: {\n                                        serverId: serverWithAuth.id,\n                                    },\n                                });\n\n                                if (serverInfo && serverInfo.id === serverWithAuth.id) {\n                                    const { features, version } = serverInfo;\n                                    const currentServer = getServerById(serverWithAuth.id);\n\n                                    if (\n                                        currentServer &&\n                                        (version !== currentServer.version ||\n                                            !isEqual(features, currentServer.features))\n                                    ) {\n                                        updateServer(serverWithAuth.id, {\n                                            features,\n                                            version,\n                                        });\n                                    }\n                                }\n                            } catch (serverInfoError) {\n                                // Log but don't fail authentication if server info fetch fails\n                                logFn.warn(logMsg[LogCategory.SYSTEM].serverAuthenticationSuccess, {\n                                    category: LogCategory.SYSTEM,\n                                    meta: {\n                                        action: 'server_info_fetch_failed',\n                                        error: (serverInfoError as Error).message,\n                                        serverId: serverWithAuth.id,\n                                        serverName: serverWithAuth.name,\n                                    },\n                                });\n                            }\n\n                            logFn.info(logMsg[LogCategory.SYSTEM].serverAuthenticationSuccess, {\n                                category: LogCategory.SYSTEM,\n                                meta: {\n                                    isAdmin: authData.isAdmin,\n                                    method: 'authenticate',\n                                    serverId: serverWithAuth.id,\n                                    serverName: serverWithAuth.name,\n                                    serverType: serverWithAuth.type,\n                                    userId: authData.userId,\n                                    username: authData.username,\n                                },\n                            });\n\n                            // Ensure minimum delay before completing authentication\n                            const elapsedTime = Date.now() - authStartTime;\n                            const remainingDelay = Math.max(0, MIN_AUTH_DELAY_MS - elapsedTime);\n\n                            if (remainingDelay > 0) {\n                                await new Promise((resolve) => setTimeout(resolve, remainingDelay));\n                            }\n\n                            setReady(AuthState.VALID);\n                            return;\n                        }\n                    }\n\n                    // If not a forbidden error, or no password saved, rethrow the error\n                    throw getUserInfoError;\n                }\n            } catch (error) {\n                const errorMessage = (error as Error).message || 'Authentication failed';\n                const isNetwork = isNetworkError(error);\n\n                // If it's a network error and we haven't exhausted retries, retry\n                if (isNetwork && retryAttempt < MAX_NETWORK_RETRIES) {\n                    const nextRetry = retryAttempt + 1;\n\n                    logFn.warn(logMsg[LogCategory.SYSTEM].serverAuthenticationFailed, {\n                        category: LogCategory.SYSTEM,\n                        meta: {\n                            action: 'network_error_retry',\n                            attempt: nextRetry,\n                            error: errorMessage,\n                            maxRetries: MAX_NETWORK_RETRIES,\n                            retryDelayMs: NETWORK_RETRY_DELAY_MS,\n                            serverId: serverWithAuth.id,\n                            serverName: serverWithAuth.name,\n                            serverType: serverWithAuth.type,\n                        },\n                    });\n\n                    // Wait before retrying\n                    await new Promise((resolve) => setTimeout(resolve, NETWORK_RETRY_DELAY_MS));\n\n                    // Retry authentication\n                    return authenticateServer(serverWithAuth, nextRetry);\n                }\n\n                // If network error and retries exhausted, redirect to no-network page\n                if (isNetwork && retryAttempt >= MAX_NETWORK_RETRIES) {\n                    logFn.error(logMsg[LogCategory.SYSTEM].serverAuthenticationFailed, {\n                        category: LogCategory.SYSTEM,\n                        meta: {\n                            action: 'network_error_max_retries_exceeded',\n                            attempts: retryAttempt + 1,\n                            error: errorMessage,\n                            serverId: serverWithAuth.id,\n                            serverName: serverWithAuth.name,\n                            serverType: serverWithAuth.type,\n                        },\n                    });\n\n                    // Don't clear credentials on network failure - preserve them for when network returns\n                    setReady(AuthState.INVALID);\n                    navigate(AppRoute.NO_NETWORK, { replace: true });\n                    return;\n                }\n\n                // For non-network errors, handle normally\n                logFn.error(logMsg[LogCategory.SYSTEM].serverAuthenticationFailed, {\n                    category: LogCategory.SYSTEM,\n                    meta: {\n                        error: errorMessage,\n                        serverId: serverWithAuth.id,\n                        serverName: serverWithAuth.name,\n                        serverType: serverWithAuth.type,\n                    },\n                });\n\n                // Clear server credentials and saved password on failure\n                if (serverWithAuth.savePassword && localSettings) {\n                    localSettings.passwordRemove(serverWithAuth.id);\n                }\n\n                toast.error({\n                    message: errorMessage,\n                });\n\n                // Log the user out by setting current server to null\n                setCurrentServer(null);\n                setReady(AuthState.INVALID);\n            }\n        },\n        [updateServer, setCurrentServer, navigate],\n    );\n\n    const debouncedAuth = debounce(\n        (serverWithAuth: NonNullable<ReturnType<typeof getServerById>>) => {\n            authenticateServer(serverWithAuth).catch(console.error);\n        },\n        300,\n    );\n\n    useEffect(() => {\n        if (!server) {\n            logFn.debug(logMsg[LogCategory.SYSTEM].serverAuthenticationInvalid, {\n                category: LogCategory.SYSTEM,\n                meta: {\n                    reason: 'No server selected',\n                },\n            });\n            setReady(AuthState.INVALID);\n            return;\n        }\n\n        if (priorServerId.current !== server.id) {\n            const serverWithAuth = getServerById(server.id);\n            priorServerId.current = server.id;\n            retryCountRef.current = 0; // Reset retry count when server changes\n\n            if (!serverWithAuth) {\n                logFn.error(logMsg[LogCategory.SYSTEM].serverAuthenticationError, {\n                    category: LogCategory.SYSTEM,\n                    meta: {\n                        reason: 'Server not found in store',\n                        serverId: server.id,\n                    },\n                });\n                setReady(AuthState.INVALID);\n                return;\n            }\n\n            setReady(AuthState.LOADING);\n            debouncedAuth(serverWithAuth);\n        }\n    }, [debouncedAuth, server]);\n\n    return ready;\n};\n"
  },
  {
    "path": "src/renderer/hooks/use-should-pad-titlebar.tsx",
    "content": "import isElectron from 'is-electron';\nimport { useLocation } from 'react-router';\n\nimport { AppRoute } from '/@/renderer/router/routes';\nimport { useSidebarRightExpanded, useSideQueueType, useWindowSettings } from '/@/renderer/store';\nimport { Platform } from '/@/shared/types/types';\n\nexport const useShouldPadTitlebar = () => {\n    const location = useLocation();\n    const isSidebarExpanded = useSidebarRightExpanded();\n    const isQueuePage = location.pathname === AppRoute.NOW_PLAYING;\n    const sideQueueType = useSideQueueType();\n    const { windowBarStyle } = useWindowSettings();\n\n    const conditions = [\n        isElectron(),\n        windowBarStyle === Platform.WEB,\n        !(isSidebarExpanded && sideQueueType === 'sideQueue' && !isQueuePage),\n    ];\n\n    const shouldPadTitlebar = conditions.every((condition) => condition);\n\n    return shouldPadTitlebar;\n};\n"
  },
  {
    "path": "src/renderer/hooks/use-sync-settings-to-main.ts",
    "content": "import isElectron from 'is-electron';\nimport { useEffect, useRef } from 'react';\n\nimport i18n from '/@/i18n/i18n';\nimport { openRestartRequiredToast } from '/@/renderer/features/settings/restart-toast';\nimport { useSettingsStore } from '/@/renderer/store/settings.store';\nimport { logFn } from '/@/renderer/utils/logger';\nimport { logMsg } from '/@/renderer/utils/logger-message';\n\n// Synchronizes settings from the renderer store to the main process electron store\n// on app initialization. If there are differences, it updates the main store and shows\n// a restart required toast.\nexport const useSyncSettingsToMain = () => {\n    const hasRunRef = useRef(false);\n\n    useEffect(() => {\n        if (hasRunRef.current) {\n            return;\n        }\n\n        if (!isElectron() || !window.api.localSettings) {\n            hasRunRef.current = true;\n            return;\n        }\n\n        // Wait some before checking for differences to ensure the store is hydrated from localStorage\n        const timeoutId = setTimeout(() => {\n            if (hasRunRef.current) {\n                return;\n            }\n\n            const settingsFromStore = useSettingsStore.getState();\n\n            const settings = {\n                general: settingsFromStore.general,\n                hotkeys: settingsFromStore.hotkeys,\n                lyrics: settingsFromStore.lyrics,\n                playback: settingsFromStore.playback,\n                window: settingsFromStore.window,\n            };\n\n            hasRunRef.current = true;\n\n            const localSettings = window.api.localSettings;\n\n            const settingsMappings: Array<{\n                mainStoreKey: string;\n                rendererValue: any;\n            }> = [\n                {\n                    mainStoreKey: 'lyrics',\n                    rendererValue: settings.lyrics.sources,\n                },\n                {\n                    mainStoreKey: 'window_window_bar_style',\n                    rendererValue: settings.window.windowBarStyle,\n                },\n                {\n                    mainStoreKey: 'window_start_minimized',\n                    rendererValue: settings.window.startMinimized,\n                },\n                {\n                    mainStoreKey: 'window_exit_to_tray',\n                    rendererValue: settings.window.exitToTray,\n                },\n                {\n                    mainStoreKey: 'window_minimize_to_tray',\n                    rendererValue: settings.window.minimizeToTray,\n                },\n                {\n                    mainStoreKey: 'disable_auto_updates',\n                    rendererValue: settings.window.disableAutoUpdate,\n                },\n                // For some reason after the application is updated, the release channel from the\n                // renderer is always set to the latest channel. This causes an infinite update loop\n                // {\n                //     mainStoreKey: 'release_channel',\n                //     rendererValue: settings.window.releaseChannel,\n                // },\n                {\n                    mainStoreKey: 'window_enable_tray',\n                    rendererValue: settings.window.tray,\n                },\n                {\n                    mainStoreKey: 'password_store',\n                    rendererValue: settings.general.passwordStore,\n                },\n                {\n                    mainStoreKey: 'mediaSession',\n                    rendererValue: settings.playback.mediaSession,\n                },\n                {\n                    mainStoreKey: 'playbackType',\n                    rendererValue: settings.playback.type,\n                },\n                {\n                    mainStoreKey: 'global_media_hotkeys',\n                    rendererValue: settings.hotkeys.globalMediaHotkeys,\n                },\n                {\n                    mainStoreKey: 'enableNeteaseTranslation',\n                    rendererValue: settings.lyrics.enableNeteaseTranslation,\n                },\n            ];\n\n            // Compare and sync each setting\n            (async () => {\n                let hasDifferences = false;\n\n                for (const mapping of settingsMappings) {\n                    const mainValue = await localSettings.get(mapping.mainStoreKey);\n                    const rendererValue = mapping.rendererValue;\n\n                    const mainValueNormalized = mainValue === undefined ? null : mainValue;\n                    const rendererValueNormalized =\n                        rendererValue === undefined ? null : rendererValue;\n\n                    if (\n                        JSON.stringify(mainValueNormalized) !==\n                        JSON.stringify(rendererValueNormalized)\n                    ) {\n                        hasDifferences = true;\n                        logFn.warn(logMsg.system.settingsSynchronized, {\n                            meta: {\n                                mainStoreKey: mapping.mainStoreKey,\n                                mainValue: mainValueNormalized,\n                                rendererValue: rendererValueNormalized,\n                            },\n                        });\n                        localSettings.set(mapping.mainStoreKey, rendererValue);\n                    }\n                }\n\n                // Show restart toast if there were differences\n                if (hasDifferences) {\n                    openRestartRequiredToast(\n                        i18n.t('error.settingsSyncError', { postProcess: 'sentenceCase' }),\n                    );\n                }\n            })();\n        }, 5000);\n\n        return () => {\n            clearTimeout(timeoutId);\n        };\n        // Only run once on mount\n    }, []);\n};\n"
  },
  {
    "path": "src/renderer/index.html",
    "content": "<!doctype html>\n<html>\n    <head>\n        <meta charset=\"utf-8\" />\n        <meta http-equiv=\"Content-Security-Policy\" />\n        <meta name=\"viewport\" content=\"width=device-width, initial-scale=1, viewport-fit=cover\" />\n        <script>\n            (function () {\n                if (localStorage.getItem('umami.disabled') !== '1') {\n                    var s = document.createElement('script');\n                    s.defer = true;\n                    s.src = 'https://umami.jeffvli.org/script.js';\n                    s.setAttribute('data-website-id', '5120fc56-cffa-4d42-8b6c-9afb6f459251');\n                    s.setAttribute('data-exclude-search', 'true');\n                    s.setAttribute('data-exclude-hash', 'true');\n                    s.setAttribute('data-auto-track', 'false');\n                    document.head.appendChild(s);\n                }\n            })();\n        </script>\n        <title>Feishin</title>\n        <% if (web) { %>\n        <link rel=\"icon\" href=\"./assets/favicon.ico\" />\n        <script src=\"settings.js\"></script>\n        <% } %>\n    </head>\n\n    <body style=\"background-color: #000\">\n        <div id=\"root\">\n            <script type=\"module\" src=\"main.tsx\"></script>\n        </div>\n    </body>\n</html>\n"
  },
  {
    "path": "src/renderer/layouts/auth-layout.module.css",
    "content": ".window-titlebar-container {\n    position: absolute;\n    z-index: 1000;\n    display: flex;\n    width: 100%;\n    height: 50px;\n    user-select: none;\n    -webkit-app-region: drag;\n}\n\n.content-container {\n    display: flex;\n    height: 100%;\n}\n"
  },
  {
    "path": "src/renderer/layouts/auth-layout.tsx",
    "content": "import { Outlet } from 'react-router';\n\nimport styles from './auth-layout.module.css';\n\nimport { Titlebar } from '/@/renderer/features/titlebar/components/titlebar';\n\nexport const AuthLayout = () => {\n    return (\n        <>\n            <div className={styles.windowTitlebarContainer}>\n                <Titlebar />\n            </div>\n            <div className={styles.contentContainer}>\n                <Outlet />\n            </div>\n        </>\n    );\n};\n"
  },
  {
    "path": "src/renderer/layouts/authentication-outlet.tsx",
    "content": "import { Outlet } from 'react-router';\n\nimport { useServerAuthenticated } from '/@/renderer/hooks/use-server-authenticated';\nimport { Center } from '/@/shared/components/center/center';\nimport { Spinner } from '/@/shared/components/spinner/spinner';\nimport { AuthState } from '/@/shared/types/types';\n\nexport const AuthenticationOutlet = () => {\n    const authState = useServerAuthenticated();\n\n    if (authState === AuthState.LOADING) {\n        return (\n            <Center h=\"100vh\" w=\"100%\">\n                <Spinner container />\n            </Center>\n        );\n    }\n\n    return <Outlet />;\n};\n"
  },
  {
    "path": "src/renderer/layouts/default-layout/full-screen-overlay.tsx",
    "content": "import { AnimatePresence } from 'motion/react';\n\nimport { FullScreenPlayer } from '/@/renderer/features/player/components/full-screen-player';\nimport { useFullScreenPlayerStore } from '/@/renderer/store';\n\nexport const FullScreenOverlay = () => {\n    const { expanded: isFullScreenPlayerExpanded } = useFullScreenPlayerStore();\n\n    return (\n        <AnimatePresence initial={false}>\n            {isFullScreenPlayerExpanded && <FullScreenPlayer />}\n        </AnimatePresence>\n    );\n};\n"
  },
  {
    "path": "src/renderer/layouts/default-layout/full-screen-visualizer-overlay.tsx",
    "content": "import { AnimatePresence } from 'motion/react';\n\nimport { FullScreenVisualizer } from '/@/renderer/features/player/components/full-screen-visualizer';\nimport { useFullScreenPlayerStore } from '/@/renderer/store/full-screen-player.store';\n\nexport const FullScreenVisualizerOverlay = () => {\n    const { visualizerExpanded: isFullScreenVisualizerExpanded } = useFullScreenPlayerStore();\n\n    return (\n        <AnimatePresence initial={false}>\n            {isFullScreenVisualizerExpanded && <FullScreenVisualizer />}\n        </AnimatePresence>\n    );\n};\n"
  },
  {
    "path": "src/renderer/layouts/default-layout/left-sidebar.module.css",
    "content": ".container {\n    position: relative;\n    grid-area: sidebar;\n    background: var(--theme-colors-background-alternate);\n    border-right: 1px solid alpha(var(--theme-colors-border), 0.5);\n}\n"
  },
  {
    "path": "src/renderer/layouts/default-layout/left-sidebar.tsx",
    "content": "import { lazy, Suspense, useRef } from 'react';\n\nimport styles from './left-sidebar.module.css';\n\nimport { ResizeHandle } from '/@/renderer/features/shared/components/resize-handle';\nimport { useAppStore } from '/@/renderer/store';\n\nconst CollapsedSidebar = lazy(() =>\n    import('/@/renderer/features/sidebar/components/collapsed-sidebar').then((module) => ({\n        default: module.CollapsedSidebar,\n    })),\n);\n\nconst Sidebar = lazy(() =>\n    import('/@/renderer/features/sidebar/components/sidebar').then((module) => ({\n        default: module.Sidebar,\n    })),\n);\n\ninterface LeftSidebarProps {\n    isResizing: boolean;\n    startResizing: (direction: 'left' | 'right', mouseEvent?: MouseEvent) => void;\n}\n\nexport const LeftSidebar = ({ isResizing, startResizing }: LeftSidebarProps) => {\n    const sidebarRef = useRef<HTMLDivElement | null>(null);\n    const collapsed = useAppStore((state) => state.sidebar.collapsed);\n\n    return (\n        <aside className={styles.container} id=\"sidebar\">\n            <ResizeHandle\n                isResizing={isResizing}\n                onMouseDown={(e) => {\n                    e.preventDefault();\n                    startResizing('left');\n                }}\n                placement=\"right\"\n                ref={sidebarRef}\n            />\n            <Suspense fallback={<></>}>{collapsed ? <CollapsedSidebar /> : <Sidebar />}</Suspense>\n        </aside>\n    );\n};\n"
  },
  {
    "path": "src/renderer/layouts/default-layout/main-content.module.css",
    "content": ".main-content-container {\n    position: relative;\n    display: grid;\n    grid-area: main-content;\n    grid-template-areas: 'sidebar . right-sidebar';\n    grid-template-rows: 1fr;\n    gap: 0;\n    background: var(--theme-colors-background);\n}\n\n.main-content-container.shell {\n    display: flex;\n}\n\n.main-content-container.sidebar-collapsed {\n    grid-template-columns: 80px 1fr;\n}\n\n.main-content-container.sidebar-expanded {\n    grid-template-columns: var(--sidebar-width) 1fr;\n}\n\n.main-content-container.right-expanded {\n    grid-template-columns: var(--sidebar-width) 1fr var(--right-sidebar-width);\n}\n\n.main-content-container.sidebar-collapsed.right-expanded {\n    grid-template-columns: 80px 1fr var(--right-sidebar-width);\n}\n\n.main-content-container.vertical-layout {\n    grid-template-areas:\n        'sidebar .'\n        'sidebar right-sidebar';\n    grid-template-rows: minmax(0, 1fr) var(--right-sidebar-height);\n    grid-template-columns: var(--sidebar-width) 1fr;\n}\n\n.main-content-container.sidebar-collapsed.vertical-layout {\n    grid-template-columns: 80px 1fr;\n}\n\n.main-content-container.vertical-layout #sidebar-queue {\n    border-top: 1px solid alpha(var(--theme-colors-border), 0.5);\n    border-left: 0;\n}\n\n.main-content-body {\n    display: flex;\n    flex: 1;\n    flex-direction: column;\n    min-height: 0;\n    overflow: hidden;\n}\n\n.main-content-body-scroll {\n    flex: 1;\n    min-height: 0;\n    overflow: auto;\n}\n"
  },
  {
    "path": "src/renderer/layouts/default-layout/main-content.tsx",
    "content": "import clsx from 'clsx';\nimport { motion } from 'motion/react';\nimport { Suspense, useCallback, useEffect, useRef, useState } from 'react';\nimport { Outlet } from 'react-router';\nimport { shallow } from 'zustand/shallow';\n\nimport styles from './main-content.module.css';\n\nimport { ExpandedListContainer } from '/@/renderer/components/item-list/expanded-list-container';\nimport { ExpandedListItem } from '/@/renderer/components/item-list/expanded-list-item';\nimport { FullScreenOverlay } from '/@/renderer/layouts/default-layout/full-screen-overlay';\nimport { FullScreenVisualizerOverlay } from '/@/renderer/layouts/default-layout/full-screen-visualizer-overlay';\nimport { LeftSidebar } from '/@/renderer/layouts/default-layout/left-sidebar';\nimport { RightSidebar } from '/@/renderer/layouts/default-layout/right-sidebar';\nimport {\n    useAppStore,\n    useAppStoreActions,\n    useGlobalExpanded,\n    useSideQueueLayout,\n    useSideQueueType,\n} from '/@/renderer/store';\nimport { constrainRightSidebarWidth, constrainSidebarWidth } from '/@/renderer/utils';\nimport { Spinner } from '/@/shared/components/spinner/spinner';\n\nconst MINIMUM_SIDEBAR_WIDTH = 260;\n\nexport const MainContent = ({ shell }: { shell?: boolean }) => {\n    const { collapsed, leftWidth, rightExpanded, rightHeight, rightWidth } = useAppStore(\n        (state) => ({\n            collapsed: state.sidebar.collapsed,\n            leftWidth: state.sidebar.leftWidth,\n            rightExpanded: state.sidebar.rightExpanded,\n            rightHeight: state.sidebar.rightHeight,\n            rightWidth: state.sidebar.rightWidth,\n        }),\n        shallow,\n    );\n    const { setSideBar } = useAppStoreActions();\n    const sideQueueType = useSideQueueType();\n    const sideQueueLayout = useSideQueueLayout();\n    const [isResizing, setIsResizing] = useState(false);\n    const [isResizingRight, setIsResizingRight] = useState(false);\n\n    const rightSidebarRef = useRef<HTMLDivElement | null>(null);\n    const mainContentRef = useRef<HTMLDivElement | null>(null);\n    const initialRightWidthRef = useRef<string>(rightWidth);\n    const initialRightHeightRef = useRef<string>(rightHeight);\n    const initialMouseXRef = useRef<number>(0);\n    const initialMouseYRef = useRef<number>(0);\n    const wasCollapsedDuringDragRef = useRef<boolean>(false);\n\n    useEffect(() => {\n        if (mainContentRef.current && !isResizing && !isResizingRight) {\n            mainContentRef.current.style.setProperty('--sidebar-width', leftWidth);\n            mainContentRef.current.style.setProperty('--right-sidebar-width', rightWidth);\n            mainContentRef.current.style.setProperty('--right-sidebar-height', rightHeight);\n            initialRightWidthRef.current = rightWidth;\n            initialRightHeightRef.current = rightHeight;\n        }\n    }, [leftWidth, rightWidth, rightHeight, isResizing, isResizingRight]);\n\n    const startResizing = useCallback(\n        (position: 'left' | 'right' | 'top', mouseEvent?: MouseEvent) => {\n            if (position === 'left') {\n                setIsResizing(true);\n                wasCollapsedDuringDragRef.current = false;\n            } else {\n                setIsResizingRight(true);\n                if (mainContentRef.current && rightSidebarRef.current && mouseEvent) {\n                    if (position === 'top') {\n                        const currentHeight =\n                            mainContentRef.current.style.getPropertyValue('--right-sidebar-height');\n                        if (currentHeight) {\n                            initialRightHeightRef.current = currentHeight;\n                        } else {\n                            initialRightHeightRef.current = rightHeight;\n                        }\n                        initialMouseYRef.current = mouseEvent.clientY;\n                    } else {\n                        const currentWidth =\n                            mainContentRef.current.style.getPropertyValue('--right-sidebar-width');\n                        if (currentWidth) {\n                            initialRightWidthRef.current = currentWidth;\n                        } else {\n                            initialRightWidthRef.current = rightWidth;\n                        }\n                        initialMouseXRef.current = mouseEvent.clientX;\n                    }\n                } else {\n                    if (position === 'top') {\n                        initialRightHeightRef.current = rightHeight;\n                    } else {\n                        initialRightWidthRef.current = rightWidth;\n                    }\n                }\n            }\n        },\n        [rightHeight, rightWidth],\n    );\n\n    const stopResizing = useCallback(() => {\n        if (isResizing && mainContentRef.current) {\n            if (!wasCollapsedDuringDragRef.current) {\n                const finalWidth = mainContentRef.current.style.getPropertyValue('--sidebar-width');\n                if (finalWidth) {\n                    setSideBar({ collapsed: false, leftWidth: finalWidth });\n                }\n            }\n            setIsResizing(false);\n            wasCollapsedDuringDragRef.current = false;\n        } else if (isResizingRight && mainContentRef.current) {\n            if (sideQueueLayout === 'vertical') {\n                const finalHeight =\n                    mainContentRef.current.style.getPropertyValue('--right-sidebar-height');\n                if (finalHeight) {\n                    setSideBar({ rightHeight: finalHeight });\n                }\n            } else {\n                const finalWidth =\n                    mainContentRef.current.style.getPropertyValue('--right-sidebar-width');\n                if (finalWidth) {\n                    setSideBar({ rightWidth: finalWidth });\n                }\n            }\n            setIsResizingRight(false);\n        }\n    }, [isResizing, isResizingRight, setSideBar, sideQueueLayout]);\n\n    const resize = useCallback(\n        (mouseMoveEvent: any) => {\n            if (!mainContentRef.current) return;\n\n            if (isResizing) {\n                const width = mouseMoveEvent.clientX;\n                const constrainedWidthValue = constrainSidebarWidth(width);\n                const constrainedWidth = `${constrainedWidthValue}px`;\n\n                if (width < MINIMUM_SIDEBAR_WIDTH - 100) {\n                    if (!wasCollapsedDuringDragRef.current) {\n                        wasCollapsedDuringDragRef.current = true;\n                        setSideBar({ collapsed: true });\n                    }\n                } else {\n                    if (wasCollapsedDuringDragRef.current) {\n                        wasCollapsedDuringDragRef.current = false;\n                        setSideBar({ collapsed: false });\n                    }\n                    mainContentRef.current.style.setProperty('--sidebar-width', constrainedWidth);\n                }\n            } else if (isResizingRight) {\n                if (sideQueueLayout === 'vertical') {\n                    const initialHeight = Number(initialRightHeightRef.current.split('px')[0]);\n                    const initialMouseY = initialMouseYRef.current;\n                    const deltaY = mouseMoveEvent.clientY - initialMouseY;\n                    const containerHeight = mainContentRef.current.clientHeight;\n                    const minHeight = 220;\n                    const maxHeight = Math.max(minHeight, containerHeight - 200);\n                    const newHeight = initialHeight - deltaY;\n                    const clampedHeight = Math.min(Math.max(newHeight, minHeight), maxHeight);\n                    mainContentRef.current.style.setProperty(\n                        '--right-sidebar-height',\n                        `${clampedHeight}px`,\n                    );\n                } else {\n                    const initialWidth = Number(initialRightWidthRef.current.split('px')[0]);\n                    const initialMouseX = initialMouseXRef.current;\n                    const deltaX = mouseMoveEvent.clientX - initialMouseX;\n                    const newWidth = initialWidth - deltaX;\n                    const width = `${constrainRightSidebarWidth(newWidth)}px`;\n                    mainContentRef.current.style.setProperty('--right-sidebar-width', width);\n                }\n            }\n        },\n        [isResizing, isResizingRight, setSideBar, sideQueueLayout],\n    );\n\n    useEffect(() => {\n        window.addEventListener('mousemove', resize);\n        window.addEventListener('mouseup', stopResizing);\n        return () => {\n            window.removeEventListener('mousemove', resize);\n            window.removeEventListener('mouseup', stopResizing);\n        };\n    }, [resize, stopResizing]);\n\n    return (\n        <motion.div\n            className={clsx(styles.mainContentContainer, {\n                [styles.rightExpanded]: rightExpanded && sideQueueType === 'sideQueue',\n                [styles.shell]: shell,\n                [styles.sidebarCollapsed]: collapsed,\n                [styles.sidebarExpanded]: !collapsed,\n                [styles.verticalLayout]:\n                    rightExpanded &&\n                    sideQueueType === 'sideQueue' &&\n                    sideQueueLayout === 'vertical',\n            })}\n            id=\"main-content\"\n            ref={mainContentRef}\n        >\n            {!shell && (\n                <>\n                    <FullScreenVisualizerOverlay />\n                    <FullScreenOverlay />\n                    <LeftSidebar isResizing={isResizing} startResizing={startResizing} />\n                    <RightSidebar\n                        isResizing={isResizingRight}\n                        ref={rightSidebarRef}\n                        startResizing={startResizing}\n                    />\n                </>\n            )}\n            <MainContentBody />\n        </motion.div>\n    );\n};\n\nfunction GlobalExpandedPanel() {\n    const globalExpanded = useGlobalExpanded();\n\n    if (!globalExpanded) return null;\n\n    return (\n        <ExpandedListContainer>\n            <ExpandedListItem item={globalExpanded.item} itemType={globalExpanded.itemType} />\n        </ExpandedListContainer>\n    );\n}\n\nfunction MainContentBody() {\n    return (\n        <div className={styles.mainContentBody}>\n            <div className={styles.mainContentBodyScroll}>\n                <Suspense fallback={<Spinner container />}>\n                    <Outlet />\n                </Suspense>\n            </div>\n            <GlobalExpandedPanel />\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/layouts/default-layout/player-bar.module.css",
    "content": ".container {\n    z-index: 200;\n    grid-area: player;\n\n    @mixin light {\n        background: darken(var(--theme-colors-background), 5%);\n    }\n\n    @mixin dark {\n        background: darken(var(--theme-colors-background), 10%);\n    }\n\n    transition: background 0.5s;\n}\n\n.open-drawer {\n    &:hover {\n        background: darken(var(--theme-colors-background), 20%);\n    }\n}\n"
  },
  {
    "path": "src/renderer/layouts/default-layout/player-bar.tsx",
    "content": "import clsx from 'clsx';\n\nimport styles from './player-bar.module.css';\n\nimport { Playerbar } from '/@/renderer/features/player/components/playerbar';\nimport { usePlayerbarOpenDrawer } from '/@/renderer/store';\n\nexport const PlayerBar = () => {\n    const playerbarOpenDrawer = usePlayerbarOpenDrawer();\n\n    return (\n        <div\n            className={clsx({\n                [styles.container]: true,\n                [styles.openDrawer]: playerbarOpenDrawer,\n            })}\n            id=\"player-bar\"\n        >\n            <Playerbar />\n        </div>\n    );\n};\n"
  },
  {
    "path": "src/renderer/layouts/default-layout/right-sidebar.module.css",
    "content": ".right-sidebar-container {\n    position: relative;\n    display: flex;\n    flex-direction: column;\n    grid-area: right-sidebar;\n    height: 100%;\n    min-height: 0;\n    overflow: hidden;\n    background: var(--theme-colors-background-alternate);\n    border-left: 1px solid alpha(var(--theme-colors-border), 0.5);\n\n    .current-song-cell:not(.current-playlist-song-cell) svg {\n        display: none;\n    }\n}\n\n.right-sidebar-container.vertical-layout {\n    border-top: 1px solid alpha(var(--theme-colors-border), 0.5);\n    border-left: 0;\n}\n\n.queue-drawer {\n    border-radius: var(--theme-radius-lg);\n}\n"
  },
  {
    "path": "src/renderer/layouts/default-layout/right-sidebar.tsx",
    "content": "import clsx from 'clsx';\nimport { forwardRef, Ref } from 'react';\n\nimport styles from './right-sidebar.module.css';\n\nimport { SidebarPlayQueue } from '/@/renderer/features/now-playing/components/sidebar-play-queue';\nimport { ResizeHandle } from '/@/renderer/features/shared/components/resize-handle';\nimport { useAppStore, useSideQueueLayout, useSideQueueType } from '/@/renderer/store';\n\n// const queueDrawerVariants: Variants = {\n//     closed: (windowBarStyle) => ({\n//         height:\n//             windowBarStyle === Platform.WINDOWS || Platform.MACOS\n//                 ? 'calc(100vh - 205px)'\n//                 : 'calc(100vh - 175px)',\n//         position: 'absolute',\n//         right: 0,\n//         top: '75px',\n//         transition: {\n//             duration: 0.4,\n//             ease: 'anticipate',\n//         },\n//         width: '450px',\n//         x: '50vw',\n//     }),\n//     open: (windowBarStyle) => ({\n//         boxShadow: '0px 0px 10px 0px rgba(0, 0, 0, 0.8)',\n//         height:\n//             windowBarStyle === Platform.WINDOWS || Platform.MACOS\n//                 ? 'calc(100vh - 205px)'\n//                 : 'calc(100vh - 175px)',\n//         position: 'absolute',\n//         right: '20px',\n//         top: '75px',\n//         transition: {\n//             damping: 10,\n//             delay: 0,\n//             duration: 0.4,\n//             ease: 'anticipate',\n//             mass: 0.5,\n//         },\n//         width: '450px',\n//         x: 0,\n//         zIndex: 120,\n//     }),\n// };\n\ninterface RightSidebarProps {\n    isResizing: boolean;\n    startResizing: (direction: 'left' | 'right' | 'top', mouseEvent?: MouseEvent) => void;\n}\n\nexport const RightSidebar = forwardRef(\n    (\n        { isResizing: isResizingRight, startResizing }: RightSidebarProps,\n        ref: Ref<HTMLDivElement>,\n    ) => {\n        const rightExpanded = useAppStore((state) => state.sidebar.rightExpanded);\n        const sideQueueType = useSideQueueType();\n        const sideQueueLayout = useSideQueueLayout();\n        const isVerticalLayout = sideQueueLayout === 'vertical';\n\n        return (\n            <>\n                {rightExpanded && sideQueueType === 'sideQueue' && (\n                    <aside\n                        className={clsx(styles.rightSidebarContainer, {\n                            [styles.verticalLayout]: isVerticalLayout,\n                        })}\n                        id=\"sidebar-queue\"\n                        key=\"queue-sidebar\"\n                    >\n                        <ResizeHandle\n                            isResizing={isResizingRight}\n                            onMouseDown={(e) => {\n                                e.preventDefault();\n                                startResizing(isVerticalLayout ? 'top' : 'right', e.nativeEvent);\n                            }}\n                            placement={isVerticalLayout ? 'top' : 'left'}\n                            ref={ref}\n                        />\n                        <SidebarPlayQueue />\n                    </aside>\n                )}\n            </>\n        );\n    },\n);\n"
  },
  {
    "path": "src/renderer/layouts/default-layout/side-drawer-queue.module.css",
    "content": ".queue-drawer-area {\n    position: absolute;\n    top: 50%;\n    right: 25px;\n    z-index: 100;\n    display: flex;\n    align-items: center;\n    width: 20px;\n    height: 30px;\n    user-select: none;\n}\n\n.queue-drawer {\n    background: var(--theme-colors-background);\n    border: 3px solid var(--theme-generic-border-color);\n    border-radius: var(--theme-radius-lg);\n}\n"
  },
  {
    "path": "src/renderer/layouts/default-layout/side-drawer-queue.tsx",
    "content": "import { AnimatePresence, motion, Variants } from 'motion/react';\nimport { useCallback } from 'react';\nimport { useLocation } from 'react-router';\n\nimport styles from './side-drawer-queue.module.css';\n\nimport { DrawerPlayQueue } from '/@/renderer/features/now-playing/components/drawer-play-queue';\nimport { AppRoute } from '/@/renderer/router/routes';\nimport { useAppStore } from '/@/renderer/store';\nimport { Icon } from '/@/shared/components/icon/icon';\nimport { useDisclosure } from '/@/shared/hooks/use-disclosure';\nimport { useTimeout } from '/@/shared/hooks/use-timeout';\nimport { Platform } from '/@/shared/types/types';\n\nconst queueDrawerVariants: Variants = {\n    closed: (windowBarStyle) => ({\n        height:\n            windowBarStyle === Platform.WINDOWS || Platform.MACOS\n                ? 'calc(100vh - 205px)'\n                : 'calc(100vh - 175px)',\n        position: 'absolute',\n        right: 0,\n        top: '75px',\n        transition: {\n            duration: 0.4,\n            ease: 'anticipate',\n        },\n        width: '450px',\n        x: '50vw',\n    }),\n    open: (windowBarStyle) => ({\n        boxShadow: '0px 0px 10px 0px rgba(0, 0, 0, 0.8)',\n        height:\n            windowBarStyle === Platform.WINDOWS || Platform.MACOS\n                ? 'calc(100vh - 205px)'\n                : 'calc(100vh - 175px)',\n        position: 'absolute',\n        right: '20px',\n        top: '75px',\n        transition: {\n            damping: 10,\n            delay: 0,\n            duration: 0.4,\n            ease: 'anticipate',\n            mass: 0.5,\n        },\n        width: '450px',\n        x: 0,\n        zIndex: 120,\n    }),\n};\n\nconst queueDrawerButtonVariants: Variants = {\n    hidden: {\n        opacity: 0,\n        transition: { duration: 0.2 },\n        x: 100,\n    },\n    visible: {\n        opacity: 0.5,\n        transition: { duration: 0.1, ease: 'anticipate' },\n        x: 0,\n    },\n};\n\nexport const SideDrawerQueue = () => {\n    const location = useLocation();\n    const [drawer, drawerHandler] = useDisclosure(false);\n    const rightExpanded = useAppStore((state) => state.sidebar.rightExpanded);\n\n    const drawerTimeout = useTimeout(() => drawerHandler.open(), 500);\n\n    const handleEnterDrawerButton = useCallback(() => {\n        drawerTimeout.start();\n    }, [drawerTimeout]);\n\n    const handleLeaveDrawerButton = useCallback(() => {\n        drawerTimeout.clear();\n    }, [drawerTimeout]);\n\n    const isQueueDrawerButtonVisible =\n        !rightExpanded && !drawer && location.pathname !== AppRoute.NOW_PLAYING;\n\n    return (\n        <AnimatePresence initial={false} mode=\"wait\">\n            {isQueueDrawerButtonVisible && (\n                <motion.div\n                    animate=\"visible\"\n                    className={styles.queueDrawerArea}\n                    exit=\"hidden\"\n                    initial=\"hidden\"\n                    key=\"queue-drawer-button\"\n                    onMouseEnter={handleEnterDrawerButton}\n                    onMouseLeave={handleLeaveDrawerButton}\n                    variants={queueDrawerButtonVariants}\n                    whileHover={{ opacity: 1, scale: 2, transition: { duration: 0.5 } }}\n                >\n                    <Icon icon=\"arrowLeftToLine\" size=\"lg\" />\n                </motion.div>\n            )}\n\n            {drawer && (\n                <motion.div\n                    animate=\"open\"\n                    className={styles.queueDrawer}\n                    exit=\"closed\"\n                    initial=\"closed\"\n                    key=\"queue-drawer\"\n                    onMouseLeave={() => {\n                        // The drawer will close due to the delay when setting isReorderingQueue\n                        setTimeout(() => {\n                            if (useAppStore.getState().isReorderingQueue) return;\n                            drawerHandler.close();\n                        }, 50);\n                    }}\n                    variants={queueDrawerVariants}\n                >\n                    <DrawerPlayQueue />\n                </motion.div>\n            )}\n        </AnimatePresence>\n    );\n};\n"
  },
  {
    "path": "src/renderer/layouts/default-layout.module.css",
    "content": ".layout {\n    display: grid;\n    grid-template-areas:\n        'window-bar'\n        'main-content'\n        'player';\n    grid-template-rows: 0 calc(100vh - 90px) 90px;\n    grid-template-rows: 0 calc(100dvh - 90px) 90px;\n    grid-template-columns: 1fr;\n    gap: 0;\n    height: 100%;\n    overflow: hidden;\n}\n\n.windows {\n    grid-template-rows: 30px calc(100vh - 120px) 90px;\n    grid-template-rows: 30px calc(100dvh - 120px) 90px;\n}\n\n.macos {\n    grid-template-rows: 30px calc(100vh - 120px) 90px;\n    grid-template-rows: 30px calc(100dvh - 120px) 90px;\n}\n"
  },
  {
    "path": "src/renderer/layouts/default-layout.tsx",
    "content": "import clsx from 'clsx';\nimport isElectron from 'is-electron';\nimport { lazy } from 'react';\n\nimport styles from './default-layout.module.css';\n\nimport { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';\nimport { MainContent } from '/@/renderer/layouts/default-layout/main-content';\nimport { PlayerBar } from '/@/renderer/layouts/default-layout/player-bar';\nimport { useSettingsStore, useWindowSettings } from '/@/renderer/store/settings.store';\nimport { Platform, PlayerType } from '/@/shared/types/types';\n\nif (!isElectron()) {\n    useSettingsStore.getState().actions.setSettings({\n        playback: {\n            type: PlayerType.WEB,\n        },\n    });\n}\n\nconst WindowBar = lazy(() =>\n    import('/@/renderer/layouts/window-bar').then((module) => ({\n        default: module.WindowBar,\n    })),\n);\n\ninterface DefaultLayoutProps {\n    shell?: boolean;\n}\n\nexport const DefaultLayout = ({ shell }: DefaultLayoutProps) => {\n    const { windowBarStyle } = useWindowSettings();\n\n    return (\n        <>\n            <div\n                className={clsx(styles.layout, {\n                    [styles.macos]: windowBarStyle === Platform.MACOS,\n                    [styles.windows]: windowBarStyle === Platform.WINDOWS,\n                })}\n                id=\"default-layout\"\n            >\n                <WindowBar />\n                <MainContent shell={shell} />\n                <PlayerBar />\n            </div>\n            <ContextMenuController.Root />\n        </>\n    );\n};\n"
  },
  {
    "path": "src/renderer/layouts/mobile-layout/mobile-layout.module.css",
    "content": ".layout {\n    position: relative;\n    display: grid;\n    grid-template-areas:\n        'window-bar'\n        'main-content'\n        'player';\n    grid-template-rows: 0 calc(100vh - 90px) 90px;\n    grid-template-rows: 0 calc(100dvh - 90px) 90px;\n    grid-template-columns: 1fr;\n    gap: 0;\n    width: 100vw;\n    height: 100vh;\n    height: 100dvh;\n    overflow: hidden;\n    background: var(--theme-colors-background);\n}\n\n.windows,\n.macos {\n    grid-template-rows: 30px calc(100vh - 120px) 90px;\n    grid-template-rows: 30px calc(100dvh - 120px) 90px;\n}\n\n.drawer-button {\n    position: absolute;\n    bottom: calc(90px + 0.75rem);\n    left: 0.75rem;\n    z-index: 100;\n    background: color-mix(in srgb, var(--theme-colors-background) 90%, transparent);\n    border: 1px solid var(--theme-colors-border);\n    backdrop-filter: blur(10px);\n}\n\n@media (height < 192px) {\n    .drawer-button {\n        display: none;\n    }\n}\n\n.main-content {\n    position: relative;\n    grid-area: main-content;\n    overflow-x: hidden;\n    overflow-y: auto;\n    -webkit-overflow-scrolling: touch;\n}\n\n.full-screen-player-overlay {\n    position: fixed;\n    top: 0;\n    right: 0;\n    bottom: 0;\n    left: 0;\n    z-index: 200;\n    overflow: hidden;\n}\n"
  },
  {
    "path": "src/renderer/layouts/mobile-layout/mobile-layout.tsx",
    "content": "import clsx from 'clsx';\nimport { AnimatePresence } from 'motion/react';\nimport { lazy } from 'react';\nimport { Outlet } from 'react-router';\n\nimport styles from './mobile-layout.module.css';\n\nimport { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';\nimport { FullScreenVisualizer } from '/@/renderer/features/player/components/full-screen-visualizer';\nimport { MobileFullscreenPlayer } from '/@/renderer/features/player/components/mobile-fullscreen-player';\nimport { MobileSidebar } from '/@/renderer/features/sidebar/components/mobile-sidebar';\nimport { PlayerBar } from '/@/renderer/layouts/default-layout/player-bar';\nimport { useFullScreenPlayerStore } from '/@/renderer/store';\nimport { useWindowSettings } from '/@/renderer/store';\nimport { ActionIcon } from '/@/shared/components/action-icon/action-icon';\nimport { Drawer } from '/@/shared/components/drawer/drawer';\nimport { useDisclosure } from '/@/shared/hooks/use-disclosure';\nimport { Platform } from '/@/shared/types/types';\n\nconst WindowBar = lazy(() =>\n    import('/@/renderer/layouts/window-bar').then((module) => ({\n        default: module.WindowBar,\n    })),\n);\n\ninterface MobileLayoutProps {\n    shell?: boolean;\n}\n\nexport const MobileLayout = ({ shell }: MobileLayoutProps) => {\n    const [sidebarOpened, { close: closeSidebar, open: openSidebar }] = useDisclosure(false);\n    const {\n        expanded: isFullScreenPlayerExpanded,\n        visualizerExpanded: isFullScreenVisualizerExpanded,\n    } = useFullScreenPlayerStore();\n    const { windowBarStyle } = useWindowSettings();\n\n    return (\n        <>\n            <div\n                className={clsx(styles.layout, {\n                    [styles.macos]: windowBarStyle === Platform.MACOS,\n                    [styles.windows]: windowBarStyle === Platform.WINDOWS,\n                })}\n                id=\"mobile-layout\"\n            >\n                {!shell && <WindowBar />}\n                <ActionIcon\n                    className={styles.drawerButton}\n                    icon=\"menu\"\n                    onClick={openSidebar}\n                    size=\"lg\"\n                    tooltip={{ label: 'Menu' }}\n                    variant=\"subtle\"\n                />\n                <main className={styles.mainContent}>\n                    <Outlet />\n                </main>\n                <PlayerBar />\n            </div>\n            <Drawer\n                onClose={closeSidebar}\n                opened={sidebarOpened}\n                position=\"left\"\n                size=\"320px\"\n                styles={{\n                    body: {\n                        height: '100%',\n                        padding: 0,\n                    },\n                    content: {\n                        height: '100%',\n                        width: '100%',\n                    },\n                }}\n                withCloseButton={false}\n            >\n                <MobileSidebar />\n            </Drawer>\n            <AnimatePresence initial={false}>\n                {isFullScreenPlayerExpanded && (\n                    <div className={styles.fullScreenPlayerOverlay}>\n                        <MobileFullscreenPlayer />\n                    </div>\n                )}\n            </AnimatePresence>\n            <AnimatePresence initial={false}>\n                {isFullScreenVisualizerExpanded && (\n                    <div className={styles.fullScreenPlayerOverlay}>\n                        <FullScreenVisualizer />\n                    </div>\n                )}\n            </AnimatePresence>\n            <ContextMenuController.Root />\n        </>\n    );\n};\n"
  },
  {
    "path": "src/renderer/layouts/responsive-layout.tsx",
    "content": "import isElectron from 'is-electron';\nimport { useNavigate } from 'react-router';\n\nimport { useAppTracker } from '/@/renderer/features/analytics/hooks/use-app-tracker';\nimport { CommandPalette } from '/@/renderer/features/search/components/command-palette';\nimport { useGarbageCollection } from '/@/renderer/hooks/use-garbage-collection';\nimport { useIsMobile } from '/@/renderer/hooks/use-is-mobile';\nimport { DefaultLayout } from '/@/renderer/layouts/default-layout';\nimport { MobileLayout } from '/@/renderer/layouts/mobile-layout/mobile-layout';\nimport { AppRoute } from '/@/renderer/router/routes';\nimport {\n    useCommandPalette,\n    useHotkeySettings,\n    useSettingsStoreActions,\n    useZoomFactor,\n} from '/@/renderer/store';\nimport { HotkeyItem, useHotkeys } from '/@/shared/hooks/use-hotkeys';\n\ninterface ResponsiveLayoutProps {\n    shell?: boolean;\n}\n\nconst ResponsiveLayoutBase = ({ shell }: ResponsiveLayoutProps) => {\n    const isMobile = useIsMobile();\n\n    if (isMobile) {\n        return <MobileLayout shell={shell} />;\n    }\n\n    return <DefaultLayout shell={shell} />;\n};\n\nexport const ResponsiveLayout = ({ shell }: ResponsiveLayoutProps) => {\n    useAppTracker();\n\n    return (\n        <>\n            <ResponsiveLayoutBase shell={shell} />\n            <LayoutHotkeys />\n            <GarbageCollection />\n        </>\n    );\n};\n\nconst LayoutHotkeys = () => {\n    const navigate = useNavigate();\n    const localSettings = isElectron() ? window.api.localSettings : null;\n    const zoomFactor = useZoomFactor();\n    const { setSettings } = useSettingsStoreActions();\n    const { bindings } = useHotkeySettings();\n    const { opened, ...handlers } = useCommandPalette();\n\n    const updateZoom = (increase: number) => {\n        const newVal = zoomFactor + increase;\n        if (newVal > 300 || newVal < 50 || !isElectron()) return;\n        setSettings({\n            general: {\n                zoomFactor: newVal,\n            },\n        });\n        localSettings?.setZoomFactor(zoomFactor);\n    };\n    localSettings?.setZoomFactor(zoomFactor);\n\n    const zoomHotkeys: HotkeyItem[] = [\n        [bindings.zoomIn.hotkey, () => updateZoom(5)],\n        [bindings.zoomOut.hotkey, () => updateZoom(-5)],\n    ];\n\n    useHotkeys([\n        [bindings.globalSearch.hotkey, () => handlers.open()],\n        [bindings.browserBack.hotkey, () => navigate(-1)],\n        [bindings.browserForward.hotkey, () => navigate(1)],\n        [bindings.navigateHome.hotkey, () => navigate(AppRoute.HOME)],\n        ...(isElectron() ? zoomHotkeys : []),\n    ]);\n\n    return <CommandPalette modalProps={{ handlers, opened }} />;\n};\n\nconst GarbageCollection = () => {\n    useGarbageCollection();\n    return null;\n};\n"
  },
  {
    "path": "src/renderer/layouts/window-bar.module.css",
    "content": ".window-bar {\n    grid-area: window-bar;\n    height: 30px;\n}\n\n.windows-container {\n    display: flex;\n    gap: 0.5rem;\n    align-items: center;\n    width: 100%;\n    height: 100%;\n    background: var(--theme-colors-background);\n    border-bottom: 1px solid alpha(var(--theme-colors-border), 0.5);\n    -webkit-app-region: drag;\n}\n\n.windows-button-group {\n    display: flex;\n    flex-shrink: 0;\n    width: 130px;\n    height: 100%;\n    -webkit-app-region: no-drag;\n}\n\n.windows-button {\n    display: flex;\n    flex: 1;\n    align-items: center;\n    justify-content: center;\n    -webkit-app-region: no-drag;\n    width: 50px;\n    height: 30px;\n\n    img {\n        width: 35%;\n        height: 50%;\n    }\n\n    &:hover {\n        background: rgb(125 125 125 / 30%);\n    }\n}\n\n.windows-button.exit {\n    &:hover {\n        background: var(--theme-colors-state-error);\n    }\n}\n\n.player-status-container {\n    display: flex;\n    flex: 1;\n    gap: 0.5rem;\n    align-items: center;\n    min-width: 0;\n    padding-left: 1rem;\n    overflow: hidden;\n}\n\n.player-status-container > img {\n    flex-shrink: 0;\n}\n\n.player-status-text {\n    flex: 1;\n    width: 0;\n    min-width: 0;\n    overflow: hidden;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n}\n\n.macos-container .player-status-text {\n    flex: 0 1 auto;\n    width: auto;\n    max-width: 100%;\n    text-align: center;\n}\n\n@media only screen and (width < 768px) {\n    .player-status-container {\n        padding-left: 0.5rem;\n    }\n\n    .windows-button-group {\n        flex-shrink: 0;\n        width: 120px;\n    }\n}\n\n.macos-container {\n    position: relative;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    width: 100%;\n    height: 100%;\n    background: var(--theme-colors-background);\n    border-bottom: 1px solid alpha(var(--theme-colors-border), 0.5);\n    -webkit-app-region: drag;\n}\n\n.macos-container .player-status-container {\n    display: flex;\n    gap: 0;\n    align-items: center;\n    justify-content: center;\n    min-width: 0;\n    max-width: calc(100% - 140px);\n    padding: 0 0.5rem;\n    margin: 0 auto;\n    overflow: hidden;\n}\n\n.macos-button-group {\n    position: absolute;\n    top: 5px;\n    left: 0.5rem;\n    display: grid;\n    grid-template-columns: repeat(3, 20px);\n    height: 100%;\n    -webkit-app-region: no-drag;\n}\n\n.macos-button {\n    grid-row: 1 / span 1;\n    grid-column: 1;\n    align-items: center;\n    justify-content: center;\n    width: 100%;\n    height: 100%;\n    user-select: none;\n\n    img {\n        width: 18px;\n        height: 18px;\n    }\n}\n\n.macos-button.min-button {\n    grid-column: 2;\n}\n\n.macos-button.max-button,\n.macos-button.restore-button {\n    grid-column: 3;\n}\n"
  },
  {
    "path": "src/renderer/layouts/window-bar.tsx",
    "content": "import clsx from 'clsx';\nimport isElectron from 'is-electron';\nimport { useCallback, useEffect, useMemo, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { RiCheckboxBlankLine, RiCloseLine, RiSubtractLine } from 'react-icons/ri';\n\nimport appIcon from '../../../assets/icons/32x32.png';\nimport macCloseHover from './assets/close-mac-hover.png';\nimport macClose from './assets/close-mac.png';\nimport macMaxHover from './assets/max-mac-hover.png';\nimport macMax from './assets/max-mac.png';\nimport macMinHover from './assets/min-mac-hover.png';\nimport macMin from './assets/min-mac.png';\nimport styles from './window-bar.module.css';\n\nimport { useRadioPlayer } from '/@/renderer/features/radio/hooks/use-radio-player';\nimport { useAppStore, usePlayerData, usePlayerStatus, useWindowSettings } from '/@/renderer/store';\nimport { Text } from '/@/shared/components/text/text';\nimport { Platform, PlayerStatus } from '/@/shared/types/types';\n\nconst localSettings = isElectron() ? window.api.localSettings : null;\n\nconst browser = isElectron() ? window.api.browser : null;\nconst close = () => browser?.exit();\nconst minimize = () => browser?.minimize();\nconst maximize = () => browser?.maximize();\nconst unmaximize = () => browser?.unmaximize();\n\ninterface WindowBarControlsProps {\n    controls: {\n        handleClose: () => void;\n        handleMaximize: () => void;\n        handleMinimize: () => void;\n    };\n    title: string;\n}\n\nconst WindowsControls = ({ controls, title }: WindowBarControlsProps) => {\n    const { handleClose, handleMaximize, handleMinimize } = controls;\n\n    return (\n        <div className={styles.windowsContainer}>\n            <div className={styles.playerStatusContainer}>\n                <img alt=\"\" height={16} src={appIcon} style={{ flexShrink: 0 }} width={16} />\n                <Text className={styles.playerStatusText} overflow=\"hidden\" size=\"sm\">\n                    {title}\n                </Text>\n            </div>\n            <div className={styles.windowsButtonGroup}>\n                <div className={styles.windowsButton} onClick={handleMinimize} role=\"button\">\n                    <RiSubtractLine size={19} />\n                </div>\n                <div className={styles.windowsButton} onClick={handleMaximize} role=\"button\">\n                    <RiCheckboxBlankLine size={13} />\n                </div>\n                <div\n                    className={clsx(styles.windowsButton, styles.exit)}\n                    onClick={handleClose}\n                    role=\"button\"\n                >\n                    <RiCloseLine size={19} />\n                </div>\n            </div>\n        </div>\n    );\n};\n\nconst MacOsControls = ({ controls, title }: WindowBarControlsProps) => {\n    const { handleClose, handleMaximize, handleMinimize } = controls;\n\n    const [hoverMin, setHoverMin] = useState(false);\n    const [hoverMax, setHoverMax] = useState(false);\n    const [hoverClose, setHoverClose] = useState(false);\n\n    return (\n        <div className={styles.macosContainer}>\n            <div className={styles.macosButtonGroup}>\n                <div\n                    className={clsx(styles.macosButton, styles.minButton)}\n                    id=\"min-button\"\n                    onClick={handleMinimize}\n                    onMouseLeave={() => setHoverMin(false)}\n                    onMouseOver={() => setHoverMin(true)}\n                >\n                    <img\n                        alt=\"\"\n                        className=\"icon\"\n                        draggable=\"false\"\n                        src={hoverMin ? macMinHover : macMin}\n                    />\n                </div>\n                <div\n                    className={clsx(styles.macosButton, styles.maxButton)}\n                    id=\"max-button\"\n                    onClick={handleMaximize}\n                    onMouseLeave={() => setHoverMax(false)}\n                    onMouseOver={() => setHoverMax(true)}\n                >\n                    <img\n                        alt=\"\"\n                        className=\"icon\"\n                        draggable=\"false\"\n                        src={hoverMax ? macMaxHover : macMax}\n                    />\n                </div>\n                <div\n                    className={clsx(styles.macosButton)}\n                    id=\"close-button\"\n                    onClick={handleClose}\n                    onMouseLeave={() => setHoverClose(false)}\n                    onMouseOver={() => setHoverClose(true)}\n                >\n                    <img\n                        alt=\"\"\n                        className=\"icon\"\n                        draggable=\"false\"\n                        src={hoverClose ? macCloseHover : macClose}\n                    />\n                </div>\n            </div>\n            <div className={styles.playerStatusContainer}>\n                <Text className={styles.playerStatusText} overflow=\"hidden\" size=\"sm\">\n                    {title}\n                </Text>\n            </div>\n        </div>\n    );\n};\n\nexport const WindowBar = () => {\n    const { t } = useTranslation();\n    const { windowBarStyle } = useWindowSettings();\n    const playerStatus = usePlayerStatus();\n    const privateMode = useAppStore((state) => state.privateMode);\n    const handleMinimize = () => minimize();\n\n    const { currentSong, index, queueLength } = usePlayerData();\n    const { isPlaying: isRadioPlaying, metadata, stationName } = useRadioPlayer();\n    const isRadioActive = Boolean(stationName || metadata);\n    const [max, setMax] = useState(localSettings?.env.START_MAXIMIZED || false);\n\n    const handleMaximize = useCallback(() => {\n        if (max) {\n            unmaximize();\n        } else {\n            maximize();\n        }\n        setMax(!max);\n    }, [max]);\n\n    const handleClose = useCallback(() => close(), []);\n\n    const title = useMemo(() => {\n        const privateModeString = privateMode ? t('page.windowBar.privateMode') : '';\n\n        // Show radio information if radio is active\n        if (isRadioActive) {\n            const radioStatusString = !isRadioPlaying ? t('page.windowBar.paused') : '';\n            const radioTitle = stationName;\n\n            // Format metadata: show title, or combine artist and title if both available\n            let radioMetadata = '';\n            if (metadata) {\n                if (metadata.title && metadata.artist) {\n                    radioMetadata = ` — ${metadata.artist} — ${metadata.title}`;\n                } else if (metadata.title) {\n                    radioMetadata = ` — ${metadata.title}`;\n                } else if (metadata.artist) {\n                    radioMetadata = ` — ${metadata.artist}`;\n                }\n            }\n\n            return `${radioStatusString}${radioTitle}${radioMetadata} — Feishin${privateMode ? ` ${privateModeString}` : ''}`;\n        }\n\n        // Show regular song information\n        const statusString = playerStatus === PlayerStatus.PAUSED ? t('page.windowBar.paused') : '';\n        const queueString = queueLength ? `(${index + 1} / ${queueLength}) ` : '';\n        const title = `${\n            queueLength\n                ? `${statusString}${queueString}${currentSong?.name}${currentSong?.artistName ? ` — ${currentSong?.artistName} — Feishin` : ''}`\n                : 'Feishin'\n        }${privateMode ? ` ${privateModeString}` : ''}`;\n        return title;\n    }, [\n        currentSong?.artistName,\n        currentSong?.name,\n        index,\n        isRadioActive,\n        isRadioPlaying,\n        metadata,\n        playerStatus,\n        privateMode,\n        queueLength,\n        stationName,\n        t,\n    ]);\n\n    useEffect(() => {\n        document.title = title;\n    }, [title]);\n\n    if (windowBarStyle === Platform.WEB) {\n        return null;\n    }\n\n    return (\n        <div className={styles.windowBar}>\n            {windowBarStyle === Platform.WINDOWS && (\n                <WindowsControls\n                    controls={{ handleClose, handleMaximize, handleMinimize }}\n                    title={title}\n                />\n            )}\n            {windowBarStyle === Platform.MACOS && (\n                <MacOsControls\n                    controls={{ handleClose, handleMaximize, handleMinimize }}\n                    title={title}\n                />\n            )}\n        </div>\n    );\n};\n"
  },
  {
    "path": "src/renderer/lib/react-query.ts",
    "content": "import type {\n    DefaultOptions,\n    QueryOptions,\n    UseInfiniteQueryOptions,\n    UseMutationOptions,\n    UseQueryOptions,\n} from '@tanstack/react-query';\n\nimport { QueryCache, QueryClient } from '@tanstack/react-query';\n\nimport { toast } from '/@/shared/components/toast/toast';\n\nconst queryCache = new QueryCache({\n    onError: (error: any, query) => {\n        if (query.state.data !== undefined) {\n            console.error(error);\n            toast.show({ message: `${error.message}`, type: 'error' });\n        }\n    },\n});\n\nconst queryConfig: DefaultOptions = {\n    mutations: {\n        retry: process.env.NODE_ENV === 'production' ? 3 : false,\n    },\n    queries: {\n        gcTime: 1000 * 20, // 20 seconds\n        refetchOnWindowFocus: false,\n        retry: process.env.NODE_ENV === 'production',\n        staleTime: 1000 * 10, // 10 seconds\n        throwOnError: (error: any) => {\n            return error?.response?.status >= 500;\n        },\n    },\n};\n\nexport const queryClient = new QueryClient({\n    defaultOptions: queryConfig,\n    queryCache,\n});\n\nexport type InfiniteQueryHookArgs<T> = {\n    options?: UseInfiniteQueryOptions;\n    query: T;\n    serverId: string | undefined;\n};\n\nexport type MutationHookArgs = {\n    options?: MutationOptions;\n};\n\nexport type MutationOptions = {\n    mutationKey: UseMutationOptions['mutationKey'];\n    onError?: (err: any) => void;\n    onSettled?: any;\n    onSuccess?: any;\n    retry?: UseQueryOptions['retry'];\n    retryDelay?: UseQueryOptions['retryDelay'];\n    useErrorBoundary?: boolean;\n};\n\nexport type QueryHookArgs<T> = {\n    options?: UseQueryHookOptions;\n    query: T;\n    serverId: string;\n};\n\ntype UseQueryHookOptions = {\n    enabled?: boolean;\n    gcTime?: QueryOptions['gcTime'];\n    // initialData?: UseQueryOptions['initialData'];\n    // initialDataUpdatedAt?: UseQueryOptions['initialDataUpdatedAt'];\n    meta?: UseQueryOptions['meta'];\n    networkMode?: UseQueryOptions['networkMode'];\n    notifyOnChangeProps?: UseQueryOptions['notifyOnChangeProps'];\n    placeholderData?: (prev: any) => any;\n    // queryFn?: UseQueryOptions['queryFn'];\n    queryKey?: UseQueryOptions['queryKey'];\n    queryKeyHashFn?: UseQueryOptions['queryKeyHashFn'];\n    refetchInterval?: number;\n    refetchIntervalInBackground?: UseQueryOptions['refetchIntervalInBackground'];\n    refetchOnMount?: boolean;\n    refetchOnReconnect?: boolean;\n    refetchOnWindowFocus?: boolean;\n    retry?: UseQueryOptions['retry'];\n    retryDelay?: UseQueryOptions['retryDelay'];\n    retryOnMount?: UseQueryOptions['retryOnMount'];\n    // select?: UseQueryOptions['select'];\n    staleTime?: number;\n    structuralSharing?: UseQueryOptions['structuralSharing'];\n    subscribed?: UseQueryOptions['subscribed'];\n    throwOnError?: boolean;\n};\n"
  },
  {
    "path": "src/renderer/lib/zustand.ts",
    "content": "import type { StoreApi, UseBoundStore } from 'zustand';\n\ntype WithSelectors<S> = S extends { getState: () => infer T }\n    ? S & { use: { [K in keyof T]: () => T[K] } }\n    : never;\n\nexport const createSelectors = <S extends UseBoundStore<StoreApi<object>>>(_store: S) => {\n    const store = _store as WithSelectors<typeof _store>;\n    store.use = {};\n    for (const k of Object.keys(store.getState())) {\n        (store.use as any)[k] = () => store((s) => s[k as keyof typeof s]);\n    }\n\n    return store;\n};\n"
  },
  {
    "path": "src/renderer/main.tsx",
    "content": "import {\n    PersistedClient,\n    Persister,\n    PersistQueryClientProvider,\n} from '@tanstack/react-query-persist-client';\nimport { del, get, set } from 'idb-keyval';\nimport { createRoot } from 'react-dom/client';\n\nimport { App } from '/@/renderer/app';\nimport { queryClient } from '/@/renderer/lib/react-query';\n\nfunction createIDBPersister(idbValidKey: IDBValidKey = 'reactQuery') {\n    return {\n        persistClient: async (client: PersistedClient) => {\n            set(idbValidKey, client);\n        },\n        removeClient: async () => {\n            await del(idbValidKey);\n        },\n        restoreClient: async () => {\n            return await get<PersistedClient>(idbValidKey);\n        },\n    } as Persister;\n}\n\nconst indexedDbPersister = createIDBPersister('feishin');\n\ncreateRoot(document.getElementById('root')!).render(\n    <PersistQueryClientProvider\n        client={queryClient}\n        persistOptions={{\n            buster: 'feishin',\n            dehydrateOptions: {\n                shouldDehydrateQuery: (query) => {\n                    const isSuccess = query.state.status === 'success';\n                    const isLyricsQueryKey =\n                        query.queryKey.includes('song') &&\n                        query.queryKey.includes('lyrics') &&\n                        query.queryKey.includes('select');\n\n                    return isSuccess && isLyricsQueryKey;\n                },\n            },\n            hydrateOptions: {\n                defaultOptions: {\n                    queries: {\n                        gcTime: Infinity,\n                    },\n                },\n            },\n            maxAge: Infinity,\n            persister: indexedDbPersister,\n        }}\n    >\n        <App />\n    </PersistQueryClientProvider>,\n);\n"
  },
  {
    "path": "src/renderer/release-notes-modal.tsx",
    "content": "import { closeAllModals, openModal } from '@mantine/modals';\nimport { useQuery } from '@tanstack/react-query';\nimport axios from 'axios';\nimport DOMPurify from 'dompurify';\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport packageJson from '../../package.json';\n\nimport { formatHrDateTime } from '/@/renderer/utils/format';\nimport { Button } from '/@/shared/components/button/button';\nimport { Center } from '/@/shared/components/center/center';\nimport { Group } from '/@/shared/components/group/group';\nimport { Icon } from '/@/shared/components/icon/icon';\nimport { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';\nimport { Select } from '/@/shared/components/select/select';\nimport { Spinner } from '/@/shared/components/spinner/spinner';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { Text } from '/@/shared/components/text/text';\nimport { useLocalStorage } from '/@/shared/hooks/use-local-storage';\n\nconst GITHUB_RELEASES_URL = 'https://api.github.com/repos/jeffvli/feishin/releases';\nconst GITHUB_COMPARE_URL = 'https://api.github.com/repos/jeffvli/feishin/compare';\nconst RELEASES_TO_FETCH = 30;\n\ninterface GitHubCompareCommit {\n    commit: {\n        author: { date: string; name: string };\n        message: string;\n    };\n    html_url: string;\n    sha: string;\n}\n\ninterface GitHubCompareResponse {\n    commits: GitHubCompareCommit[];\n    total_commits: number;\n}\n\ninterface GitHubRelease {\n    body: null | string;\n    name: null | string;\n    prerelease: boolean;\n    published_at: string;\n    tag_name: string;\n}\n\ninterface ReleaseNotesContentProps {\n    onDismiss: () => void;\n    version: string;\n}\n\nfunction isAlphaVersion(version: string): boolean {\n    return version.includes('-alpha');\n}\n\nfunction parseVersionFromTag(tagName: string): string {\n    return tagName.startsWith('v') ? tagName.slice(1) : tagName;\n}\n\nfunction toTag(version: string): string {\n    return version.startsWith('v') ? version : `v${version}`;\n}\n\nconst ReleaseNotesContent = ({ onDismiss, version }: ReleaseNotesContentProps) => {\n    const { t } = useTranslation();\n    const [selectedVersion, setSelectedVersion] = useState(version);\n    const isAlpha = isAlphaVersion(selectedVersion);\n\n    // Fetch list of recent releases for the selector\n    const { data: releasesList = [] } = useQuery({\n        queryFn: async () => {\n            const response = await axios.get<GitHubRelease[]>(GITHUB_RELEASES_URL, {\n                params: { per_page: RELEASES_TO_FETCH },\n            });\n            return response.data;\n        },\n        queryKey: ['github-releases-list'],\n        retry: 2,\n    });\n\n    const latestStableRelease = useMemo(() => {\n        return releasesList.find((r) => !r.prerelease);\n    }, [releasesList]);\n\n    const releaseOptions = useMemo(() => {\n        const options = releasesList.slice(0, RELEASES_TO_FETCH).map((r) => {\n            const v = parseVersionFromTag(r.tag_name);\n            const dateStr = formatHrDateTime(r.published_at);\n            return {\n                label: dateStr ? `${v} - ${dateStr}` : v,\n                value: v,\n            };\n        });\n        const versions = options.map((o) => o.value);\n        if (!versions.includes(version)) {\n            options.unshift({ label: version, value: version });\n        }\n        return options;\n    }, [releasesList, version]);\n\n    // For alpha: fetch commits between latest stable and development branch\n    const {\n        data: compareData,\n        isError: isCompareError,\n        isLoading: isCompareLoading,\n    } = useQuery({\n        enabled: isAlpha && !!latestStableRelease,\n        queryFn: async () => {\n            const base = latestStableRelease!.tag_name;\n            const head = 'development';\n            const response = await axios.get<GitHubCompareResponse>(\n                `${GITHUB_COMPARE_URL}/${base}...${head}`,\n                { params: { per_page: 100 } },\n            );\n            return response.data;\n        },\n        queryKey: ['github-compare', latestStableRelease?.tag_name, 'development'],\n        retry: 2,\n    });\n\n    // For non-alpha: fetch release by tag\n    const {\n        data: releaseData,\n        isError,\n        isLoading,\n    } = useQuery({\n        enabled: !isAlpha,\n        queryFn: async () => {\n            const response = await axios.get<GitHubRelease>(\n                `${GITHUB_RELEASES_URL}/tags/${toTag(selectedVersion)}`,\n            );\n            return response.data;\n        },\n        queryKey: ['github-release', selectedVersion],\n        retry: 2,\n    });\n\n    // Convert markdown to HTML using GitHub's markdown API\n    const { data: htmlContent, isLoading: isConverting } = useQuery({\n        enabled: !isAlpha && !!releaseData?.body,\n        queryFn: async () => {\n            const response = await axios.post(\n                'https://api.github.com/markdown',\n                {\n                    mode: 'gfm',\n                    text: releaseData?.body ?? '',\n                },\n                {\n                    headers: {\n                        'Content-Type': 'application/json',\n                    },\n                    responseType: 'text',\n                },\n            );\n            return response.data;\n        },\n        queryKey: ['github-markdown', releaseData?.body],\n        retry: 2,\n    });\n\n    const sanitizedHtml = useMemo(() => {\n        if (!htmlContent) return '';\n        return DOMPurify.sanitize(htmlContent, {\n            ALLOWED_ATTR: ['alt', 'href', 'src', 'title'],\n            ALLOWED_TAGS: [\n                'a',\n                'blockquote',\n                'br',\n                'code',\n                'em',\n                'h1',\n                'h2',\n                'h3',\n                'h4',\n                'h5',\n                'h6',\n                'img',\n                'li',\n                'ol',\n                'p',\n                'pre',\n                'strong',\n                'u',\n                'ul',\n            ],\n        });\n    }, [htmlContent]);\n\n    const isLoadingState = isAlpha ? isCompareLoading : isLoading || isConverting;\n    const isErrorState = isAlpha ? isCompareError : isError || !releaseData;\n\n    if (isLoadingState) {\n        return (\n            <Center h={400}>\n                <Spinner />\n            </Center>\n        );\n    }\n\n    if (isErrorState) {\n        const showCompareError = isAlpha && latestStableRelease;\n        return (\n            <Stack gap=\"md\">\n                {releaseOptions.length > 1 && (\n                    <Select\n                        data={releaseOptions}\n                        onChange={(v) => v && setSelectedVersion(v)}\n                        value={selectedVersion}\n                    />\n                )}\n                <Text size=\"sm\">{t('error.genericError', { postProcess: 'sentenceCase' })}</Text>\n                <Group justify=\"flex-end\">\n                    <Button\n                        component=\"a\"\n                        href={\n                            showCompareError\n                                ? `https://github.com/jeffvli/feishin/compare/${latestStableRelease.tag_name}...${toTag(selectedVersion)}`\n                                : `https://github.com/jeffvli/feishin/releases/tag/${toTag(selectedVersion)}`\n                        }\n                        onClick={onDismiss}\n                        rightSection={<Icon icon=\"externalLink\" />}\n                        target=\"_blank\"\n                        variant=\"filled\"\n                    >\n                        {t('common.viewReleaseNotes', { postProcess: 'sentenceCase' })}\n                    </Button>\n                    <Button onClick={onDismiss} variant=\"default\">\n                        {t('common.dismiss', { postProcess: 'titleCase' })}\n                    </Button>\n                </Group>\n            </Stack>\n        );\n    }\n\n    if (isAlpha && !latestStableRelease) {\n        return (\n            <Stack gap=\"md\">\n                {releaseOptions.length > 1 && (\n                    <Select\n                        data={releaseOptions}\n                        onChange={(v) => v && setSelectedVersion(v)}\n                        value={selectedVersion}\n                    />\n                )}\n                <Text isMuted size=\"sm\">\n                    {t('page.releasenotes.noStableReleaseToCompare', {\n                        postProcess: 'sentenceCase',\n                    })}\n                </Text>\n                <Group justify=\"flex-end\">\n                    <Button\n                        component=\"a\"\n                        href={`https://github.com/jeffvli/feishin/releases/tag/${toTag(selectedVersion)}`}\n                        onClick={onDismiss}\n                        rightSection={<Icon icon=\"externalLink\" />}\n                        target=\"_blank\"\n                        variant=\"subtle\"\n                    >\n                        {t('action.viewMore', { postProcess: 'sentenceCase' })}\n                    </Button>\n                    <Button onClick={onDismiss} variant=\"filled\">\n                        {t('common.dismiss', { postProcess: 'titleCase' })}\n                    </Button>\n                </Group>\n            </Stack>\n        );\n    }\n\n    if (isAlpha && compareData) {\n        const commits = compareData.commits ?? [];\n        const compareUrl = `https://github.com/jeffvli/feishin/compare/${latestStableRelease?.tag_name}...development`;\n        return (\n            <Stack gap=\"md\">\n                {releaseOptions.length > 1 && (\n                    <Select\n                        data={releaseOptions}\n                        onChange={(v) => v && setSelectedVersion(v)}\n                        value={selectedVersion}\n                    />\n                )}\n                <Text isMuted size=\"sm\">\n                    {t('page.releasenotes.commitsSinceStable', {\n                        postProcess: 'sentenceCase',\n                        stable: latestStableRelease\n                            ? parseVersionFromTag(latestStableRelease.tag_name)\n                            : '',\n                    })}\n                </Text>\n                <ScrollArea\n                    style={{\n                        height: '400px',\n                    }}\n                >\n                    <Stack gap=\"xs\">\n                        {commits.length === 0 ? (\n                            <Text isMuted size=\"sm\">\n                                {t('page.releasenotes.noNewCommits', {\n                                    postProcess: 'sentenceCase',\n                                })}\n                            </Text>\n                        ) : (\n                            commits.map((c) => {\n                                const firstLine = c.commit.message.split('\\n')[0];\n                                return (\n                                    <Group\n                                        gap=\"sm\"\n                                        key={c.sha}\n                                        style={{ alignItems: 'flex-start' }}\n                                        wrap=\"nowrap\"\n                                    >\n                                        <Text\n                                            size=\"sm\"\n                                            style={{ flex: 1 }}\n                                            title={c.commit.message}\n                                            truncate\n                                        >\n                                            {firstLine}\n                                        </Text>\n                                        <Text isMuted size=\"xs\">\n                                            {formatHrDateTime(c.commit.author.date)}\n                                        </Text>\n                                        <Button\n                                            component=\"a\"\n                                            href={c.html_url}\n                                            rightSection={<Icon icon=\"externalLink\" />}\n                                            size=\"compact-xs\"\n                                            target=\"_blank\"\n                                            variant=\"subtle\"\n                                        >\n                                            {t('common.view', { postProcess: 'sentenceCase' })}\n                                        </Button>\n                                    </Group>\n                                );\n                            })\n                        )}\n                    </Stack>\n                </ScrollArea>\n                <Group justify=\"flex-end\">\n                    <Button\n                        component=\"a\"\n                        href={compareUrl}\n                        onClick={onDismiss}\n                        rightSection={<Icon icon=\"externalLink\" />}\n                        target=\"_blank\"\n                        variant=\"subtle\"\n                    >\n                        {t('action.viewMore', { postProcess: 'sentenceCase' })}\n                    </Button>\n                    <Button onClick={onDismiss} variant=\"filled\">\n                        {t('common.dismiss', { postProcess: 'titleCase' })}\n                    </Button>\n                </Group>\n            </Stack>\n        );\n    }\n\n    return (\n        <Stack gap=\"md\">\n            {releaseOptions.length > 1 && (\n                <Select\n                    data={releaseOptions}\n                    onChange={(v) => v && setSelectedVersion(v)}\n                    value={selectedVersion}\n                />\n            )}\n            <ScrollArea\n                style={{\n                    height: '400px',\n                }}\n            >\n                <Text\n                    dangerouslySetInnerHTML={{ __html: sanitizedHtml }}\n                    fw={400}\n                    lh=\"1.5\"\n                    size=\"md\"\n                />\n            </ScrollArea>\n            <Group justify=\"flex-end\">\n                <Button\n                    component=\"a\"\n                    href={`https://github.com/jeffvli/feishin/releases/tag/${toTag(selectedVersion)}`}\n                    onClick={onDismiss}\n                    rightSection={<Icon icon=\"externalLink\" />}\n                    target=\"_blank\"\n                    variant=\"subtle\"\n                >\n                    {t('action.viewMore', { postProcess: 'sentenceCase' })}\n                </Button>\n                <Button onClick={onDismiss} variant=\"filled\">\n                    {t('common.dismiss', { postProcess: 'titleCase' })}\n                </Button>\n            </Group>\n        </Stack>\n    );\n};\n\nconst WAIT_FOR_LOCAL_STORAGE = 1000 * 2;\n\ninterface ReleaseNotesModalContentWrapperProps {\n    setDismissRef?: (fn: (() => void) | undefined) => void;\n}\n\nconst ReleaseNotesModalContentWrapper = ({\n    setDismissRef,\n}: ReleaseNotesModalContentWrapperProps) => {\n    const { version } = packageJson;\n    const [, setValue] = useLocalStorage({ key: 'version' });\n\n    const handleDismiss = useCallback(() => {\n        setValue(version);\n        closeAllModals();\n    }, [setValue, version]);\n\n    useEffect(() => {\n        setDismissRef?.(handleDismiss);\n        return () => setDismissRef?.(undefined);\n    }, [handleDismiss, setDismissRef]);\n\n    return <ReleaseNotesContent onDismiss={handleDismiss} version={version} />;\n};\n\nexport const openReleaseNotesModal = (title: string) => {\n    const dismissRef = { current: null as (() => void) | null };\n\n    openModal({\n        children: (\n            <ReleaseNotesModalContentWrapper\n                setDismissRef={(fn) => {\n                    dismissRef.current = fn ?? null;\n                }}\n            />\n        ),\n        onClose: () => dismissRef.current?.(),\n        size: 'xl',\n        title,\n    });\n};\n\nexport const ReleaseNotesModal = () => {\n    const { version } = packageJson;\n    const { t } = useTranslation();\n    const dismissRef = useRef<(() => void) | null>(null);\n\n    useEffect(() => {\n        const timeoutId = setTimeout(() => {\n            const valueFromLocalStorage = localStorage.getItem('version');\n            const versionString = `\"${version}\"`;\n\n            // Only show modal if the stored version is different from current version\n            if (valueFromLocalStorage !== versionString) {\n                openModal({\n                    children: (\n                        <ReleaseNotesModalContentWrapper\n                            setDismissRef={(fn) => {\n                                dismissRef.current = fn ?? null;\n                            }}\n                        />\n                    ),\n                    onClose: () => dismissRef.current?.(),\n                    size: 'xl',\n                    title: t('common.newVersion', {\n                        postProcess: 'sentenceCase',\n                        version,\n                    }) as string,\n                });\n            }\n        }, WAIT_FOR_LOCAL_STORAGE);\n\n        return () => {\n            clearTimeout(timeoutId);\n        };\n    }, [t, version]);\n\n    return null;\n};\n"
  },
  {
    "path": "src/renderer/router/app-outlet.tsx",
    "content": "import { useMemo } from 'react';\nimport { Navigate, Outlet } from 'react-router';\n\nimport { isServerLock } from '/@/renderer/features/action-required/utils/window-properties';\nimport { AppRoute } from '/@/renderer/router/routes';\nimport { useAuthStoreActions, useCurrentServer } from '/@/renderer/store';\n\nconst normalizeUrl = (url: string) => url.replace(/\\/$/, '');\n\nexport const AppOutlet = () => {\n    const currentServer = useCurrentServer();\n    const { deleteServer, setCurrentServer } = useAuthStoreActions();\n\n    const isActionsRequired = useMemo(() => {\n        // When SERVER_LOCK is enabled and the configured URL has changed,\n        // clear the stale session so the user re-authenticates against the new server.\n        if (isServerLock() && currentServer && window.SERVER_URL) {\n            const configuredUrl = normalizeUrl(window.SERVER_URL);\n            const persistedUrl = normalizeUrl(currentServer.url);\n\n            if (configuredUrl !== persistedUrl) {\n                deleteServer(currentServer.id);\n                setCurrentServer(null);\n                return true;\n            }\n        }\n\n        const isServerRequired = !currentServer;\n\n        const actions = [isServerRequired];\n        const isActionRequired = actions.some((c) => c);\n\n        return isActionRequired;\n    }, [currentServer, deleteServer, setCurrentServer]);\n\n    if (isActionsRequired) {\n        return <Navigate replace to={AppRoute.ACTION_REQUIRED} />;\n    }\n\n    return <Outlet />;\n};\n"
  },
  {
    "path": "src/renderer/router/app-router.tsx",
    "content": "import { lazy, Suspense } from 'react';\nimport { HashRouter, Route, Routes } from 'react-router';\n\nimport { RouterErrorBoundary } from '/@/renderer/features/shared/components/router-error-boundary';\nimport { AuthenticationOutlet } from '/@/renderer/layouts/authentication-outlet';\nimport { ResponsiveLayout } from '/@/renderer/layouts/responsive-layout';\nimport { AppOutlet } from '/@/renderer/router/app-outlet';\nimport { AppRoute } from '/@/renderer/router/routes';\nimport { TitlebarOutlet } from '/@/renderer/router/titlebar-outlet';\nimport { BaseContextModal, ModalsProvider } from '/@/shared/components/modal/modal';\nimport { Spinner } from '/@/shared/components/spinner/spinner';\n\nconst NowPlayingRoute = lazy(\n    () => import('/@/renderer/features/now-playing/routes/now-playing-route'),\n);\n\nconst AlbumListRoute = lazy(() => import('/@/renderer/features/albums/routes/album-list-route'));\n\nconst SongListRoute = lazy(() => import('/@/renderer/features/songs/routes/song-list-route'));\n\nconst PlaylistDetailSongListRoute = lazy(\n    () => import('/@/renderer/features/playlists/routes/playlist-detail-song-list-route'),\n);\n\nconst PlaylistListRoute = lazy(\n    () => import('/@/renderer/features/playlists/routes/playlist-list-route'),\n);\n\nconst ActionRequiredRoute = lazy(\n    () => import('/@/renderer/features/action-required/routes/action-required-route'),\n);\n\nconst InvalidRoute = lazy(\n    () => import('/@/renderer/features/action-required/routes/invalid-route'),\n);\n\nconst LoginRoute = lazy(() => import('/@/renderer/features/login/routes/login-route'));\n\nconst NoNetworkRoute = lazy(\n    () => import('/@/renderer/features/action-required/routes/no-network-route'),\n);\n\nconst HomeRoute = lazy(() => import('/@/renderer/features/home/routes/home-route'));\n\nconst ArtistListRoute = lazy(() => import('/@/renderer/features/artists/routes/artist-list-route'));\n\nconst AlbumArtistListRoute = lazy(\n    () => import('/@/renderer/features/artists/routes/album-artist-list-route'),\n);\n\nconst AlbumArtistDetailRoute = lazy(\n    () => import('/@/renderer/features/artists/routes/album-artist-detail-route'),\n);\n\nconst AlbumArtistDetailTopSongsListRoute = lazy(\n    () => import('../features/artists/routes/album-artist-detail-top-songs-list-route'),\n);\n\nconst AlbumArtistDetailFavoriteSongsListRoute = lazy(\n    () => import('../features/artists/routes/album-artist-detail-favorite-songs-list-route'),\n);\n\nconst AlbumDetailRoute = lazy(\n    () => import('/@/renderer/features/albums/routes/album-detail-route'),\n);\n\nconst DummyAlbumDetailRoute = lazy(\n    () => import('/@/renderer/features/albums/routes/dummy-album-detail-route'),\n);\n\nconst GenreListRoute = lazy(() => import('/@/renderer/features/genres/routes/genre-list-route'));\n\nconst GenreDetailRoute = lazy(\n    () => import('/@/renderer/features/genres/routes/genre-detail-route'),\n);\n\nconst FolderListRoute = lazy(() => import('/@/renderer/features/folders/routes/folder-list-route'));\n\nconst RadioListRoute = lazy(() => import('/@/renderer/features/radio/routes/radio-list-route'));\n\nconst SearchRoute = lazy(() => import('/@/renderer/features/search/routes/search-route'));\n\nconst FavoritesRoute = lazy(() => import('/@/renderer/features/favorites/routes/favorites-route'));\n\nconst SettingsRoute = lazy(() => import('/@/renderer/features/settings/routes/settings-route'));\n\nconst LazyLyricsSettingsContextModal = lazy(() =>\n    import('/@/renderer/features/lyrics/components/lyrics-settings-modal').then((module) => ({\n        default: module.LyricsSettingsContextModal,\n    })),\n);\n\nconst LyricsSettingsContextModal = (props: any) => (\n    <Suspense fallback={<Spinner container />}>\n        <LazyLyricsSettingsContextModal {...props} />\n    </Suspense>\n);\n\nconst LazyShuffleAllContextModal = lazy(() =>\n    import('/@/renderer/features/player/components/shuffle-all-modal').then((module) => ({\n        default: module.ShuffleAllContextModal,\n    })),\n);\n\nconst ShuffleAllContextModal = (props: any) => (\n    <Suspense fallback={<Spinner container />}>\n        <LazyShuffleAllContextModal {...props} />\n    </Suspense>\n);\n\nconst LazyAddToPlaylistContextModal = lazy(() =>\n    import('/@/renderer/features/playlists/components/add-to-playlist-context-modal').then(\n        (module) => ({\n            default: module.AddToPlaylistContextModal,\n        }),\n    ),\n);\n\nconst AddToPlaylistContextModal = (props: any) => (\n    <Suspense fallback={<Spinner container />}>\n        <LazyAddToPlaylistContextModal {...props} />\n    </Suspense>\n);\n\nconst LazySaveAndReplaceContextModal = lazy(() =>\n    import('/@/renderer/features/playlists/components/save-and-replace-context-modal').then(\n        (module) => ({\n            default: module.SaveAndReplaceContextModal,\n        }),\n    ),\n);\n\nconst SaveAndReplaceContextModal = (props: any) => (\n    <Suspense fallback={<Spinner container />}>\n        <LazySaveAndReplaceContextModal {...props} />\n    </Suspense>\n);\n\nconst LazyUpdatePlaylistContextModal = lazy(() =>\n    import('/@/renderer/features/playlists/components/update-playlist-form').then((module) => ({\n        default: module.UpdatePlaylistContextModal,\n    })),\n);\n\nconst UpdatePlaylistContextModal = (props: any) => (\n    <Suspense fallback={<Spinner container />}>\n        <LazyUpdatePlaylistContextModal {...props} />\n    </Suspense>\n);\n\nconst LazySettingsContextModal = lazy(() =>\n    import('/@/renderer/features/settings/components/settings-modal').then((module) => ({\n        default: module.SettingsContextModal,\n    })),\n);\n\nconst SettingsContextModal = (props: any) => (\n    <Suspense fallback={<Spinner container />}>\n        <LazySettingsContextModal {...props} />\n    </Suspense>\n);\n\nconst LazyShareItemContextModal = lazy(() =>\n    import('/@/renderer/features/sharing/components/share-item-context-modal').then((module) => ({\n        default: module.ShareItemContextModal,\n    })),\n);\n\nconst ShareItemContextModal = (props: any) => (\n    <Suspense fallback={<Spinner container />}>\n        <LazyShareItemContextModal {...props} />\n    </Suspense>\n);\n\nconst LazyVisualizerSettingsContextModal = lazy(() =>\n    import(\n        '/@/renderer/features/visualizer/components/audiomotionanalyzer/visualizer-settings-modal'\n    ).then((module) => ({\n        default: module.VisualizerSettingsContextModal,\n    })),\n);\n\nconst VisualizerSettingsContextModal = (props: any) => (\n    <Suspense fallback={<Spinner container />}>\n        <LazyVisualizerSettingsContextModal {...props} />\n    </Suspense>\n);\n\nexport const AppRouter = () => {\n    const router = (\n        <HashRouter>\n            <ModalsProvider\n                modals={{\n                    addToPlaylist: AddToPlaylistContextModal,\n                    base: BaseContextModal,\n                    lyricsSettings: LyricsSettingsContextModal,\n                    saveAndReplace: SaveAndReplaceContextModal,\n                    settings: SettingsContextModal,\n                    shareItem: ShareItemContextModal,\n                    shuffleAll: ShuffleAllContextModal,\n                    updatePlaylist: UpdatePlaylistContextModal,\n                    visualizerSettings: VisualizerSettingsContextModal,\n                }}\n            >\n                <RouterErrorBoundary>\n                    <Routes>\n                        <Route element={<AuthenticationOutlet />}>\n                            <Route element={<TitlebarOutlet />}>\n                                <Route element={<AppOutlet />}>\n                                    <Route element={<ResponsiveLayout />}>\n                                        <Route element={<HomeRoute />} index />\n                                        <Route element={<HomeRoute />} path={AppRoute.HOME} />\n                                        <Route element={<SearchRoute />} path={AppRoute.SEARCH} />\n                                        <Route\n                                            element={<FavoritesRoute />}\n                                            path={AppRoute.FAVORITES}\n                                        />\n                                        <Route\n                                            element={<SettingsRoute />}\n                                            path={AppRoute.SETTINGS}\n                                        />\n                                        <Route\n                                            element={<NowPlayingRoute />}\n                                            path={AppRoute.NOW_PLAYING}\n                                        />\n                                        <Route path={AppRoute.LIBRARY_GENRES}>\n                                            <Route element={<GenreListRoute />} index />\n                                            <Route\n                                                element={<GenreDetailRoute />}\n                                                path={AppRoute.LIBRARY_GENRES_DETAIL}\n                                            />\n                                        </Route>\n                                        <Route\n                                            element={<AlbumListRoute />}\n                                            path={AppRoute.LIBRARY_ALBUMS}\n                                        />\n                                        <Route\n                                            element={<AlbumDetailRoute />}\n                                            path={AppRoute.LIBRARY_ALBUMS_DETAIL}\n                                        />\n                                        <Route\n                                            element={<ArtistListRoute />}\n                                            path={AppRoute.LIBRARY_ARTISTS}\n                                        />\n                                        <Route path={AppRoute.LIBRARY_ARTISTS_DETAIL}>\n                                            <Route element={<AlbumArtistDetailRoute />} index />\n                                            <Route\n                                                element={<AlbumListRoute />}\n                                                path={AppRoute.LIBRARY_ARTISTS_DETAIL_DISCOGRAPHY}\n                                            />\n                                            <Route\n                                                element={<SongListRoute />}\n                                                path={AppRoute.LIBRARY_ARTISTS_DETAIL_SONGS}\n                                            />\n                                            <Route\n                                                element={<AlbumArtistDetailTopSongsListRoute />}\n                                                path={AppRoute.LIBRARY_ARTISTS_DETAIL_TOP_SONGS}\n                                            />\n                                            <Route\n                                                element={\n                                                    <AlbumArtistDetailFavoriteSongsListRoute />\n                                                }\n                                                path={\n                                                    AppRoute.LIBRARY_ARTISTS_DETAIL_FAVORITE_SONGS\n                                                }\n                                            />\n                                        </Route>\n                                        <Route\n                                            element={<DummyAlbumDetailRoute />}\n                                            path={AppRoute.FAKE_LIBRARY_ALBUM_DETAILS}\n                                        />\n                                        <Route\n                                            element={<SongListRoute />}\n                                            path={AppRoute.LIBRARY_SONGS}\n                                        />\n                                        <Route\n                                            element={<FolderListRoute />}\n                                            path={AppRoute.LIBRARY_FOLDERS}\n                                        />\n                                        <Route\n                                            element={<PlaylistListRoute />}\n                                            path={AppRoute.PLAYLISTS}\n                                        />\n                                        <Route element={<RadioListRoute />} path={AppRoute.RADIO} />\n                                        <Route\n                                            element={<PlaylistDetailSongListRoute />}\n                                            path={AppRoute.PLAYLISTS_DETAIL_SONGS}\n                                        />\n                                        <Route path={AppRoute.LIBRARY_ALBUM_ARTISTS}>\n                                            <Route element={<AlbumArtistListRoute />} index />\n                                            <Route path={AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL}>\n                                                <Route element={<AlbumArtistDetailRoute />} index />\n                                                <Route\n                                                    element={<AlbumListRoute />}\n                                                    path={\n                                                        AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL_DISCOGRAPHY\n                                                    }\n                                                />\n                                                <Route\n                                                    element={<SongListRoute />}\n                                                    path={\n                                                        AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL_SONGS\n                                                    }\n                                                />\n                                                <Route\n                                                    element={<AlbumArtistDetailTopSongsListRoute />}\n                                                    path={\n                                                        AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL_TOP_SONGS\n                                                    }\n                                                />\n                                                <Route\n                                                    element={\n                                                        <AlbumArtistDetailFavoriteSongsListRoute />\n                                                    }\n                                                    path={\n                                                        AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL_FAVORITE_SONGS\n                                                    }\n                                                />\n                                            </Route>\n                                        </Route>\n                                        <Route element={<InvalidRoute />} path=\"*\" />\n                                    </Route>\n                                </Route>\n                            </Route>\n                        </Route>\n                        <Route element={<TitlebarOutlet />}>\n                            <Route element={<ResponsiveLayout shell />}>\n                                <Route\n                                    element={<ActionRequiredRoute />}\n                                    path={AppRoute.ACTION_REQUIRED}\n                                />\n                                <Route element={<LoginRoute />} path={AppRoute.LOGIN} />\n                            </Route>\n                            <Route element={<ResponsiveLayout />}>\n                                <Route element={<NoNetworkRoute />} path={AppRoute.NO_NETWORK} />\n                            </Route>\n                        </Route>\n                    </Routes>\n                </RouterErrorBoundary>\n            </ModalsProvider>\n        </HashRouter>\n    );\n\n    return <Suspense fallback={<></>}>{router}</Suspense>;\n};\n"
  },
  {
    "path": "src/renderer/router/routes.ts",
    "content": "export enum AppRoute {\n    ACTION_REQUIRED = '/action-required',\n    EXPLORE = '/explore',\n    FAKE_LIBRARY_ALBUM_DETAILS = '/library/albums/dummy/:albumId',\n    FAVORITES = '/favorites',\n    HOME = '/',\n    LIBRARY_ALBUM_ARTISTS = '/library/album-artists',\n    LIBRARY_ALBUM_ARTISTS_DETAIL = '/library/album-artists/:albumArtistId',\n    LIBRARY_ALBUM_ARTISTS_DETAIL_DISCOGRAPHY = '/library/album-artists/:albumArtistId/discography',\n    LIBRARY_ALBUM_ARTISTS_DETAIL_FAVORITE_SONGS = '/library/album-artists/:albumArtistId/favorite-songs',\n    LIBRARY_ALBUM_ARTISTS_DETAIL_SONGS = '/library/album-artists/:albumArtistId/songs',\n    LIBRARY_ALBUM_ARTISTS_DETAIL_TOP_SONGS = '/library/album-artists/:albumArtistId/top-songs',\n    LIBRARY_ALBUMS = '/library/albums',\n    LIBRARY_ALBUMS_DETAIL = '/library/albums/:albumId',\n    LIBRARY_ARTISTS = '/library/artists',\n    LIBRARY_ARTISTS_DETAIL = '/library/artists/:artistId',\n    LIBRARY_ARTISTS_DETAIL_DISCOGRAPHY = '/library/artists/:artistId/discography',\n    LIBRARY_ARTISTS_DETAIL_FAVORITE_SONGS = '/library/artists/:artistId/favorite-songs',\n    LIBRARY_ARTISTS_DETAIL_SONGS = '/library/artists/:artistId/songs',\n    LIBRARY_ARTISTS_DETAIL_TOP_SONGS = '/library/artists/:artistId/top-songs',\n    LIBRARY_FOLDERS = '/library/folders',\n    LIBRARY_GENRES = '/library/genres',\n    LIBRARY_GENRES_DETAIL = '/library/genres/:genreId',\n    LIBRARY_SONGS = '/library/songs',\n    LOGIN = '/login',\n    NO_NETWORK = '/no-network',\n    NOW_PLAYING = '/now-playing',\n    PLAYING = '/playing',\n    PLAYLISTS = '/playlists',\n    PLAYLISTS_DETAIL_SONGS = '/playlists/:playlistId/songs',\n    RADIO = '/radio',\n    SEARCH = '/search/:itemType',\n    SERVERS = '/servers',\n    SETTINGS = '/settings',\n}\n"
  },
  {
    "path": "src/renderer/router/titlebar-outlet.module.css",
    "content": ".container {\n    position: absolute;\n    top: 0;\n    right: 0;\n    z-index: 195;\n    height: 65px;\n    -webkit-app-region: drag;\n}\n"
  },
  {
    "path": "src/renderer/router/titlebar-outlet.tsx",
    "content": "import { Outlet } from 'react-router';\n\nimport styles from './titlebar-outlet.module.css';\n\nimport { Titlebar } from '/@/renderer/features/titlebar/components/titlebar';\nimport { useWindowSettings } from '/@/renderer/store/settings.store';\nimport { Platform } from '/@/shared/types/types';\n\nexport const TitlebarOutlet = () => {\n    const { windowBarStyle } = useWindowSettings();\n\n    return (\n        <>\n            {windowBarStyle === Platform.WEB && (\n                <header className={styles.container}>\n                    <Titlebar />\n                </header>\n            )}\n            <Outlet />\n        </>\n    );\n};\n"
  },
  {
    "path": "src/renderer/store/app.store.ts",
    "content": "import type { ItemListStateItem } from '/@/renderer/components/item-list/helpers/item-list-state';\nimport type { LibraryItem } from '/@/shared/types/domain-types';\n\nimport merge from 'lodash/merge';\nimport { devtools, persist } from 'zustand/middleware';\nimport { immer } from 'zustand/middleware/immer';\nimport { createWithEqualityFn } from 'zustand/traditional';\n\nimport { AlbumListSort, SongListSort, SortOrder } from '/@/shared/types/domain-types';\nimport { Platform } from '/@/shared/types/types';\n\nexport interface AppSlice extends AppState {\n    actions: {\n        setAlbumArtistDetailFavoriteSongsSort: (sortBy: SongListSort, sortOrder: SortOrder) => void;\n        setAlbumArtistDetailGroupingType: (groupingType: 'all' | 'primary') => void;\n        setAlbumArtistDetailSort: (sortBy: AlbumListSort, sortOrder: SortOrder) => void;\n        setAlbumArtistIdsMode: (mode: 'and' | 'or') => void;\n        setAlbumArtistSelectMode: (mode: 'multi' | 'single') => void;\n        setAppStore: (data: Partial<AppSlice>) => void;\n        setArtistIdsMode: (mode: 'and' | 'or') => void;\n        setArtistSelectMode: (mode: 'multi' | 'single') => void;\n        setCommandPaletteSearchSectionExpanded: (sectionId: string, expanded: boolean) => void;\n        setGenreIdsMode: (mode: 'and' | 'or') => void;\n        setGenreSelectMode: (mode: 'multi' | 'single') => void;\n        setGlobalExpanded: (value: GlobalExpandedState | null) => void;\n        setPageSidebar: (key: string, value: boolean) => void;\n        setPrivateMode: (enabled: boolean) => void;\n        setShowTimeRemaining: (enabled: boolean) => void;\n        setSideBar: (options: Partial<SidebarProps>) => void;\n        setTitleBar: (options: Partial<TitlebarProps>) => void;\n    };\n}\n\nexport interface AppState {\n    albumArtistDetailFavoriteSongsSort: {\n        sortBy: SongListSort;\n        sortOrder: SortOrder;\n    };\n    albumArtistDetailSort: {\n        groupingType: 'all' | 'primary';\n        sortBy: AlbumListSort;\n        sortOrder: SortOrder;\n    };\n    albumArtistIdsMode: 'and' | 'or';\n    albumArtistSelectMode: 'multi' | 'single';\n    artistIdsMode: 'and' | 'or';\n    artistSelectMode: 'multi' | 'single';\n    commandPalette: CommandPaletteProps;\n    commandPaletteSearchSectionsExpanded: Record<string, boolean>;\n    genreIdsMode: 'and' | 'or';\n    genreSelectMode: 'multi' | 'single';\n    globalExpanded: GlobalExpandedState | null;\n    isReorderingQueue: boolean;\n    pageSidebar: Record<string, boolean>;\n    platform: Platform;\n    privateMode: boolean;\n    showTimeRemaining: boolean;\n    sidebar: SidebarProps;\n    titlebar: TitlebarProps;\n}\n\nexport interface GlobalExpandedState {\n    item: ItemListStateItem;\n    itemType: LibraryItem;\n}\n\ntype CommandPaletteProps = {\n    close: () => void;\n    open: () => void;\n    opened: boolean;\n    toggle: () => void;\n};\n\ntype SidebarProps = {\n    collapsed: boolean;\n    expanded: string[];\n    image: boolean;\n    leftWidth: string;\n    rightExpanded: boolean;\n    rightHeight: string;\n    rightWidth: string;\n};\n\ntype TitlebarProps = {\n    backgroundColor: string;\n    outOfView: boolean;\n};\n\nexport const useAppStore = createWithEqualityFn<AppSlice>()(\n    persist(\n        devtools(\n            immer((set, get) => ({\n                actions: {\n                    setAlbumArtistDetailFavoriteSongsSort: (sortBy, sortOrder) => {\n                        set((state) => {\n                            state.albumArtistDetailFavoriteSongsSort = {\n                                sortBy,\n                                sortOrder,\n                            };\n                        });\n                    },\n                    setAlbumArtistDetailGroupingType: (groupingType) => {\n                        set((state) => {\n                            state.albumArtistDetailSort.groupingType = groupingType;\n                        });\n                    },\n                    setAlbumArtistDetailSort: (sortBy, sortOrder) => {\n                        set((state) => {\n                            state.albumArtistDetailSort = {\n                                ...state.albumArtistDetailSort,\n                                sortBy,\n                                sortOrder,\n                            };\n                        });\n                    },\n                    setAlbumArtistIdsMode: (mode) => {\n                        set((state) => {\n                            state.albumArtistIdsMode = mode;\n                        });\n                    },\n                    setAlbumArtistSelectMode: (mode) => {\n                        set((state) => {\n                            state.albumArtistSelectMode = mode;\n                        });\n                    },\n                    setAppStore: (data) => {\n                        set({ ...get(), ...data });\n                    },\n                    setArtistIdsMode: (mode) => {\n                        set((state) => {\n                            state.artistIdsMode = mode;\n                        });\n                    },\n                    setArtistSelectMode: (mode) => {\n                        set((state) => {\n                            state.artistSelectMode = mode;\n                        });\n                    },\n                    setCommandPaletteSearchSectionExpanded: (sectionId, expanded) => {\n                        set((state) => {\n                            state.commandPaletteSearchSectionsExpanded[sectionId] = expanded;\n                        });\n                    },\n                    setGenreIdsMode: (mode) => {\n                        set((state) => {\n                            state.genreIdsMode = mode;\n                        });\n                    },\n                    setGenreSelectMode: (mode) => {\n                        set((state) => {\n                            state.genreSelectMode = mode;\n                        });\n                    },\n                    setGlobalExpanded: (value) => {\n                        set((state) => {\n                            state.globalExpanded = value;\n                        });\n                    },\n                    setPageSidebar: (key, value) => {\n                        set((state) => {\n                            state.pageSidebar[key] = value;\n                        });\n                    },\n                    setPrivateMode: (privateMode) => {\n                        set((state) => {\n                            state.privateMode = privateMode;\n                        });\n                    },\n                    setShowTimeRemaining: (showTimeRemaining) => {\n                        set((state) => {\n                            state.showTimeRemaining = showTimeRemaining;\n                        });\n                    },\n                    setSideBar: (options) => {\n                        set((state) => {\n                            state.sidebar = { ...state.sidebar, ...options };\n                        });\n                    },\n                    setTitleBar: (options) => {\n                        set((state) => {\n                            state.titlebar = { ...state.titlebar, ...options };\n                        });\n                    },\n                },\n                albumArtistDetailFavoriteSongsSort: {\n                    sortBy: SongListSort.ID,\n                    sortOrder: SortOrder.ASC,\n                },\n                albumArtistDetailSort: {\n                    groupingType: 'primary',\n                    sortBy: AlbumListSort.RELEASE_DATE,\n                    sortOrder: SortOrder.DESC,\n                },\n                albumArtistIdsMode: 'and',\n                albumArtistSelectMode: 'multi',\n                artistIdsMode: 'and',\n                artistSelectMode: 'multi',\n                commandPalette: {\n                    close: () => {\n                        set((state) => {\n                            state.commandPalette.opened = false;\n                        });\n                    },\n                    open: () => {\n                        set((state) => {\n                            state.commandPalette.opened = true;\n                        });\n                    },\n                    opened: false,\n                    toggle: () => {\n                        set((state) => {\n                            state.commandPalette.opened = !state.commandPalette.opened;\n                        });\n                    },\n                },\n                commandPaletteSearchSectionsExpanded: {},\n                genreIdsMode: 'and',\n                genreSelectMode: 'multi',\n                globalExpanded: null,\n                isReorderingQueue: false,\n                pageSidebar: {\n                    album: true,\n                    song: true,\n                },\n                platform: Platform.WINDOWS,\n                privateMode: false,\n                showTimeRemaining: false,\n                sidebar: {\n                    collapsed: false,\n                    expanded: [],\n                    image: false,\n                    leftWidth: '400px',\n                    rightExpanded: false,\n                    rightHeight: '320px',\n                    rightWidth: '600px',\n                },\n                titlebar: {\n                    backgroundColor: '#000000',\n                    outOfView: false,\n                },\n            })),\n            { name: 'store_app' },\n        ),\n        {\n            merge: (persistedState, currentState) => {\n                return merge(currentState, persistedState);\n            },\n            migrate: (persistedState, version) => {\n                if (version <= 2) {\n                    return {} as AppSlice;\n                }\n\n                const state = persistedState as AppSlice;\n                if (version <= 4 && !state.sidebar.rightHeight) {\n                    state.sidebar.rightHeight = '320px';\n                }\n\n                return state;\n            },\n            name: 'store_app',\n            partialize: (state) => {\n                // eslint-disable-next-line @typescript-eslint/no-unused-vars -- ignore non-persisted state\n                const { globalExpanded: _, ...rest } = state;\n                return rest;\n            },\n            version: 5,\n        },\n    ),\n);\n\nexport const useAppStoreActions = () => useAppStore((state) => state.actions);\n\nexport const useSidebarStore = () => useAppStore((state) => state.sidebar);\n\nexport const useSidebarRightExpanded = () => useAppStore((state) => state.sidebar.rightExpanded);\n\nexport const useSetTitlebar = () => useAppStore((state) => state.actions.setTitleBar);\n\nexport const useTitlebarStore = () => useAppStore((state) => state.titlebar);\n\nexport const useCommandPalette = () => useAppStore((state) => state.commandPalette);\n\nexport const usePageSidebar = (key: string): [boolean, (value: boolean) => void] => {\n    const isOpen = useAppStore((state) => state.pageSidebar[key] ?? false);\n    const setPageSidebar = useAppStore((state) => state.actions.setPageSidebar);\n\n    const setIsOpen = (value: boolean) => {\n        setPageSidebar(key, value);\n    };\n\n    return [isOpen, setIsOpen];\n};\n\nexport const useGlobalExpanded = () => useAppStore((state) => state.globalExpanded);\n\nexport const useSetGlobalExpanded = () => useAppStore((state) => state.actions.setGlobalExpanded);\n\nexport const useGlobalExpandedState = () => {\n    const globalExpanded = useGlobalExpanded();\n    const setGlobalExpanded = useSetGlobalExpanded();\n\n    const clearGlobalExpanded = () => setGlobalExpanded(null);\n\n    return { clearGlobalExpanded, globalExpanded, setGlobalExpanded };\n};\n"
  },
  {
    "path": "src/renderer/store/auth.store.ts",
    "content": "import merge from 'lodash/merge';\nimport { nanoid } from 'nanoid/non-secure';\nimport { devtools, persist } from 'zustand/middleware';\nimport { immer } from 'zustand/middleware/immer';\nimport { shallow } from 'zustand/shallow';\nimport { createWithEqualityFn } from 'zustand/traditional';\n\nimport { ServerListItem, ServerListItemWithCredential } from '/@/shared/types/domain-types';\n\nexport interface AuthSlice extends AuthState {\n    actions: {\n        addServer: (args: ServerListItemWithCredential) => void;\n        deleteServer: (id: string) => void;\n        getServer: (id: string) => null | ServerListItemWithCredential;\n        setCurrentServer: (server: null | ServerListItemWithCredential) => void;\n        setMusicFolderId: (musicFolderId: string[] | undefined) => void;\n        updateServer: (id: string, args: Partial<ServerListItemWithCredential>) => void;\n    };\n}\n\nexport interface AuthState {\n    currentServer: null | ServerListItemWithCredential;\n    deviceId: string;\n    serverList: Record<string, ServerListItemWithCredential>;\n}\n\nexport const useAuthStore = createWithEqualityFn<AuthSlice>()(\n    persist(\n        devtools(\n            immer((set, get) => ({\n                actions: {\n                    addServer: (args) => {\n                        set((state) => {\n                            state.serverList[args.id] = args;\n                        });\n                    },\n                    deleteServer: (id) => {\n                        set((state) => {\n                            delete state.serverList[id];\n\n                            if (state.currentServer?.id === id) {\n                                state.currentServer = null;\n                            }\n                        });\n                    },\n                    getServer: (id) => {\n                        const server = get().serverList[id];\n                        if (server) return server;\n                        return null;\n                    },\n                    setCurrentServer: (server) => {\n                        set((state) => {\n                            state.currentServer = server;\n                        });\n                    },\n                    setMusicFolderId: (musicFolderId: string[] | undefined) => {\n                        set((state) => {\n                            if (state.currentServer) {\n                                state.currentServer.musicFolderId = musicFolderId;\n                                const serverId = state.currentServer.id;\n                                if (state.serverList[serverId]) {\n                                    state.serverList[serverId].musicFolderId = musicFolderId;\n                                }\n                            }\n                        });\n                    },\n                    updateServer: (id: string, args: Partial<ServerListItemWithCredential>) => {\n                        set((state) => {\n                            const updatedServer = {\n                                ...state.serverList[id],\n                                ...args,\n                            };\n\n                            if (\n                                state.currentServer?.id === id &&\n                                !('musicFolderId' in args) &&\n                                state.currentServer.musicFolderId !== undefined\n                            ) {\n                                updatedServer.musicFolderId = state.currentServer.musicFolderId;\n                            }\n\n                            state.serverList[id] = updatedServer;\n                            if (state.currentServer?.id === id) {\n                                state.currentServer = updatedServer;\n                            }\n                        });\n                    },\n                },\n                currentServer: null,\n                deviceId: nanoid(),\n                serverList: {},\n            })),\n            { name: 'store_authentication' },\n        ),\n        {\n            merge: (persistedState, currentState) => merge(currentState, persistedState),\n            name: 'store_authentication',\n            version: 2,\n        },\n    ),\n);\n\nexport const useCurrentServerId = (): string =>\n    useAuthStore((state) => {\n        const currentServer = state.currentServer;\n\n        if (!currentServer) {\n            return '';\n        }\n\n        return currentServer.id;\n    }, shallow);\n\nexport const useCurrentServer = () =>\n    useAuthStore((state) => {\n        if (!state.currentServer) {\n            return null;\n        }\n\n        return {\n            features: state.currentServer?.features,\n            id: state.currentServer?.id,\n            isAdmin: state.currentServer?.isAdmin,\n            musicFolderId: state.currentServer?.musicFolderId,\n            name: state.currentServer?.name,\n            preferInstantMix: state.currentServer?.preferInstantMix,\n            preferRemoteUrl: state.currentServer?.preferRemoteUrl,\n            remoteUrl: state.currentServer?.remoteUrl,\n            savePassword: state.currentServer?.savePassword,\n            type: state.currentServer?.type,\n            url: state.currentServer?.url,\n            userId: state.currentServer?.userId,\n            username: state.currentServer?.username,\n            version: state.currentServer?.version,\n        };\n    }, shallow) as ServerListItem;\n\nexport const useIsAdmin = () =>\n    useAuthStore((state) => {\n        return {\n            isAdmin: state.currentServer?.isAdmin ?? false,\n            userId: state.currentServer?.userId,\n        };\n    }, shallow);\n\nexport const useCurrentServerWithCredential = () =>\n    useAuthStore((state) => state.currentServer) as ServerListItemWithCredential;\n\nexport const useServerList = () => useAuthStore((state) => state.serverList);\n\nexport const useAuthStoreActions = () => useAuthStore((state) => state.actions);\n\nexport const getServerById = (id?: string) => {\n    if (!id) {\n        return null;\n    }\n\n    return useAuthStore.getState().actions.getServer(id);\n};\n\nexport const usePermissions = () => {\n    const { isAdmin, userId } = useIsAdmin();\n\n    return {\n        playlists: {\n            editOwner: isAdmin,\n            editPublic: isAdmin,\n        },\n        radio: {\n            create: true,\n            delete: isAdmin,\n            edit: isAdmin,\n        },\n        userId: userId,\n    };\n};\n"
  },
  {
    "path": "src/renderer/store/env-settings-overrides.ts",
    "content": "import type { SettingsState } from './settings.store';\n\nimport { sanitizeCss } from '/@/renderer/utils/sanitize';\n\nconst APP_THEMES = new Set([\n    'ayuDark',\n    'ayuLight',\n    'catppuccinLatte',\n    'catppuccinMocha',\n    'defaultDark',\n    'defaultLight',\n    'dracula',\n    'githubDark',\n    'githubLight',\n    'glassyDark',\n    'gruvboxDark',\n    'gruvboxLight',\n    'highContrastDark',\n    'highContrastLight',\n    'materialDark',\n    'materialLight',\n    'monokai',\n    'nightOwl',\n    'nord',\n    'oneDark',\n    'rosePine',\n    'rosePineDawn',\n    'rosePineMoon',\n    'shadesOfPurple',\n    'solarizedDark',\n    'solarizedLight',\n    'tokyoNight',\n    'vscodeDarkPlus',\n    'vscodeLightPlus',\n]);\n\nconst DISCORD_DISPLAY_TYPES = new Set(['artist', 'feishin', 'song']);\nconst DISCORD_LINK_TYPES = new Set(['last_fm', 'musicbrainz', 'musicbrainz_last_fm', 'none']);\nconst LYRICS_ALIGNMENTS = new Set(['center', 'left', 'right']);\nconst FONT_TYPES = new Set(['builtIn', 'custom', 'system']);\nconst HOME_FEATURE_STYLES = new Set(['multiple', 'single']);\nconst SIDE_QUEUE_TYPES = new Set(['sideDrawerQueue', 'sideQueue']);\nconst SIDE_QUEUE_LAYOUTS = new Set(['horizontal', 'vertical']);\n\nexport type EnvSettingsOverrides = DeepPartial<\n    Pick<SettingsState, 'autoDJ' | 'css' | 'discord' | 'font' | 'general' | 'lyrics' | 'playback'>\n>;\n\ntype DeepPartial<T> = {\n    [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];\n};\n\ninterface EnvSettingSpec {\n    enumSet?: Set<string>;\n    key: string;\n    path: [string, string, string] | [string, string];\n    skipIfEmpty?: boolean;\n    transform?: (raw: string) => unknown;\n    type: 'bool' | 'enum' | 'num' | 'string';\n}\n\nfunction setAtPath(\n    obj: EnvSettingsOverrides,\n    path: [string, string, string] | [string, string],\n    value: unknown,\n): void {\n    const [a, b, c] = path;\n    const root = (obj as Record<string, unknown>)[a] ?? {};\n    (obj as Record<string, unknown>)[a] = root;\n    const branch = root as Record<string, unknown>;\n    if (c === undefined) {\n        branch[b] = value;\n    } else {\n        const nested = branch[b] ?? {};\n        branch[b] = nested;\n        (nested as Record<string, unknown>)[c] = value;\n    }\n}\n\nconst RGB_ACCENT_REGEX = /^rgb\\(\\s*(\\d{1,3})\\s*,\\s*(\\d{1,3})\\s*,\\s*(\\d{1,3})\\s*\\)$/;\n\nconst ENV_SETTING_SPECS: EnvSettingSpec[] = [\n    {\n        key: 'FS_GENERAL_ACCENT',\n        path: ['general', 'accent'],\n        transform: (s) => (RGB_ACCENT_REGEX.test(s) ? s : undefined),\n        type: 'string',\n    },\n    { key: 'FS_GENERAL_ALBUM_BACKGROUND', path: ['general', 'albumBackground'], type: 'bool' },\n    {\n        key: 'FS_GENERAL_ALBUM_BACKGROUND_BLUR',\n        path: ['general', 'albumBackgroundBlur'],\n        type: 'num',\n    },\n    { key: 'FS_GENERAL_ARTIST_BACKGROUND', path: ['general', 'artistBackground'], type: 'bool' },\n    {\n        key: 'FS_GENERAL_ARTIST_BACKGROUND_BLUR',\n        path: ['general', 'artistBackgroundBlur'],\n        type: 'num',\n    },\n    {\n        key: 'FS_GENERAL_BLUR_EXPLICIT_IMAGES',\n        path: ['general', 'blurExplicitImages'],\n        type: 'bool',\n    },\n    {\n        key: 'FS_GENERAL_COMBINED_LYRICS_AND_VISUALIZER',\n        path: ['general', 'combinedLyricsAndVisualizer'],\n        type: 'bool',\n    },\n    {\n        key: 'FS_GENERAL_ENABLE_GRID_MULTI_SELECT',\n        path: ['general', 'enableGridMultiSelect'],\n        type: 'bool',\n    },\n    { key: 'FS_GENERAL_FOLLOW_CURRENT_SONG', path: ['general', 'followCurrentSong'], type: 'bool' },\n    { key: 'FS_GENERAL_HOME_FEATURE', path: ['general', 'homeFeature'], type: 'bool' },\n    {\n        enumSet: HOME_FEATURE_STYLES,\n        key: 'FS_GENERAL_HOME_FEATURE_STYLE',\n        path: ['general', 'homeFeatureStyle'],\n        type: 'enum',\n    },\n    {\n        key: 'FS_GENERAL_LANGUAGE',\n        path: ['general', 'language'],\n        skipIfEmpty: true,\n        type: 'string',\n    },\n    {\n        key: 'FS_GENERAL_PRIMARY_SHADE',\n        path: ['general', 'primaryShade'],\n        transform: (s) => {\n            const n = parseNum(s);\n            return n !== undefined ? Math.min(9, Math.max(0, Math.round(n))) : undefined;\n        },\n        type: 'num',\n    },\n    { enumSet: APP_THEMES, key: 'FS_GENERAL_THEME', path: ['general', 'theme'], type: 'enum' },\n    {\n        enumSet: APP_THEMES,\n        key: 'FS_GENERAL_THEME_DARK',\n        path: ['general', 'themeDark'],\n        type: 'enum',\n    },\n    {\n        enumSet: APP_THEMES,\n        key: 'FS_GENERAL_THEME_LIGHT',\n        path: ['general', 'themeLight'],\n        type: 'enum',\n    },\n    { key: 'FS_GENERAL_FOLLOW_SYSTEM_THEME', path: ['general', 'followSystemTheme'], type: 'bool' },\n    { key: 'FS_GENERAL_PATH_REPLACE', path: ['general', 'pathReplace'], type: 'string' },\n    { key: 'FS_GENERAL_PATH_REPLACE_WITH', path: ['general', 'pathReplaceWith'], type: 'string' },\n    { key: 'FS_GENERAL_LASTFM_API_KEY', path: ['general', 'lastfmApiKey'], type: 'string' },\n    { key: 'FS_GENERAL_LAST_FM', path: ['general', 'lastFM'], type: 'bool' },\n    { key: 'FS_GENERAL_LISTEN_BRAINZ', path: ['general', 'listenBrainz'], type: 'bool' },\n    { key: 'FS_GENERAL_MUSIC_BRAINZ', path: ['general', 'musicBrainz'], type: 'bool' },\n    { key: 'FS_GENERAL_QOBUZ', path: ['general', 'qobuz'], type: 'bool' },\n    { key: 'FS_GENERAL_SPOTIFY', path: ['general', 'spotify'], type: 'bool' },\n    { key: 'FS_GENERAL_SPOTIFY_NATIVE_APP', path: ['general', 'nativeSpotify'], type: 'bool' },\n    { key: 'FS_GENERAL_NATIVE_ASPECT_RATIO', path: ['general', 'nativeAspectRatio'], type: 'bool' },\n    {\n        key: 'FS_GENERAL_PLAYERBAR_OPEN_DRAWER',\n        path: ['general', 'playerbarOpenDrawer'],\n        type: 'bool',\n    },\n    { key: 'FS_GENERAL_EXTERNAL_LINKS', path: ['general', 'externalLinks'], type: 'bool' },\n    {\n        key: 'FS_GENERAL_SHOW_LYRICS_IN_SIDEBAR',\n        path: ['general', 'showLyricsInSidebar'],\n        type: 'bool',\n    },\n    { key: 'FS_GENERAL_SHOW_RATINGS', path: ['general', 'showRatings'], type: 'bool' },\n    {\n        key: 'FS_GENERAL_SHOW_VISUALIZER_IN_SIDEBAR',\n        path: ['general', 'showVisualizerInSidebar'],\n        type: 'bool',\n    },\n    {\n        key: 'FS_GENERAL_SIDEBAR_COLLAPSED_NAVIGATION',\n        path: ['general', 'sidebarCollapsedNavigation'],\n        type: 'bool',\n    },\n    {\n        key: 'FS_GENERAL_SIDEBAR_COLLAPSE_SHARED',\n        path: ['general', 'sidebarCollapseShared'],\n        type: 'bool',\n    },\n    {\n        key: 'FS_GENERAL_SIDEBAR_PLAYLIST_LIST',\n        path: ['general', 'sidebarPlaylistList'],\n        type: 'bool',\n    },\n    {\n        key: 'FS_GENERAL_SIDEBAR_PLAYLIST_SORTING',\n        path: ['general', 'sidebarPlaylistSorting'],\n        type: 'bool',\n    },\n    {\n        enumSet: SIDE_QUEUE_TYPES,\n        key: 'FS_GENERAL_SIDE_QUEUE_TYPE',\n        path: ['general', 'sideQueueType'],\n        type: 'enum',\n    },\n    {\n        enumSet: SIDE_QUEUE_LAYOUTS,\n        key: 'FS_GENERAL_SIDE_QUEUE_LAYOUT',\n        path: ['general', 'sideQueueLayout'],\n        type: 'enum',\n    },\n    { key: 'FS_GENERAL_RESUME', path: ['general', 'resume'], type: 'bool' },\n    {\n        key: 'FS_GENERAL_USE_THEME_ACCENT_COLOR',\n        path: ['general', 'useThemeAccentColor'],\n        type: 'bool',\n    },\n    {\n        key: 'FS_GENERAL_USE_THEME_PRIMARY_SHADE',\n        path: ['general', 'useThemePrimaryShade'],\n        type: 'bool',\n    },\n    { key: 'FS_GENERAL_ZOOM_FACTOR', path: ['general', 'zoomFactor'], type: 'num' },\n    { key: 'FS_PLAYBACK_MEDIA_SESSION', path: ['playback', 'mediaSession'], type: 'bool' },\n    { key: 'FS_PLAYBACK_WEB_AUDIO', path: ['playback', 'webAudio'], type: 'bool' },\n    {\n        key: 'FS_PLAYBACK_AUDIO_FADE_ON_STATUS_CHANGE',\n        path: ['playback', 'audioFadeOnStatusChange'],\n        type: 'bool',\n    },\n    { key: 'FS_PLAYBACK_PRESERVE_PITCH', path: ['playback', 'preservePitch'], type: 'bool' },\n    {\n        key: 'FS_PLAYBACK_SCROBBLE_ENABLED',\n        path: ['playback', 'scrobble', 'enabled'],\n        type: 'bool',\n    },\n    { key: 'FS_PLAYBACK_SCROBBLE_NOTIFY', path: ['playback', 'scrobble', 'notify'], type: 'bool' },\n    {\n        key: 'FS_PLAYBACK_SCROBBLE_AT_DURATION',\n        path: ['playback', 'scrobble', 'scrobbleAtDuration'],\n        type: 'num',\n    },\n    {\n        key: 'FS_PLAYBACK_SCROBBLE_AT_PERCENTAGE',\n        path: ['playback', 'scrobble', 'scrobbleAtPercentage'],\n        type: 'num',\n    },\n    {\n        key: 'FS_PLAYBACK_TRANSCODE_ENABLED',\n        path: ['playback', 'transcode', 'enabled'],\n        type: 'bool',\n    },\n    { key: 'FS_DISCORD_ENABLED', path: ['discord', 'enabled'], type: 'bool' },\n    {\n        key: 'FS_DISCORD_CLIENT_ID',\n        path: ['discord', 'clientId'],\n        skipIfEmpty: true,\n        type: 'string',\n    },\n    {\n        enumSet: DISCORD_DISPLAY_TYPES,\n        key: 'FS_DISCORD_DISPLAY_TYPE',\n        path: ['discord', 'displayType'],\n        type: 'enum',\n    },\n    {\n        enumSet: DISCORD_LINK_TYPES,\n        key: 'FS_DISCORD_LINK_TYPE',\n        path: ['discord', 'linkType'],\n        type: 'enum',\n    },\n    { key: 'FS_DISCORD_SHOW_AS_LISTENING', path: ['discord', 'showAsListening'], type: 'bool' },\n    { key: 'FS_DISCORD_SHOW_PAUSED', path: ['discord', 'showPaused'], type: 'bool' },\n    { key: 'FS_DISCORD_SHOW_SERVER_IMAGE', path: ['discord', 'showServerImage'], type: 'bool' },\n    { key: 'FS_DISCORD_SHOW_STATE_ICON', path: ['discord', 'showStateIcon'], type: 'bool' },\n    { key: 'FS_LYRICS_FETCH', path: ['lyrics', 'fetch'], type: 'bool' },\n    { key: 'FS_LYRICS_FOLLOW', path: ['lyrics', 'follow'], type: 'bool' },\n    { key: 'FS_LYRICS_DELAY_MS', path: ['lyrics', 'delayMs'], type: 'num' },\n    { key: 'FS_LYRICS_PREFER_LOCAL', path: ['lyrics', 'preferLocalLyrics'], type: 'bool' },\n    { key: 'FS_LYRICS_SHOW_MATCH', path: ['lyrics', 'showMatch'], type: 'bool' },\n    { key: 'FS_LYRICS_SHOW_PROVIDER', path: ['lyrics', 'showProvider'], type: 'bool' },\n    {\n        key: 'FS_LYRICS_ENABLE_AUTO_TRANSLATION',\n        path: ['lyrics', 'enableAutoTranslation'],\n        type: 'bool',\n    },\n    { key: 'FS_LYRICS_TRANSLATION_API_KEY', path: ['lyrics', 'translationApiKey'], type: 'string' },\n    {\n        key: 'FS_LYRICS_TRANSLATION_TARGET_LANGUAGE',\n        path: ['lyrics', 'translationTargetLanguage'],\n        skipIfEmpty: true,\n        type: 'string',\n    },\n    {\n        enumSet: LYRICS_ALIGNMENTS,\n        key: 'FS_LYRICS_ALIGNMENT',\n        path: ['lyrics', 'alignment'],\n        type: 'enum',\n    },\n    { key: 'FS_AUTO_DJ_ENABLED', path: ['autoDJ', 'enabled'], type: 'bool' },\n    { key: 'FS_AUTO_DJ_ITEM_COUNT', path: ['autoDJ', 'itemCount'], type: 'num' },\n    { key: 'FS_AUTO_DJ_TIMING', path: ['autoDJ', 'timing'], type: 'num' },\n    {\n        key: 'FS_CSS_CONTENT',\n        path: ['css', 'content'],\n        transform: (s) => (s.trim() === '' ? undefined : sanitizeCss(`<style>${s}`)),\n        type: 'string',\n    },\n    { key: 'FS_CSS_ENABLED', path: ['css', 'enabled'], type: 'bool' },\n    { enumSet: FONT_TYPES, key: 'FS_FONT_TYPE', path: ['font', 'type'], type: 'enum' },\n    { key: 'FS_FONT_BUILT_IN', path: ['font', 'builtIn'], skipIfEmpty: true, type: 'string' },\n    {\n        key: 'FS_FONT_SYSTEM',\n        path: ['font', 'system'],\n        transform: (s) => (s === '' ? null : s),\n        type: 'string',\n    },\n];\n\nexport function getEnvSettingsOverrides(): EnvSettingsOverrides {\n    const w = getWin();\n    const get = (key: string): string | undefined => {\n        const v = w[key];\n        if (typeof v !== 'string') return undefined;\n        if (isUnsubstitutedPlaceholder(v)) return undefined;\n        return v;\n    };\n\n    const overrides: EnvSettingsOverrides = {};\n\n    for (const spec of ENV_SETTING_SPECS) {\n        const raw = get(spec.key);\n        const value = parseValue(raw, spec);\n        if (value !== undefined) {\n            setAtPath(overrides, spec.path, value);\n        }\n    }\n\n    return overrides;\n}\n\nfunction getWin(): Record<string, unknown> & Window {\n    if (typeof window === 'undefined') return {} as Record<string, unknown> & Window;\n    return window as unknown as Record<string, unknown> & Window;\n}\n\nfunction isUnsubstitutedPlaceholder(s: string): boolean {\n    return s.length > 0 && s.startsWith('${FS_') && s.endsWith('}');\n}\n\nfunction parseBool(s: string | undefined): boolean | undefined {\n    if (s === undefined || s === '') return undefined;\n    const lower = s.toLowerCase();\n    if (lower === 'true' || lower === '1') return true;\n    if (lower === 'false' || lower === '0') return false;\n    return undefined;\n}\n\nfunction parseEnum<T extends string>(s: string | undefined, allowed: Set<string>): T | undefined {\n    if (s === undefined || s === '') return undefined;\n    const v = s.trim();\n    return allowed.has(v) ? (v as T) : undefined;\n}\n\nfunction parseNum(s: string | undefined): number | undefined {\n    if (s === undefined || s === '') return undefined;\n    const n = Number(s);\n    return Number.isFinite(n) ? n : undefined;\n}\n\nfunction parseValue(raw: string | undefined, spec: EnvSettingSpec): unknown {\n    if (raw === undefined) return undefined;\n    if (spec.transform) return spec.transform(raw);\n    switch (spec.type) {\n        case 'bool':\n            return parseBool(raw);\n        case 'enum':\n            return spec.enumSet ? parseEnum(raw, spec.enumSet) : undefined;\n        case 'num':\n            return parseNum(raw);\n        case 'string':\n            if (spec.skipIfEmpty && raw === '') return undefined;\n            return raw;\n        default:\n            return undefined;\n    }\n}\n"
  },
  {
    "path": "src/renderer/store/full-screen-player.store.ts",
    "content": "import merge from 'lodash/merge';\nimport { devtools, persist } from 'zustand/middleware';\nimport { immer } from 'zustand/middleware/immer';\nimport { createWithEqualityFn } from 'zustand/traditional';\n\nexport interface FullScreenPlayerSlice extends FullScreenPlayerState {\n    actions: {\n        setStore: (data: Partial<FullScreenPlayerSlice>) => void;\n    };\n}\n\ninterface FullScreenPlayerState {\n    activeTab: 'lyrics' | 'queue' | 'related' | string;\n    dynamicBackground?: boolean;\n    dynamicImageBlur: number;\n    dynamicIsImage?: boolean;\n    expanded: boolean;\n    opacity: number;\n    useImageAspectRatio: boolean;\n    visualizerExpanded: boolean;\n}\n\nexport const useFullScreenPlayerStore = createWithEqualityFn<FullScreenPlayerSlice>()(\n    persist(\n        devtools(\n            immer((set, get) => ({\n                actions: {\n                    setStore: (data) => {\n                        set({ ...get(), ...data });\n                    },\n                },\n                activeTab: 'queue',\n                dynamicBackground: true,\n                dynamicImageBlur: 1.5,\n                dynamicIsImage: false,\n                expanded: false,\n                opacity: 60,\n                useImageAspectRatio: false,\n                visualizerExpanded: false,\n            })),\n            { name: 'store_full_screen_player' },\n        ),\n        {\n            merge: (persistedState, currentState) => {\n                return merge(currentState, persistedState);\n            },\n            migrate: (persistedState, version) => {\n                if (version <= 2) {\n                    return {} as FullScreenPlayerState;\n                }\n\n                return persistedState;\n            },\n            name: 'store_full_screen_player',\n            version: 3,\n        },\n    ),\n);\n\nexport const useFullScreenPlayerStoreActions = () =>\n    useFullScreenPlayerStore((state) => state.actions);\n\nexport const useSetFullScreenPlayerStore = () =>\n    useFullScreenPlayerStore((state) => state.actions.setStore);\n"
  },
  {
    "path": "src/renderer/store/index.ts",
    "content": "export * from './app.store';\nexport * from './auth.store';\nexport * from './full-screen-player.store';\nexport * from './player.store';\nexport * from './scroll.store';\nexport * from './settings.store';\nexport * from './timestamp.store';\n"
  },
  {
    "path": "src/renderer/store/player.store.ts",
    "content": "import merge from 'lodash/merge';\nimport { nanoid } from 'nanoid';\nimport { createJSONStorage, persist, subscribeWithSelector } from 'zustand/middleware';\nimport { immer } from 'zustand/middleware/immer';\nimport { useShallow } from 'zustand/react/shallow';\nimport { createWithEqualityFn } from 'zustand/traditional';\n\nimport { eventEmitter } from '/@/renderer/events/event-emitter';\nimport { createSelectors } from '/@/renderer/lib/zustand';\nimport { useSettingsStore } from '/@/renderer/store/settings.store';\nimport {\n    setTimestamp as setTimestampStore,\n    useTimestampStoreBase,\n} from '/@/renderer/store/timestamp.store';\nimport { idbStateStorage } from '/@/renderer/store/utils';\nimport { shuffleInPlace } from '/@/renderer/utils/shuffle';\nimport { PlayerData, QueueData, QueueSong, Song } from '/@/shared/types/domain-types';\nimport {\n    CrossfadeStyle,\n    Play,\n    PlayerRepeat,\n    PlayerShuffle,\n    PlayerStatus,\n    PlayerStyle,\n} from '/@/shared/types/types';\n\nexport interface PlayerState extends Actions, State {}\n\nexport type QueueGroupingProperty = keyof QueueSong;\n\ninterface Actions {\n    addToQueueByType: (items: Song[], playType: Play, playSongId?: string) => void;\n    addToQueueByUniqueId: (\n        items: Song[],\n        uniqueId: string,\n        edge: 'bottom' | 'top',\n        playSongId?: string,\n    ) => void;\n    clearQueue: () => void;\n    clearSelected: (items: QueueSong[]) => void;\n    decreaseVolume: (value: number) => void;\n    getCurrentSong: () => QueueSong | undefined;\n    getPlayerData: () => PlayerData;\n    getQueue: (groupBy?: QueueGroupingProperty) => GroupedQueue;\n    getQueueOrder: () => {\n        groups: { count: number; name: string }[];\n        items: QueueSong[];\n    };\n    increaseVolume: (value: number) => void;\n    isFirstTrackInQueue: () => boolean;\n    isLastTrackInQueue: () => boolean;\n    mediaAutoNext: () => PlayerData;\n    mediaNext: () => void;\n    mediaPause: () => void;\n    mediaPlay: (id?: string) => void;\n    mediaPlayByIndex: (index: number) => void;\n    mediaPrevious: () => void;\n    mediaSeekToTimestamp: (timestamp: number) => void;\n    mediaSkipBackward: (offset?: number) => void;\n    mediaSkipForward: (offset?: number) => void;\n    mediaStop: () => void;\n    mediaToggleMute: () => void;\n    mediaTogglePlayPause: () => void;\n    moveSelectedTo: (items: QueueSong[], uniqueId: string, edge: 'bottom' | 'top') => void;\n    moveSelectedToBottom: (items: QueueSong[]) => void;\n    moveSelectedToNext: (items: QueueSong[]) => void;\n    moveSelectedToTop: (items: QueueSong[]) => void;\n    setCrossfadeDuration: (duration: number) => void;\n    setCrossfadeStyle: (style: CrossfadeStyle) => void;\n    setPauseOnNextSongEnd: (value: boolean) => void;\n    setQueue: (data: Song[], index?: number, position?: number) => void;\n    setRepeat: (repeat: PlayerRepeat) => void;\n    setShuffle: (shuffle: PlayerShuffle) => void;\n    setSpeed: (speed: number) => void;\n    setTransitionType: (transitionType: PlayerStyle) => void;\n    setVolume: (volume: number) => void;\n    shuffle: () => void;\n    shuffleAll: () => void;\n    shuffleSelected: (items: QueueSong[]) => void;\n    toggleRepeat: () => void;\n    toggleShuffle: () => void;\n}\n\ninterface GroupedQueue {\n    groups: { count: number; name: string }[];\n    items: QueueSong[];\n}\n\ninterface State {\n    player: {\n        crossfadeDuration: number;\n        crossfadeStyle: CrossfadeStyle;\n        index: number;\n        muted: boolean;\n        pauseOnNextSongEnd: boolean;\n        playerNum: 1 | 2;\n        repeat: PlayerRepeat;\n        seekToTimestamp: string;\n        shuffle: PlayerShuffle;\n        speed: number;\n        status: PlayerStatus;\n        transitionType: PlayerStyle;\n        volume: number;\n    };\n    queue: QueueData;\n}\n\n// Calculates the next song based on repeat mode and current position\nexport function calculateNextSong(\n    currentIndex: number,\n    queueItems: QueueSong[],\n    repeat: PlayerRepeat,\n): QueueSong | undefined {\n    if (queueItems.length === 0) {\n        return undefined;\n    }\n\n    if (repeat === PlayerRepeat.ONE) {\n        // When repeating one, next song is the same as current\n        return queueItems[currentIndex];\n    } else if (repeat === PlayerRepeat.ALL) {\n        // When repeating all, next song wraps to first if at the end\n        const isLastTrack = currentIndex === queueItems.length - 1;\n        if (isLastTrack) {\n            return queueItems[0];\n        } else {\n            return queueItems[currentIndex + 1];\n        }\n    } else {\n        // When repeat is none, next song is undefined if at the end\n        return queueItems[currentIndex + 1];\n    }\n}\n\n// Helper function to check if shuffle is enabled\nexport function isShuffleEnabled(state: {\n    player: { shuffle: PlayerShuffle };\n    queue: { shuffled: number[] };\n}): boolean {\n    return state.player.shuffle === PlayerShuffle.TRACK && state.queue.shuffled.length > 0;\n}\n\n// Helper function to map shuffled position to actual queue position\nexport function mapShuffledToQueueIndex(shuffledIndex: number, shuffled: number[]): number {\n    if (shuffledIndex >= 0 && shuffledIndex < shuffled.length) {\n        return shuffled[shuffledIndex];\n    }\n    return shuffledIndex;\n}\n\n// Helper function to add new indexes to shuffled array after current position\nfunction addIndexesToShuffled(\n    shuffled: number[],\n    currentShuffledIndex: number,\n    newIndexes: number[],\n): number[] {\n    // Keep everything before and including current position\n    const beforeCurrent = shuffled.slice(0, currentShuffledIndex + 1);\n    // Shuffle everything after current position plus new indexes\n    const afterCurrent = shuffled.slice(currentShuffledIndex + 1);\n    const toShuffle = [...afterCurrent, ...newIndexes];\n    return [...beforeCurrent, ...shuffleInPlace(toShuffle)];\n}\n\n// Helper function to adjust shuffled indexes when items are inserted\nfunction adjustShuffledIndexesForInsertion(\n    shuffled: number[],\n    insertPosition: number,\n    insertCount: number,\n): number[] {\n    return shuffled.map((idx) => {\n        if (idx >= insertPosition) {\n            return idx + insertCount;\n        }\n        return idx;\n    });\n}\n\n// Calculates the next index based on repeat mode and current position\nfunction calculateNextIndex(\n    currentIndex: number,\n    queueLength: number,\n    repeat: PlayerRepeat,\n): { nextIndex: number; shouldPause: boolean } {\n    const isLastTrack = currentIndex === queueLength - 1;\n\n    if (repeat === PlayerRepeat.ONE) {\n        // Repeat one: stay on the same track\n        return { nextIndex: currentIndex, shouldPause: false };\n    } else if (repeat === PlayerRepeat.ALL) {\n        // Repeat all: loop to first track if at the end\n        if (isLastTrack) {\n            return { nextIndex: 0, shouldPause: false };\n        } else {\n            return { nextIndex: currentIndex + 1, shouldPause: false };\n        }\n    } else {\n        // Repeat none: move to next track, or pause if at the end\n        if (isLastTrack) {\n            return { nextIndex: 0, shouldPause: true };\n        } else {\n            return { nextIndex: currentIndex + 1, shouldPause: false };\n        }\n    }\n}\n\nfunction emitPlayerPlayEvent(\n    targetSongUniqueId: string | undefined,\n    set: (fn: (state: PlayerState) => void) => void,\n    get: () => PlayerState,\n): void {\n    // If playSongId is provided, find the song and start playback on it\n    if (targetSongUniqueId) {\n        let playIndex: number | undefined;\n        set((state) => {\n            const queue = state.getQueue();\n            const queueIndex = queue.items.findIndex(\n                (item) => item._uniqueId === targetSongUniqueId,\n            );\n\n            if (queueIndex !== -1) {\n                if (\n                    state.player.shuffle === PlayerShuffle.TRACK &&\n                    state.queue.shuffled.length > 0\n                ) {\n                    // Find the shuffled position for this queue index\n                    const shuffledPosition = state.queue.shuffled.findIndex(\n                        (idx) => idx === queueIndex,\n                    );\n                    if (shuffledPosition !== -1) {\n                        state.player.index = shuffledPosition;\n                        playIndex = shuffledPosition;\n                    } else {\n                        state.player.index = queueIndex;\n                        playIndex = queueIndex;\n                    }\n                } else {\n                    state.player.index = queueIndex;\n                    playIndex = queueIndex;\n                }\n                state.player.status = PlayerStatus.PLAYING;\n                setTimestampStore(0);\n            }\n        });\n\n        // Emit PLAYER_PLAY event if playback was started\n        if (playIndex !== undefined) {\n            eventEmitter.emit('PLAYER_PLAY', {\n                id: targetSongUniqueId,\n                index: playIndex,\n            });\n        }\n    } else {\n        // Otherwise, emit PLAYER_PLAY event for current song if available\n        const currentState = get();\n        const queue = currentState.getQueue();\n        const currentIndex = currentState.player.index;\n        const currentSong = queue.items[currentIndex];\n\n        if (currentSong && currentIndex !== undefined && currentIndex >= 0) {\n            eventEmitter.emit('PLAYER_PLAY', {\n                id: currentSong._uniqueId,\n                index: currentIndex,\n            });\n        }\n    }\n}\n\n// Helper function to find shuffled position for a given queue index\nfunction findShuffledPositionForQueueIndex(\n    queueIndex: number,\n    shuffled: number[],\n): number | undefined {\n    const shuffledPosition = shuffled.findIndex((idx) => idx === queueIndex);\n    return shuffledPosition !== -1 ? shuffledPosition : undefined;\n}\n\n// Helper function to generate shuffled indexes for a queue of given length\nfunction generateShuffledIndexes(length: number): number[] {\n    const indexes = Array.from({ length }, (_, i) => i);\n    return shuffleInPlace(indexes);\n}\n\n// Helper function to regenerate shuffled indexes if shuffle is enabled\nfunction regenerateShuffledIndexesIfNeeded(state: {\n    player: { shuffle: PlayerShuffle };\n    queue: { default: string[]; shuffled: number[] };\n}): void {\n    if (isShuffleEnabled(state)) {\n        state.queue.shuffled = generateShuffledIndexes(state.queue.default.length);\n    }\n}\n\nconst initialState: State = {\n    player: {\n        crossfadeDuration: 5,\n        crossfadeStyle: CrossfadeStyle.EQUAL_POWER,\n        index: -1,\n        muted: false,\n        pauseOnNextSongEnd: false,\n        playerNum: 1,\n        repeat: PlayerRepeat.NONE,\n        seekToTimestamp: uniqueSeekToTimestamp(0),\n        shuffle: PlayerShuffle.NONE,\n        speed: 1,\n        status: PlayerStatus.PAUSED,\n        transitionType: PlayerStyle.GAPLESS,\n        volume: 30,\n    },\n    queue: {\n        default: [],\n        shuffled: [],\n        songs: {},\n    },\n};\n\nexport const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(\n    persist(\n        subscribeWithSelector(\n            immer((set, get) => ({\n                addToQueueByType: (items, playType, playSongId) => {\n                    const newItems = items.map(toQueueSong);\n                    const newUniqueIds = newItems.map((item) => item._uniqueId);\n\n                    // Find the target song's uniqueId if playSongId is provided\n                    const targetSongUniqueId = playSongId\n                        ? newItems.find((item) => item.id === playSongId)?._uniqueId\n                        : undefined;\n\n                    switch (playType) {\n                        case Play.LAST: {\n                            set((state) => {\n                                newItems.forEach((item) => {\n                                    state.queue.songs[item._uniqueId] = item;\n                                });\n\n                                const oldQueueLength = state.queue.default.length;\n                                state.queue.default = [...state.queue.default, ...newUniqueIds];\n\n                                if (isShuffleEnabled(state)) {\n                                    // New items will be at indexes starting from oldQueueLength\n                                    const newIndexes = Array.from(\n                                        { length: newUniqueIds.length },\n                                        (_, i) => oldQueueLength + i,\n                                    );\n                                    // Shuffle the new indexes and add to the end of shuffled array\n                                    const shuffledNewIndexes = shuffleInPlace([...newIndexes]);\n                                    state.queue.shuffled = [\n                                        ...state.queue.shuffled,\n                                        ...shuffledNewIndexes,\n                                    ];\n                                }\n                            });\n                            break;\n                        }\n                        case Play.LAST_SHUFFLE: {\n                            set((state) => {\n                                newItems.forEach((item) => {\n                                    state.queue.songs[item._uniqueId] = item;\n                                });\n\n                                // Shuffle the new items before appending\n                                const shuffledIds = shuffleInPlace([...newUniqueIds]);\n\n                                const oldQueueLength = state.queue.default.length;\n                                state.queue.default = [...state.queue.default, ...shuffledIds];\n\n                                if (state.player.shuffle === PlayerShuffle.TRACK) {\n                                    // New items will be at indexes starting from oldQueueLength\n                                    const newIndexes = Array.from(\n                                        { length: shuffledIds.length },\n                                        (_, i) => oldQueueLength + i,\n                                    );\n                                    // Shuffle the new indexes and add to the end of shuffled array\n                                    const shuffledNewIndexes = shuffleInPlace([...newIndexes]);\n                                    state.queue.shuffled = [\n                                        ...state.queue.shuffled,\n                                        ...shuffledNewIndexes,\n                                    ];\n                                }\n                            });\n                            break;\n                        }\n                        case Play.NEXT: {\n                            set((state) => {\n                                const currentShuffledIndex = state.player.index;\n                                newItems.forEach((item) => {\n                                    state.queue.songs[item._uniqueId] = item;\n                                });\n\n                                const insertPosition =\n                                    state.player.shuffle === PlayerShuffle.TRACK\n                                        ? state.queue.shuffled[currentShuffledIndex] + 1\n                                        : currentShuffledIndex + 1;\n\n                                state.queue.default = [\n                                    ...state.queue.default.slice(0, insertPosition),\n                                    ...newUniqueIds,\n                                    ...state.queue.default.slice(insertPosition),\n                                ];\n\n                                if (isShuffleEnabled(state)) {\n                                    // Adjust existing indexes that are >= insertPosition\n                                    const adjustedShuffled = adjustShuffledIndexesForInsertion(\n                                        state.queue.shuffled,\n                                        insertPosition,\n                                        newUniqueIds.length,\n                                    );\n\n                                    // New items will be at indexes starting from insertPosition\n                                    const newIndexes = Array.from(\n                                        { length: newUniqueIds.length },\n                                        (_, i) => insertPosition + i,\n                                    );\n\n                                    // Shuffle the new indexes and add directly after current shuffled index\n                                    const shuffledNewIndexes = shuffleInPlace([...newIndexes]);\n                                    state.queue.shuffled = [\n                                        ...adjustedShuffled.slice(0, currentShuffledIndex + 1),\n                                        ...shuffledNewIndexes,\n                                        ...adjustedShuffled.slice(currentShuffledIndex + 1),\n                                    ];\n                                }\n                            });\n                            break;\n                        }\n                        case Play.NEXT_SHUFFLE: {\n                            set((state) => {\n                                const currentShuffledIndex = state.player.index;\n                                newItems.forEach((item) => {\n                                    state.queue.songs[item._uniqueId] = item;\n                                });\n\n                                // Shuffle the new items before inserting\n                                const shuffledIds = shuffleInPlace([...newUniqueIds]);\n\n                                const insertPosition = isShuffleEnabled(state)\n                                    ? state.queue.shuffled[currentShuffledIndex] + 1\n                                    : currentShuffledIndex + 1;\n\n                                state.queue.default = [\n                                    ...state.queue.default.slice(0, insertPosition),\n                                    ...shuffledIds,\n                                    ...state.queue.default.slice(insertPosition),\n                                ];\n\n                                if (isShuffleEnabled(state)) {\n                                    // Adjust existing indexes that are >= insertPosition\n                                    const adjustedShuffled = adjustShuffledIndexesForInsertion(\n                                        state.queue.shuffled,\n                                        insertPosition,\n                                        shuffledIds.length,\n                                    );\n\n                                    // New items will be at indexes starting from insertPosition\n                                    const newIndexes = Array.from(\n                                        { length: shuffledIds.length },\n                                        (_, i) => insertPosition + i,\n                                    );\n\n                                    // Shuffle the new indexes and add directly after current shuffled index\n                                    const shuffledNewIndexes = shuffleInPlace([...newIndexes]);\n                                    state.queue.shuffled = [\n                                        ...adjustedShuffled.slice(0, currentShuffledIndex + 1),\n                                        ...shuffledNewIndexes,\n                                        ...adjustedShuffled.slice(currentShuffledIndex + 1),\n                                    ];\n                                }\n                            });\n                            break;\n                        }\n                        case Play.NOW: {\n                            set((state) => {\n                                newItems.forEach((item) => {\n                                    state.queue.songs[item._uniqueId] = item;\n                                });\n\n                                state.queue.default = [];\n                                state.player.index = 0;\n                                state.player.status = PlayerStatus.PLAYING;\n                                state.player.playerNum = 1;\n                                setTimestampStore(0);\n                                state.queue.default = newUniqueIds;\n\n                                if (state.player.shuffle === PlayerShuffle.TRACK) {\n                                    // If targetSongUniqueId is provided, ensure it's at position 0 in shuffled array\n                                    if (targetSongUniqueId) {\n                                        const initialIndex = newUniqueIds.findIndex(\n                                            (id) => id === targetSongUniqueId,\n                                        );\n                                        if (initialIndex !== -1) {\n                                            const allIndexes = Array.from(\n                                                { length: newUniqueIds.length },\n                                                (_, i) => i,\n                                            );\n\n                                            const remainingIndexes = allIndexes.filter(\n                                                (idx) => idx !== initialIndex,\n                                            );\n\n                                            const shuffledRemaining = shuffleInPlace([\n                                                ...remainingIndexes,\n                                            ]);\n\n                                            state.queue.shuffled = [\n                                                initialIndex,\n                                                ...shuffledRemaining,\n                                            ];\n                                        } else {\n                                            // Fallback: if initial song not found, generate normally\n                                            state.queue.shuffled = generateShuffledIndexes(\n                                                newUniqueIds.length,\n                                            );\n                                        }\n                                    } else {\n                                        state.queue.shuffled = generateShuffledIndexes(\n                                            newUniqueIds.length,\n                                        );\n                                    }\n                                }\n                            });\n\n                            emitPlayerPlayEvent(targetSongUniqueId, set, get);\n                            break;\n                        }\n                        case Play.SHUFFLE: {\n                            set((state) => {\n                                newItems.forEach((item) => {\n                                    state.queue.songs[item._uniqueId] = item;\n                                });\n\n                                // Shuffle the new items before adding to queue\n                                const shuffledIds = shuffleInPlace([...newUniqueIds]);\n\n                                state.queue.default = [];\n                                state.player.index = 0;\n                                state.player.status = PlayerStatus.PLAYING;\n                                state.player.playerNum = 1;\n                                setTimestampStore(0);\n                                state.queue.default = shuffledIds;\n\n                                // Always maintain shuffled array when using Play.SHUFFLE\n                                state.queue.shuffled = generateShuffledIndexes(shuffledIds.length);\n                            });\n\n                            emitPlayerPlayEvent(targetSongUniqueId, set, get);\n                            break;\n                        }\n                    }\n                },\n                addToQueueByUniqueId: (items, uniqueId, edge, playSongId) => {\n                    const newItems = items.map(toQueueSong);\n                    const newUniqueIds = newItems.map((item) => item._uniqueId);\n\n                    // Find the target song's uniqueId if playSongId is provided\n                    const targetSongUniqueId = playSongId\n                        ? newItems.find((item) => item.id === playSongId)?._uniqueId\n                        : undefined;\n\n                    set((state) => {\n                        // Add new songs to songs object\n                        newItems.forEach((item) => {\n                            state.queue.songs[item._uniqueId] = item;\n                        });\n\n                        const index = state.queue.default.findIndex((id) => id === uniqueId);\n\n                        const insertIndex = Math.max(0, edge === 'top' ? index : index + 1);\n\n                        const newQueue = [\n                            ...state.queue.default.slice(0, insertIndex),\n                            ...newUniqueIds,\n                            ...state.queue.default.slice(insertIndex),\n                        ];\n\n                        state.queue.default = newQueue;\n\n                        if (state.player.shuffle === PlayerShuffle.TRACK) {\n                            const currentTrack = state.getCurrentSong() as QueueSong | undefined;\n                            const currentTrackUniqueId = currentTrack?._uniqueId;\n\n                            if (currentTrackUniqueId) {\n                                // Adjust existing shuffled indexes that are >= insertIndex\n                                const adjustedShuffled = state.queue.shuffled.map((idx) => {\n                                    if (idx >= insertIndex) {\n                                        return idx + newUniqueIds.length;\n                                    }\n                                    return idx;\n                                });\n\n                                // New items will be at indexes starting from insertIndex\n                                const newIndexes = Array.from(\n                                    { length: newUniqueIds.length },\n                                    (_, i) => insertIndex + i,\n                                );\n\n                                const currentShuffledIndex = state.player.index;\n                                state.queue.shuffled = addIndexesToShuffled(\n                                    adjustedShuffled,\n                                    currentShuffledIndex,\n                                    newIndexes,\n                                );\n\n                                // Recalculate player index to the shuffled position\n                                const queueIndex = newQueue.findIndex(\n                                    (id) => id === currentTrackUniqueId,\n                                );\n                                if (queueIndex !== -1) {\n                                    const shuffledPosition = state.queue.shuffled.findIndex(\n                                        (idx) => idx === queueIndex,\n                                    );\n                                    if (shuffledPosition !== -1) {\n                                        state.player.index = shuffledPosition;\n                                    }\n                                }\n                            } else {\n                                // No current track, regenerate shuffled indexes\n                                state.queue.shuffled = generateShuffledIndexes(newQueue.length);\n                            }\n                        } else {\n                            // Recalculate the player index if we're inserting items above the current index\n                            if (insertIndex <= state.player.index) {\n                                state.player.index = state.player.index + newUniqueIds.length;\n                            }\n\n                            recalculatePlayerIndex(state, newQueue);\n                        }\n                    });\n\n                    // If playSongId is provided, find the song and start playback on it\n                    if (targetSongUniqueId) {\n                        let playIndex: number | undefined;\n                        set((state) => {\n                            const queue = state.getQueue();\n                            const queueIndex = queue.items.findIndex(\n                                (item) => item._uniqueId === targetSongUniqueId,\n                            );\n\n                            if (queueIndex !== -1) {\n                                if (\n                                    state.player.shuffle === PlayerShuffle.TRACK &&\n                                    state.queue.shuffled.length > 0\n                                ) {\n                                    // Find the shuffled position for this queue index\n                                    const shuffledPosition = state.queue.shuffled.findIndex(\n                                        (idx) => idx === queueIndex,\n                                    );\n                                    if (shuffledPosition !== -1) {\n                                        state.player.index = shuffledPosition;\n                                        playIndex = shuffledPosition;\n                                    } else {\n                                        state.player.index = queueIndex;\n                                        playIndex = queueIndex;\n                                    }\n                                } else {\n                                    state.player.index = queueIndex;\n                                    playIndex = queueIndex;\n                                }\n                                state.player.status = PlayerStatus.PLAYING;\n                                setTimestampStore(0);\n                            }\n                        });\n\n                        // Emit PLAYER_PLAY event if playback was started\n                        if (playIndex !== undefined) {\n                            eventEmitter.emit('PLAYER_PLAY', {\n                                id: targetSongUniqueId,\n                                index: playIndex,\n                            });\n                        }\n                    }\n                },\n                clearQueue: () => {\n                    set((state) => {\n                        state.player.index = -1;\n                        state.queue.default = [];\n                        state.queue.shuffled = [];\n                        state.queue.songs = {};\n                    });\n                },\n                clearSelected: (items: QueueSong[]) => {\n                    set((state) => {\n                        const uniqueIds = new Set(items.map((item) => item._uniqueId));\n\n                        const indexesToRemove = new Set<number>();\n\n                        state.queue.default.forEach((id, index) => {\n                            if (uniqueIds.has(id)) {\n                                indexesToRemove.add(index);\n                            }\n                        });\n\n                        state.queue.default = state.queue.default.filter(\n                            (id) => !uniqueIds.has(id),\n                        );\n\n                        if (isShuffleEnabled(state)) {\n                            // Remove indexes from shuffled array and adjust remaining indexes\n                            const newShuffled = state.queue.shuffled\n                                .filter((idx) => !indexesToRemove.has(idx))\n                                .map((idx) => {\n                                    // Count how many removed indexes are before this index\n                                    let adjustment = 0;\n                                    for (const removedIdx of indexesToRemove) {\n                                        if (removedIdx < idx) {\n                                            adjustment++;\n                                        }\n                                    }\n                                    return idx - adjustment;\n                                });\n                            state.queue.shuffled = newShuffled;\n                        } else {\n                            state.queue.shuffled = [];\n                        }\n\n                        cleanupOrphanedSongs(state);\n\n                        recalculatePlayerIndex(state, state.queue.default);\n                    });\n                },\n                decreaseVolume: (value: number) => {\n                    set((state) => {\n                        state.player.volume = Math.max(0, state.player.volume - value);\n                    });\n                },\n                getCurrentSong: () => {\n                    const state = get();\n                    const queue = state.getQueue();\n                    let index = state.player.index;\n\n                    // If shuffle is enabled, map shuffled position to actual queue position\n                    if (isShuffleEnabled(state)) {\n                        index = mapShuffledToQueueIndex(index, state.queue.shuffled);\n                    }\n\n                    return queue.items[index];\n                },\n                getPlayerData: () => {\n                    const state = get();\n                    const queue = state.getQueue();\n                    const index = state.player.index;\n\n                    // If shuffle is enabled, map shuffled position to actual queue position for display\n                    let queueIndex = index;\n                    if (isShuffleEnabled(state)) {\n                        queueIndex = mapShuffledToQueueIndex(index, state.queue.shuffled);\n                    }\n\n                    const currentSong = queue.items[queueIndex];\n                    const repeat = state.player.repeat;\n\n                    // For previousSong calculation, we need to consider the shuffled order\n                    let previousSong: QueueSong | undefined;\n                    if (isShuffleEnabled(state)) {\n                        // Calculate previous in shuffled order\n                        const previousShuffledIndex = index - 1;\n                        if (previousShuffledIndex >= 0) {\n                            const previousQueueIndex = state.queue.shuffled[previousShuffledIndex];\n                            previousSong = queue.items[previousQueueIndex];\n                        } else if (repeat === PlayerRepeat.ALL) {\n                            // Wrap to last in shuffled order\n                            const lastShuffledIndex = state.queue.shuffled.length - 1;\n                            const lastQueueIndex = state.queue.shuffled[lastShuffledIndex];\n                            previousSong = queue.items[lastQueueIndex];\n                        }\n                    } else {\n                        previousSong = queueIndex > 0 ? queue.items[queueIndex - 1] : undefined;\n                    }\n\n                    // For nextSong calculation, we need to consider the shuffled order\n                    let nextSong: QueueSong | undefined;\n                    if (isShuffleEnabled(state)) {\n                        // Calculate next in shuffled order\n                        const nextShuffledIndex = index + 1;\n                        if (nextShuffledIndex < state.queue.shuffled.length) {\n                            const nextQueueIndex = state.queue.shuffled[nextShuffledIndex];\n                            nextSong = queue.items[nextQueueIndex];\n                        } else if (repeat === PlayerRepeat.ALL) {\n                            // Wrap to first in shuffled order\n                            const firstQueueIndex = state.queue.shuffled[0];\n                            nextSong = queue.items[firstQueueIndex];\n                        }\n                    } else {\n                        nextSong = calculateNextSong(queueIndex, queue.items, repeat);\n                    }\n\n                    return {\n                        currentSong,\n                        index: queueIndex, // Return the actual queue position for display\n                        nextSong,\n                        num: state.player.playerNum,\n                        player1: state.player.playerNum === 1 ? currentSong : nextSong,\n                        player2: state.player.playerNum === 2 ? currentSong : nextSong,\n                        previousSong,\n                        queueLength: state.queue.default.length,\n                        status: state.player.status,\n                    };\n                },\n                getQueue: (groupBy?: QueueGroupingProperty) => {\n                    const queue = get().getQueueOrder();\n\n                    if (!groupBy) {\n                        return queue;\n                    }\n\n                    // Track groups in order of appearance\n                    const groups: { count: number; name: string }[] = [];\n                    const seenGroups = new Set<string>();\n\n                    // Process items and build groups in order\n                    queue.items.forEach((item) => {\n                        const groupValue = String(item[groupBy] || 'Unknown');\n\n                        if (!seenGroups.has(groupValue)) {\n                            seenGroups.add(groupValue);\n                            groups.push({ count: 1, name: groupValue });\n                        } else {\n                            // Find the last occurrence of this group value\n                            const lastIndex = [...groups]\n                                .reverse()\n                                .findIndex((g) => g.name === groupValue);\n                            if (lastIndex === -1) return;\n\n                            // If the previous group is different, create a new group\n                            const previousGroup = groups[groups.length - 1];\n                            if (previousGroup.name !== groupValue) {\n                                groups.push({ count: 1, name: groupValue });\n                            } else {\n                                // Increment the count of the last matching group\n                                groups[groups.length - 1].count++;\n                            }\n                        }\n                    });\n\n                    return { groups, items: queue.items };\n                },\n                getQueueOrder: () => {\n                    const state = get();\n                    const songs = state.queue.songs;\n                    const defaultIds = state.queue.default;\n                    const defaultQueue: QueueSong[] = [];\n\n                    for (const id of defaultIds) {\n                        const song = songs[id];\n                        if (song) defaultQueue.push(song);\n                    }\n\n                    // Always return original order (shuffle only affects playback, not display)\n                    return {\n                        groups: [{ count: defaultQueue.length, name: 'All' }],\n                        items: defaultQueue,\n                    };\n                },\n                increaseVolume: (value: number) => {\n                    set((state) => {\n                        state.player.volume = Math.min(100, state.player.volume + value);\n                    });\n                },\n                isFirstTrackInQueue: () => {\n                    const state = get();\n                    const currentIndex = state.player.index;\n                    return currentIndex === 0;\n                },\n                isLastTrackInQueue: () => {\n                    const state = get();\n                    const queue = state.getQueueOrder();\n                    const currentIndex = state.player.index;\n                    return currentIndex === queue.items.length - 1;\n                },\n                mediaAutoNext: () => {\n                    const stateSnapshot = get();\n                    const currentIndex = stateSnapshot.player.index;\n                    const player = stateSnapshot.player;\n                    const repeat = player.repeat;\n                    const queue = stateSnapshot.getQueueOrder();\n                    const isShuffle = isShuffleEnabled(stateSnapshot);\n\n                    const playbackLength = isShuffle\n                        ? stateSnapshot.queue.shuffled.length\n                        : queue.items.length;\n\n                    const newPlayerNum = player.playerNum === 1 ? 2 : 1;\n                    const { nextIndex: nextPlaybackIndex, shouldPause } = calculateNextIndex(\n                        currentIndex,\n                        playbackLength,\n                        repeat,\n                    );\n                    const pauseOnNext = player.pauseOnNextSongEnd;\n                    const newStatus =\n                        shouldPause || pauseOnNext ? PlayerStatus.PAUSED : PlayerStatus.PLAYING;\n\n                    set((state) => {\n                        state.player.index = nextPlaybackIndex;\n                        state.player.playerNum = newPlayerNum;\n                        setTimestampStore(0);\n                        state.player.status = newStatus;\n\n                        if (pauseOnNext) {\n                            state.player.pauseOnNextSongEnd = false;\n                        }\n                    });\n\n                    if (repeat === PlayerRepeat.ONE && nextPlaybackIndex === currentIndex) {\n                        eventEmitter.emit('PLAYER_REPEATED', {\n                            index: nextPlaybackIndex,\n                        });\n                    }\n\n                    // Compute current/next/previous using the same shuffle-aware mapping as getPlayerData().\n                    let currentQueueIndex = nextPlaybackIndex;\n                    if (isShuffle) {\n                        currentQueueIndex = mapShuffledToQueueIndex(\n                            nextPlaybackIndex,\n                            stateSnapshot.queue.shuffled,\n                        );\n                    }\n\n                    const currentSong = queue.items[currentQueueIndex];\n\n                    let nextSong: QueueSong | undefined;\n                    if (isShuffle) {\n                        const nextShuffledIndex = nextPlaybackIndex + 1;\n                        if (nextShuffledIndex < stateSnapshot.queue.shuffled.length) {\n                            const nextQueueIndex = stateSnapshot.queue.shuffled[nextShuffledIndex];\n                            nextSong = queue.items[nextQueueIndex];\n                        } else if (repeat === PlayerRepeat.ALL) {\n                            const firstQueueIndex = stateSnapshot.queue.shuffled[0];\n                            nextSong = queue.items[firstQueueIndex];\n                        } else if (repeat === PlayerRepeat.ONE) {\n                            nextSong = currentSong;\n                        }\n                    } else {\n                        nextSong = calculateNextSong(currentQueueIndex, queue.items, repeat);\n                    }\n\n                    let previousSong: QueueSong | undefined;\n                    if (isShuffle) {\n                        const prevShuffledIndex = nextPlaybackIndex - 1;\n                        if (prevShuffledIndex >= 0) {\n                            const prevQueueIndex = stateSnapshot.queue.shuffled[prevShuffledIndex];\n                            previousSong = queue.items[prevQueueIndex];\n                        } else if (repeat === PlayerRepeat.ALL) {\n                            const lastShuffledIndex = stateSnapshot.queue.shuffled.length - 1;\n                            const lastQueueIndex = stateSnapshot.queue.shuffled[lastShuffledIndex];\n                            previousSong = queue.items[lastQueueIndex];\n                        }\n                    } else {\n                        previousSong =\n                            currentQueueIndex > 0 ? queue.items[currentQueueIndex - 1] : undefined;\n                    }\n\n                    return {\n                        currentSong,\n                        index: currentQueueIndex,\n                        nextSong,\n                        num: newPlayerNum,\n                        player1: newPlayerNum === 1 ? currentSong : nextSong,\n                        player2: newPlayerNum === 2 ? currentSong : nextSong,\n                        previousSong,\n                        queueLength: queue.items.length,\n                        status: newStatus,\n                    };\n                },\n                mediaNext: () => {\n                    const state = get();\n                    const currentIndex = state.player.index;\n                    const player = state.player;\n                    const queue = state.getQueueOrder();\n                    const isLastTrack = currentIndex === queue.items.length - 1;\n\n                    let nextIndex: number;\n\n                    if (player.repeat === PlayerRepeat.ALL && isLastTrack) {\n                        // Repeat all: wrap to first track when on last track\n                        nextIndex = 0;\n                    } else if (player.repeat === PlayerRepeat.NONE && isLastTrack) {\n                        // Repeat none: stay on last track if already there\n                        nextIndex = currentIndex;\n                    } else {\n                        // Otherwise, advance to next track (including repeat ONE for manual navigation)\n                        // When shuffle is enabled, currentIndex is already the position in the shuffled array\n                        nextIndex = Math.min(queue.items.length - 1, currentIndex + 1);\n                    }\n\n                    set((state) => {\n                        state.player.index = nextIndex;\n                        state.player.playerNum = 1;\n                        setTimestampStore(0);\n                    });\n\n                    eventEmitter.emit('MEDIA_NEXT', {\n                        currentIndex,\n                        nextIndex,\n                    });\n                },\n                mediaPause: () => {\n                    set((state) => {\n                        state.player.status = PlayerStatus.PAUSED;\n                    });\n                },\n                mediaPlay: (id?: string) => {\n                    let playIndex: number | undefined;\n\n                    set((state) => {\n                        if (id) {\n                            const queue = state.getQueue();\n\n                            // Find the song in the original queue\n                            const queueIndex = queue.items.findIndex(\n                                (item) => item._uniqueId === id,\n                            );\n\n                            if (queueIndex !== -1) {\n                                if (\n                                    state.player.shuffle === PlayerShuffle.TRACK &&\n                                    state.queue.shuffled.length > 0\n                                ) {\n                                    // Find the shuffled position for this queue index\n                                    const shuffledPosition = state.queue.shuffled.findIndex(\n                                        (idx) => idx === queueIndex,\n                                    );\n                                    if (shuffledPosition !== -1) {\n                                        state.player.index = shuffledPosition;\n                                        playIndex = shuffledPosition;\n                                    } else {\n                                        state.player.index = queueIndex;\n                                        playIndex = queueIndex;\n                                    }\n                                } else {\n                                    state.player.index = queueIndex;\n                                    playIndex = queueIndex;\n                                }\n                                setTimestampStore(0);\n                            }\n                        }\n\n                        state.player.status = PlayerStatus.PLAYING;\n                    });\n\n                    if (id && playIndex !== undefined) {\n                        eventEmitter.emit('PLAYER_PLAY', {\n                            id,\n                            index: playIndex,\n                        });\n                    }\n                },\n                mediaPlayByIndex: (index: number) => {\n                    let playIndex: number | undefined;\n                    let songId: string | undefined;\n\n                    set((state) => {\n                        const queue = state.getQueue();\n\n                        if (index === -1 || index >= queue.items.length) {\n                            state.player.status = PlayerStatus.PAUSED;\n                            return;\n                        }\n\n                        // Get the song's unique ID from the queue\n                        const song = queue.items[index];\n                        if (song) {\n                            songId = song._uniqueId;\n                        }\n\n                        // index is the position in the original queue\n                        if (isShuffleEnabled(state)) {\n                            // Find the shuffled position for this queue index\n                            const shuffledPosition = findShuffledPositionForQueueIndex(\n                                index,\n                                state.queue.shuffled,\n                            );\n                            playIndex = shuffledPosition !== undefined ? shuffledPosition : index;\n                            state.player.index = playIndex;\n                        } else {\n                            playIndex = index;\n                            state.player.index = index;\n                        }\n                        setTimestampStore(0);\n\n                        state.player.status = PlayerStatus.PLAYING;\n                    });\n\n                    if (songId && playIndex !== undefined) {\n                        eventEmitter.emit('PLAYER_PLAY', {\n                            id: songId,\n                            index: playIndex,\n                        });\n                    }\n                },\n                mediaPrevious: () => {\n                    const currentIndex = get().player.index;\n                    const player = get().player;\n                    const queue = get().getQueueOrder();\n                    const currentTimestamp = useTimestampStoreBase.getState().timestamp;\n                    const isFirstTrack = currentIndex === 0;\n\n                    // If timestamp is greater than 10 seconds, restart current song\n                    if (currentTimestamp > 10) {\n                        set((state) => {\n                            state.player.seekToTimestamp = uniqueSeekToTimestamp(0);\n                        });\n                        return;\n                    }\n\n                    let previousIndex: number;\n\n                    if (player.repeat === PlayerRepeat.ALL && isFirstTrack) {\n                        // Repeat all: wrap to last track when on first track\n                        previousIndex = queue.items.length - 1;\n                    } else if (player.repeat === PlayerRepeat.NONE && isFirstTrack) {\n                        // Repeat none: stay on first track if already there\n                        previousIndex = currentIndex;\n                    } else {\n                        // Otherwise, go to previous track\n                        previousIndex = Math.max(0, currentIndex - 1);\n                    }\n\n                    set((state) => {\n                        state.player.index = previousIndex;\n                        state.player.playerNum = 1;\n                        setTimestampStore(0);\n                    });\n\n                    eventEmitter.emit('MEDIA_PREV', {\n                        currentIndex,\n                        prevIndex: previousIndex,\n                    });\n                },\n                mediaSeekToTimestamp: (timestamp: number) => {\n                    set((state) => {\n                        state.player.seekToTimestamp = uniqueSeekToTimestamp(timestamp);\n                    });\n                },\n                mediaSkipBackward: (offset?: number) => {\n                    const offsetFromSettings =\n                        useSettingsStore.getState().general.skipButtons.skipBackwardSeconds;\n                    const timeToSkip = offset ?? offsetFromSettings ?? 5;\n                    const currentTimestamp = useTimestampStoreBase.getState().timestamp;\n                    const newTimestamp = Math.max(0, currentTimestamp - timeToSkip);\n\n                    set((state) => {\n                        state.player.seekToTimestamp = uniqueSeekToTimestamp(newTimestamp);\n                    });\n                },\n                mediaSkipForward: (offset?: number) => {\n                    const state = get();\n                    const queue = state.getQueue();\n                    const index = state.player.index;\n                    const currentTrack = queue.items[index];\n                    const duration = currentTrack?.duration;\n                    const offsetFromSettings =\n                        useSettingsStore.getState().general.skipButtons.skipForwardSeconds;\n                    const timeToSkip = offset ?? offsetFromSettings ?? 5;\n\n                    if (!duration) {\n                        return;\n                    }\n\n                    const currentTimestamp = useTimestampStoreBase.getState().timestamp;\n                    const newTimestamp = Math.min(duration - 1, currentTimestamp + timeToSkip);\n\n                    set((state) => {\n                        state.player.seekToTimestamp = uniqueSeekToTimestamp(newTimestamp);\n                    });\n                },\n                mediaStop: () => {\n                    set((state) => {\n                        state.player.status = PlayerStatus.PAUSED;\n                        state.player.seekToTimestamp = uniqueSeekToTimestamp(0);\n                        setTimestampStore(0);\n                    });\n                },\n                mediaToggleMute: () => {\n                    set((state) => {\n                        state.player.muted = !state.player.muted;\n                    });\n                },\n                mediaTogglePlayPause: () => {\n                    set((state) => {\n                        if (state.player.status === PlayerStatus.PLAYING) {\n                            state.player.status = PlayerStatus.PAUSED;\n                        } else {\n                            state.player.status = PlayerStatus.PLAYING;\n                        }\n                    });\n                },\n                moveSelectedTo: (items: QueueSong[], uniqueId: string, edge: 'bottom' | 'top') => {\n                    const itemUniqueIds = items.map((item) => item._uniqueId);\n\n                    set((state) => {\n                        const existingIds = new Set(Object.keys(state.queue.songs));\n\n                        // Add new songs to songs object (avoiding duplicates)\n                        items.forEach((item) => {\n                            if (!existingIds.has(item._uniqueId)) {\n                                state.queue.songs[item._uniqueId] = item;\n                            }\n                        });\n\n                        // Find the index of the drop target\n                        const index = state.queue.default.findIndex((id) => id === uniqueId);\n\n                        // Get the new index based on the edge\n                        const insertIndex = Math.max(0, edge === 'top' ? index : index + 1);\n\n                        const idsBefore = state.queue.default\n                            .slice(0, insertIndex)\n                            .filter((id) => !itemUniqueIds.includes(id));\n\n                        const idsAfter = state.queue.default\n                            .slice(insertIndex)\n                            .filter((id) => !itemUniqueIds.includes(id));\n\n                        const newQueue = [...idsBefore, ...itemUniqueIds, ...idsAfter];\n\n                        recalculatePlayerIndex(state, newQueue);\n                        state.queue.default = newQueue;\n                    });\n                },\n                moveSelectedToBottom: (items: QueueSong[]) => {\n                    set((state) => {\n                        const uniqueIds = items.map((item) => item._uniqueId);\n\n                        // Add new songs to songs object\n                        items.forEach((item) => {\n                            state.queue.songs[item._uniqueId] = item;\n                        });\n\n                        const filtered = state.queue.default.filter(\n                            (id) => !uniqueIds.includes(id),\n                        );\n\n                        const newQueue = [...filtered, ...uniqueIds];\n\n                        recalculatePlayerIndex(state, newQueue);\n\n                        state.queue.default = newQueue;\n                    });\n                },\n                moveSelectedToNext: (items: QueueSong[]) => {\n                    set((state) => {\n                        const uniqueIds = items.map((item) => item._uniqueId);\n\n                        // Add new songs to songs object\n                        items.forEach((item) => {\n                            state.queue.songs[item._uniqueId] = item;\n                        });\n\n                        const currentIndex = state.player.index;\n                        let beforeCurrent = 0;\n                        const filtered = state.queue.default.filter((id, idx) => {\n                            const shouldMove = uniqueIds.includes(id);\n                            if (shouldMove && idx < currentIndex) {\n                                beforeCurrent++;\n                            }\n\n                            return !shouldMove;\n                        });\n\n                        // For every item that is before the current item, subtract one as\n                        // these items will shift the queue up\n                        const insertIndex = currentIndex + 1 - beforeCurrent;\n\n                        const newQueue = [\n                            ...filtered.slice(0, insertIndex),\n                            ...uniqueIds,\n                            ...filtered.slice(insertIndex),\n                        ];\n\n                        recalculatePlayerIndex(state, newQueue);\n                        state.queue.default = newQueue;\n                    });\n                },\n                moveSelectedToTop: (items: QueueSong[]) => {\n                    set((state) => {\n                        const uniqueIds = items.map((item) => item._uniqueId);\n\n                        // Add new songs to songs object\n                        items.forEach((item) => {\n                            state.queue.songs[item._uniqueId] = item;\n                        });\n\n                        const filtered = state.queue.default.filter(\n                            (id) => !uniqueIds.includes(id),\n                        );\n\n                        const newQueue = [...uniqueIds, ...filtered];\n\n                        recalculatePlayerIndex(state, newQueue);\n\n                        state.queue.default = newQueue;\n                    });\n                },\n                setQueue: (items, index, position) => {\n                    const newItems = items.map(toQueueSong);\n                    const newUniqueIds = newItems.map((item) => item._uniqueId);\n\n                    set((state) => {\n                        newItems.forEach((item) => {\n                            state.queue.songs[item._uniqueId] = item;\n                        });\n\n                        state.player.index = index ?? 0;\n                        state.player.status = PlayerStatus.PLAYING;\n                        state.player.playerNum = 1;\n                        state.queue.default = newUniqueIds;\n                    });\n\n                    eventEmitter.emit('QUEUE_RESTORED', {\n                        data: items,\n                        index: index ?? 0,\n                        position: position ?? 0,\n                    });\n                },\n                ...initialState,\n                setCrossfadeDuration: (duration: number) => {\n                    set((state) => {\n                        const normalizedDuration = Math.max(0, Math.min(10, duration));\n                        state.player.crossfadeDuration = normalizedDuration;\n                    });\n                },\n                setCrossfadeStyle: (style: CrossfadeStyle) => {\n                    set((state) => {\n                        state.player.crossfadeStyle = style;\n                    });\n                },\n                setPauseOnNextSongEnd: (value: boolean) => {\n                    set((state) => {\n                        state.player.pauseOnNextSongEnd = value;\n                    });\n                },\n                setRepeat: (repeat: PlayerRepeat) => {\n                    set((state) => {\n                        state.player.repeat = repeat;\n                    });\n                },\n                setShuffle: (shuffle: PlayerShuffle) => {\n                    set((state) => {\n                        const wasShuffled = state.player.shuffle === PlayerShuffle.TRACK;\n                        const willBeShuffled = shuffle === PlayerShuffle.TRACK;\n                        const currentIndex = state.player.index;\n\n                        state.player.shuffle = shuffle;\n\n                        if (willBeShuffled) {\n                            state.queue.shuffled = generateShuffledIndexes(\n                                state.queue.default.length,\n                            );\n\n                            // Convert current index to shuffled position if there's a current song\n                            if (currentIndex >= 0 && currentIndex < state.queue.default.length) {\n                                // Find the shuffled position that corresponds to the current queue position\n                                const shuffledPosition = findShuffledPositionForQueueIndex(\n                                    currentIndex,\n                                    state.queue.shuffled,\n                                );\n                                if (shuffledPosition !== undefined) {\n                                    state.player.index = shuffledPosition;\n                                }\n                            }\n                        } else {\n                            // When disabling shuffle, convert shuffled position back to queue position\n                            if (\n                                wasShuffled &&\n                                currentIndex >= 0 &&\n                                currentIndex < state.queue.shuffled.length\n                            ) {\n                                const queuePosition = state.queue.shuffled[currentIndex];\n                                if (queuePosition !== undefined) {\n                                    state.player.index = queuePosition;\n                                }\n                            }\n                            state.queue.shuffled = [];\n                        }\n                        cleanupOrphanedSongs(state);\n                    });\n                },\n                setSpeed: (speed: number) => {\n                    set((state) => {\n                        const normalizedSpeed = Math.max(0.5, Math.min(2, speed));\n                        state.player.speed = normalizedSpeed;\n                    });\n                },\n                setTransitionType: (transitionType: PlayerStyle) => {\n                    set((state) => {\n                        state.player.transitionType = transitionType;\n                    });\n                },\n                setVolume: (volume: number) => {\n                    set((state) => {\n                        state.player.volume = volume;\n                    });\n                },\n                shuffle: () => {\n                    set((state) => {\n                        if (state.player.shuffle === PlayerShuffle.TRACK) {\n                            state.queue.shuffled = generateShuffledIndexes(\n                                state.queue.default.length,\n                            );\n                        }\n                    });\n                },\n                shuffleAll: () => {\n                    set((state) => {\n                        const queue = state.getQueue();\n                        const currentIndex = state.player.index;\n                        const currentSong = queue.items[currentIndex];\n\n                        // If there's a current song playing, keep it in place\n                        if (currentSong && currentIndex >= 0 && currentIndex < queue.items.length) {\n                            const currentUniqueId = currentSong._uniqueId;\n                            const currentQueueIndex = state.queue.default.findIndex(\n                                (id) => id === currentUniqueId,\n                            );\n\n                            if (currentQueueIndex !== -1) {\n                                const beforeItems = state.queue.default.slice(0, currentQueueIndex);\n                                const afterItems = state.queue.default.slice(currentQueueIndex + 1);\n\n                                const shuffledBefore = shuffleInPlace([...beforeItems]);\n                                const shuffledAfter = shuffleInPlace([...afterItems]);\n\n                                state.queue.default = [\n                                    ...shuffledBefore,\n                                    currentUniqueId,\n                                    ...shuffledAfter,\n                                ];\n                            } else {\n                                // Current song not in default queue, just shuffle everything\n                                state.queue.default = shuffleInPlace([...state.queue.default]);\n                            }\n                        } else {\n                            // No current song, shuffle everything\n                            state.queue.default = shuffleInPlace([...state.queue.default]);\n                        }\n\n                        // Regenerate shuffled indexes if shuffle is enabled\n                        regenerateShuffledIndexesIfNeeded(state);\n                    });\n                },\n                shuffleSelected: (items: QueueSong[]) => {\n                    set((state) => {\n                        const itemUniqueIds = items.map((item) => item._uniqueId);\n\n                        // Find positions of selected items in the default queue\n                        const selectedPositions = itemUniqueIds\n                            .map((id) => state.queue.default.findIndex((i) => i === id))\n                            .filter((idx) => idx !== -1)\n                            .sort((a, b) => a - b); // Sort to maintain order\n\n                        if (selectedPositions.length === 0) {\n                            return;\n                        }\n\n                        // Get the selected items in their current order\n                        const selectedItems = selectedPositions.map(\n                            (pos) => state.queue.default[pos],\n                        );\n\n                        // Shuffle the selected items\n                        const shuffledItems = shuffleInPlace([...selectedItems]);\n\n                        // Rebuild the default queue with shuffled selected items\n                        const newDefaultQueue = [...state.queue.default];\n                        selectedPositions.forEach((pos, i) => {\n                            newDefaultQueue[pos] = shuffledItems[i];\n                        });\n\n                        state.queue.default = newDefaultQueue;\n\n                        // Regenerate shuffled indexes if shuffle is enabled\n                        regenerateShuffledIndexesIfNeeded(state);\n                    });\n                },\n                toggleRepeat: () => {\n                    set((state) => {\n                        if (state.player.repeat === PlayerRepeat.NONE) {\n                            state.player.repeat = PlayerRepeat.ONE;\n                        } else if (state.player.repeat === PlayerRepeat.ONE) {\n                            state.player.repeat = PlayerRepeat.ALL;\n                        } else {\n                            state.player.repeat = PlayerRepeat.NONE;\n                        }\n                    });\n                },\n                toggleShuffle: () => {\n                    set((state) => {\n                        const wasShuffled = state.player.shuffle === PlayerShuffle.TRACK;\n                        const willBeShuffled = state.player.shuffle !== PlayerShuffle.TRACK;\n                        const currentIndex = state.player.index;\n\n                        state.player.shuffle =\n                            state.player.shuffle === PlayerShuffle.NONE\n                                ? PlayerShuffle.TRACK\n                                : PlayerShuffle.NONE;\n\n                        if (willBeShuffled) {\n                            // Enabling shuffle: create shuffled indexes with current track as first\n                            const combinedLength = state.queue.default.length;\n\n                            if (\n                                combinedLength > 0 &&\n                                currentIndex >= 0 &&\n                                currentIndex < combinedLength\n                            ) {\n                                // Get the current queue position (actual index in combined queue)\n                                const currentQueuePosition = currentIndex;\n\n                                // Create shuffled indexes with current track first\n                                const remainingIndexes = Array.from(\n                                    { length: combinedLength },\n                                    (_, i) => i,\n                                ).filter((idx) => idx !== currentQueuePosition);\n                                const shuffledRemaining = shuffleInPlace([...remainingIndexes]);\n\n                                state.queue.shuffled = [currentQueuePosition, ...shuffledRemaining];\n\n                                // Set player index to 0 since current track is now first in shuffled array\n                                state.player.index = 0;\n                            } else {\n                                // No current track, just generate shuffled indexes normally\n                                state.queue.shuffled = generateShuffledIndexes(combinedLength);\n                            }\n                        } else {\n                            // Disabling shuffle: clear shuffled indexes and convert index back\n                            if (\n                                wasShuffled &&\n                                currentIndex >= 0 &&\n                                currentIndex < state.queue.shuffled.length\n                            ) {\n                                const queuePosition = state.queue.shuffled[currentIndex];\n                                if (queuePosition !== undefined) {\n                                    state.player.index = queuePosition;\n                                }\n                            }\n                            state.queue.shuffled = [];\n                        }\n                    });\n                },\n            })),\n        ),\n        {\n            merge: (persistedState: any, currentState: any) => {\n                return merge(currentState, persistedState);\n            },\n            migrate: (persistedState, version) => {\n                if (version <= 3) {\n                    return {} as PlayerState;\n                }\n\n                return persistedState;\n            },\n            name: 'player-store',\n            partialize: (state) => {\n                const shouldRestorePlayQueue = useSettingsStore.getState().general.resume;\n\n                // Exclude playerNum, seekToTimestamp, and status from stored player object\n                // These are not needed to be stored since they are ephemeral properties\n                // Note: timestamp is now in a separate store and doesn't need to be excluded here\n                const excludedPlayerKeys = ['playerNum', 'seekToTimestamp', 'status'];\n\n                // If we're not restoring the play queue, we don't need the index property\n                if (!shouldRestorePlayQueue) {\n                    excludedPlayerKeys.push('index');\n                }\n\n                // Filter top-level state entries\n                const filteredStateEntries = Object.entries(state).filter(([key]) => {\n                    // Exclude queue if shouldRestorePlayQueue is false\n                    if (!shouldRestorePlayQueue && key === 'queue') {\n                        return false;\n                    }\n                    return true;\n                });\n\n                const filteredState = Object.fromEntries(\n                    filteredStateEntries,\n                ) as Partial<PlayerState>;\n\n                // Filter player object\n                if (filteredState.player) {\n                    filteredState.player = Object.fromEntries(\n                        Object.entries(filteredState.player).filter(\n                            ([key]) => !excludedPlayerKeys.includes(key),\n                        ),\n                    ) as typeof filteredState.player;\n                }\n\n                if (filteredState.queue) {\n                    const allQueueIds = new Set([\n                        ...(filteredState.queue.default || []),\n                        // shuffled now contains indexes, not uniqueIds, so we don't include it here\n                    ]);\n\n                    const songs = filteredState.queue.songs || {};\n                    const cleanedSongs: Record<string, QueueSong> = {};\n\n                    for (const [id, song] of Object.entries(songs)) {\n                        if (allQueueIds.has(id)) {\n                            cleanedSongs[id] = song;\n                        }\n                    }\n\n                    filteredState.queue = {\n                        ...filteredState.queue,\n                        songs: cleanedSongs,\n                    };\n                }\n\n                return filteredState;\n            },\n            storage: createJSONStorage(() => idbStateStorage),\n            version: 3,\n        },\n    ),\n);\n\nexport const usePlayerStore = createSelectors(usePlayerStoreBase);\n\nexport const usePlayerActions = () => {\n    const actions = usePlayerStoreBase(\n        useShallow((state) => ({\n            addToQueueByType: state.addToQueueByType,\n            addToQueueByUniqueId: state.addToQueueByUniqueId,\n            clearQueue: state.clearQueue,\n            clearSelected: state.clearSelected,\n            decreaseVolume: state.decreaseVolume,\n            getQueue: state.getQueue,\n            increaseVolume: state.increaseVolume,\n            isFirstTrackInQueue: state.isFirstTrackInQueue,\n            isLastTrackInQueue: state.isLastTrackInQueue,\n            mediaAutoNext: state.mediaAutoNext,\n            mediaNext: state.mediaNext,\n            mediaPause: state.mediaPause,\n            mediaPlay: state.mediaPlay,\n            mediaPlayByIndex: state.mediaPlayByIndex,\n            mediaPrevious: state.mediaPrevious,\n            mediaSeekToTimestamp: state.mediaSeekToTimestamp,\n            mediaSkipBackward: state.mediaSkipBackward,\n            mediaSkipForward: state.mediaSkipForward,\n            mediaStop: state.mediaStop,\n            mediaToggleMute: state.mediaToggleMute,\n            mediaTogglePlayPause: state.mediaTogglePlayPause,\n            moveSelectedTo: state.moveSelectedTo,\n            moveSelectedToBottom: state.moveSelectedToBottom,\n            moveSelectedToNext: state.moveSelectedToNext,\n            moveSelectedToTop: state.moveSelectedToTop,\n            setCrossfadeDuration: state.setCrossfadeDuration,\n            setCrossfadeStyle: state.setCrossfadeStyle,\n            setPauseOnNextSongEnd: state.setPauseOnNextSongEnd,\n            setQueue: state.setQueue,\n            setRepeat: state.setRepeat,\n            setShuffle: state.setShuffle,\n            setSpeed: state.setSpeed,\n            setTransitionType: state.setTransitionType,\n            setVolume: state.setVolume,\n            shuffle: state.shuffle,\n            shuffleAll: state.shuffleAll,\n            shuffleSelected: state.shuffleSelected,\n            toggleRepeat: state.toggleRepeat,\n            toggleShuffle: state.toggleShuffle,\n        })),\n    );\n\n    return {\n        ...actions,\n        setTimestamp: setTimestampStore,\n    };\n};\n\nexport type AddToQueueByPlayType = Play;\n\nexport type AddToQueueByUniqueId = {\n    edge: 'bottom' | 'left' | 'right' | 'top' | null;\n    uniqueId: string;\n};\n\nexport type AddToQueueType = AddToQueueByPlayType | AddToQueueByUniqueId;\n\nexport async function addToQueueByData(type: AddToQueueType, data: Song[]) {\n    const items = data.map(toQueueSong);\n\n    if (typeof type === 'string') {\n        usePlayerStoreBase.getState().addToQueueByType(items, type);\n    } else {\n        const normalizedEdge = type.edge === 'top' ? 'top' : 'bottom';\n        usePlayerStoreBase.getState().addToQueueByUniqueId(items, type.uniqueId, normalizedEdge);\n    }\n}\n\nexport const subscribePlayerQueue = (\n    onChange: (queue: QueueData, prevQueue: QueueData) => void,\n) => {\n    return usePlayerStoreBase.subscribe(\n        (state) => state.queue,\n        (queue, prevQueue) => {\n            onChange(queue, prevQueue);\n        },\n    );\n};\n\nexport const subscribeCurrentTrack = (\n    onChange: (\n        properties: { index: number; song: QueueSong | undefined },\n        prev: { index: number; song: QueueSong | undefined },\n    ) => void,\n) => {\n    return usePlayerStoreBase.subscribe(\n        (state) => {\n            const queue = state.getQueue();\n            let index = state.player.index;\n\n            if (isShuffleEnabled(state)) {\n                index = mapShuffledToQueueIndex(index, state.queue.shuffled);\n            }\n\n            return { index, song: queue.items[index] };\n        },\n        (song, prevSong) => {\n            onChange(song, prevSong);\n        },\n        {\n            equalityFn: (a, b) => {\n                return a.song?._uniqueId === b.song?._uniqueId;\n            },\n        },\n    );\n};\n\nexport const subscribeNextSongInsertion = (onChange: (song: QueueSong | undefined) => void) => {\n    return usePlayerStoreBase.subscribe(\n        (state) => {\n            const queue = state.getQueue();\n            let queueIndex = state.player.index;\n            const repeat = state.player.repeat;\n\n            // If shuffle is enabled, map shuffled position to actual queue position\n            if (isShuffleEnabled(state)) {\n                queueIndex = mapShuffledToQueueIndex(queueIndex, state.queue.shuffled);\n            }\n\n            // Calculate next song based on shuffle and repeat settings\n            let nextSong: QueueSong | undefined;\n            if (isShuffleEnabled(state)) {\n                // Calculate next in shuffled order\n                const nextShuffledIndex = state.player.index + 1;\n                if (nextShuffledIndex < state.queue.shuffled.length) {\n                    const nextQueueIndex = state.queue.shuffled[nextShuffledIndex];\n                    nextSong = queue.items[nextQueueIndex];\n                } else if (repeat === PlayerRepeat.ALL) {\n                    // Wrap to first in shuffled order\n                    const firstQueueIndex = state.queue.shuffled[0];\n                    nextSong = queue.items[firstQueueIndex];\n                }\n            } else {\n                nextSong = calculateNextSong(queueIndex, queue.items, repeat);\n            }\n\n            return { index: queueIndex, song: nextSong };\n        },\n        (current, prev) => {\n            // Only trigger if:\n            // 1. We have a previous value (not the first call)\n            // 2. Index hasn't changed (not a natural advance)\n            // 3. Next song has changed (song was inserted)\n            if (\n                prev &&\n                current.index === prev.index &&\n                current.song?._uniqueId !== prev.song?._uniqueId\n            ) {\n                // Index stayed the same but next song changed = insertion at next position\n                onChange(current.song);\n            }\n        },\n        {\n            // Always allow the subscription to fire so we can check conditions in the callback\n            equalityFn: () => false,\n        },\n    );\n};\n\nexport const subscribePlayerVolume = (\n    onChange: (properties: { volume: number }, prev: { volume: number }) => void,\n) => {\n    return usePlayerStoreBase.subscribe(\n        (state) => state.player.volume,\n        (volume, prevVolume) => {\n            onChange({ volume }, { volume: prevVolume });\n        },\n    );\n};\n\nexport const subscribePlayerStatus = (\n    onChange: (properties: { status: PlayerStatus }, prev: { status: PlayerStatus }) => void,\n) => {\n    return usePlayerStoreBase.subscribe(\n        (state) => state.player.status,\n        (status, prevStatus) => {\n            onChange({ status }, { status: prevStatus });\n        },\n    );\n};\n\nexport const subscribePlayerSeekToTimestamp = (\n    onChange: (properties: { timestamp: number }, prev: { timestamp: number }) => void,\n) => {\n    return usePlayerStoreBase.subscribe(\n        (state) => state.player.seekToTimestamp,\n        (timestamp, prevTimestamp) => {\n            onChange(\n                { timestamp: parseUniqueSeekToTimestamp(timestamp) },\n                { timestamp: parseUniqueSeekToTimestamp(prevTimestamp) },\n            );\n        },\n    );\n};\n\nexport const subscribePlayerMute = (\n    onChange: (properties: { muted: boolean }, prev: { muted: boolean }) => void,\n) => {\n    return usePlayerStoreBase.subscribe(\n        (state) => state.player.muted,\n        (muted, prevMuted) => {\n            onChange({ muted }, { muted: prevMuted });\n        },\n    );\n};\n\nexport const subscribePlayerSpeed = (\n    onChange: (properties: { speed: number }, prev: { speed: number }) => void,\n) => {\n    return usePlayerStoreBase.subscribe(\n        (state) => state.player.speed,\n        (speed, prevSpeed) => {\n            onChange({ speed }, { speed: prevSpeed });\n        },\n    );\n};\n\nexport const subscribePlayerRepeat = (\n    onChange: (properties: { repeat: PlayerRepeat }, prev: { repeat: PlayerRepeat }) => void,\n) => {\n    return usePlayerStoreBase.subscribe(\n        (state) => state.player.repeat,\n        (repeat, prevRepeat) => {\n            onChange({ repeat }, { repeat: prevRepeat });\n        },\n    );\n};\n\nexport const subscribePlayerShuffle = (\n    onChange: (properties: { shuffle: PlayerShuffle }, prev: { shuffle: PlayerShuffle }) => void,\n) => {\n    return usePlayerStoreBase.subscribe(\n        (state) => state.player.shuffle,\n        (shuffle, prevShuffle) => {\n            onChange({ shuffle }, { shuffle: prevShuffle });\n        },\n    );\n};\n\nexport const subscribeQueueCleared = (onChange: () => void) => {\n    return usePlayerStoreBase.subscribe(\n        (state) => state.queue,\n        (queue, prevQueue) => {\n            // Detect if queue became empty\n            const wasNotEmpty = prevQueue.default.length > 0;\n            const isEmpty = queue.default.length === 0;\n\n            if (wasNotEmpty && isEmpty) {\n                onChange();\n            }\n        },\n    );\n};\n\nexport const usePlayerProperties = () => {\n    return usePlayerStoreBase(\n        useShallow((state) => ({\n            crossfadeDuration: state.player.crossfadeDuration,\n            crossfadeStyle: state.player.crossfadeStyle,\n            isMuted: state.player.muted,\n            playerNum: state.player.playerNum,\n            repeat: state.player.repeat,\n            shuffle: state.player.shuffle,\n            speed: state.player.speed,\n            status: state.player.status,\n            transitionType: state.player.transitionType,\n            volume: state.player.volume,\n        })),\n    );\n};\n\nexport const usePlayerDuration = () => {\n    return usePlayerStoreBase((state) => {\n        const queue = state.getQueue();\n        let index = state.player.index;\n\n        // If shuffle is enabled, map shuffled position to actual queue position\n        if (state.player.shuffle === PlayerShuffle.TRACK && state.queue.shuffled.length > 0) {\n            if (index >= 0 && index < state.queue.shuffled.length) {\n                index = state.queue.shuffled[index];\n            }\n        }\n\n        const currentTrack = queue.items[index];\n        return currentTrack?.duration;\n    });\n};\n\nexport const usePlayerData = (): PlayerData => {\n    return usePlayerStoreBase(\n        useShallow((state) => {\n            const queue = state.getQueue();\n            const index = state.player.index;\n\n            // If shuffle is enabled, map shuffled position to actual queue position for display\n            let queueIndex = index;\n            if (isShuffleEnabled(state)) {\n                queueIndex = mapShuffledToQueueIndex(index, state.queue.shuffled);\n            }\n\n            const currentSong = queue.items[queueIndex];\n            const repeat = state.player.repeat;\n\n            // For previousSong calculation, we need to consider the shuffled order\n            let previousSong: QueueSong | undefined;\n            if (isShuffleEnabled(state)) {\n                // Calculate previous in shuffled order\n                const previousShuffledIndex = index - 1;\n                if (previousShuffledIndex >= 0) {\n                    const previousQueueIndex = state.queue.shuffled[previousShuffledIndex];\n                    previousSong = queue.items[previousQueueIndex];\n                } else if (repeat === PlayerRepeat.ALL) {\n                    // Wrap to last in shuffled order\n                    const lastShuffledIndex = state.queue.shuffled.length - 1;\n                    const lastQueueIndex = state.queue.shuffled[lastShuffledIndex];\n                    previousSong = queue.items[lastQueueIndex];\n                }\n            } else {\n                previousSong = queueIndex > 0 ? queue.items[queueIndex - 1] : undefined;\n            }\n\n            // For nextSong calculation, we need to consider the shuffled order\n            let nextSong: QueueSong | undefined;\n            if (isShuffleEnabled(state)) {\n                // Calculate next in shuffled order\n                const nextShuffledIndex = index + 1;\n                if (nextShuffledIndex < state.queue.shuffled.length) {\n                    const nextQueueIndex = state.queue.shuffled[nextShuffledIndex];\n                    nextSong = queue.items[nextQueueIndex];\n                } else if (repeat === PlayerRepeat.ALL) {\n                    // Wrap to first in shuffled order\n                    const firstQueueIndex = state.queue.shuffled[0];\n                    nextSong = queue.items[firstQueueIndex];\n                }\n            } else {\n                nextSong = calculateNextSong(queueIndex, queue.items, repeat);\n            }\n\n            return {\n                currentSong,\n                index: queueIndex, // Return the actual queue position for display\n                nextSong,\n                num: state.player.playerNum,\n                player1: state.player.playerNum === 1 ? currentSong : nextSong,\n                player2: state.player.playerNum === 2 ? currentSong : nextSong,\n                previousSong,\n                queueLength: state.queue.default.length,\n                status: state.player.status,\n            };\n        }),\n    );\n};\n\nexport const updateQueueFavorites = (ids: string[], favorite: boolean) => {\n    usePlayerStoreBase.setState((state) => {\n        Object.values(state.queue.songs).forEach((song) => {\n            if (ids.includes(song.id)) {\n                song.userFavorite = favorite;\n            }\n        });\n    });\n};\n\nexport const updateQueueRatings = (ids: string[], rating: null | number) => {\n    usePlayerStoreBase.setState((state) => {\n        Object.values(state.queue.songs).forEach((song) => {\n            if (ids.includes(song.id)) {\n                song.userRating = rating;\n            }\n        });\n    });\n};\n\nexport const incrementQueuePlayCount = (ids: string[]) => {\n    usePlayerStoreBase.setState((state) => {\n        Object.values(state.queue.songs).forEach((song) => {\n            if (ids.includes(song.id)) {\n                song.playCount = (song.playCount || 0) + 1;\n            }\n        });\n    });\n};\n\nexport const updateQueueSong = (songId: string, updatedSong: Song) => {\n    usePlayerStoreBase.setState((state) => {\n        Object.values(state.queue.songs).forEach((song) => {\n            if (song.id === songId) {\n                const uniqueId = song._uniqueId;\n                state.queue.songs[song._uniqueId] = {\n                    ...updatedSong,\n                    _uniqueId: uniqueId,\n                };\n            }\n        });\n    });\n};\n\nexport const usePlayerMuted = () => {\n    return usePlayerStoreBase((state) => state.player.muted);\n};\n\nexport const usePlayerRepeat = () => {\n    return usePlayerStoreBase((state) => state.player.repeat);\n};\n\nexport const usePlayerShuffle = () => {\n    return usePlayerStoreBase((state) => state.player.shuffle);\n};\n\nexport const usePlayerStatus = () => {\n    return usePlayerStoreBase((state) => state.player.status);\n};\n\nexport const usePlayerVolume = () => {\n    return usePlayerStoreBase((state) => state.player.volume);\n};\n\nexport const usePlayerSpeed = () => {\n    return usePlayerStoreBase((state) => state.player.speed);\n};\n\nexport const usePlayerSong = () => {\n    return usePlayerStoreBase(\n        (state) => {\n            return state.getCurrentSong();\n        },\n        (prev, next) => {\n            return (\n                prev?._uniqueId === next?._uniqueId &&\n                prev?.userFavorite === next?.userFavorite &&\n                prev?.userRating === next?.userRating\n            );\n        },\n    );\n};\n\nexport const usePlayerSongProperties = <T extends keyof QueueSong>(\n    properties: T[],\n): Partial<Pick<QueueSong, T>> => {\n    return usePlayerStoreBase(\n        useShallow((state) => {\n            const song = state.getCurrentSong();\n            if (!song) {\n                return {};\n            }\n\n            const result = {} as Pick<QueueSong, T>;\n\n            for (const prop of properties) {\n                result[prop] = song[prop];\n            }\n            return result;\n        }),\n    );\n};\n\nexport const usePlayerNum = () => {\n    return usePlayerStoreBase((state) => state.player.playerNum);\n};\n\nexport const usePlayerQueue = () => {\n    return usePlayerStoreBase(\n        useShallow((state) => {\n            const songs = state.queue.songs;\n            const queue = state.queue.default;\n            const result: QueueSong[] = [];\n            for (const id of queue) {\n                const song = songs[id];\n                if (song) result.push(song);\n            }\n            return result;\n        }),\n    );\n};\n\nfunction cleanupOrphanedSongs(state: any): boolean {\n    const allQueueIds = new Set([\n        ...state.queue.default,\n        // shuffled now contains indexes, not uniqueIds, so we don't include it here\n    ]);\n\n    const songs = state.queue.songs;\n    const songIds = Object.keys(songs);\n    let hasOrphans = false;\n    const orphanedIds: string[] = [];\n\n    for (const songId of songIds) {\n        if (!allQueueIds.has(songId)) {\n            orphanedIds.push(songId);\n            hasOrphans = true;\n        }\n    }\n\n    if (hasOrphans) {\n        const cleanedSongs: Record<string, QueueSong> = {};\n        for (const songId of songIds) {\n            if (!orphanedIds.includes(songId)) {\n                cleanedSongs[songId] = songs[songId];\n            }\n        }\n        state.queue.songs = cleanedSongs;\n    }\n\n    return hasOrphans;\n}\n\nfunction parseUniqueSeekToTimestamp(timestamp: string) {\n    return Number(timestamp.split('-')[0]);\n}\n\nfunction recalculatePlayerIndex(state: any, queue: string[]) {\n    const currentTrack = state.getCurrentSong() as QueueSong | undefined;\n\n    if (!currentTrack) {\n        return;\n    }\n\n    const index = queue.findIndex((id) => id === currentTrack._uniqueId);\n    state.player.index = Math.max(0, index);\n}\n\nfunction toQueueSong(item: Song): QueueSong {\n    return {\n        ...item,\n        _uniqueId: nanoid(),\n    };\n}\n\n// We need to use a unique id so that the equalityFn can work if attempting to set the same timestamp\nfunction uniqueSeekToTimestamp(timestamp: number) {\n    return `${timestamp}-${nanoid()}`;\n}\n"
  },
  {
    "path": "src/renderer/store/scroll.store.ts",
    "content": "import { create } from 'zustand';\n\ntype ScrollState = {\n    getOffset: (key: string) => number | undefined;\n    offsets: Record<string, number>;\n    setOffset: (key: string, offset: number) => void;\n};\n\nexport const useScrollStore = create<ScrollState>((set, get) => ({\n    getOffset: (key) => get().offsets[key],\n    offsets: {},\n    setOffset: (key, offset) =>\n        set((s) => ({\n            offsets: { ...s.offsets, [key]: offset },\n        })),\n}));\n"
  },
  {
    "path": "src/renderer/store/settings.store.ts",
    "content": "import isElectron from 'is-electron';\nimport cloneDeep from 'lodash/cloneDeep';\nimport mergeWith from 'lodash/mergeWith';\nimport { nanoid } from 'nanoid';\nimport { useMemo } from 'react';\nimport { generatePath } from 'react-router';\nimport { z } from 'zod';\nimport { devtools, persist, subscribeWithSelector } from 'zustand/middleware';\nimport { immer } from 'zustand/middleware/immer';\nimport { shallow } from 'zustand/shallow';\nimport { createWithEqualityFn } from 'zustand/traditional';\n\nimport i18n from '/@/i18n/i18n';\nimport {\n    ALBUM_ARTIST_TABLE_COLUMNS,\n    ALBUM_TABLE_COLUMNS,\n    GENRE_TABLE_COLUMNS,\n    pickGridRows,\n    pickTableColumns,\n    PLAYLIST_SONG_TABLE_COLUMNS,\n    PLAYLIST_TABLE_COLUMNS,\n    SONG_TABLE_COLUMNS,\n} from '/@/renderer/components/item-list/item-table-list/default-columns';\nimport { audiomotionanalyzerPresets } from '/@/renderer/features/visualizer/components/audiomotionanalyzer/presets';\nimport { AppRoute } from '/@/renderer/router/routes';\nimport { getEnvSettingsOverrides } from '/@/renderer/store/env-settings-overrides';\nimport { mergeOverridingColumns } from '/@/renderer/store/utils';\nimport { FontValueSchema } from '/@/renderer/types/fonts';\nimport { randomString } from '/@/renderer/utils';\nimport { sanitizeCss } from '/@/renderer/utils/sanitize';\nimport { AppTheme } from '/@/shared/themes/app-theme-types';\nimport { LibraryItem, LyricSource, SavedCollection } from '/@/shared/types/domain-types';\nimport {\n    FontType,\n    ItemListKey,\n    ListDisplayType,\n    ListPaginationType,\n    Platform,\n    Play,\n    PlayerType,\n    TableColumn,\n} from '/@/shared/types/types';\n\nconst utils = isElectron() ? window.api.utils : null;\n\ntype DeepPartial<T> = {\n    [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];\n};\n\nconst deepMergeIntoState = <T extends Record<string, any>>(\n    state: T,\n    updates: DeepPartial<T>,\n): void => {\n    // Skip 'actions' property\n    // eslint-disable-next-line @typescript-eslint/no-unused-vars\n    const { actions, ...updatesWithoutActions } = updates as any;\n\n    // Use mergeWith to replace arrays instead of merging them by index\n    mergeWith(state, updatesWithoutActions, (_objValue, srcValue) => {\n        // If source value is an array, replace the entire array instead of merging\n        if (Array.isArray(srcValue)) {\n            return srcValue;\n        }\n\n        // Default merge behavior\n        return undefined;\n    });\n};\n\nconst HomeItemSchema = z.enum([\n    'genres',\n    'mostPlayed',\n    'random',\n    'recentlyAdded',\n    'recentlyPlayed',\n    'recentlyReleased',\n]);\n\nconst PlayerItemSchema = z.enum([\n    'bit_depth',\n    'bit_rate',\n    'bpm',\n    'disc_number',\n    'sample_rate',\n    'track_number',\n    'codec',\n    'release_year',\n    'release_type',\n    'release_date',\n    'genres',\n]);\n\nconst ArtistItemSchema = z.enum([\n    'biography',\n    'compilations',\n    'favoriteSongs',\n    'recentAlbums',\n    'similarArtists',\n    'topSongs',\n]);\n\nconst ArtistReleaseTypeItemSchema = z.enum([\n    'releaseTypeAlbum',\n    'releaseTypeEp',\n    'releaseTypeSingle',\n    'releaseTypeBroadcast',\n    'releaseTypeOther',\n    'releaseTypeCompilation',\n    'appearsOn',\n    'releaseTypeAudioDrama',\n    'releaseTypeAudiobook',\n    'releaseTypeDemo',\n    'releaseTypeDjMix',\n    'releaseTypeFieldRecording',\n    'releaseTypeInterview',\n    'releaseTypeLive',\n    'releaseTypeMixtapeStreet',\n    'releaseTypeRemix',\n    'releaseTypeSoundtrack',\n    'releaseTypeSpokenWord',\n]);\n\nconst BindingActionsSchema = z.enum([\n    'browserBack',\n    'browserForward',\n    'favoriteCurrentAdd',\n    'favoriteCurrentRemove',\n    'favoriteCurrentToggle',\n    'favoritePreviousAdd',\n    'favoritePreviousRemove',\n    'favoritePreviousToggle',\n    'globalSearch',\n    'localSearch',\n    'volumeMute',\n    'navigateHome',\n    'next',\n    'pause',\n    'play',\n    'playPause',\n    'previous',\n    'rate0',\n    'rate1',\n    'rate2',\n    'rate3',\n    'rate4',\n    'rate5',\n    'toggleShuffle',\n    'skipBackward',\n    'skipForward',\n    'stop',\n    'toggleFullscreenPlayer',\n    'toggleQueue',\n    'toggleRepeat',\n    'volumeDown',\n    'volumeUp',\n    'zoomIn',\n    'zoomOut',\n    'listPlayDefault',\n    'listPlayNow',\n    'listPlayNext',\n    'listPlayLast',\n    'listNavigateToPage',\n]);\n\nconst DiscordDisplayTypeSchema = z.enum(['artist', 'feishin', 'song']);\n\nconst DiscordLinkTypeSchema = z.enum(['last_fm', 'musicbrainz', 'musicbrainz_last_fm', 'none']);\n\nconst GenreTargetSchema = z.enum(['album', 'track']);\n\nconst PlaylistTargetSchema = z.enum(['album', 'track']);\n\nconst SideQueueTypeSchema = z.enum(['sideDrawerQueue', 'sideQueue']);\nconst SideQueueLayoutSchema = z.enum(['horizontal', 'vertical']);\n\nconst SidebarPanelTypeSchema = z.enum(['queue', 'lyrics', 'visualizer']);\n\nconst CollectionSchema = z.object({\n    filterQueryString: z.string(),\n    id: z.string(),\n    name: z.string(),\n    type: z.enum([LibraryItem.ALBUM, LibraryItem.SONG]),\n});\n\nconst SidebarItemTypeSchema = z.object({\n    disabled: z.boolean(),\n    id: z.string(),\n    label: z.string(),\n    route: z.union([z.nativeEnum(AppRoute), z.string()]),\n});\n\nconst SortableItemSchema = <T extends z.ZodTypeAny>(itemSchema: T) =>\n    z.object({\n        disabled: z.boolean(),\n        id: itemSchema,\n    });\n\nconst ItemTableListColumnConfigSchema = z.object({\n    align: z.enum(['center', 'end', 'start']),\n    autoSize: z.boolean().optional(),\n    id: z.nativeEnum(TableColumn),\n    isEnabled: z.boolean(),\n    pinned: z.union([z.literal('left'), z.literal('right'), z.literal(null)]),\n    width: z.number(),\n});\n\nexport type ItemTableListColumnConfig = z.infer<typeof ItemTableListColumnConfigSchema>;\n\nconst ItemGridListRowConfigSchema = z.object({\n    align: z.enum(['center', 'end', 'start']),\n    id: z.nativeEnum(TableColumn),\n    isEnabled: z.boolean(),\n});\n\nexport type ItemGridListRowConfig = z.infer<typeof ItemGridListRowConfigSchema>;\n\nconst ItemTableListPropsSchema = z.object({\n    autoFitColumns: z.boolean(),\n    columns: z.array(ItemTableListColumnConfigSchema),\n    enableAlternateRowColors: z.boolean(),\n    enableHeader: z.boolean(),\n    enableHorizontalBorders: z.boolean(),\n    enableRowHoverHighlight: z.boolean(),\n    enableVerticalBorders: z.boolean(),\n    size: z.enum(['compact', 'default', 'large']),\n});\n\nconst ItemDetailListPropsSchema = z.object({\n    columns: z.array(ItemTableListColumnConfigSchema),\n    enableAlternateRowColors: z.boolean(),\n    enableHeader: z.boolean(),\n    enableHorizontalBorders: z.boolean(),\n    enableRowHoverHighlight: z.boolean(),\n    enableVerticalBorders: z.boolean(),\n    size: z.enum(['compact', 'default', 'large']),\n});\n\nconst ItemListConfigSchema = z.object({\n    detail: ItemDetailListPropsSchema.optional(),\n    display: z.nativeEnum(ListDisplayType),\n    grid: z.object({\n        itemGap: z.enum(['lg', 'md', 'sm', 'xl', 'xs']),\n        itemsPerRow: z.number(),\n        itemsPerRowEnabled: z.boolean(),\n        rows: z.array(ItemGridListRowConfigSchema),\n        size: z.enum(['compact', 'default', 'large']),\n    }),\n    itemsPerPage: z.number(),\n    pagination: z.nativeEnum(ListPaginationType),\n    table: ItemTableListPropsSchema,\n});\n\nconst TranscodingConfigSchema = z.object({\n    bitrate: z.number().optional(),\n    enabled: z.boolean(),\n    format: z.string().optional(),\n});\n\nconst MpvSettingsSchema = z.object({\n    audioExclusiveMode: z.enum(['no', 'yes']),\n    audioFormat: z.enum(['float', 's16', 's32']).optional(),\n    audioSampleRateHz: z.number().optional(),\n    gaplessAudio: z.enum(['no', 'weak', 'yes']),\n    replayGainClip: z.boolean(),\n    replayGainFallbackDB: z.number().optional(),\n    replayGainMode: z.enum(['album', 'no', 'track']),\n    replayGainPreampDB: z.number().optional(),\n});\n\nconst CssSettingsSchema = z.object({\n    content: z.string().transform((val) => sanitizeCss(`<style>${val}`)),\n    enabled: z.boolean(),\n});\n\nconst DiscordSettingsSchema = z.object({\n    clientId: z.string(),\n    displayType: DiscordDisplayTypeSchema,\n    enabled: z.boolean(),\n    linkType: DiscordLinkTypeSchema,\n    showAsListening: z.boolean(),\n    showPaused: z.boolean(),\n    showServerImage: z.boolean(),\n    showStateIcon: z.boolean(),\n});\n\nconst FontSettingsSchema = z.object({\n    builtIn: FontValueSchema,\n    custom: z.string().nullable(),\n    system: z.string().nullable(),\n    type: z.nativeEnum(FontType),\n});\n\nconst SkipButtonsSchema = z.object({\n    enabled: z.boolean(),\n    skipBackwardSeconds: z.number(),\n    skipForwardSeconds: z.number(),\n});\n\nconst PlayerbarSliderTypeSchema = z.enum(['slider', 'waveform']);\n\nconst BarAlignSchema = z.enum(['top', 'bottom', 'center']);\n\nconst PlayerbarSliderSchema = z.object({\n    barAlign: BarAlignSchema,\n    barGap: z.number(),\n    barRadius: z.number(),\n    barWidth: z.number(),\n    type: PlayerbarSliderTypeSchema,\n});\n\nconst AudioMotionAnalyzerSettingsSchema = z.object({\n    alphaBars: z\n        .boolean()\n        .describe(\n            'When set to true each bar’s amplitude affects its opacity, i.e., higher bars are rendered more opaque while shorter bars are more transparent. This is similar to the lumiBars effect, but bars’ amplitudes are preserved and it also works on Discrete mode and radial spectrum.',\n        ),\n    ansiBands: z\n        .boolean()\n        .describe(\n            'When set to true, ANSI/IEC preferred frequencies are used to generate the bands for octave bands modes (see mode). The preferred base-10 scale is used to compute the center and bandedge frequencies, as specified in the ANSI S1.11-2004 standard. When false, bands are based on the equal-tempered scale, so that in 1/12 octave bands the center of each band is perfectly tuned to a musical note.',\n        ),\n    barSpace: z\n        .number()\n        .describe(\n            'Customize the spacing between bars in frequency bands modes (see mode). Use a value between 0 and 1 for spacing proportional to the band width. Values >= 1 will be considered as a literal number of pixels.',\n        ),\n    channelLayout: z\n        .enum(['single', 'dual-combined', 'dual-horizontal', 'dual-vertical'])\n        .describe('Defines the number and layout of analyzer channels.'),\n    colorMode: z\n        .enum(['gradient', 'bar-index', 'bar-level'])\n        .describe('Selects the desired mode for coloring the analyzer bars.'),\n    customGradients: z.array(\n        z.object({\n            colorStops: z.array(\n                z.object({\n                    color: z.string(),\n                    level: z.number().min(0).max(1).optional(),\n                    levelEnabled: z.boolean().optional(),\n                    pos: z.number().min(0).max(1).optional(),\n                    positionEnabled: z.boolean().optional(),\n                }),\n            ),\n            dir: z.string().optional(),\n            name: z.string(),\n        }),\n    ),\n    fadePeaks: z\n        .boolean()\n        .describe(\n            'When true, peaks fade out instead of falling down. It has no effect when peakLine is active.',\n        ),\n    fftSize: z\n        .number()\n        .describe(\n            'Number of samples used for the FFT performed by the AnalyzerNode. It must be a power of 2 between 32 and 32768, so valid values are: 32, 64, 128, 256, 512, 1024, 2048, 4096, 8192, 16384, and 32768. Higher values provide more detail in the frequency domain, but less detail in the time domain (slower response), so you may need to adjust smoothing accordingly.',\n        ),\n    fillAlpha: z.number(),\n    frequencyScale: z.enum(['bark', 'linear', 'log', 'mel']),\n    gradient: z.string(),\n    gradientLeft: z.string().optional(),\n    gradientRight: z.string().optional(),\n    gravity: z.number(),\n    ledBars: z.boolean(),\n    linearAmplitude: z.boolean(),\n    linearBoost: z.number(),\n    lineWidth: z.number(),\n    loRes: z.boolean(),\n    lumiBars: z.boolean(),\n    maxDecibels: z.number(),\n    maxFPS: z.number(),\n    maxFreq: z.number(),\n    minDecibels: z.number(),\n    minFreq: z.number(),\n    mirror: z.number(),\n    mode: z.number(),\n    noteLabels: z.boolean(),\n    opacity: z.number().min(0).max(1),\n    outlineBars: z.boolean(),\n    peakFadeTime: z.number(),\n    peakHoldTime: z.number(),\n    peakLine: z.boolean(),\n    presets: z.array(\n        z.object({\n            id: z.string(),\n            name: z.string(),\n            value: z.any(),\n        }),\n    ),\n    radial: z.boolean(),\n    radialInvert: z.boolean(),\n    radius: z.number(),\n    reflexAlpha: z.number(),\n    reflexBright: z.number(),\n    reflexFit: z.boolean(),\n    reflexRatio: z.number(),\n    roundBars: z.boolean(),\n    showFPS: z.boolean(),\n    showPeaks: z.boolean(),\n    showScaleX: z.boolean(),\n    showScaleY: z.boolean(),\n    smoothing: z.number(),\n    spinSpeed: z.number(),\n    splitGradient: z.boolean(),\n    trueLeds: z.boolean(),\n    volume: z.number(),\n    weightingFilter: z.enum(['', 'A', 'B', 'C', 'D', 'Z']),\n});\n\nconst ButterchurnSettingsSchema = z.object({\n    blendTime: z.number().min(0).max(10),\n    currentPreset: z.string().optional(),\n    cyclePresets: z.boolean(),\n    cycleTime: z.number().min(1).max(300),\n    ignoredPresets: z.array(z.string()),\n    includeAllPresets: z.boolean(),\n    maxFPS: z.number().min(0),\n    opacity: z.number().min(0).max(1),\n    randomizeNextPreset: z.boolean(),\n    selectedPresets: z.array(z.string()),\n});\n\nconst VisualizerSettingsSchema = z.object({\n    audiomotionanalyzer: AudioMotionAnalyzerSettingsSchema,\n    butterchurn: ButterchurnSettingsSchema,\n    type: z.enum(['audiomotionanalyzer', 'butterchurn']),\n});\n\nexport enum HomeFeatureStyle {\n    MULTIPLE = 'multiple',\n    SINGLE = 'single',\n}\n\nconst AutoSaveSchema = z.object({\n    count: z.number().min(0),\n    enabled: z.boolean(),\n});\n\nexport const GeneralSettingsSchema = z.object({\n    accent: z\n        .string()\n        .refine(\n            (val) => /^rgb\\(\\s*([0-9]{1,3})\\s*,\\s*([0-9]{1,3})\\s*,\\s*([0-9]{1,3})\\s*\\)$/.test(val),\n            {\n                message: 'Accent must be a valid rgb() color string',\n            },\n        ),\n    albumBackground: z.boolean(),\n    albumBackgroundBlur: z.number(),\n    artistBackground: z.boolean(),\n    artistBackgroundBlur: z.number(),\n    artistItems: z.array(SortableItemSchema(ArtistItemSchema)),\n    artistRadioCount: z.number(),\n    artistReleaseTypeItems: z.array(SortableItemSchema(ArtistReleaseTypeItemSchema)),\n    autoSave: AutoSaveSchema,\n    blurExplicitImages: z.boolean(),\n    buttonSize: z.number(),\n    collections: z.array(CollectionSchema),\n    combinedLyricsAndVisualizer: z.boolean(),\n    disabledContextMenu: z.record(z.string(), z.boolean()),\n    enableGridMultiSelect: z.boolean(),\n    externalLinks: z.boolean(),\n    followCurrentSong: z.boolean(),\n    followSystemTheme: z.boolean(),\n    genreTarget: GenreTargetSchema,\n    homeFeature: z.boolean(),\n    homeFeatureStyle: z.nativeEnum(HomeFeatureStyle),\n    homeItems: z.array(SortableItemSchema(HomeItemSchema)),\n    imageRes: z.object({\n        fullScreenPlayer: z.number(),\n        header: z.number(),\n        itemCard: z.number(),\n        sidebar: z.number(),\n        table: z.number(),\n    }),\n    language: z.string(),\n    lastFM: z.boolean(),\n    lastfmApiKey: z.string(),\n    listenBrainz: z.boolean(),\n    musicBrainz: z.boolean(),\n    nativeAspectRatio: z.boolean(),\n    nativeSpotify: z.boolean(),\n    passwordStore: z.string().optional(),\n    pathReplace: z.string(),\n    pathReplaceWith: z.string(),\n    playButtonBehavior: z.nativeEnum(Play),\n    playerbarOpenDrawer: z.boolean(),\n    playerbarSlider: PlayerbarSliderSchema,\n    playerItems: z.array(SortableItemSchema(PlayerItemSchema)),\n    playlistTarget: PlaylistTargetSchema,\n    primaryShade: z.number().min(0).max(9),\n    qobuz: z.boolean(),\n    resume: z.boolean(),\n    showLyricsInSidebar: z.boolean(),\n    showRatings: z.boolean(),\n    showVisualizerInSidebar: z.boolean(),\n    sidebarCollapsedNavigation: z.boolean(),\n    sidebarCollapseShared: z.boolean(),\n    sidebarItems: z.array(SidebarItemTypeSchema),\n    sidebarPanelOrder: z.array(SidebarPanelTypeSchema),\n    sidebarPlaylistList: z.boolean(),\n    sidebarPlaylistListFilterRegex: z.string(),\n    sidebarPlaylistSorting: z.boolean(),\n    sideQueueLayout: SideQueueLayoutSchema,\n    sideQueueType: SideQueueTypeSchema,\n    skipButtons: SkipButtonsSchema,\n    spotify: z.boolean(),\n    theme: z.nativeEnum(AppTheme),\n    themeDark: z.nativeEnum(AppTheme),\n    themeLight: z.nativeEnum(AppTheme),\n    useThemeAccentColor: z.boolean(),\n    useThemePrimaryShade: z.boolean(),\n    volumeWheelStep: z.number(),\n    volumeWidth: z.number(),\n    zoomFactor: z.number(),\n});\n\nconst HotkeyBindingSchema = z.object({\n    allowGlobal: z.boolean(),\n    hotkey: z.string(),\n    isGlobal: z.boolean(),\n});\n\nconst HotkeysSettingsSchema = z.object({\n    bindings: z\n        .record(BindingActionsSchema, HotkeyBindingSchema)\n        .refine((obj): obj is Required<typeof obj> =>\n            BindingActionsSchema.options.every((key) => obj[key] != null),\n        ),\n    globalMediaHotkeys: z.boolean(),\n});\n\nconst LyricsDisplaySettingsSchema = z.object({\n    fontSize: z.number(),\n    fontSizeUnsync: z.number(),\n    gap: z.number(),\n    gapUnsync: z.number(),\n});\n\nconst LyricsSettingsSchema = z.object({\n    alignment: z.enum(['center', 'left', 'right']),\n    delayMs: z.number(),\n    enableAutoTranslation: z.boolean(),\n    enableNeteaseTranslation: z.boolean(),\n    fetch: z.boolean(),\n    follow: z.boolean(),\n    preferLocalLyrics: z.boolean(),\n    showMatch: z.boolean(),\n    showProvider: z.boolean(),\n    sources: z.array(z.nativeEnum(LyricSource)),\n    translationApiKey: z.string(),\n    translationApiProvider: z.string().nullable(),\n    translationTargetLanguage: z.string().nullable(),\n});\n\nconst ScrobbleSettingsSchema = z.object({\n    enabled: z.boolean(),\n    notify: z.boolean(),\n    scrobbleAtDuration: z.number(),\n    scrobbleAtPercentage: z.number(),\n});\n\nconst PlayerFilterFieldSchema = z.enum([\n    'name',\n    'albumArtist',\n    'artist',\n    'duration',\n    'genre',\n    'year',\n    'note',\n    'path',\n    'playCount',\n    'favorite',\n    'rating',\n]);\n\nconst PlayerFilterOperatorSchema = z.enum([\n    'is',\n    'isNot',\n    'contains',\n    'notContains',\n    'startsWith',\n    'endsWith',\n    'regex',\n    'gt',\n    'lt',\n    'inTheRange',\n    'before',\n    'after',\n    'beforeDate',\n    'afterDate',\n    'inTheRangeDate',\n    'inTheLast',\n    'notInTheLast',\n]);\n\nconst PlayerFilterSchema = z.object({\n    field: PlayerFilterFieldSchema,\n    id: z.string(),\n    isEnabled: z.boolean().optional(),\n    operator: PlayerFilterOperatorSchema,\n    value: z.union([\n        z.string(),\n        z.number(),\n        z.boolean(),\n        z.array(z.union([z.string(), z.number()])),\n    ]),\n});\n\nconst PlaybackSettingsSchema = z.object({\n    audioDeviceId: z.string().nullable().optional(),\n    audioFadeOnStatusChange: z.boolean(),\n    filters: z.array(PlayerFilterSchema),\n    mediaSession: z.boolean(),\n    mpvAudioDeviceId: z.string().nullable().optional(),\n    mpvExtraParameters: z.array(z.string()),\n    mpvProperties: MpvSettingsSchema,\n    preservePitch: z.boolean(),\n    scrobble: ScrobbleSettingsSchema,\n    transcode: TranscodingConfigSchema,\n    type: z.nativeEnum(PlayerType),\n    webAudio: z.boolean(),\n});\n\nconst RemoteSettingsSchema = z.object({\n    enabled: z.boolean(),\n    password: z.string(),\n    port: z.number(),\n    username: z.string(),\n});\n\nconst WindowSettingsSchema = z.object({\n    disableAutoUpdate: z.boolean(),\n    exitToTray: z.boolean(),\n    minimizeToTray: z.boolean(),\n    preventSleepOnPlayback: z.boolean(),\n    releaseChannel: z.enum(['alpha', 'beta', 'latest']),\n    startMinimized: z.boolean(),\n    tray: z.boolean(),\n    windowBarStyle: z.nativeEnum(Platform),\n});\n\nconst QueryValueInputTypeSchema = z.enum([\n    'boolean',\n    'date',\n    'dateRange',\n    'number',\n    'playlist',\n    'string',\n]);\n\nconst QueryBuilderCustomFieldSchema = z.object({\n    label: z.string(),\n    type: QueryValueInputTypeSchema,\n    value: z.string(),\n});\n\nconst QueryBuilderSettingsSchema = z.object({\n    tag: z.array(QueryBuilderCustomFieldSchema),\n});\n\nconst AutoDJSettingsSchema = z.object({\n    enabled: z.boolean(),\n    itemCount: z.number(),\n    timing: z.number(),\n});\n\n/**\n * This schema is used for validation of the imported settings json\n */\nexport const ValidationSettingsStateSchema = z.object({\n    autoDJ: AutoDJSettingsSchema,\n    css: CssSettingsSchema,\n    discord: DiscordSettingsSchema,\n    font: FontSettingsSchema,\n    general: GeneralSettingsSchema,\n    hotkeys: HotkeysSettingsSchema,\n    lists: z.record(z.nativeEnum(ItemListKey), ItemListConfigSchema),\n    lyrics: LyricsSettingsSchema,\n    lyricsDisplay: z.record(z.string(), LyricsDisplaySettingsSchema),\n    playback: PlaybackSettingsSchema,\n    queryBuilder: QueryBuilderSettingsSchema,\n    remote: RemoteSettingsSchema,\n    tab: z.union([\n        z.literal('general'),\n        z.literal('hotkeys'),\n        z.literal('playback'),\n        z.literal('window'),\n        z.string(),\n    ]),\n    visualizer: VisualizerSettingsSchema,\n    window: WindowSettingsSchema,\n});\n\n/**\n * This schema is merged below to create the full SettingsSchema but not used during import validation\n */\nexport const NonValidatedSettingsStateSchema = z.object({});\n\nexport const SettingsStateSchema = ValidationSettingsStateSchema.merge(\n    NonValidatedSettingsStateSchema,\n);\n\nexport enum ArtistItem {\n    BIOGRAPHY = 'biography',\n    FAVORITE_SONGS = 'favoriteSongs',\n    RECENT_ALBUMS = 'recentAlbums',\n    SIMILAR_ARTISTS = 'similarArtists',\n    TOP_SONGS = 'topSongs',\n}\n\nexport enum ArtistReleaseTypeItem {\n    APPEARS_ON = 'appearsOn',\n    RELEASE_TYPE_ALBUM = 'releaseTypeAlbum',\n    RELEASE_TYPE_AUDIO_DRAMA = 'releaseTypeAudioDrama',\n    RELEASE_TYPE_AUDIOBOOK = 'releaseTypeAudiobook',\n    RELEASE_TYPE_BROADCAST = 'releaseTypeBroadcast',\n    RELEASE_TYPE_COMPILATION = 'releaseTypeCompilation',\n    RELEASE_TYPE_DEMO = 'releaseTypeDemo',\n    RELEASE_TYPE_DJ_MIX = 'releaseTypeDjMix',\n    RELEASE_TYPE_EP = 'releaseTypeEp',\n    RELEASE_TYPE_FIELD_RECORDING = 'releaseTypeFieldRecording',\n    RELEASE_TYPE_INTERVIEW = 'releaseTypeInterview',\n    RELEASE_TYPE_LIVE = 'releaseTypeLive',\n    RELEASE_TYPE_MIXTAPE_STREET = 'releaseTypeMixtapeStreet',\n    RELEASE_TYPE_OTHER = 'releaseTypeOther',\n    RELEASE_TYPE_REMIX = 'releaseTypeRemix',\n    RELEASE_TYPE_SINGLE = 'releaseTypeSingle',\n    RELEASE_TYPE_SOUNDTRACK = 'releaseTypeSoundtrack',\n    RELEASE_TYPE_SPOKENWORD = 'releaseTypeSpokenWord',\n}\n\nexport enum BarAlign {\n    BOTTOM = 'bottom',\n    CENTER = 'center',\n    TOP = 'top',\n}\n\nexport enum BindingActions {\n    BROWSER_BACK = 'browserBack',\n    BROWSER_FORWARD = 'browserForward',\n    FAVORITE_CURRENT_ADD = 'favoriteCurrentAdd',\n    FAVORITE_CURRENT_REMOVE = 'favoriteCurrentRemove',\n    FAVORITE_CURRENT_TOGGLE = 'favoriteCurrentToggle',\n    FAVORITE_PREVIOUS_ADD = 'favoritePreviousAdd',\n    FAVORITE_PREVIOUS_REMOVE = 'favoritePreviousRemove',\n    FAVORITE_PREVIOUS_TOGGLE = 'favoritePreviousToggle',\n    GLOBAL_SEARCH = 'globalSearch',\n    LIST_NAVIGATE_TO_PAGE = 'listNavigateToPage',\n    LIST_PLAY_DEFAULT = 'listPlayDefault',\n    LIST_PLAY_LAST = 'listPlayLast',\n    LIST_PLAY_NEXT = 'listPlayNext',\n    LIST_PLAY_NOW = 'listPlayNow',\n    LOCAL_SEARCH = 'localSearch',\n    MUTE = 'volumeMute',\n    NAVIGATE_HOME = 'navigateHome',\n    NEXT = 'next',\n    PAUSE = 'pause',\n    PLAY = 'play',\n    PLAY_PAUSE = 'playPause',\n    PREVIOUS = 'previous',\n    RATE_0 = 'rate0',\n    RATE_1 = 'rate1',\n    RATE_2 = 'rate2',\n    RATE_3 = 'rate3',\n    RATE_4 = 'rate4',\n    RATE_5 = 'rate5',\n    SHUFFLE = 'toggleShuffle',\n    SKIP_BACKWARD = 'skipBackward',\n    SKIP_FORWARD = 'skipForward',\n    STOP = 'stop',\n    TOGGLE_FULLSCREEN_PLAYER = 'toggleFullscreenPlayer',\n    TOGGLE_QUEUE = 'toggleQueue',\n    TOGGLE_REPEAT = 'toggleRepeat',\n    VOLUME_DOWN = 'volumeDown',\n    VOLUME_UP = 'volumeUp',\n    ZOOM_IN = 'zoomIn',\n    ZOOM_OUT = 'zoomOut',\n}\n\nexport enum DiscordDisplayType {\n    ARTIST_NAME = 'artist',\n    FEISHIN = 'feishin',\n    SONG_NAME = 'song',\n}\n\nexport enum DiscordLinkType {\n    LAST_FM = 'last_fm',\n    MBZ = 'musicbrainz',\n    MBZ_LAST_FM = 'musicbrainz_last_fm',\n    NONE = 'none',\n}\n\nexport enum GenreTarget {\n    ALBUM = 'album',\n    TRACK = 'track',\n}\n\nexport enum HomeItem {\n    GENRES = 'genres',\n    MOST_PLAYED = 'mostPlayed',\n    RANDOM = 'random',\n    RECENTLY_ADDED = 'recentlyAdded',\n    RECENTLY_PLAYED = 'recentlyPlayed',\n    RECENTLY_RELEASED = 'recentlyReleased',\n}\n\nexport enum PlayerbarSliderType {\n    SLIDER = 'slider',\n    WAVEFORM = 'waveform',\n}\n\nexport enum PlayerItem {\n    BIT_DEPTH = 'bit_depth',\n    BIT_RATE = 'bit_rate',\n    BPM = 'bpm',\n    CODEC = 'codec',\n    DISC_NUMBER = 'disc_number',\n    GENRES = 'genres',\n    RELEASE_DATE = 'release_date',\n    RELEASE_TYPE = 'release_type',\n    RELEASE_YEAR = 'release_year',\n    SAMPLE_RATE = 'sample_rate',\n    TRACK_NUMBER = 'track_number',\n}\n\nexport enum PlaylistTarget {\n    ALBUM = 'album',\n    TRACK = 'track',\n}\n\nexport enum SidebarItem {\n    ALBUMS = 'Albums',\n    ARTISTS = 'Artists',\n    ARTISTS_ALL = 'Artists-all',\n    COLLECTIONS = 'Collections',\n    FAVORITES = 'Favorites',\n    FOLDERS = 'Folders',\n    GENRES = 'Genres',\n    HOME = 'Home',\n    NOW_PLAYING = 'Now Playing',\n    PLAYLISTS = 'Playlists',\n    RADIO = 'Radio',\n    SEARCH = 'Search',\n    SETTINGS = 'Settings',\n    TRACKS = 'Tracks',\n}\n\nexport type DataGridProps = {\n    itemGap: 'lg' | 'md' | 'sm' | 'xl' | 'xs';\n    itemsPerRow: number;\n    itemsPerRowEnabled: boolean;\n    rows: ItemGridListRowConfig[];\n    size: 'compact' | 'default' | 'large';\n};\n\nexport type DataTableProps = z.infer<typeof ItemTableListPropsSchema>;\nexport type ItemDetailListProps = z.infer<typeof ItemDetailListPropsSchema>;\nexport type ItemListSettings = {\n    detail?: ItemDetailListProps;\n    display: ListDisplayType;\n    grid: DataGridProps;\n    itemsPerPage: number;\n    pagination: ListPaginationType;\n    table: DataTableProps;\n};\n\nexport type PlayerFilter = z.infer<typeof PlayerFilterSchema>;\n\nexport type PlayerFilterField = z.infer<typeof PlayerFilterFieldSchema>;\n\nexport type PlayerFilterOperator = z.infer<typeof PlayerFilterOperatorSchema>;\n\nexport interface SettingsSlice extends z.infer<typeof SettingsStateSchema> {\n    actions: {\n        addCollection: (collection: SavedCollection) => void;\n        removeCollection: (id: string) => void;\n        reset: () => void;\n        resetSampleRate: () => void;\n        setArtistItems: (item: SortableItem<ArtistItem>[]) => void;\n        setArtistReleaseTypeItems: (item: SortableItem<ArtistReleaseTypeItem>[]) => void;\n        setGenreBehavior: (target: GenreTarget) => void;\n        setHomeItems: (item: SortableItem<HomeItem>[]) => void;\n        setList: (type: ItemListKey, data: DeepPartial<ItemListSettings>) => void;\n        setPlaybackFilters: (filters: PlayerFilter[]) => void;\n        setPlayerItems: (items: SortableItem<PlayerItem>[]) => void;\n        setPlaylistBehavior: (target: PlaylistTarget) => void;\n        setSettings: (data: DeepPartial<SettingsState>) => void;\n        setSidebarItems: (items: SidebarItemType[]) => void;\n        setTable: (type: ItemListKey, data: DataTableProps) => void;\n        setTranscodingConfig: (config: TranscodingConfig) => void;\n        toggleMediaSession: () => void;\n        toggleSidebarCollapseShare: () => void;\n        updateCollection: (id: string, updates: Partial<Omit<SavedCollection, 'id'>>) => void;\n    };\n}\nexport interface SettingsState extends z.infer<typeof SettingsStateSchema> {}\nexport type SidebarItemType = z.infer<typeof SidebarItemTypeSchema>;\n\nexport type SideQueueLayout = z.infer<typeof SideQueueLayoutSchema>;\nexport type SideQueueType = z.infer<typeof SideQueueTypeSchema>;\n\nexport type SortableItem<T extends string> = {\n    disabled: boolean;\n    id: T;\n};\n\nexport type TranscodingConfig = z.infer<typeof TranscodingConfigSchema>;\n\nexport type VersionedSettings = SettingsState & { version: number };\n\nexport const playerItems: SortableItem<PlayerItem>[] = [\n    {\n        disabled: true,\n        id: PlayerItem.BIT_DEPTH,\n    },\n    {\n        disabled: true,\n        id: PlayerItem.BIT_RATE,\n    },\n    {\n        disabled: true,\n        id: PlayerItem.BPM,\n    },\n    {\n        disabled: false,\n        id: PlayerItem.CODEC,\n    },\n    {\n        disabled: true,\n        id: PlayerItem.DISC_NUMBER,\n    },\n    {\n        disabled: true,\n        id: PlayerItem.GENRES,\n    },\n    {\n        disabled: true,\n        id: PlayerItem.RELEASE_DATE,\n    },\n    {\n        disabled: true,\n        id: PlayerItem.RELEASE_TYPE,\n    },\n    {\n        disabled: false,\n        id: PlayerItem.RELEASE_YEAR,\n    },\n    {\n        disabled: true,\n        id: PlayerItem.SAMPLE_RATE,\n    },\n    {\n        disabled: true,\n        id: PlayerItem.TRACK_NUMBER,\n    },\n];\n\nexport const sidebarItems: SidebarItemType[] = [\n    {\n        disabled: true,\n        id: 'Now Playing',\n        label: i18n.t('page.sidebar.nowPlaying'),\n        route: AppRoute.NOW_PLAYING,\n    },\n    {\n        disabled: true,\n        id: 'Search',\n        label: i18n.t('page.sidebar.search'),\n        route: generatePath(AppRoute.SEARCH, { itemType: LibraryItem.SONG }),\n    },\n    { disabled: false, id: 'Home', label: i18n.t('page.sidebar.home'), route: AppRoute.HOME },\n    {\n        disabled: false,\n        id: 'Favorites',\n        label: i18n.t('page.sidebar.favorites'),\n        route: AppRoute.FAVORITES,\n    },\n    {\n        disabled: false,\n        id: 'Albums',\n        label: i18n.t('page.sidebar.albums'),\n        route: AppRoute.LIBRARY_ALBUMS,\n    },\n    {\n        disabled: false,\n        id: 'Tracks',\n        label: i18n.t('page.sidebar.tracks'),\n        route: AppRoute.LIBRARY_SONGS,\n    },\n    {\n        disabled: false,\n        id: 'Artists',\n        label: i18n.t('page.sidebar.albumArtists'),\n        route: AppRoute.LIBRARY_ALBUM_ARTISTS,\n    },\n    {\n        disabled: false,\n        id: 'Artists-all',\n        label: i18n.t('page.sidebar.artists'),\n        route: AppRoute.LIBRARY_ARTISTS,\n    },\n    {\n        disabled: false,\n        id: 'Genres',\n        label: i18n.t('page.sidebar.genres'),\n        route: AppRoute.LIBRARY_GENRES,\n    },\n    {\n        disabled: false,\n        id: 'Folders',\n        label: i18n.t('page.sidebar.folders'),\n        route: AppRoute.LIBRARY_FOLDERS,\n    },\n    {\n        disabled: true,\n        id: 'Playlists',\n        label: i18n.t('page.sidebar.playlists'),\n        route: AppRoute.PLAYLISTS,\n    },\n    {\n        disabled: false,\n        id: 'Collections',\n        label: i18n.t('page.sidebar.collections'),\n        route: '',\n    },\n    {\n        disabled: false,\n        id: 'Radio',\n        label: i18n.t('page.sidebar.radio'),\n        route: AppRoute.RADIO,\n    },\n    {\n        disabled: true,\n        id: 'Settings',\n        label: i18n.t('page.sidebar.settings'),\n        route: AppRoute.SETTINGS,\n    },\n];\n\nconst homeItems = Object.values(HomeItem).map((item) => ({\n    disabled: false,\n    id: item,\n}));\n\nconst artistItems = Object.values(ArtistItem).map((item) => ({\n    disabled: false,\n    id: item,\n}));\n\nconst artistReleaseTypeItems = Object.values(ArtistReleaseTypeItem).map((item) => ({\n    disabled: false,\n    id: item,\n}));\n\n// Determines the default/initial windowBarStyle value based on the current platform.\nconst getPlatformDefaultWindowBarStyle = (): Platform => {\n    if (utils?.isWindows()) {\n        return Platform.WINDOWS;\n    }\n\n    if (utils?.isMacOS()) {\n        return Platform.MACOS;\n    }\n\n    if (utils?.isLinux()) {\n        return Platform.WINDOWS;\n    }\n\n    return Platform.WEB;\n};\n\nconst platformDefaultWindowBarStyle: Platform = getPlatformDefaultWindowBarStyle();\n\nconst initialState: SettingsState = {\n    autoDJ: {\n        enabled: false,\n        itemCount: 5,\n        timing: 1,\n    },\n    css: {\n        content: '',\n        enabled: false,\n    },\n    discord: {\n        clientId: '1165957668758900787',\n        displayType: DiscordDisplayType.FEISHIN,\n        enabled: false,\n        linkType: DiscordLinkType.NONE,\n        showAsListening: false,\n        showPaused: true,\n        showServerImage: false,\n        showStateIcon: true,\n    },\n    font: {\n        builtIn: 'Inter',\n        custom: null,\n        system: null,\n        type: FontType.BUILT_IN,\n    },\n    general: {\n        accent: 'rgb(53, 116, 252)',\n        albumBackground: false,\n        albumBackgroundBlur: 3,\n        artistBackground: true,\n        artistBackgroundBlur: 3,\n        artistItems,\n        artistRadioCount: 20,\n        artistReleaseTypeItems,\n        autoSave: {\n            count: 10,\n            enabled: false,\n        },\n        blurExplicitImages: false,\n        buttonSize: 15,\n        collections: [],\n        combinedLyricsAndVisualizer: false,\n        disabledContextMenu: {},\n        enableGridMultiSelect: false,\n        externalLinks: true,\n        followCurrentSong: true,\n        followSystemTheme: false,\n        genreTarget: GenreTarget.TRACK,\n        homeFeature: true,\n        homeFeatureStyle: HomeFeatureStyle.SINGLE,\n        homeItems,\n        imageRes: {\n            fullScreenPlayer: 0,\n            header: 300,\n            itemCard: 300,\n            sidebar: 400,\n            table: 80,\n        },\n        language: 'en',\n        lastFM: true,\n        lastfmApiKey: '',\n        listenBrainz: true,\n        musicBrainz: true,\n        nativeAspectRatio: false,\n        nativeSpotify: false,\n        passwordStore: undefined,\n        pathReplace: '',\n        pathReplaceWith: '',\n        playButtonBehavior: Play.NOW,\n        playerbarOpenDrawer: false,\n        playerbarSlider: {\n            barAlign: BarAlign.CENTER,\n            barGap: 1,\n            barRadius: 4,\n            barWidth: 2,\n            type: PlayerbarSliderType.SLIDER,\n        },\n        playerItems,\n        playlistTarget: PlaylistTarget.TRACK,\n        primaryShade: 6,\n        qobuz: true,\n        resume: true,\n        showLyricsInSidebar: true,\n        showRatings: true,\n        showVisualizerInSidebar: true,\n        sidebarCollapsedNavigation: true,\n        sidebarCollapseShared: false,\n        sidebarItems,\n        sidebarPanelOrder: ['queue', 'lyrics', 'visualizer'],\n        sidebarPlaylistList: true,\n        sidebarPlaylistListFilterRegex: '',\n        sidebarPlaylistSorting: false,\n        sideQueueLayout: 'horizontal',\n        sideQueueType: 'sideQueue',\n        skipButtons: {\n            enabled: false,\n            skipBackwardSeconds: 5,\n            skipForwardSeconds: 10,\n        },\n        spotify: true,\n        theme: AppTheme.DEFAULT_DARK,\n        themeDark: AppTheme.DEFAULT_DARK,\n        themeLight: AppTheme.DEFAULT_LIGHT,\n        useThemeAccentColor: false,\n        useThemePrimaryShade: true,\n        volumeWheelStep: 5,\n        volumeWidth: 70,\n        zoomFactor: 100,\n    },\n    hotkeys: {\n        bindings: {\n            browserBack: { allowGlobal: false, hotkey: '', isGlobal: false },\n            browserForward: { allowGlobal: false, hotkey: '', isGlobal: false },\n            favoriteCurrentAdd: { allowGlobal: true, hotkey: '', isGlobal: false },\n            favoriteCurrentRemove: { allowGlobal: true, hotkey: '', isGlobal: false },\n            favoriteCurrentToggle: { allowGlobal: true, hotkey: '', isGlobal: false },\n            favoritePreviousAdd: { allowGlobal: true, hotkey: '', isGlobal: false },\n            favoritePreviousRemove: { allowGlobal: true, hotkey: '', isGlobal: false },\n            favoritePreviousToggle: { allowGlobal: true, hotkey: '', isGlobal: false },\n            globalSearch: { allowGlobal: false, hotkey: 'mod+k', isGlobal: false },\n            listNavigateToPage: { allowGlobal: false, hotkey: 'mod+g', isGlobal: false },\n            listPlayDefault: { allowGlobal: false, hotkey: 'enter', isGlobal: false },\n            listPlayLast: { allowGlobal: false, hotkey: '', isGlobal: false },\n            listPlayNext: { allowGlobal: false, hotkey: '', isGlobal: false },\n            listPlayNow: { allowGlobal: false, hotkey: '', isGlobal: false },\n            localSearch: { allowGlobal: false, hotkey: 'mod+f', isGlobal: false },\n            navigateHome: { allowGlobal: false, hotkey: '', isGlobal: false },\n            next: { allowGlobal: true, hotkey: '', isGlobal: false },\n            pause: { allowGlobal: true, hotkey: '', isGlobal: false },\n            play: { allowGlobal: true, hotkey: '', isGlobal: false },\n            playPause: { allowGlobal: true, hotkey: 'space', isGlobal: false },\n            previous: { allowGlobal: true, hotkey: '', isGlobal: false },\n            rate0: { allowGlobal: true, hotkey: '', isGlobal: false },\n            rate1: { allowGlobal: true, hotkey: '', isGlobal: false },\n            rate2: { allowGlobal: true, hotkey: '', isGlobal: false },\n            rate3: { allowGlobal: true, hotkey: '', isGlobal: false },\n            rate4: { allowGlobal: true, hotkey: '', isGlobal: false },\n            rate5: { allowGlobal: true, hotkey: '', isGlobal: false },\n            skipBackward: { allowGlobal: true, hotkey: '', isGlobal: false },\n            skipForward: { allowGlobal: true, hotkey: '', isGlobal: false },\n            stop: { allowGlobal: true, hotkey: '', isGlobal: false },\n            toggleFullscreenPlayer: { allowGlobal: false, hotkey: '', isGlobal: false },\n            toggleQueue: { allowGlobal: false, hotkey: '', isGlobal: false },\n            toggleRepeat: { allowGlobal: true, hotkey: '', isGlobal: false },\n            toggleShuffle: { allowGlobal: true, hotkey: '', isGlobal: false },\n            volumeDown: { allowGlobal: true, hotkey: '', isGlobal: false },\n            volumeMute: { allowGlobal: true, hotkey: '', isGlobal: false },\n            volumeUp: { allowGlobal: true, hotkey: '', isGlobal: false },\n            zoomIn: { allowGlobal: true, hotkey: '', isGlobal: false },\n            zoomOut: { allowGlobal: true, hotkey: '', isGlobal: false },\n        },\n        globalMediaHotkeys: true,\n    },\n    lists: {\n        ['albumDetail']: {\n            display: ListDisplayType.TABLE,\n            grid: {\n                itemGap: 'sm',\n                itemsPerRow: 6,\n                itemsPerRowEnabled: false,\n                rows: [],\n                size: 'default',\n            },\n            itemsPerPage: 100,\n            pagination: ListPaginationType.INFINITE,\n            table: {\n                autoFitColumns: true,\n                columns: pickTableColumns({\n                    autoSizeColumns: [],\n                    columns: SONG_TABLE_COLUMNS,\n                    columnWidths: {\n                        [TableColumn.DURATION]: 100,\n                        [TableColumn.TITLE]: 400,\n                        [TableColumn.TRACK_NUMBER]: 50,\n                        [TableColumn.USER_FAVORITE]: 60,\n                    },\n                    enabledColumns: [\n                        TableColumn.TRACK_NUMBER,\n                        TableColumn.TITLE,\n                        TableColumn.DURATION,\n                        TableColumn.USER_FAVORITE,\n                    ],\n                }),\n                enableAlternateRowColors: false,\n                enableHeader: true,\n                enableHorizontalBorders: false,\n                enableRowHoverHighlight: true,\n                enableVerticalBorders: false,\n                size: 'compact',\n            },\n        },\n        fullScreen: {\n            display: ListDisplayType.TABLE,\n            grid: {\n                itemGap: 'sm',\n                itemsPerRow: 6,\n                itemsPerRowEnabled: false,\n                rows: [],\n                size: 'default',\n            },\n            itemsPerPage: 100,\n            pagination: ListPaginationType.INFINITE,\n            table: {\n                autoFitColumns: true,\n                columns: SONG_TABLE_COLUMNS.map((column) => ({\n                    align: column.align,\n                    autoSize: column.autoSize,\n                    id: column.value,\n                    isEnabled: column.isEnabled,\n                    pinned: column.pinned,\n                    width: column.width,\n                })),\n                enableAlternateRowColors: false,\n                enableHeader: true,\n                enableHorizontalBorders: false,\n                enableRowHoverHighlight: true,\n                enableVerticalBorders: false,\n                size: 'default',\n            },\n        },\n        [ItemListKey.PLAYLIST_ALBUM]: {\n            detail: {\n                columns: pickTableColumns({\n                    autoSizeColumns: [],\n                    columns: SONG_TABLE_COLUMNS,\n                    columnWidths: {\n                        [TableColumn.ACTIONS]: 60,\n                        [TableColumn.DURATION]: 100,\n                        [TableColumn.TITLE]: 400,\n                        [TableColumn.TRACK_NUMBER]: 50,\n                        [TableColumn.USER_FAVORITE]: 60,\n                    },\n                    enabledColumns: [\n                        TableColumn.TRACK_NUMBER,\n                        TableColumn.TITLE,\n                        TableColumn.DURATION,\n                        TableColumn.USER_FAVORITE,\n                        TableColumn.ACTIONS,\n                    ],\n                }),\n                enableAlternateRowColors: false,\n                enableHeader: true,\n                enableHorizontalBorders: false,\n                enableRowHoverHighlight: true,\n                enableVerticalBorders: false,\n                size: 'compact',\n            },\n            display: ListDisplayType.GRID,\n            grid: {\n                itemGap: 'sm',\n                itemsPerRow: 6,\n                itemsPerRowEnabled: false,\n                rows: pickGridRows({\n                    alignLeftColumns: [\n                        TableColumn.TITLE,\n                        TableColumn.ALBUM_ARTIST,\n                        TableColumn.YEAR,\n                    ],\n                    columns: ALBUM_TABLE_COLUMNS,\n                    enabledColumns: [TableColumn.TITLE, TableColumn.ALBUM_ARTIST, TableColumn.YEAR],\n                    pickColumns: [\n                        TableColumn.TITLE,\n                        TableColumn.DURATION,\n                        TableColumn.ALBUM_ARTIST,\n                        TableColumn.BIT_RATE,\n                        TableColumn.BPM,\n                        TableColumn.DATE_ADDED,\n                        TableColumn.GENRE,\n                        TableColumn.PLAY_COUNT,\n                        TableColumn.SONG_COUNT,\n                        TableColumn.RELEASE_DATE,\n                        TableColumn.LAST_PLAYED,\n                        TableColumn.YEAR,\n                    ],\n                }),\n                size: 'default',\n            },\n            itemsPerPage: 100,\n            pagination: ListPaginationType.INFINITE,\n            table: {\n                autoFitColumns: true,\n                columns: ALBUM_TABLE_COLUMNS.map((column) => ({\n                    align: column.align,\n                    autoSize: column.autoSize,\n                    id: column.value,\n                    isEnabled: column.isEnabled,\n                    pinned: column.pinned,\n                    width: column.width,\n                })),\n                enableAlternateRowColors: false,\n                enableHeader: true,\n                enableHorizontalBorders: false,\n                enableRowHoverHighlight: true,\n                enableVerticalBorders: false,\n                size: 'default',\n            },\n        },\n        [LibraryItem.ALBUM]: {\n            detail: {\n                columns: pickTableColumns({\n                    autoSizeColumns: [],\n                    columns: SONG_TABLE_COLUMNS,\n                    columnWidths: {\n                        [TableColumn.ACTIONS]: 60,\n                        [TableColumn.DURATION]: 100,\n                        [TableColumn.TITLE]: 400,\n                        [TableColumn.TRACK_NUMBER]: 50,\n                        [TableColumn.USER_FAVORITE]: 60,\n                    },\n                    enabledColumns: [\n                        TableColumn.TRACK_NUMBER,\n                        TableColumn.TITLE,\n                        TableColumn.DURATION,\n                        TableColumn.USER_FAVORITE,\n                        TableColumn.ACTIONS,\n                    ],\n                }),\n                enableAlternateRowColors: false,\n                enableHeader: true,\n                enableHorizontalBorders: false,\n                enableRowHoverHighlight: true,\n                enableVerticalBorders: false,\n                size: 'compact',\n            },\n            display: ListDisplayType.GRID,\n            grid: {\n                itemGap: 'sm',\n                itemsPerRow: 6,\n                itemsPerRowEnabled: false,\n                rows: pickGridRows({\n                    alignLeftColumns: [\n                        TableColumn.TITLE,\n                        TableColumn.ALBUM_ARTIST,\n                        TableColumn.YEAR,\n                    ],\n                    columns: ALBUM_TABLE_COLUMNS,\n                    enabledColumns: [TableColumn.TITLE, TableColumn.ALBUM_ARTIST, TableColumn.YEAR],\n                    pickColumns: [\n                        TableColumn.TITLE,\n                        TableColumn.DURATION,\n                        TableColumn.ALBUM_ARTIST,\n                        TableColumn.BIT_RATE,\n                        TableColumn.BPM,\n                        TableColumn.DATE_ADDED,\n                        TableColumn.GENRE,\n                        TableColumn.PLAY_COUNT,\n                        TableColumn.SONG_COUNT,\n                        TableColumn.RELEASE_DATE,\n                        TableColumn.LAST_PLAYED,\n                        TableColumn.YEAR,\n                    ],\n                }),\n                size: 'default',\n            },\n            itemsPerPage: 100,\n            pagination: ListPaginationType.INFINITE,\n            table: {\n                autoFitColumns: true,\n                columns: ALBUM_TABLE_COLUMNS.map((column) => ({\n                    align: column.align,\n                    autoSize: column.autoSize,\n                    id: column.value,\n                    isEnabled: column.isEnabled,\n                    pinned: column.pinned,\n                    width: column.width,\n                })),\n                enableAlternateRowColors: false,\n                enableHeader: true,\n                enableHorizontalBorders: false,\n                enableRowHoverHighlight: true,\n                enableVerticalBorders: false,\n                size: 'default',\n            },\n        },\n        [LibraryItem.ALBUM_ARTIST]: {\n            display: ListDisplayType.GRID,\n            grid: {\n                itemGap: 'sm',\n                itemsPerRow: 6,\n                itemsPerRowEnabled: false,\n                rows: pickGridRows({\n                    alignLeftColumns: [TableColumn.TITLE],\n                    columns: ALBUM_ARTIST_TABLE_COLUMNS,\n                    enabledColumns: [TableColumn.TITLE],\n                    pickColumns: [\n                        TableColumn.TITLE,\n                        TableColumn.PLAY_COUNT,\n                        TableColumn.ALBUM_COUNT,\n                        TableColumn.SONG_COUNT,\n                    ],\n                }),\n                size: 'default',\n            },\n            itemsPerPage: 100,\n            pagination: ListPaginationType.INFINITE,\n            table: {\n                autoFitColumns: true,\n                columns: pickTableColumns({\n                    autoSizeColumns: [TableColumn.TITLE],\n                    columns: ALBUM_ARTIST_TABLE_COLUMNS,\n                    enabledColumns: [\n                        TableColumn.ROW_INDEX,\n                        TableColumn.IMAGE,\n                        TableColumn.TITLE,\n                        TableColumn.USER_FAVORITE,\n                    ],\n                }),\n                enableAlternateRowColors: false,\n                enableHeader: true,\n                enableHorizontalBorders: false,\n                enableRowHoverHighlight: true,\n                enableVerticalBorders: false,\n                size: 'default',\n            },\n        },\n        [LibraryItem.ARTIST]: {\n            display: ListDisplayType.GRID,\n            grid: {\n                itemGap: 'sm',\n                itemsPerRow: 6,\n                itemsPerRowEnabled: false,\n                rows: pickGridRows({\n                    alignLeftColumns: [TableColumn.TITLE],\n                    columns: ALBUM_ARTIST_TABLE_COLUMNS,\n                    enabledColumns: [TableColumn.TITLE],\n                    pickColumns: [\n                        TableColumn.TITLE,\n                        TableColumn.PLAY_COUNT,\n                        TableColumn.ALBUM_COUNT,\n                        TableColumn.SONG_COUNT,\n                    ],\n                }),\n                size: 'default',\n            },\n            itemsPerPage: 100,\n            pagination: ListPaginationType.INFINITE,\n            table: {\n                autoFitColumns: true,\n                columns: pickTableColumns({\n                    autoSizeColumns: [TableColumn.TITLE],\n                    columns: ALBUM_ARTIST_TABLE_COLUMNS,\n                    enabledColumns: [\n                        TableColumn.ROW_INDEX,\n                        TableColumn.IMAGE,\n                        TableColumn.TITLE,\n                        TableColumn.ALBUM_COUNT,\n                        TableColumn.SONG_COUNT,\n                        TableColumn.PLAY_COUNT,\n                        TableColumn.LAST_PLAYED,\n                        TableColumn.USER_FAVORITE,\n                        TableColumn.USER_RATING,\n                    ],\n                }),\n                enableAlternateRowColors: false,\n                enableHeader: true,\n                enableHorizontalBorders: false,\n                enableRowHoverHighlight: true,\n                enableVerticalBorders: false,\n                size: 'default',\n            },\n        },\n        [LibraryItem.GENRE]: {\n            display: ListDisplayType.TABLE,\n            grid: {\n                itemGap: 'sm',\n                itemsPerRow: 6,\n                itemsPerRowEnabled: false,\n                rows: pickGridRows({\n                    alignLeftColumns: [\n                        TableColumn.TITLE,\n                        TableColumn.SONG_COUNT,\n                        TableColumn.ALBUM_COUNT,\n                    ],\n                    columns: GENRE_TABLE_COLUMNS,\n                    enabledColumns: [\n                        TableColumn.TITLE,\n                        TableColumn.SONG_COUNT,\n                        TableColumn.ALBUM_COUNT,\n                    ],\n                    pickColumns: [\n                        TableColumn.TITLE,\n                        TableColumn.ALBUM_COUNT,\n                        TableColumn.SONG_COUNT,\n                    ],\n                }),\n                size: 'default',\n            },\n            itemsPerPage: 100,\n            pagination: ListPaginationType.INFINITE,\n            table: {\n                autoFitColumns: false,\n                columns: GENRE_TABLE_COLUMNS.map((column) => ({\n                    align: column.align,\n                    autoSize: column.autoSize,\n                    id: column.value,\n                    isEnabled: column.isEnabled,\n                    pinned: column.pinned,\n                    width: column.width,\n                })),\n                enableAlternateRowColors: false,\n                enableHeader: true,\n                enableHorizontalBorders: false,\n                enableRowHoverHighlight: true,\n                enableVerticalBorders: false,\n                size: 'compact',\n            },\n        },\n        [LibraryItem.PLAYLIST]: {\n            display: ListDisplayType.TABLE,\n            grid: {\n                itemGap: 'sm',\n                itemsPerRow: 6,\n                itemsPerRowEnabled: false,\n                rows: pickGridRows({\n                    alignLeftColumns: [TableColumn.TITLE, TableColumn.SONG_COUNT],\n                    columns: PLAYLIST_TABLE_COLUMNS,\n                    enabledColumns: [TableColumn.TITLE],\n                    pickColumns: [TableColumn.TITLE, TableColumn.SONG_COUNT],\n                }),\n                size: 'default',\n            },\n            itemsPerPage: 100,\n            pagination: ListPaginationType.INFINITE,\n            table: {\n                autoFitColumns: true,\n                columns: pickTableColumns({\n                    autoSizeColumns: [TableColumn.TITLE],\n                    columns: PLAYLIST_TABLE_COLUMNS,\n                    enabledColumns: [\n                        TableColumn.ROW_INDEX,\n                        TableColumn.TITLE,\n                        TableColumn.DURATION,\n                        TableColumn.SONG_COUNT,\n                    ],\n                }),\n                enableAlternateRowColors: false,\n                enableHeader: true,\n                enableHorizontalBorders: false,\n                enableRowHoverHighlight: true,\n                enableVerticalBorders: false,\n                size: 'default',\n            },\n        },\n        [LibraryItem.PLAYLIST_SONG]: {\n            display: ListDisplayType.TABLE,\n            grid: {\n                itemGap: 'sm',\n                itemsPerRow: 6,\n                itemsPerRowEnabled: false,\n                rows: pickGridRows({\n                    alignLeftColumns: [TableColumn.TITLE, TableColumn.ARTIST],\n                    columns: PLAYLIST_SONG_TABLE_COLUMNS,\n                    enabledColumns: [TableColumn.TITLE, TableColumn.ARTIST],\n                    pickColumns: [\n                        TableColumn.TITLE,\n                        TableColumn.ARTIST,\n                        TableColumn.DURATION,\n                        TableColumn.YEAR,\n                        TableColumn.BIT_RATE,\n                        TableColumn.BPM,\n                        TableColumn.CODEC,\n                        TableColumn.DATE_ADDED,\n                        TableColumn.GENRE,\n                        TableColumn.LAST_PLAYED,\n                        TableColumn.RELEASE_DATE,\n                        TableColumn.TRACK_NUMBER,\n                    ],\n                }),\n                size: 'default',\n            },\n            itemsPerPage: 100,\n            pagination: ListPaginationType.INFINITE,\n            table: {\n                autoFitColumns: true,\n                columns: PLAYLIST_SONG_TABLE_COLUMNS.map((column) => ({\n                    align: column.align,\n                    autoSize: column.autoSize,\n                    id: column.value,\n                    isEnabled: column.isEnabled,\n                    pinned: column.pinned,\n                    width: column.width,\n                })),\n                enableAlternateRowColors: false,\n                enableHeader: true,\n                enableHorizontalBorders: false,\n                enableRowHoverHighlight: true,\n                enableVerticalBorders: false,\n                size: 'default',\n            },\n        },\n        [LibraryItem.QUEUE_SONG]: {\n            display: ListDisplayType.TABLE,\n            grid: {\n                itemGap: 'sm',\n                itemsPerRow: 6,\n                itemsPerRowEnabled: false,\n                rows: [],\n                size: 'default',\n            },\n            itemsPerPage: 100,\n            pagination: ListPaginationType.INFINITE,\n            table: {\n                autoFitColumns: true,\n                columns: SONG_TABLE_COLUMNS.map((column) => ({\n                    align: column.align,\n                    autoSize: column.autoSize,\n                    id: column.value,\n                    isEnabled: column.isEnabled,\n                    pinned: column.pinned,\n                    width: column.width,\n                })),\n                enableAlternateRowColors: false,\n                enableHeader: true,\n                enableHorizontalBorders: false,\n                enableRowHoverHighlight: true,\n                enableVerticalBorders: false,\n                size: 'default',\n            },\n        },\n        [LibraryItem.SONG]: {\n            display: ListDisplayType.TABLE,\n            grid: {\n                itemGap: 'sm',\n                itemsPerRow: 6,\n                itemsPerRowEnabled: false,\n                rows: pickGridRows({\n                    alignLeftColumns: [TableColumn.TITLE, TableColumn.ARTIST],\n                    columns: SONG_TABLE_COLUMNS,\n                    enabledColumns: [TableColumn.TITLE, TableColumn.ARTIST],\n                    pickColumns: [\n                        TableColumn.TITLE,\n                        TableColumn.ARTIST,\n                        TableColumn.DURATION,\n                        TableColumn.YEAR,\n                        TableColumn.BIT_RATE,\n                        TableColumn.BPM,\n                        TableColumn.CODEC,\n                        TableColumn.DATE_ADDED,\n                        TableColumn.GENRE,\n                        TableColumn.LAST_PLAYED,\n                        TableColumn.RELEASE_DATE,\n                        TableColumn.TRACK_NUMBER,\n                    ],\n                }),\n                size: 'default',\n            },\n            itemsPerPage: 100,\n            pagination: ListPaginationType.PAGINATED,\n            table: {\n                autoFitColumns: true,\n                columns: SONG_TABLE_COLUMNS.map((column) => ({\n                    align: column.align,\n                    autoSize: column.autoSize,\n                    id: column.value,\n                    isEnabled: column.isEnabled,\n                    pinned: column.pinned,\n                    width: column.width,\n                })),\n                enableAlternateRowColors: false,\n                enableHeader: true,\n                enableHorizontalBorders: false,\n                enableRowHoverHighlight: true,\n                enableVerticalBorders: false,\n                size: 'default',\n            },\n        },\n        ['sideQueue']: {\n            display: ListDisplayType.TABLE,\n            grid: {\n                itemGap: 'sm',\n                itemsPerRow: 6,\n                itemsPerRowEnabled: false,\n                rows: [],\n                size: 'default',\n            },\n            itemsPerPage: 100,\n            pagination: ListPaginationType.INFINITE,\n            table: {\n                autoFitColumns: true,\n                columns: pickTableColumns({\n                    autoSizeColumns: [TableColumn.TITLE_COMBINED],\n                    columns: SONG_TABLE_COLUMNS,\n                    enabledColumns: [\n                        TableColumn.ROW_INDEX,\n                        TableColumn.TITLE_COMBINED,\n                        TableColumn.DURATION,\n                        TableColumn.USER_FAVORITE,\n                    ],\n                }),\n                enableAlternateRowColors: false,\n                enableHeader: true,\n                enableHorizontalBorders: false,\n                enableRowHoverHighlight: true,\n                enableVerticalBorders: false,\n                size: 'default',\n            },\n        },\n    },\n    lyrics: {\n        alignment: 'center',\n        delayMs: 0,\n        enableAutoTranslation: false,\n        enableNeteaseTranslation: false,\n        fetch: true,\n        follow: true,\n        preferLocalLyrics: true,\n        showMatch: true,\n        showProvider: true,\n        sources: [LyricSource.NETEASE, LyricSource.LRCLIB],\n        translationApiKey: '',\n        translationApiProvider: '',\n        translationTargetLanguage: 'en',\n    },\n    lyricsDisplay: {\n        default: {\n            fontSize: 24,\n            fontSizeUnsync: 24,\n            gap: 24,\n            gapUnsync: 24,\n        },\n    },\n    playback: {\n        audioDeviceId: undefined,\n        audioFadeOnStatusChange: true,\n        filters: [],\n        mediaSession: false,\n        mpvAudioDeviceId: undefined,\n        mpvExtraParameters: [],\n        mpvProperties: {\n            audioExclusiveMode: 'no',\n            audioFormat: undefined,\n            audioSampleRateHz: 0,\n            gaplessAudio: 'weak',\n            replayGainClip: true,\n            replayGainFallbackDB: undefined,\n            replayGainMode: 'no',\n            replayGainPreampDB: 0,\n        },\n        preservePitch: true,\n        scrobble: {\n            enabled: true,\n            notify: false,\n            scrobbleAtDuration: 240,\n            scrobbleAtPercentage: 75,\n        },\n        transcode: {\n            enabled: false,\n        },\n        type: PlayerType.WEB,\n        webAudio: true,\n    },\n    queryBuilder: {\n        tag: [],\n    },\n    remote: {\n        enabled: false,\n        password: randomString(8),\n        port: 4333,\n        username: 'feishin',\n    },\n    tab: 'general',\n    visualizer: {\n        audiomotionanalyzer: {\n            alphaBars: false,\n            ansiBands: false,\n            barSpace: 0.7,\n            channelLayout: 'single',\n            colorMode: 'gradient',\n            customGradients: [],\n            fadePeaks: true,\n            fftSize: 16384,\n            fillAlpha: 0,\n            frequencyScale: 'log',\n            gradient: 'prism',\n            gravity: 11,\n            ledBars: false,\n            linearAmplitude: false,\n            linearBoost: 4,\n            lineWidth: 1.9,\n            loRes: false,\n            lumiBars: false,\n            maxDecibels: -25,\n            maxFPS: 0,\n            maxFreq: 22050,\n            minDecibels: -85,\n            minFreq: 20,\n            mirror: 0,\n            mode: 10,\n            noteLabels: false,\n            opacity: 1,\n            outlineBars: false,\n            peakFadeTime: 900,\n            peakHoldTime: 500,\n            peakLine: true,\n            presets: audiomotionanalyzerPresets,\n            radial: false,\n            radialInvert: false,\n            radius: 0.7,\n            reflexAlpha: 0.1,\n            reflexBright: 1,\n            reflexFit: false,\n            reflexRatio: 0.5,\n            roundBars: false,\n            showFPS: false,\n            showPeaks: false,\n            showScaleX: false,\n            showScaleY: false,\n            smoothing: 0.6,\n            spinSpeed: 0,\n            splitGradient: false,\n            trueLeds: false,\n            volume: 1,\n            weightingFilter: '',\n        },\n        butterchurn: {\n            blendTime: 2.5,\n            currentPreset: undefined,\n            cyclePresets: true,\n            cycleTime: 30,\n            ignoredPresets: [],\n            includeAllPresets: true,\n            maxFPS: 0,\n            opacity: 1,\n            randomizeNextPreset: true,\n            selectedPresets: [],\n        },\n        type: 'audiomotionanalyzer',\n    },\n    window: {\n        disableAutoUpdate: false,\n        exitToTray: false,\n        minimizeToTray: false,\n        preventSleepOnPlayback: false,\n        releaseChannel: 'latest',\n        startMinimized: false,\n        tray: true,\n        windowBarStyle: platformDefaultWindowBarStyle,\n    },\n};\n\nconst initialStateWithEnv = mergeWith(\n    cloneDeep(initialState),\n    getEnvSettingsOverrides(),\n) as SettingsState;\n\nexport const useSettingsStore = createWithEqualityFn<SettingsSlice>()(\n    persist(\n        devtools(\n            subscribeWithSelector(\n                immer((set) => ({\n                    actions: {\n                        addCollection: (collection: SavedCollection) => {\n                            set((state) => {\n                                state.general.collections.push(collection);\n                            });\n                        },\n                        removeCollection: (id: string) => {\n                            set((state) => {\n                                state.general.collections = state.general.collections.filter(\n                                    (c) => c.id !== id,\n                                );\n                            });\n                        },\n                        reset: () => {\n                            localStorage.removeItem('store_settings');\n                            window.location.reload();\n                        },\n                        resetSampleRate: () => {\n                            set((state) => {\n                                state.playback.mpvProperties.audioSampleRateHz = 0;\n                            });\n                        },\n                        setArtistItems: (items) => {\n                            set((state) => {\n                                state.general.artistItems = items;\n                            });\n                        },\n                        setArtistReleaseTypeItems: (\n                            items: SortableItem<ArtistReleaseTypeItem>[],\n                        ) => {\n                            set((state) => {\n                                state.general.artistReleaseTypeItems = items;\n                            });\n                        },\n                        setGenreBehavior: (target: GenreTarget) => {\n                            set((state) => {\n                                state.general.genreTarget = target;\n                            });\n                        },\n                        setHomeItems: (items: SortableItem<HomeItem>[]) => {\n                            set((state) => {\n                                state.general.homeItems = items;\n                            });\n                        },\n                        setList: (type: ItemListKey, data: DeepPartial<ItemListSettings>) => {\n                            set((state) => {\n                                const listState = state.lists[type];\n\n                                if (listState && data.table) {\n                                    Object.assign(listState.table, data.table);\n                                    delete data.table;\n                                }\n\n                                if (listState && data.detail) {\n                                    if (!listState.detail) {\n                                        const t = listState.table;\n                                        listState.detail = {\n                                            columns: t.columns,\n                                            enableAlternateRowColors: false,\n                                            enableHeader: t.enableHeader,\n                                            enableHorizontalBorders: t.enableHorizontalBorders,\n                                            enableRowHoverHighlight: t.enableRowHoverHighlight,\n                                            enableVerticalBorders: t.enableVerticalBorders,\n                                            size: t.size,\n                                        };\n                                    }\n                                    Object.assign(listState.detail, data.detail);\n                                    delete data.detail;\n                                }\n\n                                if (listState && data.grid) {\n                                    Object.assign(listState.grid, data.grid);\n                                    delete data.grid;\n                                }\n\n                                if (listState) {\n                                    Object.assign(listState, data);\n                                }\n                            });\n                        },\n                        setPlaybackFilters: (filters: PlayerFilter[]) => {\n                            set((state) => {\n                                state.playback.filters = filters;\n                            });\n                        },\n                        setPlayerItems: (items: SortableItem<PlayerItem>[]) => {\n                            set((state) => {\n                                state.general.playerItems = items;\n                            });\n                        },\n                        setPlaylistBehavior: (target: PlaylistTarget) => {\n                            set((state) => {\n                                state.general.playlistTarget = target;\n                            });\n                        },\n                        setSettings: (data) => {\n                            set((state) => {\n                                deepMergeIntoState(state, data);\n                            });\n                        },\n                        setSidebarItems: (items: SidebarItemType[]) => {\n                            set((state) => {\n                                state.general.sidebarItems = items;\n                            });\n                        },\n                        setTable: (type: ItemListKey, data: DataTableProps) => {\n                            set((state) => {\n                                const listState = state.lists[type];\n                                if (listState) {\n                                    listState.table = data;\n                                }\n                            });\n                        },\n                        setTranscodingConfig: (config) => {\n                            set((state) => {\n                                state.playback.transcode = config;\n                            });\n                        },\n                        toggleMediaSession: () => {\n                            set((state) => {\n                                state.playback.mediaSession = !state.playback.mediaSession;\n                            });\n                        },\n                        toggleSidebarCollapseShare: () => {\n                            set((state) => {\n                                state.general.sidebarCollapseShared =\n                                    !state.general.sidebarCollapseShared;\n                            });\n                        },\n                        updateCollection: (\n                            id: string,\n                            updates: Partial<Omit<SavedCollection, 'id'>>,\n                        ) => {\n                            set((state) => {\n                                const idx = state.general.collections.findIndex((c) => c.id === id);\n                                if (idx !== -1) {\n                                    Object.assign(state.general.collections[idx], updates);\n                                }\n                            });\n                        },\n                    },\n                    ...initialStateWithEnv,\n                })),\n            ),\n            { name: 'store_settings' },\n        ),\n        {\n            merge: mergeOverridingColumns,\n            migrate(persistedState, version) {\n                const state = persistedState as SettingsSlice;\n\n                if (version === 8) {\n                    state.general.sidebarItems = state.general.sidebarItems.filter(\n                        (item) => item.id !== 'Folders',\n                    );\n                    state.general.sidebarItems.push({\n                        disabled: false,\n                        id: 'Artists-all',\n                        label: i18n.t('page.sidebar.artists'),\n                        route: AppRoute.LIBRARY_ARTISTS,\n                    });\n                }\n\n                if (version <= 9) {\n                    if (!state.window.releaseChannel) {\n                        state.window.releaseChannel = initialState.window.releaseChannel;\n                    }\n\n                    if (!state.playback.mediaSession) {\n                        state.playback.mediaSession = initialState.playback.mediaSession;\n                    }\n\n                    if (!state.general.artistBackgroundBlur) {\n                        state.general.artistBackgroundBlur =\n                            initialState.general.artistBackgroundBlur;\n                    }\n\n                    if (!state.general.artistBackground) {\n                        state.general.artistBackground = initialState.general.artistBackground;\n                    }\n\n                    state.window.windowBarStyle = Platform.LINUX;\n\n                    return state;\n                }\n\n                if (version <= 10) {\n                    state.general.sidebarItems.push({\n                        disabled: false,\n                        id: 'Favorites',\n                        label: i18n.t('page.sidebar.favorites'),\n                        route: AppRoute.FAVORITES,\n                    });\n                }\n\n                if (version <= 11) {\n                    return {};\n                }\n\n                if (version <= 12) {\n                    state.general.sidebarItems.push({\n                        disabled: false,\n                        id: 'Folders',\n                        label: i18n.t('page.sidebar.folders'),\n                        route: AppRoute.LIBRARY_FOLDERS,\n                    });\n                }\n\n                if (version <= 13) {\n                    state.general.homeItems.push({\n                        disabled: false,\n                        id: HomeItem.GENRES,\n                    });\n                }\n\n                if (version <= 14) {\n                    // Add bitDepth and sampleRate columns to song lists\n\n                    const bitDepthColumn: ItemTableListColumnConfig = {\n                        align: 'center',\n                        autoSize: false,\n                        id: TableColumn.BIT_DEPTH,\n                        isEnabled: false,\n                        pinned: null,\n                        width: 100,\n                    };\n\n                    const sampleRateColumn: ItemTableListColumnConfig = {\n                        align: 'center',\n                        autoSize: false,\n                        id: TableColumn.SAMPLE_RATE,\n                        isEnabled: false,\n                        pinned: null,\n                        width: 100,\n                    };\n\n                    const columns = [bitDepthColumn, sampleRateColumn];\n\n                    state.lists[LibraryItem.SONG]?.table.columns.push(...columns);\n                    state.lists[LibraryItem.PLAYLIST_SONG]?.table.columns.push(...columns);\n                    state.lists[LibraryItem.QUEUE_SONG]?.table.columns.push(...columns);\n                    state.lists['albumDetail']?.table.columns.push(...columns);\n                    state.lists['fullscreen']?.table.columns.push(...columns);\n                    state.lists['sidequeue']?.table.columns.push(...columns);\n                }\n\n                if (version <= 15) {\n                    state.general.sidebarItems.push({\n                        disabled: false,\n                        id: 'Radio',\n                        label: i18n.t('page.sidebar.radio'),\n                        route: AppRoute.RADIO,\n                    });\n                }\n\n                // Version 16 introduced a bug where the release channel may have been reset\n                // to the latest channel. This is to revert it.\n                if (version === 16) {\n                    state.window.releaseChannel = 'beta';\n                }\n\n                if (version <= 17) {\n                    // Migrate lyrics settings from record structure to separate lyrics and lyricsDisplay\n                    if (\n                        state.lyrics &&\n                        typeof state.lyrics === 'object' &&\n                        'default' in state.lyrics\n                    ) {\n                        const oldLyrics = state.lyrics as any;\n                        const defaultSettings = oldLyrics.default || oldLyrics;\n\n                        // Extract display settings\n                        const displaySettings = {\n                            fontSize: defaultSettings.fontSize || 24,\n                            fontSizeUnsync: defaultSettings.fontSizeUnsync || 24,\n                            gap: defaultSettings.gap || 24,\n                            gapUnsync: defaultSettings.gapUnsync || 24,\n                        };\n\n                        // Remove display properties from main settings\n                        // eslint-disable-next-line @typescript-eslint/no-unused-vars\n                        const { fontSize, fontSizeUnsync, gap, gapUnsync, ...mainSettings } =\n                            defaultSettings;\n\n                        state.lyrics = mainSettings;\n                        state.lyricsDisplay = {\n                            default: displaySettings,\n                        };\n                    }\n                }\n\n                if (version <= 18) {\n                    // Add isEnabled property to all existing player filters\n                    if (state.playback?.filters && Array.isArray(state.playback.filters)) {\n                        state.playback.filters = state.playback.filters.map((filter) => ({\n                            ...filter,\n                            isEnabled: true,\n                        }));\n                    }\n                }\n\n                if (version <= 19) {\n                    // Add IDs to presets that don't have them\n                    if (\n                        state.visualizer?.audiomotionanalyzer?.presets &&\n                        Array.isArray(state.visualizer.audiomotionanalyzer.presets)\n                    ) {\n                        state.visualizer.audiomotionanalyzer.presets =\n                            state.visualizer.audiomotionanalyzer.presets.map((preset) => {\n                                if (!preset.id) {\n                                    return {\n                                        ...preset,\n                                        id: nanoid(),\n                                    };\n                                }\n                                return preset;\n                            });\n                    }\n                }\n\n                if (version <= 20) {\n                    // Add TITLE_ARTIST column to SONG and ALBUM table configs\n                    const titleArtistColumn: ItemTableListColumnConfig = {\n                        align: 'start',\n                        autoSize: false,\n                        id: TableColumn.TITLE_ARTIST,\n                        isEnabled: false,\n                        pinned: null,\n                        width: 300,\n                    };\n\n                    const listKeysToUpdate: (LibraryItem | string)[] = [\n                        LibraryItem.SONG,\n                        LibraryItem.ALBUM,\n                        LibraryItem.PLAYLIST_SONG,\n                        LibraryItem.QUEUE_SONG,\n                        ItemListKey.ALBUM_DETAIL,\n                        ItemListKey.FULL_SCREEN,\n                        ItemListKey.SIDE_QUEUE,\n                    ];\n\n                    listKeysToUpdate.forEach((listKey) => {\n                        const listConfig = state.lists[listKey];\n                        if (listConfig?.table?.columns) {\n                            const columns = listConfig.table.columns;\n                            const hasTitleArtist = columns.some(\n                                (col) => col.id === TableColumn.TITLE_ARTIST,\n                            );\n                            if (!hasTitleArtist) {\n                                const titleCombinedIndex = columns.findIndex(\n                                    (col) => col.id === TableColumn.TITLE_COMBINED,\n                                );\n                                if (titleCombinedIndex >= 0) {\n                                    columns.splice(titleCombinedIndex + 1, 0, titleArtistColumn);\n                                } else {\n                                    columns.push(titleArtistColumn);\n                                }\n                            }\n                        }\n                    });\n                }\n\n                if (version <= 21) {\n                    // Add COMPOSER column to SONG and ALBUM table configs\n                    const composerColumn: ItemTableListColumnConfig = {\n                        align: 'start',\n                        autoSize: false,\n                        id: TableColumn.COMPOSER,\n                        isEnabled: false,\n                        pinned: null,\n                        width: 300,\n                    };\n\n                    const listKeysToUpdate: (LibraryItem | string)[] = [\n                        LibraryItem.SONG,\n                        LibraryItem.ALBUM,\n                        LibraryItem.PLAYLIST_SONG,\n                        LibraryItem.QUEUE_SONG,\n                        ItemListKey.ALBUM_DETAIL,\n                        ItemListKey.FULL_SCREEN,\n                        ItemListKey.SIDE_QUEUE,\n                    ];\n\n                    listKeysToUpdate.forEach((listKey) => {\n                        const listConfig = state.lists[listKey];\n                        if (listConfig?.table?.columns) {\n                            const columns = listConfig.table.columns;\n                            const hasComposer = columns.some(\n                                (col) => col.id === TableColumn.COMPOSER,\n                            );\n                            if (!hasComposer) {\n                                const artistIndex = columns.findIndex(\n                                    (col) => col.id === TableColumn.ARTIST,\n                                );\n                                if (artistIndex >= 0) {\n                                    columns.splice(artistIndex + 1, 0, composerColumn);\n                                } else {\n                                    columns.push(composerColumn);\n                                }\n                            }\n                        }\n                    });\n                }\n\n                if (version <= 22) {\n                    // Add enableHeader to all list table configs\n                    Object.keys(state.lists).forEach((listKey) => {\n                        const listConfig = state.lists[listKey as keyof typeof state.lists];\n                        if (\n                            listConfig?.table &&\n                            typeof listConfig.table === 'object' &&\n                            !('enableHeader' in listConfig.table)\n                        ) {\n                            (listConfig.table as any).enableHeader = true;\n                        }\n                    });\n                }\n\n                if (version <= 23) {\n                    // Add FAVORITE_SONGS to album artist page configuration\n                    const hasFavoriteSongs = state.general.artistItems?.some(\n                        (item) => item.id === ArtistItem.FAVORITE_SONGS,\n                    );\n\n                    if (!hasFavoriteSongs) {\n                        state.general.artistItems.push({\n                            disabled: false,\n                            id: ArtistItem.FAVORITE_SONGS,\n                        });\n                    }\n                }\n\n                if (version <= 26) {\n                    // Add ALBUM_GROUP column to the song table config\n                    const listKeysToUpdate: ItemListKey[] = [\n                        ItemListKey.SONG,\n                        ItemListKey.FOLDER,\n                        ItemListKey.PLAYLIST_SONG,\n                        ItemListKey.ALBUM_ARTIST_SONG,\n                        ItemListKey.GENRE_SONG,\n                        ItemListKey.QUEUE_SONG,\n                        ItemListKey.FULL_SCREEN,\n                        ItemListKey.SIDE_QUEUE,\n                    ];\n\n                    listKeysToUpdate.forEach((listKey) => {\n                        const listConfig = state.lists[listKey as keyof typeof state.lists];\n                        if (listConfig?.table?.columns) {\n                            const columns = listConfig.table.columns;\n                            const hasAlbumGroup = columns.some(\n                                (col) => col.id === TableColumn.ALBUM_GROUP,\n                            );\n                            if (!hasAlbumGroup) {\n                                columns.push({\n                                    align: 'start',\n                                    autoSize: false,\n                                    id: TableColumn.ALBUM_GROUP,\n                                    isEnabled: false,\n                                    pinned: 'left',\n                                    width: 200,\n                                });\n                            }\n                        }\n                    });\n                }\n\n                if (version <= 27) {\n                    if (!state.general.sideQueueLayout) {\n                        state.general.sideQueueLayout = initialState.general.sideQueueLayout;\n                    }\n                }\n\n                return persistedState;\n            },\n            name: 'store_settings',\n            version: 27,\n        },\n    ),\n);\n\nexport const useSettingsStoreActions = () => useSettingsStore((state) => state.actions);\n\nexport const usePlaybackSettings = () => useSettingsStore((state) => state.playback, shallow);\n\nexport const useTableSettings = (type: ItemListKey) =>\n    useSettingsStore((state) => state.lists[type as keyof typeof state.lists]);\n\nexport const useGeneralSettings = () => useSettingsStore((state) => state.general, shallow);\n\nexport const usePlaybackType = () => useSettingsStore((state) => state.playback.type, shallow);\n\nexport const usePlayButtonBehavior = () =>\n    useSettingsStore((state) => state.general.playButtonBehavior, shallow);\n\nexport const useWindowSettings = () => useSettingsStore((state) => state.window, shallow);\n\nexport const useHotkeySettings = () => useSettingsStore((state) => state.hotkeys, shallow);\n\nexport const useMpvSettings = () =>\n    useSettingsStore((state) => state.playback.mpvProperties, shallow);\n\nexport const useLyricsSettings = () => useSettingsStore((state) => state.lyrics, shallow);\n\nexport const useLyricsDisplaySettings = (key: string = 'default') =>\n    useSettingsStore((state) => state.lyricsDisplay[key] || state.lyricsDisplay.default, shallow);\n\nexport const useRemoteSettings = () => useSettingsStore((state) => state.remote, shallow);\n\nexport const useFontSettings = () => useSettingsStore((state) => state.font, shallow);\n\nexport const useDiscordSettings = () => useSettingsStore((state) => state.discord, shallow);\n\nexport const useCssSettings = () => useSettingsStore((state) => state.css, shallow);\n\nexport const useQueryBuilderSettings = () =>\n    useSettingsStore((state) => state.queryBuilder, shallow);\n\nconst getSettingsStoreVersion = () => useSettingsStore.persist.getOptions().version!;\n\nexport const useSettingsForExport = (): SettingsState & { version: number } =>\n    useSettingsStore((state) => {\n        // eslint-disable-next-line @typescript-eslint/no-unused-vars -- actions needs to be omitted from the export as it contains store functions\n        const { actions, ...otherSettings } = state;\n        return {\n            ...otherSettings,\n            version: getSettingsStoreVersion(),\n        };\n    });\n\nexport const migrateSettings = (settings: SettingsState, settingsVersion: number): SettingsState =>\n    useSettingsStore.persist.getOptions().migrate!(settings, settingsVersion) as SettingsState;\n\nexport const useListSettings = (type: ItemListKey) =>\n    useSettingsStore(\n        (state) => state.lists[type as keyof typeof state.lists],\n        shallow,\n    ) as ItemListSettings;\n\nexport const usePrimaryColor = () => useSettingsStore((store) => store.general.accent, shallow);\n\nexport const usePlayerbarSlider = () =>\n    useSettingsStore((store) => store.general.playerbarSlider, shallow);\n\nexport const useGenreTarget = () => useSettingsStore((store) => store.general.genreTarget, shallow);\n\nexport const usePlaylistTarget = () =>\n    useSettingsStore((store) => store.general.playlistTarget, shallow);\n\nexport const useLanguage = () => useSettingsStore((state) => state.general.language, shallow);\n\nexport const useAccent = () => useSettingsStore((state) => state.general.accent, shallow);\n\nexport const useNativeAspectRatio = () =>\n    useSettingsStore((state) => state.general.nativeAspectRatio, shallow);\n\nexport const useButtonSize = () => useSettingsStore((state) => state.general.buttonSize, shallow);\n\nexport const useSkipButtons = () => useSettingsStore((state) => state.general.skipButtons, shallow);\n\nexport const useImageRes = () => useSettingsStore((state) => state.general.imageRes, shallow);\n\nexport const useVolumeWidth = () => useSettingsStore((state) => state.general.volumeWidth, shallow);\n\nexport const useFollowCurrentSong = () =>\n    useSettingsStore((state) => state.general.followCurrentSong, shallow);\n\nexport const useThemeSettings = () =>\n    useSettingsStore(\n        (state) => ({\n            followSystemTheme: state.general.followSystemTheme,\n            primaryShade: state.general.primaryShade,\n            theme: state.general.theme,\n            themeDark: state.general.themeDark,\n            themeLight: state.general.themeLight,\n            useThemeAccentColor: state.general.useThemeAccentColor,\n            useThemePrimaryShade: state.general.useThemePrimaryShade,\n        }),\n        shallow,\n    );\n\nexport const useSideQueueType = () =>\n    useSettingsStore((state) => state.general.sideQueueType, shallow);\n\nexport const useSideQueueLayout = () =>\n    useSettingsStore((state) => state.general.sideQueueLayout, shallow);\n\nexport const useVolumeWheelStep = () =>\n    useSettingsStore((state) => state.general.volumeWheelStep, shallow);\n\nexport const useCollections = () => {\n    const collections = useSettingsStore((state) => state.general.collections, shallow);\n\n    return useMemo(\n        () => [...(collections ?? [])].sort((a, b) => a.name.localeCompare(b.name)),\n        [collections],\n    );\n};\n\nexport const useSidebarPlaylistList = () =>\n    useSettingsStore((state) => state.general.sidebarPlaylistList, shallow);\n\nexport const useSidebarPlaylistSorting = () =>\n    useSettingsStore((state) => state.general.sidebarPlaylistSorting, shallow);\n\nexport const useSidebarPlaylistListFilterRegex = () =>\n    useSettingsStore((state) => state.general.sidebarPlaylistListFilterRegex, shallow);\n\nexport const useSidebarItems = () =>\n    useSettingsStore((state) => state.general.sidebarItems, shallow);\n\nexport const usePlayerItems = () => useSettingsStore((state) => state.general.playerItems, shallow);\n\nexport const useSidebarCollapsedNavigation = () =>\n    useSettingsStore((state) => state.general.sidebarCollapsedNavigation, shallow);\n\nexport const usePlayerbarOpenDrawer = () =>\n    useSettingsStore((state) => state.general.playerbarOpenDrawer, shallow);\n\nexport const useShowRatings = () => useSettingsStore((state) => state.general.showRatings, shallow);\n\nexport const useArtistRadioCount = () =>\n    useSettingsStore((state) => state.general.artistRadioCount, shallow);\n\nexport const useArtistBackground = () =>\n    useSettingsStore(\n        (state) => ({\n            artistBackground: state.general.artistBackground,\n            artistBackgroundBlur: state.general.artistBackgroundBlur,\n        }),\n        shallow,\n    );\n\nexport const useAlbumBackground = () =>\n    useSettingsStore(\n        (state) => ({\n            albumBackground: state.general.albumBackground,\n            albumBackgroundBlur: state.general.albumBackgroundBlur,\n        }),\n        shallow,\n    );\n\nexport const useExternalLinks = () =>\n    useSettingsStore(\n        (state) => ({\n            externalLinks: state.general.externalLinks,\n            lastFM: state.general.lastFM,\n            listenBrainz: state.general.listenBrainz,\n            musicBrainz: state.general.musicBrainz,\n            nativeSpotify: state.general.nativeSpotify,\n            qobuz: state.general.qobuz,\n            spotify: state.general.spotify,\n        }),\n        shallow,\n    );\n\nexport const useHomeFeature = () => useSettingsStore((state) => state.general.homeFeature, shallow);\n\nexport const useHomeFeatureStyle = () =>\n    useSettingsStore((state) => state.general.homeFeatureStyle);\n\nexport const useHomeItems = () => useSettingsStore((state) => state.general.homeItems, shallow);\n\nexport const useArtistItems = () => useSettingsStore((state) => state.general.artistItems, shallow);\n\nexport const useArtistReleaseTypeItems = () =>\n    useSettingsStore((state) => state.general.artistReleaseTypeItems, shallow);\n\nexport const useZoomFactor = () => useSettingsStore((state) => state.general.zoomFactor, shallow);\n\nexport const usePathReplace = () =>\n    useSettingsStore(\n        (state) => ({\n            pathReplace: state.general.pathReplace,\n            pathReplaceWith: state.general.pathReplaceWith,\n        }),\n        shallow,\n    );\n\nexport const useLastfmApiKey = () =>\n    useSettingsStore((state) => state.general.lastfmApiKey, shallow);\n\nexport const useSidebarPanelOrder = () =>\n    useSettingsStore((state) => state.general.sidebarPanelOrder, shallow);\n\nexport const useCombinedLyricsAndVisualizer = () =>\n    useSettingsStore((state) => state.general.combinedLyricsAndVisualizer, shallow);\n\nexport const useShowLyricsInSidebar = () =>\n    useSettingsStore((state) => state.general.showLyricsInSidebar, shallow);\n\nexport const useShowVisualizerInSidebar = () =>\n    useSettingsStore((state) => state.general.showVisualizerInSidebar, shallow);\n\nexport const useAutoDJSettings = () => useSettingsStore((store) => store.autoDJ, shallow);\n\nexport const useVisualizerSettings = () => useSettingsStore((store) => store.visualizer, shallow);\n\nexport const subscribeButterchurnPreset = (\n    onChange: (preset: string | undefined, prevPreset: string | undefined) => void,\n) => {\n    return useSettingsStore.subscribe(\n        (state) => state.visualizer.butterchurn.currentPreset,\n        (preset, prevPreset) => {\n            onChange(preset, prevPreset);\n        },\n    );\n};\n\nexport const useButterchurnSettings = () => {\n    return useSettingsStore((store) => {\n        return {\n            blendTime: store.visualizer.butterchurn.blendTime,\n            cyclePresets: store.visualizer.butterchurn.cyclePresets,\n            cycleTime: store.visualizer.butterchurn.cycleTime,\n            ignoredPresets: store.visualizer.butterchurn.ignoredPresets,\n            includeAllPresets: store.visualizer.butterchurn.includeAllPresets,\n            maxFPS: store.visualizer.butterchurn.maxFPS,\n            opacity: store.visualizer.butterchurn.opacity,\n            randomizeNextPreset: store.visualizer.butterchurn.randomizeNextPreset,\n            selectedPresets: store.visualizer.butterchurn.selectedPresets,\n        };\n    }, shallow);\n};\n"
  },
  {
    "path": "src/renderer/store/sleep-timer.store.ts",
    "content": "import { useShallow } from 'zustand/react/shallow';\nimport { createWithEqualityFn } from 'zustand/traditional';\n\nexport type SleepTimerMode = 'endOfSong' | 'timed';\n\ninterface SleepTimerActions {\n    cancelTimer: () => void;\n    setRemaining: (remaining: number) => void;\n    startEndOfSongTimer: () => void;\n    startTimedTimer: (durationSeconds: number) => void;\n}\n\ninterface SleepTimerState {\n    /** Whether the timer is currently active */\n    active: boolean;\n    /** The mode of the timer */\n    mode: SleepTimerMode;\n    /** Remaining seconds (only ticks while playing) */\n    remaining: number;\n}\n\nexport const useSleepTimerStore = createWithEqualityFn<SleepTimerActions & SleepTimerState>()(\n    (set) => ({\n        active: false,\n        cancelTimer: () => {\n            set({\n                active: false,\n                mode: 'timed',\n                remaining: 0,\n            });\n        },\n        mode: 'timed',\n        remaining: 0,\n\n        setRemaining: (remaining: number) => {\n            set({ remaining });\n        },\n\n        startEndOfSongTimer: () => {\n            set({\n                active: true,\n                mode: 'endOfSong',\n                remaining: 0,\n            });\n        },\n\n        startTimedTimer: (durationSeconds: number) => {\n            set({\n                active: true,\n                mode: 'timed',\n                remaining: durationSeconds,\n            });\n        },\n    }),\n);\n\n// Selectors\nexport const useSleepTimerActive = () => useSleepTimerStore((s) => s.active);\nexport const useSleepTimerMode = () => useSleepTimerStore((s) => s.mode);\nexport const useSleepTimerRemaining = () => useSleepTimerStore((s) => s.remaining);\nexport const useSleepTimerActions = () =>\n    useSleepTimerStore(\n        useShallow((s) => ({\n            cancelTimer: s.cancelTimer,\n            setRemaining: s.setRemaining,\n            startEndOfSongTimer: s.startEndOfSongTimer,\n            startTimedTimer: s.startTimedTimer,\n        })),\n    );\n"
  },
  {
    "path": "src/renderer/store/timestamp.store.ts",
    "content": "import { subscribeWithSelector } from 'zustand/middleware';\nimport { createWithEqualityFn } from 'zustand/traditional';\n\ninterface TimestampState {\n    setTimestamp: (timestamp: number) => void;\n    timestamp: number;\n}\n\nexport const useTimestampStoreBase = createWithEqualityFn<TimestampState>()(\n    subscribeWithSelector((set) => ({\n        setTimestamp: (timestamp: number) => {\n            set({ timestamp });\n        },\n        timestamp: 0,\n    })),\n);\n\nexport const subscribePlayerProgress = (\n    onChange: (properties: { timestamp: number }, prev: { timestamp: number }) => void,\n) => {\n    return useTimestampStoreBase.subscribe(\n        (state) => state.timestamp,\n        (timestamp, prevTimestamp) => {\n            onChange({ timestamp }, { timestamp: prevTimestamp });\n        },\n        {\n            equalityFn: (a, b) => {\n                return a === b;\n            },\n        },\n    );\n};\n\nexport const usePlayerProgress = () => {\n    return useTimestampStoreBase((state) => state.timestamp);\n};\n\nexport const usePlayerTimestamp = () => {\n    return useTimestampStoreBase((state) => state.timestamp);\n};\n\nexport const setTimestamp = (timestamp: number) => {\n    useTimestampStoreBase.getState().setTimestamp(timestamp);\n};\n"
  },
  {
    "path": "src/renderer/store/utils.ts",
    "content": "import { del, get, set } from 'idb-keyval';\nimport mergeWith from 'lodash/mergeWith';\nimport { StateStorage } from 'zustand/middleware';\n/**\n * A custom deep merger that will replace all 'columns' items with the persistent\n * state, instead of the default merge behavior. This is important to preserve the user's\n * order, and not lead to an inconsistent state (e.g. multiple 'Favorite' keys)\n * @param persistedState the persistent state\n * @param currentState the current state\n * @returns the a custom deep merge\n */\nexport const mergeOverridingColumns = <T>(persistedState: unknown, currentState: T) => {\n    return mergeWith(currentState, persistedState, (_original, persistent, key) => {\n        if (key === 'columns') {\n            return persistent;\n        }\n\n        return undefined;\n    });\n};\n\nexport const idbStateStorage: StateStorage = {\n    getItem: async (name: string): Promise<null | string> => {\n        return (await get(name)) || null;\n    },\n    removeItem: async (name: string): Promise<void> => {\n        await del(name);\n    },\n    setItem: async (name: string, value: string): Promise<void> => {\n        await set(name, value);\n    },\n};\n\nconst settingsKeys = [\n    'store_settings_autoDJ',\n    'store_settings_general',\n    'store_settings_lists',\n    'store_settings_hotkeys',\n    'store_settings_playback',\n    'store_settings_lyrics',\n    'store_settings_window',\n    'store_settings_discord',\n    'store_settings_font',\n    'store_settings_css',\n    'store_settings_remote',\n    'store_settings_queryBuilder',\n    'store_settings_tab',\n];\n\nexport const splitSettingsStorage: StateStorage = {\n    getItem: (name: string): null | string => {\n        if (name !== 'store_settings') {\n            return localStorage.getItem(name);\n        }\n\n        // Read from all split keys and merge them\n        const keys = settingsKeys;\n\n        // Check if old single key exists (for migration)\n        const oldKeyRaw = localStorage.getItem('store_settings');\n        if (oldKeyRaw && !localStorage.getItem('store_settings_general')) {\n            // Only migrate if split keys don't exist yet\n            try {\n                const oldData = JSON.parse(oldKeyRaw);\n                const splitData: Record<string, unknown> = {};\n                const state = oldData.state || oldData;\n\n                if (state && typeof state === 'object') {\n                    splitData.general = state.general;\n                    splitData.lists = state.lists;\n                    splitData.hotkeys = state.hotkeys;\n                    splitData.playback = state.playback;\n                    splitData.lyrics = state.lyrics;\n                    splitData.window = state.window;\n                    splitData.discord = state.discord;\n                    splitData.font = state.font;\n                    splitData.css = state.css;\n                    splitData.remote = state.remote;\n                    splitData.queryBuilder = state.queryBuilder;\n                    splitData.tab = state.tab;\n\n                    // Save to new split keys\n                    keys.forEach((key) => {\n                        const keyName = key.replace('store_settings_', '');\n                        if (splitData[keyName] !== undefined) {\n                            localStorage.setItem(key, JSON.stringify(splitData[keyName]));\n                        }\n                    });\n\n                    // Store version if it exists\n                    if (oldData.version !== undefined) {\n                        localStorage.setItem('store_settings_version', oldData.version.toString());\n                    }\n                }\n            } catch (e) {\n                // If parsing fails, continue with reading from split keys\n                console.warn('Failed to migrate old settings format:', e);\n            }\n        }\n\n        // Read from all split keys\n        const mergedState: Record<string, unknown> = {};\n        let hasData = false;\n\n        keys.forEach((key) => {\n            const value = localStorage.getItem(key);\n            if (value) {\n                try {\n                    const keyName = key.replace('store_settings_', '');\n                    mergedState[keyName] = JSON.parse(value);\n                    hasData = true;\n                } catch (e) {\n                    console.warn(`Failed to parse ${key}:`, e);\n                }\n            }\n        });\n\n        if (!hasData) {\n            return null;\n        }\n\n        const versionKey = localStorage.getItem('store_settings_version');\n        const version = versionKey ? parseInt(versionKey, 10) : 14;\n\n        return JSON.stringify({\n            state: mergedState,\n            version,\n        });\n    },\n\n    removeItem: (name: string): void => {\n        if (name !== 'store_settings') {\n            localStorage.removeItem(name);\n            return;\n        }\n\n        // Remove all split keys\n        const keys = settingsKeys;\n\n        keys.forEach((key) => {\n            localStorage.removeItem(key);\n        });\n\n        // Also remove old key if it exists\n        localStorage.removeItem('store_settings');\n    },\n\n    setItem: (name: string, value: string): void => {\n        if (name !== 'store_settings') {\n            localStorage.setItem(name, value);\n            return;\n        }\n\n        try {\n            const data = JSON.parse(value);\n            const state = data.state || data;\n\n            const keys = settingsKeys.map((key) => ({\n                key,\n                value: state[key as keyof typeof state],\n            }));\n\n            keys.forEach(({ key, value: keyValue }) => {\n                if (keyValue !== undefined) {\n                    localStorage.setItem(key, JSON.stringify(keyValue));\n                }\n            });\n\n            // Store version separately\n            if (data.version !== undefined) {\n                localStorage.setItem('store_settings_version', data.version.toString());\n            }\n        } catch (e) {\n            console.error('Failed to split settings storage:', e);\n            localStorage.setItem(name, value);\n        }\n    },\n};\n"
  },
  {
    "path": "src/renderer/styles/helpers.ts",
    "content": "const size = {\n    desktop: '320px',\n    mobile: '640px',\n};\n\nexport const device = {\n    desktop: `(max-width: ${size.desktop})`,\n    mobile: `(max-width: ${size.mobile})`,\n};\n"
  },
  {
    "path": "src/renderer/styles/overlayscrollbars.css",
    "content": ".feishin.os-scrollbar {\n    --os-size: var(--scrollbar-size);\n    --os-handle-bg: var(--scrollbar-thumb-bg);\n    --os-handle-bg-hover: var(--scrollbar-thumb-bg-hover);\n    --os-handle-bg-active: var(--scrollbar-thumb-bg-hover);\n    --os-track-bg: var(--scrollbar-track-bg);\n    --os-track-bg-hover: var(--scrollbar-track-bg);\n    --os-track-bg-active: var(--scrollbar-track-bg);\n    --os-padding-perpendicular: 0;\n    --os-padding-axis: 0;\n    --os-track-border-radius: 0;\n    --os-handle-border-radius: 0;\n}\n\n.feishin.os-scrollbar,\n.feishin .os-scrollbar-track,\n.feishin .os-scrollbar-handle {\n    z-index: 200;\n}\n"
  },
  {
    "path": "src/renderer/themes/mantine-theme.tsx",
    "content": "import type { MantineColorsTuple, MantineThemeOverride } from '@mantine/core';\n\nimport { generateColors } from '@mantine/colors-generator';\nimport { createTheme, Loader, rem, Tooltip } from '@mantine/core';\nimport merge from 'lodash/merge';\n\nimport { Spinner } from '/@/shared/components/spinner/spinner';\nimport { AppThemeConfiguration } from '/@/shared/themes/app-theme-types';\n\n// const lightColors: MantineColorsTuple = [\n//     '#f5f5f5',\n//     '#e7e7e7',\n//     '#cdcdcd',\n//     '#b2b2b2',\n//     '#9a9a9a',\n//     '#8b8b8b',\n//     '#848484',\n//     '#717171',\n//     '#656565',\n//     '#575757',\n// ];\n\nconst darkColors: MantineColorsTuple = [\n    '#C9C9C9',\n    '#b8b8b8',\n    '#828282',\n    '#696969',\n    '#424242',\n    '#3b3b3b',\n    '#242424',\n    '#181818',\n    '#1f1f21',\n    '#141414',\n];\n\nconst mantineTheme: MantineThemeOverride = createTheme({\n    autoContrast: true,\n    breakpoints: {\n        '2xl': '120em',\n        '3xl': '160em',\n        lg: '75em',\n        md: '62em',\n        sm: '48em',\n        xl: '88em',\n        xs: '36em',\n    },\n    components: {\n        Loader: Loader.extend({\n            defaultProps: {\n                loaders: { ...Loader.defaultLoaders, spinner: Spinner as any },\n                type: 'spinner',\n            },\n        }),\n        TooltipGroup: Tooltip.Group.extend({\n            defaultProps: {\n                openDelay: 500,\n            },\n        }),\n    },\n    cursorType: 'pointer',\n    defaultRadius: 'sm',\n    focusRing: 'never',\n    fontFamily: 'var(--theme-content-font-family)',\n    fontSizes: {\n        '2xl': rem('20px'),\n        '3xl': rem('24px'),\n        '4xl': rem('28px'),\n        '5xl': rem('32px'),\n        lg: rem('16px'),\n        md: rem('14px'),\n        sm: rem('13px'),\n        xl: rem('18px'),\n        xs: rem('11px'),\n    },\n    fontSmoothing: true,\n    headings: {\n        fontFamily: 'var(--theme-content-font-family)',\n        sizes: {\n            h1: {\n                fontSize: rem('36px'),\n                fontWeight: '900',\n                lineHeight: rem('44px'),\n            },\n            h2: {\n                fontSize: rem('30px'),\n                fontWeight: '900',\n                lineHeight: rem('38px'),\n            },\n            h3: {\n                fontSize: rem('24px'),\n                fontWeight: '900',\n                lineHeight: rem('32px'),\n            },\n            h4: {\n                fontSize: rem('20px'),\n                fontWeight: '900',\n                lineHeight: rem('30px'),\n            },\n        },\n    },\n    lineHeights: {\n        lg: rem('20px'),\n        md: rem('18px'),\n        sm: rem('16px'),\n        xl: rem('24px'),\n        xs: rem('14px'),\n    },\n    luminanceThreshold: 0.3,\n    primaryColor: 'primary',\n    primaryShade: { dark: 5, light: 9 },\n    radius: {\n        lg: rem('12px'),\n        md: rem('5px'),\n        sm: rem('3px'),\n        xl: rem('16px'),\n        xs: rem('3px'),\n    },\n    scale: 1,\n    shadows: {\n        lg: '0 10px 15px rgba(0, 0, 0, 0.1), 0 4px 6px rgba(0, 0, 0, 0.05)',\n        md: '0 4px 6px rgba(0, 0, 0, 0.1), 0 2px 4px rgba(0, 0, 0, 0.06)',\n        sm: '0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06)',\n        xl: '0 20px 25px rgba(0, 0, 0, 0.1), 0 10px 10px rgba(0, 0, 0, 0.04)',\n        xs: '0 1px 2px rgba(0, 0, 0, 0.05)',\n        xxl: '0 25px 50px rgba(0, 0, 0, 0.25)',\n    },\n    spacing: {\n        '0': rem('0px'),\n        '2xl': rem('32px'),\n        '3xl': rem('36px'),\n        '4xl': rem('40px'),\n        lg: rem('16px'),\n        md: rem('12px'),\n        sm: rem('8px'),\n        xl: rem('24px'),\n        xs: rem('4px'),\n    },\n});\n\nexport function createMantineTheme(theme: AppThemeConfiguration): MantineThemeOverride {\n    const primaryColor = theme.colors?.primary ?? '#000';\n\n    const mergedTheme: MantineThemeOverride = merge(\n        {},\n        {\n            ...mantineTheme,\n            black: theme.colors?.black,\n            colors: {\n                dark: darkColors,\n                primary: generateColors(primaryColor),\n            },\n            white: theme.colors?.white,\n        },\n        theme.mantineOverride,\n    );\n    return createTheme(mergedTheme);\n}\n"
  },
  {
    "path": "src/renderer/themes/use-app-theme.ts",
    "content": "import type { MantineThemeOverride } from '@mantine/core';\n\nimport { generateColors } from '@mantine/colors-generator';\nimport { useMantineColorScheme } from '@mantine/core';\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react';\n\nimport {\n    useAccent,\n    useFontSettings,\n    useNativeAspectRatio,\n    useThemeSettings,\n} from '/@/renderer/store/settings.store';\nimport { createMantineTheme } from '/@/renderer/themes/mantine-theme';\nimport { getAppTheme } from '/@/shared/themes/app-theme';\nimport { AppTheme, AppThemeConfiguration } from '/@/shared/themes/app-theme-types';\nimport { FontType } from '/@/shared/types/types';\n\nexport const THEME_DATA = [\n    { label: 'Default Dark', type: 'dark', value: AppTheme.DEFAULT_DARK },\n    { label: 'Default Light', type: 'light', value: AppTheme.DEFAULT_LIGHT },\n    { label: 'Nord', type: 'dark', value: AppTheme.NORD },\n    { label: 'Dracula', type: 'dark', value: AppTheme.DRACULA },\n    { label: 'One Dark', type: 'dark', value: AppTheme.ONE_DARK },\n    { label: 'Solarized Dark', type: 'dark', value: AppTheme.SOLARIZED_DARK },\n    { label: 'Solarized Light', type: 'light', value: AppTheme.SOLARIZED_LIGHT },\n    { label: 'GitHub Dark', type: 'dark', value: AppTheme.GITHUB_DARK },\n    { label: 'GitHub Light', type: 'light', value: AppTheme.GITHUB_LIGHT },\n    { label: 'Glassy Dark', type: 'dark', value: AppTheme.GLASSY_DARK },\n    { label: 'Monokai', type: 'dark', value: AppTheme.MONOKAI },\n    { label: 'High Contrast Dark', type: 'dark', value: AppTheme.HIGH_CONTRAST_DARK },\n    { label: 'High Contrast Light', type: 'light', value: AppTheme.HIGH_CONTRAST_LIGHT },\n    { label: 'Tokyo Night', type: 'dark', value: AppTheme.TOKYO_NIGHT },\n    { label: 'Catppuccin Mocha', type: 'dark', value: AppTheme.CATPPUCCIN_MOCHA },\n    { label: 'Catppuccin Latte', type: 'light', value: AppTheme.CATPPUCCIN_LATTE },\n    { label: 'Gruvbox Dark', type: 'dark', value: AppTheme.GRUVBOX_DARK },\n    { label: 'Gruvbox Light', type: 'light', value: AppTheme.GRUVBOX_LIGHT },\n    { label: 'Night Owl', type: 'dark', value: AppTheme.NIGHT_OWL },\n    { label: 'Material Dark', type: 'dark', value: AppTheme.MATERIAL_DARK },\n    { label: 'Material Light', type: 'light', value: AppTheme.MATERIAL_LIGHT },\n    { label: 'Ayu Dark', type: 'dark', value: AppTheme.AYU_DARK },\n    { label: 'Ayu Light', type: 'light', value: AppTheme.AYU_LIGHT },\n    { label: 'Shades of Purple', type: 'dark', value: AppTheme.SHADES_OF_PURPLE },\n    { label: 'VS Code Dark+', type: 'dark', value: AppTheme.VSCODE_DARK_PLUS },\n    { label: 'VS Code Light+', type: 'light', value: AppTheme.VSCODE_LIGHT_PLUS },\n    { label: 'Rosé Pine', type: 'dark', value: AppTheme.ROSE_PINE },\n    { label: 'Rosé Pine Moon', type: 'dark', value: AppTheme.ROSE_PINE_MOON },\n    { label: 'Rosé Pine Dawn', type: 'light', value: AppTheme.ROSE_PINE_DAWN },\n];\n\nexport const useAppTheme = (overrideTheme?: AppTheme) => {\n    const accent = useAccent();\n    const nativeImageAspect = useNativeAspectRatio();\n    const { builtIn, custom, system, type } = useFontSettings();\n    const textStyleRef = useRef<HTMLStyleElement | null>(null);\n    const themeInlineStylesRef = useRef<HTMLStyleElement | null>(null);\n    const getCurrentTheme = () => window.matchMedia('(prefers-color-scheme: dark)').matches;\n    const [isDarkTheme, setIsDarkTheme] = useState(getCurrentTheme());\n    const {\n        followSystemTheme,\n        primaryShade,\n        theme,\n        themeDark,\n        themeLight,\n        useThemeAccentColor,\n        useThemePrimaryShade,\n    } = useThemeSettings();\n\n    const mqListener = (e: any) => {\n        setIsDarkTheme(e.matches);\n    };\n\n    const applyInlineStylesheets = useCallback((inlineCssStrings: string[] = []) => {\n        const cssText = inlineCssStrings.filter(Boolean).join('\\n');\n\n        if (!themeInlineStylesRef.current) {\n            const styleEl = document.createElement('style');\n            styleEl.id = 'theme-inline-styles';\n            document.head.appendChild(styleEl);\n            themeInlineStylesRef.current = styleEl;\n        }\n\n        themeInlineStylesRef.current.textContent = cssText;\n    }, []);\n\n    const getSelectedTheme = () => {\n        if (overrideTheme) {\n            return overrideTheme;\n        }\n\n        if (followSystemTheme) {\n            return isDarkTheme ? themeDark : themeLight;\n        }\n\n        return theme;\n    };\n\n    const selectedTheme = getSelectedTheme();\n\n    useEffect(() => {\n        const darkThemeMq = window.matchMedia('(prefers-color-scheme: dark)');\n        darkThemeMq.addListener(mqListener);\n        return () => darkThemeMq.removeListener(mqListener);\n    }, []);\n\n    useEffect(() => {\n        if (type === FontType.SYSTEM && system) {\n            const root = document.documentElement;\n            root.style.setProperty(\n                '--theme-content-font-family',\n                'dynamic-font, \"Noto Sans JP\", \"Noto Sans Hebrew\", sans-serif',\n            );\n\n            if (!textStyleRef.current) {\n                textStyleRef.current = document.createElement('style');\n                document.body.appendChild(textStyleRef.current);\n            }\n\n            textStyleRef.current.textContent = `\n            @font-face {\n                font-family: \"dynamic-font\";\n                src: local(\"${system}\");\n            }`;\n        } else if (type === FontType.CUSTOM && custom) {\n            const root = document.documentElement;\n            root.style.setProperty(\n                '--theme-content-font-family',\n                'dynamic-font, \"Noto Sans JP\", \"Noto Sans Hebrew\", sans-serif',\n            );\n\n            if (!textStyleRef.current) {\n                textStyleRef.current = document.createElement('style');\n                document.body.appendChild(textStyleRef.current);\n            }\n\n            textStyleRef.current.textContent = `\n            @font-face {\n                font-family: \"dynamic-font\";\n                src: url(\"feishin:${custom}\");\n            }`;\n        } else {\n            const root = document.documentElement;\n            root.style.setProperty(\n                '--theme-content-font-family',\n                `${builtIn}, \"Noto Sans JP\", \"Noto Sans Hebrew\", sans-serif`,\n            );\n        }\n    }, [builtIn, custom, system, type]);\n\n    const appTheme: AppThemeConfiguration = useMemo(() => {\n        const themeProperties = getAppTheme(selectedTheme);\n\n        // Use theme's primary color if useThemeAccentColor is enabled, otherwise use custom accent\n        const primaryColor = useThemeAccentColor\n            ? themeProperties.colors?.primary || themeProperties.colors?.['state-info'] || accent\n            : accent;\n\n        // Use theme's primary shade if useThemePrimaryShade is enabled, otherwise use slider value (0-9)\n        const effectivePrimaryShade: MantineThemeOverride['primaryShade'] = useThemePrimaryShade\n            ? themeProperties.mantineOverride?.primaryShade\n            : ({ dark: primaryShade, light: primaryShade } as MantineThemeOverride['primaryShade']);\n\n        return {\n            ...themeProperties,\n            colors: {\n                ...themeProperties.colors,\n                primary: primaryColor,\n            },\n            mantineOverride: {\n                ...themeProperties.mantineOverride,\n                ...(effectivePrimaryShade != null && { primaryShade: effectivePrimaryShade }),\n            },\n        };\n    }, [accent, primaryShade, selectedTheme, useThemeAccentColor, useThemePrimaryShade]);\n\n    useEffect(() => {\n        const root = document.documentElement;\n        const themeProperties = getAppTheme(selectedTheme);\n        const primaryColor = useThemeAccentColor\n            ? themeProperties.colors?.primary || themeProperties.colors?.['state-info'] || accent\n            : accent;\n        const effectivePrimaryShade: MantineThemeOverride['primaryShade'] = useThemePrimaryShade\n            ? themeProperties.mantineOverride?.primaryShade\n            : ({ dark: primaryShade, light: primaryShade } as MantineThemeOverride['primaryShade']);\n        const mode = themeProperties.mode ?? (isDarkTheme ? 'dark' : 'light');\n        const shadeIndex = Math.min(\n            9,\n            Math.max(\n                0,\n                typeof effectivePrimaryShade === 'object'\n                    ? (effectivePrimaryShade?.[mode] ?? 6)\n                    : (effectivePrimaryShade ?? 6),\n            ),\n        );\n        const primaryScale = generateColors(primaryColor);\n        const primaryAtShade = primaryScale[shadeIndex];\n        root.style.setProperty('--theme-colors-primary', primaryAtShade);\n    }, [\n        accent,\n        isDarkTheme,\n        primaryShade,\n        selectedTheme,\n        useThemeAccentColor,\n        useThemePrimaryShade,\n    ]);\n\n    useEffect(() => {\n        const root = document.documentElement;\n        root.style.setProperty('--theme-image-fit', nativeImageAspect ? 'contain' : 'cover');\n    }, [nativeImageAspect]);\n\n    useEffect(() => {\n        applyInlineStylesheets(appTheme?.stylesheets ?? []);\n    }, [selectedTheme, appTheme?.stylesheets, applyInlineStylesheets]);\n\n    const themeVars = useMemo(() => {\n        return Object.entries(appTheme?.app ?? {})\n            .map(([key, value]) => {\n                return [`--theme-${key}`, value];\n            })\n            .filter(Boolean) as [string, string][];\n    }, [appTheme]);\n\n    const colorVars = useMemo(() => {\n        return Object.entries(appTheme?.colors ?? {})\n            .map(([key, value]) => {\n                return [`--theme-colors-${key}`, value];\n            })\n            .filter(Boolean) as [string, string][];\n    }, [appTheme]);\n\n    useEffect(() => {\n        document.documentElement.setAttribute('data-theme', selectedTheme);\n\n        if (themeVars.length > 0 || colorVars.length > 0) {\n            let styleElement = document.getElementById('theme-variables');\n            if (!styleElement) {\n                styleElement = document.createElement('style');\n                styleElement.id = 'theme-variables';\n                document.head.appendChild(styleElement);\n            }\n\n            let cssText = ':root {\\n';\n\n            for (const [key, value] of themeVars) {\n                cssText += `  ${key}: ${value};\\n`;\n            }\n\n            for (const [key, value] of colorVars) {\n                cssText += `  ${key}: ${value};\\n`;\n            }\n\n            cssText += '}';\n\n            styleElement.textContent = cssText;\n        }\n    }, [colorVars, selectedTheme, themeVars]);\n\n    const mantineTheme = useMemo(\n        () => createMantineTheme(appTheme as AppThemeConfiguration),\n        [appTheme],\n    );\n\n    return {\n        mode: appTheme?.mode || 'dark',\n        theme: mantineTheme,\n    };\n};\n\nexport const useSetColorScheme = () => {\n    const { setColorScheme } = useMantineColorScheme();\n\n    return { setColorScheme };\n};\n\nexport const useColorScheme = () => {\n    const { colorScheme } = useMantineColorScheme();\n\n    return colorScheme === 'dark' ? 'dark' : 'light';\n};\n\nexport const useAppThemeColors = () => {\n    const accent = useAccent();\n    const getCurrentTheme = () => window.matchMedia('(prefers-color-scheme: dark)').matches;\n    const [isDarkTheme] = useState(getCurrentTheme());\n    const {\n        followSystemTheme,\n        primaryShade,\n        theme,\n        themeDark,\n        themeLight,\n        useThemeAccentColor,\n        useThemePrimaryShade,\n    } = useThemeSettings();\n\n    const getSelectedTheme = () => {\n        if (followSystemTheme) {\n            return isDarkTheme ? themeDark : themeLight;\n        }\n\n        return theme;\n    };\n\n    const selectedTheme = getSelectedTheme();\n\n    const appTheme: AppThemeConfiguration = useMemo(() => {\n        const themeProperties = getAppTheme(selectedTheme);\n\n        // Use theme's primary color if useThemeAccentColor is enabled, otherwise use custom accent\n        const primaryColor = useThemeAccentColor\n            ? themeProperties.colors?.primary || themeProperties.colors?.['state-info'] || accent\n            : accent;\n\n        // Use theme's primary shade if useThemePrimaryShade is enabled, otherwise use slider value (0-9)\n        const effectivePrimaryShade: MantineThemeOverride['primaryShade'] = useThemePrimaryShade\n            ? themeProperties.mantineOverride?.primaryShade\n            : ({ dark: primaryShade, light: primaryShade } as MantineThemeOverride['primaryShade']);\n\n        return {\n            ...themeProperties,\n            colors: {\n                ...themeProperties.colors,\n                primary: primaryColor,\n            },\n            mantineOverride: {\n                ...themeProperties.mantineOverride,\n                ...(effectivePrimaryShade != null && { primaryShade: effectivePrimaryShade }),\n            },\n        };\n    }, [accent, primaryShade, selectedTheme, useThemeAccentColor, useThemePrimaryShade]);\n\n    const themeVars = useMemo(() => {\n        return Object.entries(appTheme?.app ?? {})\n            .map(([key, value]) => {\n                return [`--theme-${key}`, value];\n            })\n            .filter(Boolean) as [string, string][];\n    }, [appTheme]);\n\n    const colorVars = useMemo(() => {\n        return Object.entries(appTheme?.colors ?? {})\n            .map(([key, value]) => {\n                return [`--theme-colors-${key}`, value];\n            })\n            .filter(Boolean) as [string, string][];\n    }, [appTheme]);\n\n    return {\n        color: Object.fromEntries(colorVars),\n        theme: Object.fromEntries(themeVars),\n    };\n};\n"
  },
  {
    "path": "src/renderer/types/emotion.d.ts",
    "content": "import '@emotion/react';\nimport type { MantineTheme } from '@mantine/core';\n\ndeclare module '@emotion/react' {\n    export interface GREY extends MantineTheme {}\n}\n"
  },
  {
    "path": "src/renderer/types/fonts.ts",
    "content": "import { z } from 'zod';\n\nexport type Font = {\n    label: string;\n    value: string;\n};\n\nexport const FONT_OPTIONS: Font[] = [\n    { label: 'Inter', value: 'Inter' },\n    { label: 'Poppins', value: 'Poppins' },\n];\n\nexport const FontValueSchema = z.enum(\n    FONT_OPTIONS.map((option) => option.value) as [string, ...string[]],\n);\n"
  },
  {
    "path": "src/renderer/update-available-dialog.tsx",
    "content": "import isElectron from 'is-electron';\nimport { useEffect, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport { Button } from '/@/shared/components/button/button';\nimport { Dialog } from '/@/shared/components/dialog/dialog';\nimport { Group } from '/@/shared/components/group/group';\nimport { Icon } from '/@/shared/components/icon/icon';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { Text } from '/@/shared/components/text/text';\nimport { useLocalStorage } from '/@/shared/hooks/use-local-storage';\n\nexport const UpdateAvailableDialog = () => {\n    const [opened, setOpened] = useState(false);\n    const [version, setVersion] = useState<string>('');\n    const { t } = useTranslation();\n    const [versionDismissed, setVersionDismissed] = useLocalStorage<string>({\n        key: 'version_dismissed',\n    });\n\n    useEffect(() => {\n        if (!isElectron()) return;\n\n        const handleUpdateAvailable = (_event: any, newVersion: string) => {\n            if (versionDismissed !== newVersion) {\n                setVersion(newVersion);\n                setOpened(true);\n            }\n        };\n\n        window.api.ipc.on('update-available', handleUpdateAvailable);\n\n        return () => {\n            window.api.ipc.removeListener?.('update-available', handleUpdateAvailable);\n        };\n    }, [versionDismissed]);\n\n    if (!opened) return null;\n\n    const handleDismiss = () => {\n        if (version) {\n            setVersionDismissed(version);\n        }\n        setOpened(false);\n    };\n\n    return (\n        <Dialog\n            onClose={handleDismiss}\n            opened={opened}\n            position={{ bottom: 100, right: 12 }}\n            radius=\"md\"\n            size=\"lg\"\n            withCloseButton\n        >\n            <Stack gap=\"md\">\n                <Text fw={700} size=\"md\">\n                    {t('common.newVersionAvailable', { postProcess: 'sentenceCase' })} - {version}\n                </Text>\n                <Group justify=\"flex-end\">\n                    <Button onClick={handleDismiss} size=\"xs\" variant=\"default\">\n                        {t('common.dismiss', { postProcess: 'titleCase' })}\n                    </Button>\n                    <Button\n                        component=\"a\"\n                        href=\"https://github.com/jeffvli/feishin/releases/latest\"\n                        onClick={handleDismiss}\n                        rightSection={<Icon icon=\"externalLink\" size=\"sm\" />}\n                        size=\"xs\"\n                        target=\"_blank\"\n                        variant=\"filled\"\n                    >\n                        {t('action.viewMore', { postProcess: 'titleCase' })}\n                    </Button>\n                </Group>\n            </Stack>\n        </Dialog>\n    );\n};\n"
  },
  {
    "path": "src/renderer/utils/constrain-sidebar-width.ts",
    "content": "export const constrainSidebarWidth = (num: number) => {\n    if (num < 260) {\n        return 260;\n    }\n\n    if (num > 400) {\n        return 400;\n    }\n\n    return num;\n};\n\nexport const constrainRightSidebarWidth = (num: number) => {\n    if (num < 250) {\n        return 250;\n    }\n\n    if (num > 960) {\n        return 960;\n    }\n\n    return num;\n};\n"
  },
  {
    "path": "src/renderer/utils/format.tsx",
    "content": "import type { Album, AlbumArtist, Song } from '/@/shared/types/domain-types';\n\nimport dayjs from 'dayjs';\nimport 'dayjs/locale/ar';\nimport 'dayjs/locale/ca';\nimport 'dayjs/locale/cs';\nimport 'dayjs/locale/de';\nimport 'dayjs/locale/en';\nimport 'dayjs/locale/es';\nimport 'dayjs/locale/eu';\nimport 'dayjs/locale/fa';\nimport 'dayjs/locale/fi';\nimport 'dayjs/locale/fr';\nimport 'dayjs/locale/hu';\nimport 'dayjs/locale/id';\nimport 'dayjs/locale/it';\nimport 'dayjs/locale/ja';\nimport 'dayjs/locale/ko';\nimport 'dayjs/locale/nb';\nimport 'dayjs/locale/nl';\nimport 'dayjs/locale/pl';\nimport 'dayjs/locale/pt';\nimport 'dayjs/locale/pt-br';\nimport 'dayjs/locale/ru';\nimport 'dayjs/locale/sl';\nimport 'dayjs/locale/sr';\nimport 'dayjs/locale/sv';\nimport 'dayjs/locale/ta';\nimport 'dayjs/locale/tr';\nimport 'dayjs/locale/zh-cn';\nimport 'dayjs/locale/zh-tw';\nimport localizedFormat from 'dayjs/plugin/localizedFormat';\nimport relativeTime from 'dayjs/plugin/relativeTime';\nimport utc from 'dayjs/plugin/utc';\nimport formatDuration from 'format-duration';\n\nimport i18n from '/@/i18n/i18n';\nimport { Rating } from '/@/shared/components/rating/rating';\n\ndayjs.extend(relativeTime);\ndayjs.extend(utc);\ndayjs.extend(localizedFormat);\n\nconst getDayjsLocale = (i18nLang: string): string => {\n    const localeMap: Record<string, string> = {\n        ar: 'ar',\n        ca: 'ca',\n        cs: 'cs',\n        de: 'de',\n        en: 'en',\n        es: 'es',\n        eu: 'eu',\n        fa: 'fa',\n        fi: 'fi',\n        fr: 'fr',\n        hu: 'hu',\n        id: 'id',\n        it: 'it',\n        ja: 'ja',\n        ko: 'ko',\n        'nb-NO': 'nb',\n        nl: 'nl',\n        pl: 'pl',\n        pt: 'pt',\n        'pt-BR': 'pt-br',\n        ru: 'ru',\n        sl: 'sl',\n        sr: 'sr',\n        sv: 'sv',\n        ta: 'ta',\n        tr: 'tr',\n        'zh-Hans': 'zh-cn',\n        'zh-Hant': 'zh-tw',\n    };\n\n    return localeMap[i18nLang] || 'en';\n};\n\nconst updateDayjsLocale = () => {\n    const dayjsLocale = getDayjsLocale(i18n.language);\n    dayjs.locale(dayjsLocale);\n};\n\n// Set initial locale\nupdateDayjsLocale();\n\n// Listen for i18n language changes\ni18n.on('languageChanged', updateDayjsLocale);\n\nexport const formatDateAbsolute = (key: null | string) => (key ? dayjs(key).format('ll') : '');\n\nexport const formatDateAbsoluteUTC = (key: null | string) =>\n    key ? dayjs.utc(key).format('ll') : '';\n\nexport const formatHrDateTime = (key: null | string) => (key ? dayjs(key).format('lll') : '');\n\nexport const formatDateRelative = (key: null | string) => (key ? dayjs(key).fromNow() : '');\n\nexport const formatDurationString = (duration: number) => {\n    const rawDuration = formatDuration(duration, { leading: false }).split(':');\n\n    const formattedDuration = rawDuration.map((part) => {\n        // Remove leading zero\n        return part.replace(/^0/, '');\n    });\n\n    const parts: string[] = [];\n    const len = rawDuration.length;\n\n    if (len >= 1 && formattedDuration[len - 1] !== undefined) {\n        parts.push(`${formattedDuration[len - 1]}${i18n.t('datetime.secondShort')}`);\n    }\n    if (len >= 2 && formattedDuration[len - 2]) {\n        parts.unshift(`${formattedDuration[len - 2]}${i18n.t('datetime.minuteShort')}`);\n    }\n    if (len >= 3 && formattedDuration[len - 3]) {\n        parts.unshift(`${formattedDuration[len - 3]}${i18n.t('datetime.hourShort')}`);\n    }\n    if (len >= 4 && formattedDuration[len - 4]) {\n        parts.unshift(`${formattedDuration[len - 4]}${i18n.t('datetime.dayShort')}`);\n    }\n\n    return parts.join(' ');\n};\n\nexport const formatDurationStringShort = (duration: number) => {\n    const rawDuration = formatDuration(duration).split(':');\n\n    if (rawDuration.length === 4) {\n        return `${rawDuration[0]}${i18n.t('datetime.dayShort')} ${rawDuration[1]}${i18n.t('datetime.hourShort')}`;\n    } else if (rawDuration.length === 3) {\n        return `${rawDuration[0]}${i18n.t('datetime.hourShort')} ${rawDuration[1]}${i18n.t('datetime.minuteShort')}`;\n    } else if (rawDuration.length === 2) {\n        return `${rawDuration[0]}${i18n.t('datetime.minuteShort')} ${rawDuration[1]}${i18n.t('datetime.secondShort')}`;\n    } else if (rawDuration.length === 1) {\n        return `${rawDuration[0]}${i18n.t('datetime.secondShort')}`;\n    }\n\n    return rawDuration;\n};\n\nexport const formatRating = (item: Album | AlbumArtist | Song) =>\n    item.userRating !== null ? <Rating readOnly value={item.userRating} /> : null;\n\nconst SIZES = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];\n\nexport const formatSizeString = (size?: number): string => {\n    let count = 0;\n    let finalSize = size ?? 0;\n    while (finalSize > 1024) {\n        finalSize /= 1024;\n        count += 1;\n    }\n\n    return `${finalSize.toFixed(2)} ${SIZES[count]}`;\n};\n"
  },
  {
    "path": "src/renderer/utils/get-header-color.ts",
    "content": "export const getHeaderColor = (rgbColor: string, opacity?: number) => {\n    return rgbColor.replace('rgb', 'rgba').replace(')', `, ${opacity || 0.8})`);\n};\n"
  },
  {
    "path": "src/renderer/utils/index.ts",
    "content": "export * from './constrain-sidebar-width';\nexport * from './format';\nexport * from './get-header-color';\nexport * from './normalize-server-url';\nexport * from './parse-search-params';\nexport * from './random-string';\nexport * from './rgb-to-rgba';\nexport * from './sentence-case';\nexport * from './set-local-storage-setttings';\nexport * from './title-case';\nexport * from './truncate-middle';\n"
  },
  {
    "path": "src/renderer/utils/linkify.tsx",
    "content": "import { ReactNode } from 'react';\n\n// Inspired by https://github.com/navidrome/navidrome/blob/c530ccf13854e3a840ddf63eef5e2323fbe2827d/ui/src/common/AnchorMe.js\nconst URL_REGEX =\n    /((?:https?:\\/\\/)?(?:[\\w-]{1,32}(?:\\.[\\w-]{1,32})+)(?:\\/[\\w\\-./?%&=][^.|^\\s]*)?)/g;\n\nexport const replaceURLWithHTMLLinks = (text: string) => {\n    const urlRegex = new RegExp(URL_REGEX, 'g');\n    const matches = text.matchAll(urlRegex);\n    const elements: (ReactNode | string)[] = [];\n    let lastIndex = 0;\n\n    for (const match of matches) {\n        const position = match.index!;\n\n        if (position > lastIndex) {\n            elements.push(text.substring(lastIndex, position));\n        }\n\n        const link = match[0];\n        const prefix = link.startsWith('http') ? '' : 'https://';\n        elements.push(\n            <a href={prefix + link} key={lastIndex} rel=\"noopener noreferrer\" target=\"_blank\">\n                {link}\n            </a>,\n        );\n\n        lastIndex = position + link.length;\n    }\n\n    if (text.length > lastIndex) {\n        elements.push(text.substring(lastIndex));\n    }\n\n    return elements;\n};\n"
  },
  {
    "path": "src/renderer/utils/logger-message.ts",
    "content": "import { LogCategory } from '/@/renderer/utils/logger';\n\nexport const logMsg = {\n    [LogCategory.ANALYTICS]: {\n        appTracked: 'Analytics sent',\n        pageViewTracked: 'Page view tracked',\n    },\n    [LogCategory.API]: {},\n    [LogCategory.EXTERNAL]: {\n        discordRpcActivityCleared: 'Activity was cleared for Discord RPC',\n        discordRpcInitialized: 'Discord RPC was initialized',\n        discordRpcQuit: 'Discord RPC was quit',\n        discordRpcSetActivity: 'Activity was set for Discord RPC',\n        discordRpcTrackChanged: 'Track was changed for Discord RPC',\n        discordRpcUpdateSkipped: 'Activity was not updated for Discord RPC',\n    },\n    [LogCategory.OTHER]: {\n        error: 'An error occurred',\n        warning: 'A warning occurred',\n    },\n    [LogCategory.PLAYER]: {\n        addToQueueByData: 'Added to queue by data',\n        addToQueueByFetch: 'Added to queue by fetch',\n        addToQueueByListQuery: 'Added to queue by list query',\n        addToQueueByType: 'Added to queue by type',\n        autoPlayFailed: 'Auto play failed',\n        autoPlayTriggered: 'Auto play triggered',\n        cancelledFetch: 'Cancelled fetch',\n        clearQueue: 'Cleared queue',\n        clearSelected: 'Cleared selected',\n        decreaseVolume: 'Decreased volume',\n        increaseVolume: 'Increased volume',\n        mediaNext: 'Media next',\n        mediaPause: 'Media pause',\n        mediaPlay: 'Media play',\n        mediaPlayByIndex: 'Media play by index',\n        mediaPrevious: 'Media previous',\n        mediaSeekToTimestamp: 'Media seek to timestamp',\n        mediaSkipBackward: 'Media skip backward',\n        mediaSkipForward: 'Media skip forward',\n        mediaStop: 'Media stop',\n        mediaToggleMute: 'Media toggle mute',\n        mediaTogglePlayPause: 'Media toggle play pause',\n        moveSelectedTo: 'Moved selected to',\n        moveSelectedToBottom: 'Moved selected to bottom',\n        moveSelectedToNext: 'Moved selected to next',\n        moveSelectedToTop: 'Moved selected to top',\n        playbackError: 'An error occurred during playback',\n        playerFiltersApplied: 'Player filters applied',\n        setFavorite: 'Set favorite',\n        setQueue: 'Set queue',\n        setRating: 'Set rating',\n        setRepeat: 'Set repeat',\n        setShuffle: 'Set shuffle',\n        setSpeed: 'Set speed',\n        setVolume: 'Set volume',\n        shuffle: 'Shuffle',\n        shuffleAll: 'Shuffle all',\n        shuffleSelected: 'Shuffle selected',\n        toggleRepeat: 'Toggle repeat',\n        toggleShuffle: 'Toggle shuffle',\n    },\n    [LogCategory.REMOTE]: {\n        cannotSendEvent: 'Cannot send event - socket not available',\n        closingExistingSocket: 'Closing existing socket',\n        creatingWebSocket: 'Creating new WebSocket',\n        credentialsFetched: 'Credentials fetched',\n        failedToEnableRemote: 'Failed to enable remote',\n        failedToGetCredentials: 'Failed to get credentials',\n        favoriteEventReceived: 'Favorite event received',\n        fetchingCredentials: 'Fetching credentials',\n        initializingRemoteSettings: 'Initializing remote settings',\n        playbackEventReceived: 'Playback event received',\n        positionEventReceived: 'Position event received',\n        proxyEventReceived: 'Proxy event received (image update)',\n        ratingEventReceived: 'Rating event received',\n        reconnectInitiated: 'Reconnect initiated',\n        reloadingPage: 'Reloading page due to close code',\n        repeatEventReceived: 'Repeat event received',\n        requestFavoriteReceived: 'Request favorite received',\n        requestPositionReceived: 'Request position received',\n        requestRatingReceived: 'Request rating received',\n        requestSeekReceived: 'Request seek received',\n        requestVolumeReceived: 'Request volume received',\n        sendingAuthentication: 'Sending authentication',\n        sendingEventToServer: 'Sending event to server',\n        sendingInitialSong: 'Sending initial song',\n        serverIsDown: 'Server is down',\n        shuffleEventReceived: 'Shuffle event received',\n        socketClosedUnexpectedly: 'Socket closed unexpectedly',\n        songEventReceived: 'Song event received',\n        stateEventReceived: 'State event received (full state update)',\n        updateFavoriteSent: 'Update favorite sent',\n        updatePlaybackSent: 'Update playback sent',\n        updatePositionSent: 'Update position sent',\n        updateRatingSent: 'Update rating sent',\n        updateRepeatSent: 'Update repeat sent',\n        updateShuffleSent: 'Update shuffle sent',\n        updateSongSent: 'Update song sent',\n        updateVolumeSent: 'Update volume sent',\n        volumeEventReceived: 'Volume event received',\n        webSocketClosed: 'WebSocket closed',\n        webSocketErrorEvent: 'WebSocket error event',\n        webSocketMessageReceived: 'WebSocket message received',\n        webSocketOpened: 'WebSocket opened',\n    },\n    [LogCategory.SCROBBLE]: {\n        scrobbledPause: 'Scrobbled a pause event',\n        scrobbledStart: 'Scrobbled a start event',\n        scrobbledSubmission: 'Scrobbled a submission event',\n        scrobbledTimeupdate: 'Scrobbled a timeupdate event',\n        scrobbledUnpause: 'Scrobbled an unpause event',\n    },\n    [LogCategory.SYSTEM]: {\n        authenticatingServer: 'Authenticating server',\n        serverAuthenticationAborted: 'Server authentication aborted',\n        serverAuthenticationError: 'Server authentication error',\n        serverAuthenticationFailed: 'Server authentication failed',\n        serverAuthenticationInvalid: 'Server authentication invalid',\n        serverAuthenticationSuccess: 'Server authentication successful',\n        settingsSynchronized: 'Differences found between renderer and main process settings',\n    },\n};\n"
  },
  {
    "path": "src/renderer/utils/logger.ts",
    "content": "import dayjs from 'dayjs';\n\nexport enum LogCategory {\n    ANALYTICS = 'analytics',\n    API = 'api',\n    EXTERNAL = 'external',\n    GENERAL = 'general',\n    OTHER = 'other',\n    PLAYER = 'player',\n    REMOTE = 'remote',\n    SCROBBLE = 'scrobble',\n    SYSTEM = 'system',\n}\n\nexport type LogLevel = 'debug' | 'error' | 'info' | 'warn';\n\ninterface LogFn {\n    (\n        message?: string,\n        options?: {\n            category?: string;\n            meta?: any;\n        },\n    ): void;\n}\n\ninterface Logger {\n    debug: LogFn;\n    error: LogFn;\n    info: LogFn;\n    updateLogLevel: (level: LogLevel) => void;\n    warn: LogFn;\n}\n\nconst DEFAULT_LOG_LEVEL = process.env.NODE_ENV === 'production' ? 'info' : 'debug';\n\n// eslint-disable-next-line @typescript-eslint/no-unused-vars\nconst NO_OP: LogFn = (_message?: string, ..._optionalParams: any[]) => {};\n\nconst colors = {\n    debug: '\\x1B[38;2;100;149;237m', // #6495ED\n    error: '\\x1B[38;2;255;100;100m', // #ff6464\n    info: '\\x1B[38;2;76;175;80m', // #4caf50\n    warn: '\\x1B[38;2;225;125;50m', // #e17d32\n};\n\n// Debounce configuration\nconst DEBOUNCE_INTERVAL = 200; // milliseconds\nconst DEBOUNCE_MAP = new Map<string, { count: number; lastLog: number }>();\n\n// Periodically flush the debounce map\nsetInterval(() => {\n    const now = Date.now();\n    for (const [key, value] of DEBOUNCE_MAP.entries()) {\n        if (now - value.lastLog >= DEBOUNCE_INTERVAL) {\n            const [level, message, category, meta] = JSON.parse(key);\n            const timestampStr = `${dayjs().format('HH:mm:ss')}`;\n            const levelStr = `${colors[level as keyof typeof colors]}[${String(level).toUpperCase().padEnd(5, ' ')}]\\x1B[0m`;\n            const countStr = value.count > 1 ? ` (x${value.count})` : '';\n            const categoryStr = category\n                ? String(`[${category.padEnd(9, ' ')}]`).toUpperCase()\n                : '';\n            const messageStr = message ? String(message) : '';\n            const logStr = `[${timestampStr}] ${levelStr} ${categoryStr} ${messageStr}${countStr}`;\n\n            if (meta) {\n                console.log(logStr, meta);\n            } else {\n                console.log(logStr);\n            }\n\n            DEBOUNCE_MAP.delete(key);\n        }\n    }\n}, DEBOUNCE_INTERVAL);\n\nclass ConsoleLogger implements Logger {\n    debug: LogFn = NO_OP;\n    error: LogFn = NO_OP;\n    info: LogFn = NO_OP;\n    updateLogLevel: (level: LogLevel) => void;\n    warn: LogFn = NO_OP;\n\n    constructor() {\n        const level = (localStorage.getItem('log_level') || DEFAULT_LOG_LEVEL) as LogLevel;\n        this.initializeLoggers(level);\n        this.updateLogLevel = (newLevel: LogLevel) => {\n            this.initializeLoggers(newLevel);\n        };\n    }\n\n    private initializeLoggers(level: LogLevel) {\n        // Create timestamp wrapper function with colors and debouncing\n        const withTimestamp = (logLevel: string): LogFn => {\n            return (message?: any, options?: { category?: string; meta?: any }) => {\n                const { category, meta } = options || {};\n                const key = JSON.stringify([logLevel, message, category, meta]);\n                const now = Date.now();\n                const existing = DEBOUNCE_MAP.get(key);\n\n                if (existing) {\n                    existing.count++;\n                    existing.lastLog = now;\n                } else {\n                    DEBOUNCE_MAP.set(key, { count: 1, lastLog: now });\n                }\n            };\n        };\n\n        this.error = withTimestamp('error');\n\n        if (level === 'error') {\n            this.warn = NO_OP;\n            this.info = NO_OP;\n            this.debug = NO_OP;\n            return;\n        }\n\n        this.warn = withTimestamp('warn');\n\n        if (level === 'warn') {\n            this.info = NO_OP;\n            this.debug = NO_OP;\n            return;\n        }\n\n        this.info = withTimestamp('info');\n\n        if (level === 'info') {\n            this.debug = NO_OP;\n            return;\n        }\n\n        this.debug = withTimestamp('debug');\n    }\n}\n\nexport const logFn = new ConsoleLogger();\n"
  },
  {
    "path": "src/renderer/utils/normalize-release-types.tsx",
    "content": "import { TFunction } from 'i18next';\n\nimport { titleCase } from '/@/renderer/utils/title-case';\n\n// Release types derived from https://musicbrainz.org/doc/Release_Group/Type\nconst PRIMARY_MAPPING = {\n    album: 'album',\n    broadcast: 'broadcast',\n    ep: 'ep',\n    other: 'other',\n    single: 'single',\n} as const;\n\nconst SECONDARY_MAPPING = {\n    audiobook: 'audiobook',\n    'audio drama': 'audioDrama',\n    compilation: 'compilation',\n    demo: 'demo',\n    'dj-mix': 'djMix',\n    'field recording': 'fieldRecording',\n    interview: 'interview',\n    live: 'live',\n    'mixtape/street': 'mixtape',\n    remix: 'remix',\n    soundtrack: 'soundtrack',\n    spokenword: 'spokenWord',\n} as const;\n\nexport const normalizeReleaseTypes = (types: string[], t: TFunction) => {\n    const primary: string[] = [];\n    const secondary: string[] = [];\n    const unknown: string[] = [];\n\n    for (const type of types) {\n        const lower = type.toLocaleLowerCase();\n\n        if (lower in PRIMARY_MAPPING) {\n            primary.push(\n                t(`releaseType.primary.${PRIMARY_MAPPING[lower]}`, { postProcess: 'sentenceCase' }),\n            );\n        } else if (lower in SECONDARY_MAPPING) {\n            secondary.push(\n                t(`releaseType.secondary.${SECONDARY_MAPPING[lower]}`, {\n                    postProcess: 'sentenceCase',\n                }),\n            );\n        } else {\n            unknown.push(titleCase(type));\n        }\n    }\n\n    primary.sort();\n    secondary.sort();\n    unknown.sort();\n\n    return primary.concat(secondary, unknown);\n};\n\nexport const normalizeToPrimaryReleaseTypes = (types: string[], t: TFunction) => {\n    const primary: string[] = [];\n    for (const type of types) {\n        const lower = type.toLocaleLowerCase();\n        if (lower in PRIMARY_MAPPING) {\n            primary.push(\n                t(`releaseType.primary.${PRIMARY_MAPPING[lower]}`, { postProcess: 'sentenceCase' }),\n            );\n        }\n    }\n\n    // If no primary types found, use \"other\" category\n    if (primary.length === 0) {\n        primary.push(\n            t(`releaseType.primary.${PRIMARY_MAPPING.other}`, { postProcess: 'sentenceCase' }),\n        );\n    }\n\n    return primary;\n};\n\nexport const normalizeToSecondaryReleaseTypes = (types: string[], t: TFunction) => {\n    const secondary: string[] = [];\n    for (const type of types) {\n        const lower = type.toLocaleLowerCase();\n        if (lower in SECONDARY_MAPPING) {\n            secondary.push(\n                t(`releaseType.secondary.${SECONDARY_MAPPING[lower]}`, {\n                    postProcess: 'sentenceCase',\n                }),\n            );\n        }\n    }\n\n    secondary.sort();\n\n    return secondary;\n};\n"
  },
  {
    "path": "src/renderer/utils/normalize-server-url.ts",
    "content": "import { ServerListItem } from '/@/shared/types/domain-types';\n\nexport const normalizeServerUrl = (url: string) => {\n    // Remove trailing slash\n    return url.endsWith('/') ? url.slice(0, -1) : url;\n};\n\nexport const getServerUrl = (\n    server: null | ServerListItem | undefined,\n    forceRemoteUrl?: boolean,\n): string | undefined => {\n    if (!server) {\n        return undefined;\n    }\n\n    if (!forceRemoteUrl && !server.preferRemoteUrl) {\n        return server.url;\n    }\n\n    if (!server.remoteUrl) {\n        return server.url;\n    }\n\n    return server.remoteUrl;\n};\n"
  },
  {
    "path": "src/renderer/utils/parse-search-params.ts",
    "content": "import isUndefined from 'lodash/isUndefined';\nimport omitBy from 'lodash/omitBy';\n\nexport const parseSearchParams = (searchParams: Record<any, any>) => {\n    const params = new URLSearchParams();\n    const paramsWithoutUndefined = omitBy(searchParams, isUndefined);\n\n    Object.entries(paramsWithoutUndefined).forEach(([key, value]) => {\n        if (!Array.isArray(value)) {\n            params.append(key, value.toString());\n        } else {\n            value.forEach((value) => params.append(key, value.toString()));\n        }\n    });\n\n    return params.toString();\n};\n"
  },
  {
    "path": "src/renderer/utils/query-params.ts",
    "content": "import { customFiltersSchema } from '/@/renderer/features/shared/utils';\n\n/**\n * Parse a string array from URLSearchParams\n * Returns undefined if the key doesn't exist or array is empty\n */\nexport const parseArrayParam = (\n    searchParams: URLSearchParams,\n    key: string,\n): string[] | undefined => {\n    const values = searchParams.getAll(key);\n    return values.length > 0 ? values : undefined;\n};\n\n/**\n * Parse a boolean from URLSearchParams\n * Returns undefined if the key doesn't exist\n */\nexport const parseBooleanParam = (\n    searchParams: URLSearchParams,\n    key: string,\n): boolean | undefined => {\n    const value = searchParams.get(key);\n    if (value === null) return undefined;\n    return value === 'true';\n};\n\n/**\n * Parse an integer from URLSearchParams\n * Returns undefined if the key doesn't exist or value is invalid\n */\nexport const parseIntParam = (searchParams: URLSearchParams, key: string): number | undefined => {\n    const value = searchParams.get(key);\n    if (value === null) return undefined;\n    const parsed = parseInt(value, 10);\n    return isNaN(parsed) ? undefined : parsed;\n};\n\n/**\n * Parse a string from URLSearchParams\n * Returns undefined if the key doesn't exist\n */\nexport const parseStringParam = (\n    searchParams: URLSearchParams,\n    key: string,\n): string | undefined => {\n    const value = searchParams.get(key);\n    return value === null ? undefined : value;\n};\n\n/**\n * Parse JSON from URLSearchParams\n * Returns undefined if the key doesn't exist or parsing fails\n */\nexport const parseJsonParam = <T = unknown>(\n    searchParams: URLSearchParams,\n    key: string,\n): T | undefined => {\n    const value = searchParams.get(key);\n    if (value === null) return undefined;\n    try {\n        const parsed = JSON.parse(value);\n        // Validate against schema if provided\n        return parsed;\n    } catch {\n        return undefined;\n    }\n};\n\n/**\n * Set or remove a value in URLSearchParams\n * If value is null or undefined, removes the key\n */\nexport const setSearchParam = (\n    searchParams: URLSearchParams,\n    key: string,\n    value: boolean | null | number | Record<string, any> | string | string[] | undefined,\n): URLSearchParams => {\n    const newParams = new URLSearchParams(searchParams);\n\n    if (value === null || value === undefined) {\n        newParams.delete(key);\n        return newParams;\n    }\n\n    if (Array.isArray(value)) {\n        newParams.delete(key);\n        value.forEach((v) => newParams.append(key, String(v)));\n        return newParams;\n    }\n\n    if (typeof value === 'boolean') {\n        newParams.set(key, String(value));\n        return newParams;\n    }\n\n    if (typeof value === 'number') {\n        newParams.set(key, String(value));\n        return newParams;\n    }\n\n    newParams.set(key, value as string);\n    return newParams;\n};\n\n/**\n * Set or remove a JSON value in URLSearchParams\n * If value is null or undefined, removes the key\n */\nexport const setJsonSearchParam = (\n    searchParams: URLSearchParams,\n    key: string,\n    value: null | Record<string, any> | undefined,\n): URLSearchParams => {\n    const newParams = new URLSearchParams(searchParams);\n\n    if (value === null || value === undefined) {\n        newParams.delete(key);\n        return newParams;\n    }\n\n    newParams.set(key, JSON.stringify(value));\n    return newParams;\n};\n\nexport const setMultipleSearchParams = (\n    searchParams: URLSearchParams,\n    params: Record<\n        string,\n        boolean | null | number | Record<string, any> | string | string[] | undefined\n    >,\n    jsonKeys?: Set<string>,\n): URLSearchParams => {\n    const newParams = new URLSearchParams(searchParams);\n\n    for (const [key, value] of Object.entries(params)) {\n        if (value === null || value === undefined) {\n            newParams.delete(key);\n            continue;\n        }\n\n        if (jsonKeys?.has(key)) {\n            if (typeof value === 'object' && !Array.isArray(value)) {\n                newParams.set(key, JSON.stringify(value));\n            } else {\n                newParams.delete(key);\n            }\n        } else {\n            if (Array.isArray(value)) {\n                newParams.delete(key);\n                value.forEach((v) => newParams.append(key, String(v)));\n            } else if (typeof value === 'boolean') {\n                newParams.set(key, String(value));\n            } else if (typeof value === 'number') {\n                newParams.set(key, String(value));\n            } else {\n                newParams.set(key, value as string);\n            }\n        }\n    }\n\n    return newParams;\n};\n\n/**\n * Parse custom filters from URLSearchParams with validation\n */\nexport const parseCustomFiltersParam = (\n    searchParams: URLSearchParams,\n    key: string,\n): Record<string, any> | undefined => {\n    const value = parseJsonParam(searchParams, key);\n    if (value === undefined) return undefined;\n\n    try {\n        return customFiltersSchema.parse(value);\n    } catch {\n        return undefined;\n    }\n};\n\nconst PAGINATION_KEYS = ['currentPage', 'scrollOffset'];\n\n/**\n * Build filter query string from current search params (minus pagination/scroll).\n * Optionally merge customFilters (e.g. from ListContext) into the result.\n */\nexport const getFilterQueryStringFromSearchParams = (\n    searchParams: URLSearchParams,\n    customFilters?: Record<string, boolean | number | Record<string, unknown> | string | string[]>,\n): string => {\n    const params = new URLSearchParams(searchParams);\n    for (const key of PAGINATION_KEYS) {\n        params.delete(key);\n    }\n    if (customFilters && Object.keys(customFilters).length > 0) {\n        for (const [key, value] of Object.entries(customFilters)) {\n            if (value === undefined || value === null) continue;\n            if (Array.isArray(value)) {\n                params.delete(key);\n                value.forEach((v) => params.append(key, String(v)));\n            } else if (typeof value === 'object' && value !== null && !Array.isArray(value)) {\n                params.set(key, JSON.stringify(value));\n            } else {\n                params.set(key, String(value));\n            }\n        }\n    }\n    return params.toString();\n};\n"
  },
  {
    "path": "src/renderer/utils/random-string.ts",
    "content": "export const randomString = (length?: number) => {\n    const charSet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';\n    let string = '';\n    for (let i = 0; i < (length || 12); i += 1) {\n        const randomPoz = Math.floor(Math.random() * charSet.length);\n        string += charSet.substring(randomPoz, randomPoz + 1);\n    }\n    return string;\n};\n"
  },
  {
    "path": "src/renderer/utils/rgb-to-rgba.ts",
    "content": "export const rgbToRgba = (rgb: string | undefined, alpha: number) => {\n    if (!rgb) {\n        return undefined;\n    }\n\n    return rgb.replace(')', `, ${alpha})`).replace('rgb', 'rgba');\n};\n"
  },
  {
    "path": "src/renderer/utils/sanitize.ts",
    "content": "import DomPurify, { Config } from 'dompurify';\n\nconst SANITIZE_OPTIONS: Config = {\n    ALLOWED_ATTR: ['href'],\n    ALLOWED_TAGS: ['a', 'b', 'div', 'em', 'i', 'p', 'span', 'strong'],\n    // allow http://, https://, and // (mapped to https)\n    ALLOWED_URI_REGEXP: /^(http(s?):)?\\/\\/.+/i,\n};\n\nconst regex = /(url\\([\"'](?!data:))/gim;\n\nconst addStyles = (output: string[], styles: CSSStyleDeclaration) => {\n    for (let prop = styles.length - 1; prop >= 0; prop -= 1) {\n        const key = styles[prop] as string;\n        if (key !== 'content' && styles[key]) {\n            const value = styles[key];\n            const priority = styles.getPropertyPriority(key as string);\n            const priorityString = priority === 'important' ? ` !important` : '';\n            if (typeof value === 'string') {\n                if (!value.match(regex)) {\n                    output.push(`${key}:${value}${priorityString};`);\n                }\n            } else if (typeof value === 'number') {\n                output.push(`${key}:${value}${priorityString};`);\n            }\n        } else if (styles.getPropertyValue(key)) {\n            // These will not override the value unless not declared !important\n            output.push(`${key}: ${styles.getPropertyValue(key)} !important;`);\n        }\n    }\n};\n\nconst addCssRules = (rules: CSSRuleList, output: string[]) => {\n    for (let index = rules.length - 1; index >= 0; index -= 1) {\n        const rule = rules[index];\n        if (rule.constructor.name === 'CSSStyleRule') {\n            const cssRule = rule as CSSStyleRule;\n            output.push(`${cssRule.selectorText} {`);\n            if (cssRule.style) {\n                addStyles(output, cssRule.style);\n            }\n            output.push('}');\n        } else if (rule.constructor.name === 'CSSMediaRule') {\n            const mediaRule = rule as CSSMediaRule;\n            output.push(`@media ${mediaRule.media.mediaText}{`);\n            addCssRules(mediaRule.cssRules, output);\n            output.push('}');\n        } else if (rule.constructor.name === 'CSSKeyframesRule') {\n            const keyFrameRule = rule as CSSKeyframesRule;\n            for (let i = keyFrameRule.cssRules.length - 1; i >= 0; i -= 1) {\n                const frame = keyFrameRule.cssRules[i];\n                if (frame.constructor.name === 'CSSKeyframeRule') {\n                    const keyframeRule = frame as CSSKeyframeRule;\n                    if (keyframeRule.keyText) {\n                        output.push(`${keyframeRule.keyText}{`);\n                        if (keyframeRule.style) {\n                            addStyles(output, keyframeRule.style);\n                        }\n                        output.push('}');\n                    }\n                }\n            }\n            output.push('}');\n        }\n    }\n};\n\nDomPurify.addHook('afterSanitizeAttributes', (node: Element) => {\n    if (node.tagName === 'A') {\n        if (node.getAttribute('href')?.startsWith('//')) {\n            node.setAttribute('href', `https:${node.getAttribute('href')}`);\n        }\n        node.setAttribute('target', '_blank');\n        node.setAttribute('rel', 'noopener noreferrer');\n    }\n});\n\n(DomPurify as any).addHook('uponSanitizeElement', (node: Element) => {\n    if (node.tagName === 'STYLE') {\n        const rules = (node as HTMLStyleElement).sheet?.cssRules;\n        if (rules) {\n            const output: string[] = [];\n            addCssRules(rules, output);\n            node.textContent = output.join('\\n');\n        }\n    }\n});\n\nexport const sanitize = (text: string): string => {\n    return DomPurify.sanitize(text, SANITIZE_OPTIONS);\n};\n\nexport const sanitizeCss = (text: string): string => {\n    return (DomPurify as any).sanitize(text, {\n        ALLOWED_ATTR: [],\n        ALLOWED_TAGS: ['style'],\n        RETURN_DOM: true,\n        WHOLE_DOCUMENT: true,\n    }).innerText;\n};\n"
  },
  {
    "path": "src/renderer/utils/sentence-case.ts",
    "content": "export const sentenceCase = (string: string) => {\n    return string.charAt(0).toUpperCase() + string.slice(1);\n};\n"
  },
  {
    "path": "src/renderer/utils/set-local-storage-setttings.ts",
    "content": "export const setLocalStorageSettings = (type: 'player', object: any) => {\n    const settings = JSON.parse(localStorage.getItem('settings') || '{}');\n\n    const newSettings = {\n        ...settings,\n        [type]: { ...object },\n    };\n\n    localStorage.setItem('settings', JSON.stringify(newSettings));\n};\n"
  },
  {
    "path": "src/renderer/utils/shuffle.ts",
    "content": "export function shuffle<T>(array: T[]): T[] {\n    // Create a copy of the array to avoid mutating the original\n    const shuffled = [...array];\n\n    // Loop through the array from the last element to the first\n    for (let i = shuffled.length - 1; i > 0; i--) {\n        // Generate a random index from 0 to i\n        const j = Math.floor(Math.random() * (i + 1));\n        // Swap elements at positions i and j\n        [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];\n    }\n\n    return shuffled;\n}\n\nexport function shuffleInPlace<T>(array: T[]): T[] {\n    for (let i = array.length - 1; i > 0; i--) {\n        const j = Math.floor(Math.random() * (i + 1));\n        [array[i], array[j]] = [array[j], array[i]];\n    }\n    return array;\n}\n"
  },
  {
    "path": "src/renderer/utils/title-case.ts",
    "content": "export const titleCase = (str: string) => {\n    return str.replace(/\\w\\S*/g, (txt) => {\n        return txt.charAt(0).toUpperCase() + txt.slice(1).toLowerCase();\n    });\n};\n"
  },
  {
    "path": "src/renderer/utils/truncate-middle.ts",
    "content": "export const truncateMiddle = (text: string, maxLength: number): string => {\n    if (text.length <= maxLength) {\n        return text;\n    }\n\n    const ellipsis = '…';\n    const halfLength = Math.floor((maxLength - ellipsis.length) / 2);\n    const start = text.substring(0, halfLength);\n    const end = text.substring(text.length - halfLength);\n\n    return `${start}${ellipsis}${end}`;\n};\n"
  },
  {
    "path": "src/shared/api/jellyfin/jellyfin-normalize.ts",
    "content": "import { z } from 'zod';\n\nimport { jfType } from '/@/shared/api/jellyfin/jellyfin-types';\nimport { replacePathPrefix } from '/@/shared/api/utils';\nimport {\n    Album,\n    AlbumArtist,\n    Folder,\n    Genre,\n    LibraryItem,\n    MusicFolder,\n    Playlist,\n    RelatedArtist,\n    Song,\n} from '/@/shared/types/domain-types';\nimport { ServerListItem, ServerType } from '/@/shared/types/types';\n\nconst TICKS_PER_MS = 10000;\n\ntype AlbumOrSong = z.infer<typeof jfType._response.album> | z.infer<typeof jfType._response.song>;\n\nconst KEYS_TO_OMIT = new Set(['AlbumArtist', 'Artist']);\n\nconst getPeople = (item: AlbumOrSong): null | Record<string, RelatedArtist[]> => {\n    if (item.People) {\n        const participants: Record<string, RelatedArtist[]> = {};\n\n        for (const person of item.People) {\n            const key = person.Type || '';\n            if (KEYS_TO_OMIT.has(key)) {\n                continue;\n            }\n\n            const item: RelatedArtist = {\n                // for other roles, we just want to display this and not filter.\n                // filtering (and links) would require a separate field, PersonIds\n                id: '',\n                imageId: null,\n                imageUrl: null,\n                name: person.Name,\n                userFavorite: false,\n                userRating: null,\n            };\n\n            if (key in participants) {\n                participants[key].push(item);\n            } else {\n                participants[key] = [item];\n            }\n        }\n\n        return participants;\n    }\n\n    return null;\n};\n\nconst getTags = (item: AlbumOrSong): null | Record<string, string[]> => {\n    if (item.Tags) {\n        const tags: Record<string, string[]> = {};\n        for (const tag of item.Tags) {\n            tags[tag] = [];\n        }\n\n        return tags;\n    }\n\n    return null;\n};\n\nconst getSongImageId = (item: z.infer<typeof jfType._response.song>): null | string => {\n    if (item.ImageTags?.Primary) {\n        return item.Id;\n    }\n\n    if (item.AlbumPrimaryImageTag && item.AlbumId) {\n        return item.AlbumId;\n    }\n\n    return null;\n};\n\nconst getAlbumImageId = (item: z.infer<typeof jfType._response.album>): null | string => {\n    if (item.ImageTags?.Primary) {\n        return item.Id;\n    }\n\n    return null;\n};\n\nconst getAlbumArtistImageId = (\n    item: z.infer<typeof jfType._response.albumArtist>,\n): null | string => {\n    if (item.ImageTags?.Primary) {\n        return item.Id;\n    }\n\n    return null;\n};\n\nconst getPlaylistImageId = (item: z.infer<typeof jfType._response.playlist>): null | string => {\n    if (item.ImageTags?.Primary) {\n        return item.Id;\n    }\n\n    return null;\n};\n\nconst getArtists = (\n    item: z.infer<typeof jfType._response.song>,\n    participants?: null | Record<string, RelatedArtist[]>,\n): RelatedArtist[] => {\n    if (!item?.ArtistItems?.length && !item.AlbumArtists && !participants) {\n        return [];\n    }\n\n    const result: RelatedArtist[] = [];\n\n    (item?.ArtistItems?.length ? item.ArtistItems : item.AlbumArtists)?.forEach((entry) => {\n        result.push({\n            id: entry.Id,\n            imageId: null,\n            imageUrl: null,\n            name: entry.Name,\n            userFavorite: false,\n            userRating: null,\n        });\n    });\n\n    if (participants?.['Remixer']) {\n        result.push(...participants['Remixer']);\n    }\n\n    return result;\n};\nconst normalizeSong = (\n    item: z.infer<typeof jfType._response.song>,\n    server: null | ServerListItem,\n    pathReplace?: string,\n    pathReplaceWith?: string,\n): Song => {\n    let bitDepth: null | number = null;\n    let bitRate = 0;\n    let channels: null | number = null;\n    let container: null | string = null;\n    let path: null | string = null;\n    let sampleRate: null | number = null;\n    let size = 0;\n\n    if (item.MediaSources?.length) {\n        const source = item.MediaSources[0];\n\n        container = source.Container;\n        path = source.Path;\n        size = source.Size;\n\n        if ((source.MediaStreams?.length || 0) > 0) {\n            for (const stream of source.MediaStreams) {\n                if (stream.Type === 'Audio') {\n                    bitDepth = stream.BitDepth || null;\n                    bitRate =\n                        stream.BitRate !== undefined\n                            ? Number(Math.trunc(stream.BitRate / 1000))\n                            : 0;\n                    channels = stream.Channels || null;\n                    sampleRate = stream.SampleRate || null;\n                    break;\n                }\n            }\n        }\n    } else {\n        console.warn('Jellyfin song retrieved with no media sources', item);\n    }\n\n    const participants = getPeople(item);\n\n    const artists = getArtists(item, participants);\n\n    return {\n        _itemType: LibraryItem.SONG,\n        _serverId: server?.id || '',\n        _serverType: ServerType.JELLYFIN,\n        album: item.Album,\n        albumArtistName: item.AlbumArtist || '',\n        albumArtists: item.AlbumArtists?.map((entry) => ({\n            id: entry.Id,\n            imageId: entry.Id,\n            imageUrl: null,\n            name: entry.Name,\n            userFavorite: false,\n            userRating: null,\n        })),\n        albumId: item.AlbumId || `dummy/${item.Id}`,\n        artistName: item?.ArtistItems?.map((entry) => entry.Name).join(', ') || '',\n        artists,\n        bitDepth,\n        bitRate,\n        bpm: null,\n        channels,\n        comment: null,\n        compilation: null,\n        container,\n        createdAt: item.DateCreated,\n        discNumber: (item.ParentIndexNumber && item.ParentIndexNumber) || 1,\n        discSubtitle: null,\n        duration: item.RunTimeTicks / TICKS_PER_MS,\n        explicitStatus: null,\n        gain:\n            item.NormalizationGain !== undefined\n                ? {\n                      track: item.NormalizationGain,\n                  }\n                : item.LUFS\n                  ? {\n                        track: -18 - item.LUFS,\n                    }\n                  : null,\n        genres: item.GenreItems?.map((entry) => ({\n            _itemType: LibraryItem.GENRE,\n            _serverId: server?.id || '',\n            _serverType: ServerType.JELLYFIN,\n            albumCount: null,\n            id: entry.Id,\n            imageId: null,\n            imageUrl: null,\n            name: entry.Name,\n            songCount: null,\n        })),\n        id: item.Id,\n        imageId: getSongImageId(item),\n        imageUrl: null,\n        lastPlayedAt: null,\n        lyrics: null,\n        mbzRecordingId: null,\n        mbzTrackId: item.ProviderIds?.MusicBrainzTrack || null,\n        name: item.Name,\n        participants,\n        path: replacePathPrefix(path || '', pathReplace, pathReplaceWith),\n        peak: null,\n        playCount: (item.UserData && item.UserData.PlayCount) || 0,\n        playlistItemId: item.PlaylistItemId,\n        releaseDate: item.PremiereDate || null,\n        releaseYear: item.ProductionYear || null,\n        sampleRate,\n        size,\n        sortName: item.SortName || item.Name,\n        tags: getTags(item),\n        trackNumber: item.IndexNumber,\n        trackSubtitle: null,\n        updatedAt: item.DateCreated,\n        userFavorite: (item.UserData && item.UserData.IsFavorite) || false,\n        userRating: null,\n    };\n};\n\nconst normalizeAlbum = (\n    item: z.infer<typeof jfType._response.album>,\n    server: null | ServerListItem,\n): Album => {\n    return {\n        _itemType: LibraryItem.ALBUM,\n        _serverId: server?.id || '',\n        _serverType: ServerType.JELLYFIN,\n        albumArtistName: item.AlbumArtist,\n        albumArtists:\n            item.AlbumArtists.map((entry) => ({\n                id: entry.Id,\n                imageId: entry.Id,\n                imageUrl: null,\n                name: entry.Name,\n                userFavorite: false,\n                userRating: null,\n            })) || [],\n        artists: (item.ArtistItems?.length ? item.ArtistItems : item.AlbumArtists)?.map(\n            (entry) => ({\n                id: entry.Id,\n                imageId: entry.Id,\n                imageUrl: null,\n                name: entry.Name,\n                userFavorite: false,\n                userRating: null,\n            }),\n        ),\n        comment: null,\n        createdAt: item.DateCreated,\n        duration: item.RunTimeTicks / TICKS_PER_MS,\n        explicitStatus: null,\n        genres:\n            item.GenreItems?.map((entry) => ({\n                _itemType: LibraryItem.GENRE,\n                _serverId: server?.id || '',\n                _serverType: ServerType.JELLYFIN,\n                albumCount: null,\n                id: entry.Id,\n                imageId: null,\n                imageUrl: null,\n                name: entry.Name,\n                songCount: null,\n            })) || [],\n        id: item.Id,\n        imageId: getAlbumImageId(item),\n        imageUrl: null,\n        isCompilation: null,\n        lastPlayedAt: null,\n        mbzId: item.ProviderIds?.MusicBrainzAlbum || null,\n        mbzReleaseGroupId: item.ProviderIds?.MusicBrainzReleaseGroup || null,\n        name: item.Name,\n        originalDate: item.PremiereDate || null,\n        originalYear: item.ProductionYear || null,\n        participants: getPeople(item),\n        playCount: item.UserData?.PlayCount || 0,\n        recordLabels: item.Studios?.map((entry) => entry.Name) || [],\n        releaseDate: item.PremiereDate || null,\n        releaseType: null,\n        releaseTypes: [],\n        releaseYear: item.ProductionYear || null,\n        size: null,\n        songCount: item?.ChildCount || null,\n        songs: item.Songs?.map((song) => normalizeSong(song, server)),\n        sortName: item.SortName || item.Name,\n        tags: getTags(item),\n        updatedAt: item?.DateLastMediaAdded || item.DateCreated,\n        userFavorite: item.UserData?.IsFavorite || false,\n        userRating: null,\n        version: null,\n    };\n};\n\nconst normalizeAlbumArtist = (\n    item: z.infer<typeof jfType._response.albumArtist> & {\n        similarArtists?: z.infer<typeof jfType._response.albumArtistList>;\n    },\n    server: null | ServerListItem,\n): AlbumArtist => {\n    const similarArtists =\n        item.similarArtists?.Items?.filter((entry) => entry.Name !== 'Various Artists').map(\n            (entry) => ({\n                id: entry.Id,\n                imageId: getAlbumArtistImageId(entry),\n                imageUrl: null,\n                name: entry.Name,\n                userFavorite: entry.UserData?.IsFavorite || false,\n                userRating: null,\n            }),\n        ) || [];\n\n    return {\n        _itemType: LibraryItem.ALBUM_ARTIST,\n        _serverId: server?.id || '',\n        _serverType: ServerType.JELLYFIN,\n        albumCount: item.AlbumCount ?? null,\n        biography: item.Overview || null,\n        duration: item.RunTimeTicks / TICKS_PER_MS,\n        genres: item.GenreItems?.map((entry) => ({\n            _itemType: LibraryItem.GENRE,\n            _serverId: server?.id || '',\n            _serverType: ServerType.JELLYFIN,\n            albumCount: null,\n            id: entry.Id,\n            imageId: null,\n            imageUrl: null,\n            name: entry.Name,\n            songCount: null,\n        })),\n        id: item.Id,\n        imageId: getAlbumArtistImageId(item),\n        imageUrl: null,\n        lastPlayedAt: null,\n        mbz: item.ProviderIds?.MusicBrainzArtist || null,\n        name: item.Name,\n        playCount: item.UserData?.PlayCount || 0,\n        similarArtists,\n        songCount: item.SongCount ?? null,\n        userFavorite: item.UserData?.IsFavorite || false,\n        userRating: null,\n    };\n};\n\nconst normalizePlaylist = (\n    item: z.infer<typeof jfType._response.playlist>,\n    server: null | ServerListItem,\n): Playlist => {\n    return {\n        _itemType: LibraryItem.PLAYLIST,\n        _serverId: server?.id || '',\n        _serverType: ServerType.JELLYFIN,\n        description: item.Overview || null,\n        duration: item.RunTimeTicks / TICKS_PER_MS,\n        genres: item.GenreItems?.map((entry) => ({\n            _itemType: LibraryItem.GENRE,\n            _serverId: server?.id || '',\n            _serverType: ServerType.JELLYFIN,\n            albumCount: null,\n            id: entry.Id,\n            imageId: null,\n            imageUrl: null,\n            name: entry.Name,\n            songCount: null,\n        })),\n        id: item.Id,\n        imageId: getPlaylistImageId(item),\n        imageUrl: null,\n        name: item.Name,\n        owner: null,\n        ownerId: null,\n        public: null,\n        rules: null,\n        size: null,\n        songCount: item?.ChildCount || null,\n        sync: null,\n    };\n};\n\nconst normalizeMusicFolder = (item: z.infer<typeof jfType._response.musicFolder>): MusicFolder => {\n    return {\n        id: item.Id,\n        name: item.Name,\n    };\n};\n\n// const normalizeArtist = (item: any) => {\n//   return {\n//     album: (item.album || []).map((entry: any) => normalizeAlbum(entry)),\n//     albumCount: item.AlbumCount,\n//     duration: item.RunTimeTicks / 10000000,\n//     genre: item.GenreItems && item.GenreItems.map((entry: any) => normalizeItem(entry)),\n//     id: item.Id,\n//     image: getCoverArtUrl(item),\n//     info: {\n//       biography: item.Overview,\n//       externalUrl: (item.ExternalUrls || []).map((entry: any) => normalizeItem(entry)),\n//       imageUrl: undefined,\n//       similarArtist: (item.similarArtist || []).map((entry: any) => normalizeArtist(entry)),\n//     },\n//     starred: item.UserData && item.UserData?.IsFavorite ? 'true' : undefined,\n//     title: item.Name,\n//     uniqueId: nanoid(),\n//   };\n// };\n\nconst getGenreImageId = (item: z.infer<typeof jfType._response.genre>): null | string => {\n    if (item.ImageTags?.Primary) {\n        return item.Id;\n    }\n\n    return null;\n};\n\nconst normalizeGenre = (\n    item: z.infer<typeof jfType._response.genre>,\n    server: null | ServerListItem,\n): Genre => {\n    return {\n        _itemType: LibraryItem.GENRE,\n        _serverId: server?.id || '',\n        _serverType: ServerType.JELLYFIN,\n        albumCount: null,\n        id: item.Id,\n        imageId: getGenreImageId(item),\n        imageUrl: null,\n        name: item.Name,\n        songCount: null,\n    };\n};\n\nconst normalizeFolder = (\n    item: z.infer<typeof jfType._response.folder>,\n    server: null | ServerListItem,\n): Folder => {\n    return {\n        _itemType: LibraryItem.FOLDER,\n        _serverId: server?.id || 'unknown',\n        _serverType: ServerType.JELLYFIN,\n        children: undefined,\n        id: item.Id,\n        name: item.Name || 'Unknown folder',\n        parentId: item.ParentId,\n    };\n};\n\n// const normalizeScanStatus = () => {\n//   return {\n//     count: 'N/a',\n//     scanning: false,\n//   };\n// };\n\nexport const jfNormalize = {\n    album: normalizeAlbum,\n    albumArtist: normalizeAlbumArtist,\n    folder: normalizeFolder,\n    genre: normalizeGenre,\n    musicFolder: normalizeMusicFolder,\n    playlist: normalizePlaylist,\n    song: normalizeSong,\n};\n"
  },
  {
    "path": "src/shared/api/jellyfin/jellyfin-types.ts",
    "content": "import { z } from 'zod';\n\nexport enum JFAlbumArtistListSort {\n    ALBUM = 'Album,SortName',\n    DURATION = 'Runtime,AlbumArtist,Album,SortName',\n    NAME = 'SortName,Name',\n    RANDOM = 'Random,SortName',\n    RECENTLY_ADDED = 'DateCreated,SortName',\n    RELEASE_DATE = 'PremiereDate,AlbumArtist,Album,SortName',\n}\n\nexport enum JFAlbumListSort {\n    ALBUM_ARTIST = 'AlbumArtist,SortName',\n    COMMUNITY_RATING = 'CommunityRating,SortName',\n    CRITIC_RATING = 'CriticRating,SortName',\n    NAME = 'SortName',\n    PLAY_COUNT = 'PlayCount',\n    RANDOM = 'Random,SortName',\n    RECENTLY_ADDED = 'DateCreated,SortName',\n    RELEASE_DATE = 'ProductionYear,PremiereDate,SortName',\n}\n\nexport enum JFArtistListSort {\n    ALBUM = 'Album,SortName',\n    DURATION = 'Runtime,AlbumArtist,Album,SortName',\n    NAME = 'SortName,Name',\n    RANDOM = 'Random,SortName',\n    RECENTLY_ADDED = 'DateCreated,SortName',\n    RELEASE_DATE = 'PremiereDate,AlbumArtist,Album,SortName',\n}\n\nexport enum JFGenreListSort {\n    NAME = 'SortName',\n}\n\nexport enum JFPlaylistListSort {\n    ALBUM_ARTIST = 'AlbumArtist,SortName',\n    DURATION = 'Runtime',\n    NAME = 'SortName',\n    RECENTLY_ADDED = 'DateCreated,SortName',\n    SONG_COUNT = 'ChildCount',\n}\n\nexport enum JFSongListSort {\n    ALBUM = 'Album,SortName',\n    ALBUM_ARTIST = 'AlbumArtist,Album,SortName',\n    ARTIST = 'Artist,Album,SortName',\n    COMMUNITY_RATING = 'CommunityRating,SortName',\n    DURATION = 'Runtime,AlbumArtist,Album,SortName',\n    NAME = 'Name',\n    PLAY_COUNT = 'PlayCount,SortName',\n    RANDOM = 'Random,SortName',\n    RECENTLY_ADDED = 'DateCreated,SortName',\n    RECENTLY_PLAYED = 'DatePlayed,SortName',\n    RELEASE_DATE = 'PremiereDate,AlbumArtist,Album,SortName',\n}\n\nexport enum JFSortOrder {\n    ASC = 'Ascending',\n    DESC = 'Descending',\n}\n\nconst sortOrderValues = ['Ascending', 'Descending'] as const;\n\nconst jfExternal = {\n    IMDB: 'Imdb',\n    MUSIC_BRAINZ: 'MusicBrainz',\n    THE_AUDIO_DB: 'TheAudioDb',\n    THE_MOVIE_DB: 'TheMovieDb',\n    TVDB: 'Tvdb',\n};\n\nconst jfImage = {\n    BACKDROP: 'Backdrop',\n    BANNER: 'Banner',\n    BOX: 'Box',\n    CHAPTER: 'Chapter',\n    DISC: 'Disc',\n    LOGO: 'Logo',\n    PRIMARY: 'Primary',\n    THUMB: 'Thumb',\n} as const;\n\nconst jfCollection = {\n    MUSIC: 'music',\n    PLAYLISTS: 'playlists',\n} as const;\n\nconst error = z.object({\n    errors: z.object({\n        recursive: z.array(z.string()),\n    }),\n    status: z.number(),\n    title: z.string(),\n    traceId: z.string(),\n    type: z.string(),\n});\n\nconst baseParameters = z.object({\n    AlbumArtistIds: z.string().optional(),\n    ArtistIds: z.string().optional(),\n    ContributingArtistIds: z.string().optional(),\n    EnableImageTypes: z.string().optional(),\n    EnableTotalRecordCount: z.boolean().optional(),\n    EnableUserData: z.boolean().optional(),\n    EnableUserDataTypes: z.boolean().optional(),\n    ExcludeArtistIds: z.string().optional(),\n    ExcludeItemIds: z.string().optional(),\n    ExcludeItemTypes: z.string().optional(),\n    Fields: z.array(z.string()).readonly().optional(),\n    FolderId: z.string().optional(),\n    ImageTypeLimit: z.number().optional(),\n    IncludeArtists: z.boolean().optional(),\n    IncludeGenres: z.boolean().optional(),\n    IncludeItemTypes: z.string().optional(),\n    IncludeMedia: z.boolean().optional(),\n    IncludePeople: z.boolean().optional(),\n    IncludeStudios: z.boolean().optional(),\n    IsFavorite: z.boolean().optional(),\n    Limit: z.number().optional(),\n    MediaTypes: z.string().optional(),\n    NameStartsWith: z.string().optional(),\n    ParentId: z.string().optional(),\n    Recursive: z.boolean().optional(),\n    SearchTerm: z.string().optional(),\n    SortBy: z.string().optional(),\n    SortOrder: z.enum(sortOrderValues).optional(),\n    StartIndex: z.number().optional(),\n    Tags: z.string().optional(),\n    UserId: z.string().optional(),\n    Years: z.string().optional(),\n});\n\nconst paginationParameters = z.object({\n    Limit: z.number().optional(),\n    SortOrder: z.enum(sortOrderValues).optional(),\n    StartIndex: z.number().optional(),\n});\n\nconst pagination = z.object({\n    StartIndex: z.number(),\n    TotalRecordCount: z.number(),\n});\n\nconst imageTags = z.object({\n    Logo: z.string().optional(),\n    Primary: z.string().optional(),\n});\n\nconst imageBlurHashes = z.object({\n    Backdrop: z.record(z.string(), z.string()).optional(),\n    Logo: z.record(z.string(), z.string()).optional(),\n    Primary: z.record(z.string(), z.string()).optional(),\n});\n\nconst userData = z.object({\n    IsFavorite: z.boolean(),\n    Key: z.string(),\n    PlaybackPositionTicks: z.number(),\n    PlayCount: z.number(),\n    Played: z.boolean(),\n});\n\nconst externalUrl = z.object({\n    Name: z.string(),\n    Url: z.string(),\n});\n\nconst mediaStream = z.object({\n    AspectRatio: z.string().optional(),\n    BitDepth: z.number().optional(),\n    BitRate: z.number().optional(),\n    ChannelLayout: z.string().optional(),\n    Channels: z.number().optional(),\n    Codec: z.string(),\n    CodecTimeBase: z.string(),\n    ColorSpace: z.string().optional(),\n    Comment: z.string().optional(),\n    DisplayTitle: z.string().optional(),\n    Height: z.number().optional(),\n    Index: z.number(),\n    IsDefault: z.boolean(),\n    IsExternal: z.boolean(),\n    IsForced: z.boolean(),\n    IsInterlaced: z.boolean(),\n    IsTextSubtitleStream: z.boolean(),\n    Level: z.number(),\n    PixelFormat: z.string().optional(),\n    Profile: z.string().optional(),\n    RealFrameRate: z.number().optional(),\n    RefFrames: z.number().optional(),\n    SampleRate: z.number().optional(),\n    SupportsExternalStream: z.boolean(),\n    TimeBase: z.string(),\n    Type: z.string(),\n    Width: z.number().optional(),\n});\n\nconst mediaSources = z.object({\n    Bitrate: z.number(),\n    Container: z.string(),\n    DefaultAudioStreamIndex: z.number(),\n    ETag: z.string(),\n    Formats: z.array(z.any()),\n    GenPtsInput: z.boolean(),\n    Id: z.string(),\n    IgnoreDts: z.boolean(),\n    IgnoreIndex: z.boolean(),\n    IsInfiniteStream: z.boolean(),\n    IsRemote: z.boolean(),\n    MediaAttachments: z.array(z.any()),\n    MediaStreams: z.array(mediaStream),\n    Name: z.string(),\n    Path: z.string(),\n    Protocol: z.string(),\n    ReadAtNativeFramerate: z.boolean(),\n    RequiredHttpHeaders: z.any(),\n    RequiresClosing: z.boolean(),\n    RequiresLooping: z.boolean(),\n    RequiresOpening: z.boolean(),\n    RunTimeTicks: z.number(),\n    Size: z.number(),\n    SupportsDirectPlay: z.boolean(),\n    SupportsDirectStream: z.boolean(),\n    SupportsProbing: z.boolean(),\n    SupportsTranscoding: z.boolean(),\n    Type: z.string(),\n});\n\nconst sessionInfo = z.object({\n    AdditionalUsers: z.array(z.any()),\n    ApplicationVersion: z.string(),\n    Capabilities: z.object({\n        PlayableMediaTypes: z.array(z.any()),\n        SupportedCommands: z.array(z.any()),\n        SupportsContentUploading: z.boolean(),\n        SupportsMediaControl: z.boolean(),\n        SupportsPersistentIdentifier: z.boolean(),\n        SupportsSync: z.boolean(),\n    }),\n    Client: z.string(),\n    DeviceId: z.string(),\n    DeviceName: z.string(),\n    HasCustomDeviceName: z.boolean(),\n    Id: z.string(),\n    IsActive: z.boolean(),\n    LastActivityDate: z.string(),\n    LastPlaybackCheckIn: z.string(),\n    NowPlayingQueue: z.array(z.any()),\n    NowPlayingQueueFullItems: z.array(z.any()),\n    PlayableMediaTypes: z.array(z.any()),\n    PlayState: z.object({\n        CanSeek: z.boolean(),\n        IsMuted: z.boolean(),\n        IsPaused: z.boolean(),\n        PositionTicks: z.number().optional(),\n        RepeatMode: z.string(),\n    }),\n    RemoteEndPoint: z.string(),\n    ServerId: z.string(),\n    SupportedCommands: z.array(z.any()),\n    SupportsMediaControl: z.boolean(),\n    SupportsRemoteControl: z.boolean(),\n    UserId: z.string(),\n    UserName: z.string(),\n});\n\nconst configuration = z.object({\n    DisplayCollectionsView: z.boolean(),\n    DisplayMissingEpisodes: z.boolean(),\n    EnableLocalPassword: z.boolean(),\n    EnableNextEpisodeAutoPlay: z.boolean(),\n    GroupedFolders: z.array(z.any()),\n    HidePlayedInLatest: z.boolean(),\n    LatestItemsExcludes: z.array(z.any()),\n    MyMediaExcludes: z.array(z.any()),\n    OrderedViews: z.array(z.any()),\n    PlayDefaultAudioTrack: z.boolean(),\n    RememberAudioSelections: z.boolean(),\n    RememberSubtitleSelections: z.boolean(),\n    SubtitleLanguagePreference: z.string(),\n    SubtitleMode: z.string(),\n});\n\nconst policy = z.object({\n    AccessSchedules: z.array(z.any()),\n    AuthenticationProviderId: z.string(),\n    BlockedChannels: z.array(z.any()),\n    BlockedMediaFolders: z.array(z.any()),\n    BlockedTags: z.array(z.any()),\n    BlockUnratedItems: z.array(z.any()),\n    EnableAllChannels: z.boolean(),\n    EnableAllDevices: z.boolean(),\n    EnableAllFolders: z.boolean(),\n    EnableAudioPlaybackTranscoding: z.boolean(),\n    EnableContentDeletion: z.boolean(),\n    EnableContentDeletionFromFolders: z.array(z.any()),\n    EnableContentDownloading: z.boolean(),\n    EnabledChannels: z.array(z.any()),\n    EnabledDevices: z.array(z.any()),\n    EnabledFolders: z.array(z.any()),\n    EnableLiveTvAccess: z.boolean(),\n    EnableLiveTvManagement: z.boolean(),\n    EnableMediaConversion: z.boolean(),\n    EnableMediaPlayback: z.boolean(),\n    EnablePlaybackRemuxing: z.boolean(),\n    EnablePublicSharing: z.boolean(),\n    EnableRemoteAccess: z.boolean(),\n    EnableRemoteControlOfOtherUsers: z.boolean(),\n    EnableSharedDeviceControl: z.boolean(),\n    EnableSyncTranscoding: z.boolean(),\n    EnableUserPreferenceAccess: z.boolean(),\n    EnableVideoPlaybackTranscoding: z.boolean(),\n    ForceRemoteSourceTranscoding: z.boolean(),\n    InvalidLoginAttemptCount: z.number(),\n    IsAdministrator: z.boolean(),\n    IsDisabled: z.boolean(),\n    IsHidden: z.boolean(),\n    LoginAttemptsBeforeLockout: z.number(),\n    MaxActiveSessions: z.number(),\n    PasswordResetProviderId: z.string(),\n    RemoteClientBitrateLimit: z.number(),\n    SyncPlayAccess: z.string(),\n});\n\nconst user = z.object({\n    Configuration: configuration,\n    EnableAutoLogin: z.boolean(),\n    HasConfiguredEasyPassword: z.boolean(),\n    HasConfiguredPassword: z.boolean(),\n    HasPassword: z.boolean(),\n    Id: z.string(),\n    LastActivityDate: z.string(),\n    LastLoginDate: z.string(),\n    Name: z.string(),\n    Policy: policy,\n    ServerId: z.string(),\n});\n\nconst authenticateParameters = z.object({\n    Pw: z.string(),\n    Username: z.string(),\n});\n\nconst authenticate = z.object({\n    AccessToken: z.string(),\n    ServerId: z.string(),\n    SessionInfo: sessionInfo,\n    User: user,\n});\n\nconst genreItem = z.object({\n    Id: z.string(),\n    Name: z.string(),\n});\n\nconst genre = z.object({\n    BackdropImageTags: z.array(z.any()),\n    ChannelId: z.null(),\n    Id: z.string(),\n    ImageBlurHashes: imageBlurHashes,\n    ImageTags: imageTags,\n    LocationType: z.string(),\n    Name: z.string(),\n    ServerId: z.string(),\n    Type: z.string(),\n});\n\nconst genreList = pagination.extend({\n    Items: z.array(genre),\n});\n\nconst genreListSort = {\n    NAME: 'SortName',\n} as const;\n\nconst genreListParameters = paginationParameters.merge(\n    baseParameters.extend({\n        SearchTerm: z.string().optional(),\n        SortBy: z.nativeEnum(genreListSort).optional(),\n    }),\n);\n\nconst musicFolder = z.object({\n    BackdropImageTags: z.array(z.string()),\n    ChannelId: z.null(),\n    CollectionType: z.string(),\n    Id: z.string(),\n    ImageBlurHashes: imageBlurHashes,\n    ImageTags: imageTags,\n    IsFolder: z.boolean(),\n    LocationType: z.string(),\n    Name: z.string(),\n    ServerId: z.string(),\n    Type: z.string(),\n    UserData: userData,\n});\n\nconst musicFolderListParameters = z.object({\n    UserId: z.string(),\n});\n\nconst musicFolderList = z.object({\n    Items: z.array(musicFolder),\n});\n\nconst playlist = z.object({\n    BackdropImageTags: z.array(z.string()),\n    ChannelId: z.null(),\n    ChildCount: z.number().optional(),\n    DateCreated: z.string(),\n    GenreItems: z.array(genreItem),\n    Genres: z.array(z.string()),\n    Id: z.string(),\n    ImageBlurHashes: imageBlurHashes,\n    ImageTags: imageTags,\n    IsFolder: z.boolean(),\n    LocationType: z.string(),\n    MediaType: z.string(),\n    Name: z.string(),\n    Overview: z.string().optional(),\n    RunTimeTicks: z.number(),\n    ServerId: z.string(),\n    Type: z.string(),\n    UserData: userData,\n});\n\nconst playlistListSort = {\n    ALBUM_ARTIST: 'AlbumArtist,SortName',\n    DURATION: 'Runtime',\n    NAME: 'SortName',\n    RECENTLY_ADDED: 'DateCreated,SortName',\n    SONG_COUNT: 'ChildCount',\n} as const;\n\nconst playlistListParameters = paginationParameters.merge(\n    baseParameters.extend({\n        IncludeItemTypes: z.literal('Playlist'),\n        SortBy: z.nativeEnum(playlistListSort).optional(),\n    }),\n);\n\nconst playlistList = pagination.extend({\n    Items: z.array(playlist),\n});\n\nconst genericItem = z.object({\n    Id: z.string(),\n    Name: z.string(),\n});\n\nconst participant = z.object({\n    Id: z.string(),\n    Name: z.string(),\n    Type: z.string().optional(),\n});\n\nconst providerIds = z.object({\n    MusicBrainzAlbum: z.string().optional(),\n    MusicBrainzAlbumArtist: z.string().optional(),\n    MusicBrainzArtist: z.string().optional(),\n    MusicBrainzRecording: z.string().optional(),\n    MusicBrainzReleaseGroup: z.string().optional(),\n    MusicBrainzTrack: z.string().optional(),\n});\n\nconst songDetailParameters = baseParameters;\n\nconst song = z.object({\n    Album: z.string(),\n    AlbumArtist: z.string(),\n    AlbumArtists: z.array(genericItem),\n    AlbumId: z.string().optional(),\n    AlbumPrimaryImageTag: z.string(),\n    ArtistItems: z.array(genericItem),\n    Artists: z.array(z.string()),\n    BackdropImageTags: z.array(z.string()),\n    ChannelId: z.null(),\n    DateCreated: z.string(),\n    ExternalUrls: z.array(externalUrl),\n    GenreItems: z.array(genericItem),\n    Genres: z.array(z.string()),\n    Id: z.string(),\n    ImageBlurHashes: imageBlurHashes,\n    ImageTags: imageTags,\n    IndexNumber: z.number(),\n    IsFolder: z.boolean(),\n    LocationType: z.string(),\n    LUFS: z.number().optional(),\n    MediaSources: z.array(mediaSources),\n    MediaType: z.string(),\n    Name: z.string(),\n    NormalizationGain: z.number().optional(),\n    ParentId: z.string().optional(),\n    ParentIndexNumber: z.number(),\n    People: participant.array().optional(),\n    PlaylistItemId: z.string().optional(),\n    PremiereDate: z.string().optional(),\n    ProductionYear: z.number(),\n    ProviderIds: providerIds.optional(),\n    RunTimeTicks: z.number(),\n    ServerId: z.string(),\n    SortName: z.string().optional(),\n    Tags: z.string().array().optional(),\n    Type: z.string(),\n    UserData: userData.optional(),\n});\n\nconst albumArtist = z.object({\n    AlbumCount: z.number().optional(),\n    BackdropImageTags: z.array(z.string()),\n    ChannelId: z.null(),\n    DateCreated: z.string(),\n    ExternalUrls: z.array(externalUrl),\n    GenreItems: z.array(genreItem),\n    Genres: z.array(z.string()),\n    Id: z.string(),\n    ImageBlurHashes: imageBlurHashes,\n    ImageTags: imageTags,\n    LocationType: z.string(),\n    Name: z.string(),\n    Overview: z.string(),\n    ProviderIds: providerIds.optional(),\n    RunTimeTicks: z.number(),\n    ServerId: z.string(),\n    SongCount: z.number().optional(),\n    Type: z.string(),\n    UserData: userData.optional(),\n});\n\nconst studio = z.object({\n    Id: z.string(),\n    Name: z.string(),\n});\n\nconst albumDetailParameters = baseParameters;\n\nconst album = z.object({\n    AlbumArtist: z.string(),\n    AlbumArtists: z.array(genericItem),\n    AlbumPrimaryImageTag: z.string(),\n    ArtistItems: z.array(genericItem),\n    Artists: z.array(z.string()),\n    ChannelId: z.null(),\n    ChildCount: z.number().optional(),\n    DateCreated: z.string(),\n    DateLastMediaAdded: z.string().optional(),\n    ExternalUrls: z.array(externalUrl),\n    GenreItems: z.array(genericItem),\n    Genres: z.array(z.string()),\n    Id: z.string(),\n    ImageBlurHashes: imageBlurHashes,\n    ImageTags: imageTags,\n    IsFolder: z.boolean(),\n    LocationType: z.string(),\n    Name: z.string(),\n    ParentLogoImageTag: z.string(),\n    ParentLogoItemId: z.string(),\n    People: participant.array().optional(),\n    PremiereDate: z.string().optional(),\n    ProductionYear: z.number(),\n    ProviderIds: providerIds.optional(),\n    RunTimeTicks: z.number(),\n    ServerId: z.string(),\n    Songs: z.array(song).optional(), // This is not a native Jellyfin property -- this is used for combined album detail\n    SortName: z.string().optional(),\n    Studios: z.array(studio),\n    Tags: z.string().array().optional(),\n    Type: z.string(),\n    UserData: userData.optional(),\n});\n\nconst albumListSort = {\n    ALBUM_ARTIST: 'AlbumArtist,SortName',\n    COMMUNITY_RATING: 'CommunityRating,SortName',\n    CRITIC_RATING: 'CriticRating,SortName',\n    NAME: 'SortName',\n    PLAY_COUNT: 'PlayCount',\n    RANDOM: 'Random,SortName',\n    RECENTLY_ADDED: 'DateCreated,SortName',\n    RELEASE_DATE: 'ProductionYear,PremiereDate,SortName',\n} as const;\n\nconst albumListParameters = paginationParameters.merge(\n    baseParameters.extend({\n        Filters: z.string().optional(),\n        GenreIds: z.string().optional(),\n        Genres: z.string().optional(),\n        IncludeItemTypes: z.literal('MusicAlbum'),\n        IsFavorite: z.boolean().optional(),\n        SearchTerm: z.string().optional(),\n        SortBy: z.nativeEnum(albumListSort).optional(),\n        Tags: z.string().optional(),\n        Years: z.string().optional(),\n    }),\n);\n\nconst albumList = pagination.extend({\n    Items: z.array(album),\n});\n\nconst albumArtistListSort = {\n    ALBUM: 'Album,SortName',\n    DURATION: 'Runtime,AlbumArtist,Album,SortName',\n    NAME: 'SortName,Name',\n    RANDOM: 'Random,SortName',\n    RECENTLY_ADDED: 'DateCreated,SortName',\n    RELEASE_DATE: 'PremiereDate,AlbumArtist,Album,SortName',\n} as const;\n\nconst albumArtistListParameters = paginationParameters.merge(\n    baseParameters.extend({\n        Filters: z.string().optional(),\n        Genres: z.string().optional(),\n        SortBy: z.nativeEnum(albumArtistListSort).optional(),\n        Years: z.string().optional(),\n    }),\n);\n\nconst albumArtistList = pagination.extend({\n    Items: z.array(albumArtist),\n});\n\nconst similarArtistListParameters = baseParameters.extend({\n    Limit: z.number().optional(),\n});\n\nconst songListSort = {\n    ALBUM: 'Album,SortName',\n    ALBUM_ARTIST: 'AlbumArtist,Album,SortName',\n    ALBUM_DETAIL: 'ParentIndexNumber,IndexNumber,SortName',\n    ARTIST: 'Artist,Album,SortName',\n    COMMUNITY_RATING: 'CommunityRating,SortName',\n    DURATION: 'Runtime,AlbumArtist,Album,SortName',\n    NAME: 'Name',\n    PLAY_COUNT: 'PlayCount,SortName',\n    RANDOM: 'Random,SortName',\n    RECENTLY_ADDED: 'DateCreated,SortName',\n    RECENTLY_PLAYED: 'DatePlayed,SortName',\n    RELEASE_DATE: 'PremiereDate,AlbumArtist,Album,SortName',\n} as const;\n\nconst songListParameters = paginationParameters.merge(\n    baseParameters.extend({\n        AlbumArtistIds: z.string().optional(),\n        AlbumIds: z.string().optional(),\n        ArtistIds: z.string().optional(),\n        Filters: z.string().optional(),\n        GenreIds: z.string().optional(),\n        Genres: z.string().optional(),\n        IsFavorite: z.boolean().optional(),\n        IsPlayed: z.boolean().optional(),\n        SearchTerm: z.string().optional(),\n        SortBy: z.nativeEnum(songListSort).optional(),\n        Tags: z.string().optional(),\n        Years: z.string().optional(),\n    }),\n);\n\nconst songList = pagination.extend({\n    Items: z.array(song),\n});\n\nconst playlistSongList = songList;\n\nconst topSongsList = songList;\n\nconst playlistDetailParameters = baseParameters.extend({\n    Ids: z.string(),\n});\n\nconst createPlaylistParameters = z.object({\n    IsPublic: z.boolean().optional(),\n    MediaType: z.literal('Audio'),\n    Name: z.string(),\n    UserId: z.string(),\n});\n\nconst createPlaylist = z.object({\n    Id: z.string(),\n});\n\nconst updatePlaylist = z.null();\n\nconst updatePlaylistParameters = z.object({\n    Genres: z.array(genreItem),\n    IsPublic: z.boolean().optional(),\n    MediaType: z.literal('Audio'),\n    Name: z.string(),\n    PremiereDate: z.null(),\n    ProviderIds: z.object({}),\n    Tags: z.array(genericItem),\n    UserId: z.string(),\n});\n\nconst addToPlaylist = z.object({\n    Added: z.number(),\n});\n\nconst addToPlaylistParameters = z.object({\n    Ids: z.string(),\n    UserId: z.string(),\n});\n\nconst removeFromPlaylist = z.null();\n\nconst removeFromPlaylistParameters = z.object({\n    EntryIds: z.string(),\n});\n\nconst deletePlaylist = z.null();\n\nconst deletePlaylistParameters = z.object({\n    Id: z.string(),\n});\n\nconst scrobbleParameters = z.object({\n    EventName: z.string().optional(),\n    IsPaused: z.boolean().optional(),\n    ItemId: z.string(),\n    PositionTicks: z.number().optional(),\n});\n\nconst scrobble = z.any();\n\nconst favorite = z.object({\n    IsFavorite: z.boolean(),\n    ItemId: z.string(),\n    Key: z.string(),\n    LastPlayedDate: z.string(),\n    Likes: z.boolean(),\n    PlaybackPositionTicks: z.number(),\n    PlayCount: z.number(),\n    Played: z.boolean(),\n    PlayedPercentage: z.number(),\n    Rating: z.number(),\n    UnplayedItemCount: z.number(),\n});\n\nconst favoriteParameters = z.object({});\n\nconst searchParameters = paginationParameters.merge(baseParameters);\n\nconst search = z.any();\n\nconst lyricText = z.object({\n    Start: z.number().optional(),\n    Text: z.string(),\n});\n\nconst lyrics = z.object({\n    Lyrics: z.array(lyricText),\n});\n\nconst serverInfo = z.object({\n    Version: z.string(),\n});\n\nconst similarSongsParameters = z.object({\n    Fields: z.array(z.string()).readonly().optional(),\n    Limit: z.number().optional(),\n    UserId: z.string().optional(),\n});\n\nconst similarSongs = pagination.extend({\n    Items: z.array(song),\n});\n\nexport enum JellyfinExtensions {\n    SONG_LYRICS = 'songLyrics',\n}\n\nconst moveItem = z.null();\n\nconst filterListParameters = z.object({\n    IncludeItemTypes: z.string().optional(),\n    ParentId: z.string().optional(),\n    UserId: z.string().optional(),\n});\n\nconst filters = z.object({\n    Genres: z.string().array().optional(),\n    Tags: z.string().array().optional(),\n    Years: z.number().array().optional(),\n});\n\nconst folder = z.object({\n    BackdropImageTags: z.array(z.string()),\n    ChannelId: z.null(),\n    CollectionType: z.string(),\n    Id: z.string(),\n    ImageBlurHashes: imageBlurHashes,\n    ImageTags: imageTags,\n    IsFolder: z.boolean(),\n    LocationType: z.string(),\n    MediaType: z.string(),\n    Name: z.string(),\n    ParentId: z.string().optional(),\n    ServerId: z.string(),\n    Type: z.string(),\n    UserData: userData.optional(),\n});\n\nconst folderList = pagination.extend({\n    Items: z.array(folder),\n});\n\nconst folderParameters = z.object({\n    Fields: z.array(z.string()).readonly().optional(),\n    ParentId: z.string().optional(),\n    SortBy: z.string().optional(),\n    SortOrder: z.enum(sortOrderValues).optional(),\n});\n\nconst queueItem = z.object({\n    Id: z.string(),\n    PlaylistItemId: z.string().optional(),\n});\n\nconst saveQueueParameters = scrobbleParameters.merge(\n    z.object({\n        NowPlayingQueue: z.array(queueItem),\n        PlaylistItemId: z.string().optional(),\n    }),\n);\n\nconst getQueueParameters = z.object({});\n\nconst getSessions = z.array(\n    sessionInfo.merge(\n        z.object({\n            PlaylistItemId: z.string().optional(),\n        }),\n    ),\n);\n\nconst studioListParameters = paginationParameters.merge(\n    baseParameters.extend({\n        NameStartsWithOrGreater: z.string().optional(),\n    }),\n);\n\nconst studioList = z.object({\n    Items: z.array(studio),\n});\n\nexport const jfType = {\n    _enum: {\n        albumArtistList: albumArtistListSort,\n        albumList: albumListSort,\n        collection: jfCollection,\n        external: jfExternal,\n        genreList: genreListSort,\n        image: jfImage,\n        playlistList: playlistListSort,\n        songList: songListSort,\n    },\n    _parameters: {\n        addToPlaylist: addToPlaylistParameters,\n        albumArtistDetail: baseParameters,\n        albumArtistList: albumArtistListParameters,\n        albumDetail: albumDetailParameters,\n        albumList: albumListParameters,\n        authenticate: authenticateParameters,\n        createPlaylist: createPlaylistParameters,\n        deletePlaylist: deletePlaylistParameters,\n        favorite: favoriteParameters,\n        filterList: filterListParameters,\n        folder: folderParameters,\n        genreList: genreListParameters,\n        getQueue: getQueueParameters,\n        musicFolderList: musicFolderListParameters,\n        playlistDetail: playlistDetailParameters,\n        playlistList: playlistListParameters,\n        removeFromPlaylist: removeFromPlaylistParameters,\n        saveQueue: saveQueueParameters,\n        scrobble: scrobbleParameters,\n        search: searchParameters,\n        similarArtistList: similarArtistListParameters,\n        similarSongs: similarSongsParameters,\n        songDetail: songDetailParameters,\n        songList: songListParameters,\n        studioList: studioListParameters,\n        updatePlaylist: updatePlaylistParameters,\n    },\n    _response: {\n        addToPlaylist,\n        album,\n        albumArtist,\n        albumArtistList,\n        albumList,\n        authenticate,\n        createPlaylist,\n        deletePlaylist,\n        error,\n        favorite,\n        filters,\n        folder,\n        folderList,\n        genre,\n        genreList,\n        getSessions,\n        lyrics,\n        moveItem,\n        musicFolder,\n        musicFolderList,\n        playlist,\n        playlistList,\n        playlistSongList,\n        removeFromPlaylist,\n        scrobble,\n        search,\n        serverInfo,\n        similarSongs,\n        song,\n        songList,\n        studioList,\n        topSongsList,\n        updatePlaylist,\n        user,\n    },\n};\n"
  },
  {
    "path": "src/shared/api/navidrome/navidrome-normalize.ts",
    "content": "import z from 'zod';\n\nimport { ndType } from '/@/shared/api/navidrome/navidrome-types';\nimport { ssType } from '/@/shared/api/subsonic/subsonic-types';\nimport { replacePathPrefix } from '/@/shared/api/utils';\nimport {\n    Album,\n    AlbumArtist,\n    ExplicitStatus,\n    Genre,\n    LibraryItem,\n    Playlist,\n    RelatedArtist,\n    Song,\n    User,\n} from '/@/shared/types/domain-types';\nimport { ServerListItem, ServerType } from '/@/shared/types/types';\n\nconst getImageUrl = (args: { url: null | string }) => {\n    const { url } = args;\n    if (url === '/app/artist-placeholder.webp') {\n        return null;\n    }\n\n    return url;\n};\n\ninterface WithDate {\n    playDate?: string;\n}\n\nconst normalizePlayDate = (item: WithDate): null | string => {\n    return !item.playDate || item.playDate.includes('0001-') ? null : item.playDate;\n};\n\nconst matchesFullDate = (date: string) => {\n    return Boolean(date.match(/^\\d{4}-\\d{2}-\\d{2}$/));\n};\n\nconst matchesYearOnly = (date: string) => {\n    return Boolean(date.match(/^\\d{4}$/));\n};\n\nconst normalizeReleaseDate = (item: {\n    date?: string;\n    minYear?: number;\n    releaseDate?: string;\n}): { date: null | string; year: null | number } => {\n    if (item.releaseDate && matchesFullDate(item.releaseDate)) {\n        return {\n            date: item.releaseDate,\n            year: parseInt(item.releaseDate.split('-')[0]),\n        };\n    } else if (item.releaseDate && matchesYearOnly(item.releaseDate)) {\n        return {\n            date: null,\n            year: parseInt(item.releaseDate),\n        };\n    }\n\n    if (item.date && matchesFullDate(item.date)) {\n        return {\n            date: item.date,\n            year: parseInt(item.date.split('-')[0]),\n        };\n    } else if (item.date && matchesYearOnly(item.date)) {\n        return {\n            date: null,\n            year: parseInt(item.date),\n        };\n    }\n\n    return {\n        date: null,\n        year: item.minYear ?? null,\n    };\n};\n\nconst normalizeOriginalDate = (item: {\n    date?: string;\n    minYear?: number;\n    originalDate?: string;\n    releaseDate?: string;\n}): { date: null | string; year: null | number } => {\n    if (item.originalDate && matchesFullDate(item.originalDate)) {\n        return {\n            date: item.originalDate,\n            year: parseInt(item.originalDate.split('-')[0]),\n        };\n    } else if (item.originalDate && matchesYearOnly(item.originalDate)) {\n        return {\n            date: null,\n            year: parseInt(item.originalDate),\n        };\n    }\n\n    if (item.releaseDate && matchesFullDate(item.releaseDate)) {\n        return {\n            date: item.releaseDate,\n            year: parseInt(item.releaseDate.split('-')[0]),\n        };\n    } else if (item.releaseDate && matchesYearOnly(item.releaseDate)) {\n        return {\n            date: null,\n            year: parseInt(item.releaseDate),\n        };\n    }\n\n    if (item.date && matchesFullDate(item.date)) {\n        return {\n            date: item.date,\n            year: parseInt(item.date.split('-')[0]),\n        };\n    } else if (item.date && matchesYearOnly(item.date)) {\n        return {\n            date: null,\n            year: parseInt(item.date),\n        };\n    }\n\n    return {\n        date: null,\n        year: item.minYear ?? null,\n    };\n};\n\nconst getArtists = (\n    item:\n        | z.infer<typeof ndType._response.album>\n        | z.infer<typeof ndType._response.playlistSong>\n        | z.infer<typeof ndType._response.song>,\n    includeRemixers = true,\n) => {\n    let albumArtists: RelatedArtist[] | undefined;\n    let artists: RelatedArtist[] | undefined;\n    let remixers: RelatedArtist[] | undefined;\n    let participants: null | Record<string, RelatedArtist[]> = null;\n\n    if (item.participants) {\n        participants = {};\n        for (const [role, list] of Object.entries(item.participants)) {\n            if (role === 'albumartist' || role === 'artist' || role === 'remixer') {\n                const roleList = list.map((item) => ({\n                    id: item.id,\n                    imageId: null,\n                    imageUrl: null,\n                    name: item.name,\n                    userFavorite: false,\n                    userRating: null,\n                }));\n\n                if (role === 'albumartist') {\n                    albumArtists = roleList;\n                } else if (role === 'remixer' && includeRemixers) {\n                    remixers = roleList;\n                } else {\n                    artists = roleList;\n                }\n            } else {\n                const subRoles = new Map<string | undefined, RelatedArtist[]>();\n\n                for (const artist of list) {\n                    const item: RelatedArtist = {\n                        id: artist.id,\n                        imageId: null,\n                        imageUrl: null,\n                        name: artist.name,\n                        userFavorite: false,\n                        userRating: null,\n                    };\n\n                    if (subRoles.has(artist.subRole)) {\n                        subRoles.get(artist.subRole)!.push(item);\n                    } else {\n                        subRoles.set(artist.subRole, [item]);\n                    }\n                }\n\n                for (const [subRole, items] of subRoles.entries()) {\n                    if (subRole) {\n                        participants[`${role} (${subRole})`] = items;\n                    } else {\n                        participants[role] = items;\n                    }\n                }\n            }\n        }\n    }\n\n    if (albumArtists === undefined) {\n        albumArtists = [\n            {\n                id: item.albumArtistId,\n                imageId: null,\n                imageUrl: null,\n                name: item.albumArtist,\n                userFavorite: false,\n                userRating: null,\n            },\n        ];\n    }\n\n    if (artists === undefined && (includeRemixers ? remixers === undefined : true)) {\n        artists = [\n            {\n                id: item.artistId,\n                imageId: null,\n                imageUrl: null,\n                name: item.artist,\n                userFavorite: false,\n                userRating: null,\n            },\n        ];\n    }\n\n    return {\n        albumArtists,\n        artists: [...(artists || []), ...(includeRemixers ? remixers || [] : [])],\n        participants,\n    };\n};\n\nconst normalizeSong = (\n    item: z.infer<typeof ndType._response.playlistSong> | z.infer<typeof ndType._response.song>,\n    server?: null | ServerListItem,\n    pathReplace?: string,\n    pathReplaceWith?: string,\n): Song => {\n    let id;\n    let playlistItemId;\n\n    // Dynamically determine the id field based on whether or not the item is a playlist song\n    if ('mediaFileId' in item) {\n        id = item.mediaFileId;\n        playlistItemId = item.id;\n    } else {\n        id = item.id;\n    }\n\n    return {\n        album: item.album,\n        albumId: item.albumId,\n        ...getArtists(item, true),\n        _itemType: LibraryItem.SONG,\n        _serverId: server?.id || 'unknown',\n        _serverType: ServerType.NAVIDROME,\n        albumArtistName: item.albumArtist,\n        artistName: item.artist,\n        bitDepth: item.bitDepth || null,\n        bitRate: item.bitRate,\n        bpm: item.bpm ? item.bpm : null,\n        channels: item.channels ? item.channels : null,\n        comment: item.comment ? item.comment : null,\n        compilation: item.compilation,\n        container: item.suffix,\n        createdAt: item.createdAt,\n        discNumber: item.discNumber,\n        discSubtitle: item.discSubtitle ? item.discSubtitle : null,\n        duration: item.duration * 1000,\n        explicitStatus:\n            item.explicitStatus === 'e'\n                ? ExplicitStatus.EXPLICIT\n                : item.explicitStatus === 'c'\n                  ? ExplicitStatus.CLEAN\n                  : null,\n        gain:\n            item.rgAlbumGain || item.rgTrackGain\n                ? { album: item.rgAlbumGain, track: item.rgTrackGain }\n                : null,\n        genres: (item.genres || []).map((genre) => ({\n            _itemType: LibraryItem.GENRE,\n            _serverId: server?.id || 'unknown',\n            _serverType: ServerType.NAVIDROME,\n            albumCount: null,\n            id: genre.id,\n            imageId: null,\n            imageUrl: null,\n            name: genre.name,\n            songCount: null,\n        })),\n        id,\n        imageId: id,\n        imageUrl: null,\n        lastPlayedAt: normalizePlayDate(item),\n        lyrics: item.lyrics ? item.lyrics : null,\n        mbzRecordingId: item.mbzReleaseTrackId || null,\n        mbzTrackId: item.mbzReleaseTrackId || null,\n        name: item.title,\n        // Thankfully, Windows is merciful and allows a mix of separators. So, we can use the\n        // POSIX separator here instead\n        path: item.path ? replacePathPrefix(item.path, pathReplace, pathReplaceWith) : null,\n        peak:\n            item.rgAlbumPeak || item.rgTrackPeak\n                ? { album: item.rgAlbumPeak, track: item.rgTrackPeak }\n                : null,\n        playCount: item.playCount || 0,\n        playlistItemId,\n        releaseDate: normalizeReleaseDate(item).date,\n        releaseYear: item.year || null,\n        sampleRate: item.sampleRate || null,\n        size: item.size,\n        sortName: item.orderTitle,\n        tags: item.tags || null,\n        trackNumber: item.trackNumber,\n        trackSubtitle: item.tags?.subtitle ? item.tags.subtitle.join(' · ') : null,\n        updatedAt: item.updatedAt,\n        userFavorite: item.starred || false,\n        userRating: item.rating || null,\n    };\n};\n\nconst parseAlbumTags = (\n    item: z.infer<typeof ndType._response.album>,\n): Pick<Album, 'recordLabels' | 'releaseTypes' | 'tags' | 'version'> => {\n    if (!item.tags) {\n        return {\n            recordLabels: [],\n            releaseTypes: [],\n            tags: null,\n            version: null,\n        };\n    }\n\n    // We get the genre from elsewhere. We don't need genre twice\n    delete item.tags['genre'];\n\n    let recordLabels: string[] = [];\n    if (item.tags['recordlabel']) {\n        recordLabels = item.tags['recordlabel'];\n        delete item.tags['recordlabel'];\n    }\n\n    let releaseTypes: string[] = [];\n    if (item.tags['releasetype']) {\n        releaseTypes = item.tags['releasetype'];\n        delete item.tags['releasetype'];\n    }\n\n    let version: null | string = null;\n    if (item.tags['albumversion']) {\n        version = item.tags['albumversion'].join(' · ');\n        delete item.tags['albumversion'];\n    }\n\n    return {\n        recordLabels,\n        releaseTypes,\n        tags: item.tags,\n        version,\n    };\n};\n\nconst normalizeAlbum = (\n    item: z.infer<typeof ndType._response.album> & {\n        songs?: z.infer<typeof ndType._response.songList>;\n    },\n    server?: null | ServerListItem,\n    pathReplace?: string,\n    pathReplaceWith?: string,\n): Album => {\n    const releaseDate = normalizeReleaseDate(item);\n    const originalDate = normalizeOriginalDate(item);\n\n    return {\n        ...parseAlbumTags(item),\n        ...getArtists(item, false),\n        _itemType: LibraryItem.ALBUM,\n        _serverId: server?.id || 'unknown',\n        _serverType: ServerType.NAVIDROME,\n        albumArtistName: item.albumArtist,\n        comment: item.comment || null,\n        createdAt: item.createdAt,\n        duration: item.duration !== undefined ? item.duration * 1000 : null,\n        explicitStatus:\n            item.explicitStatus === 'e'\n                ? ExplicitStatus.EXPLICIT\n                : item.explicitStatus === 'c'\n                  ? ExplicitStatus.CLEAN\n                  : null,\n        genres: (item.genres || []).map((genre) => ({\n            _itemType: LibraryItem.GENRE,\n            _serverId: server?.id || 'unknown',\n            _serverType: ServerType.NAVIDROME,\n            albumCount: null,\n            id: genre.id,\n            imageId: null,\n            imageUrl: null,\n            name: genre.name,\n            songCount: null,\n        })),\n        id: item.id,\n        imageId: item.coverArtId || item.id,\n        imageUrl: null,\n        isCompilation: item.compilation,\n        lastPlayedAt: normalizePlayDate(item),\n        mbzId: item.mbzAlbumId || null,\n        mbzReleaseGroupId: item.mbzReleaseGroupId || null,\n        name: item.name,\n        originalDate: originalDate.date,\n        originalYear: originalDate.year,\n        playCount: item.playCount || 0,\n        releaseDate: releaseDate.date,\n        releaseType: item.mbzAlbumType || null,\n        releaseYear: releaseDate.year,\n        size: item.size,\n        songCount: item.songCount,\n        songs: item.songs\n            ? item.songs.map((song) => normalizeSong(song, server, pathReplace, pathReplaceWith))\n            : undefined,\n        sortName: item.orderAlbumName,\n        tags: item.tags || null,\n        updatedAt: item.updatedAt,\n        userFavorite: item.starred || false,\n        userRating: item.rating || null,\n    };\n};\n\nconst normalizeAlbumArtist = (\n    item: z.infer<typeof ndType._response.albumArtist> & {\n        similarArtists?: z.infer<typeof ssType._response.artistInfo>['artistInfo']['similarArtist'];\n    },\n    server?: null | ServerListItem,\n): AlbumArtist => {\n    const imageUrl = getImageUrl({ url: item?.largeImageUrl?.replace(/\\?size=\\d+/, '') || null });\n\n    let albumCount: number;\n    let songCount: number;\n\n    if (item.stats) {\n        albumCount = Math.max(\n            item.stats.albumartist?.albumCount ?? 0,\n            item.stats.artist?.albumCount ?? 0,\n        );\n        songCount = Math.max(\n            item.stats.albumartist?.songCount ?? 0,\n            item.stats.artist?.songCount ?? 0,\n        );\n    } else {\n        albumCount = item.albumCount;\n        songCount = item.songCount;\n    }\n\n    return {\n        _itemType: LibraryItem.ALBUM_ARTIST,\n        _serverId: server?.id || 'unknown',\n        _serverType: ServerType.NAVIDROME,\n        albumCount,\n        biography: item.biography || null,\n        duration: null,\n        genres: (item.genres || []).map((genre) => ({\n            _itemType: LibraryItem.GENRE,\n            _serverId: server?.id || 'unknown',\n            _serverType: ServerType.NAVIDROME,\n            albumCount: null,\n            id: genre.id,\n            imageId: null,\n            imageUrl: null,\n            name: genre.name,\n            songCount: null,\n        })),\n        id: item.id,\n        imageId: item.id,\n        imageUrl: imageUrl || null,\n        lastPlayedAt: normalizePlayDate(item),\n        mbz: item.mbzArtistId || null,\n        name: item.name,\n        playCount: item.playCount || 0,\n        similarArtists:\n            item.similarArtists?.map((artist) => ({\n                id: artist.id,\n                imageId: null,\n                imageUrl: artist?.artistImageUrl?.replace(/\\?size=\\d+/, '') || null,\n                name: artist.name,\n                userFavorite: Boolean(artist.starred) || false,\n                userRating: artist.userRating || null,\n            })) || [],\n        songCount,\n        userFavorite: item.starred || false,\n        userRating: item.rating || null,\n    };\n};\n\nconst normalizePlaylist = (\n    item: z.infer<typeof ndType._response.playlist>,\n    server?: null | ServerListItem,\n): Playlist => {\n    return {\n        _itemType: LibraryItem.PLAYLIST,\n        _serverId: server?.id || 'unknown',\n        _serverType: ServerType.NAVIDROME,\n        description: item.comment,\n        duration: item.duration * 1000,\n        genres: [],\n        id: item.id,\n        imageId: item.id,\n        imageUrl: null,\n        name: item.name,\n        owner: item.ownerName,\n        ownerId: item.ownerId,\n        public: item.public,\n        rules: item?.rules || null,\n        size: item.size,\n        songCount: item.songCount,\n        sync: item.sync,\n    };\n};\n\nconst normalizeGenre = (\n    item: z.infer<typeof ndType._response.genre> & { albumCount?: number; songCount?: number },\n    server: null | ServerListItem,\n): Genre => {\n    return {\n        _itemType: LibraryItem.GENRE,\n        _serverId: server?.id || 'unknown',\n        _serverType: ServerType.NAVIDROME,\n        albumCount: item.albumCount ?? null,\n        id: item.id,\n        imageId: null,\n        imageUrl: null,\n        name: item.name,\n        songCount: item.songCount ?? null,\n    };\n};\n\nconst normalizeUser = (item: z.infer<typeof ndType._response.user>): User => {\n    return {\n        createdAt: item.createdAt,\n        email: item.email || null,\n        id: item.id,\n        isAdmin: item.isAdmin,\n        lastLoginAt: item.lastLoginAt,\n        name: item.userName,\n        updatedAt: item.updatedAt,\n    };\n};\n\nexport const ndNormalize = {\n    album: normalizeAlbum,\n    albumArtist: normalizeAlbumArtist,\n    genre: normalizeGenre,\n    playlist: normalizePlaylist,\n    song: normalizeSong,\n    user: normalizeUser,\n};\n"
  },
  {
    "path": "src/shared/api/navidrome/navidrome-types.ts",
    "content": "import i18n from 'i18next';\nimport { z } from 'zod';\n\nexport enum NDAlbumArtistListSort {\n    ALBUM_COUNT = 'albumCount',\n    FAVORITED = 'starred_at',\n    NAME = 'name',\n    PLAY_COUNT = 'playCount',\n    RATING = 'rating',\n    SONG_COUNT = 'songCount',\n}\n\nexport enum NDAlbumListSort {\n    ALBUM_ARTIST = 'album_artist',\n    ARTIST = 'artist',\n    DURATION = 'duration',\n    EXPLICIT_STATUS = 'explicitStatus',\n    NAME = 'name',\n    PLAY_COUNT = 'play_count',\n    PLAY_DATE = 'play_date',\n    RANDOM = 'random',\n    RATING = 'rating',\n    RECENTLY_ADDED = 'recently_added',\n    SONG_COUNT = 'songCount',\n    STARRED = 'starred_at',\n    YEAR = 'max_year',\n}\n\nexport enum NDGenreListSort {\n    NAME = 'name',\n}\n\nexport enum NDPlaylistListSort {\n    DURATION = 'duration',\n    NAME = 'name',\n    OWNER = 'owner_name',\n    PUBLIC = 'public',\n    SONG_COUNT = 'songCount',\n    UPDATED_AT = 'updatedAt',\n}\n\nexport enum NDSongListSort {\n    ALBUM = 'album',\n    ALBUM_ARTIST = 'order_album_artist_name',\n    ALBUM_SONGS = 'album',\n    ARTIST = 'artist',\n    BPM = 'bpm',\n    CHANNELS = 'channels',\n    COMMENT = 'comment',\n    DURATION = 'duration',\n    EXPLICIT_STATUS = 'explicitStatus',\n    FAVORITED = 'starred_at',\n    GENRE = 'genre',\n    ID = 'id',\n    PLAY_COUNT = 'playCount',\n    PLAY_DATE = 'playDate',\n    RANDOM = 'random',\n    RATING = 'rating',\n    RECENTLY_ADDED = 'createdAt',\n    TITLE = 'title',\n    TRACK = 'track',\n    YEAR = 'year',\n}\n\nexport enum NDSortOrder {\n    ASC = 'ASC',\n    DESC = 'DESC',\n}\n\nexport const NDSongQueryFields = [\n    { label: 'Album', type: 'string', value: 'album' },\n    { label: 'Album Artist', type: 'string', value: 'albumartist' },\n    { label: 'Album Artists', type: 'string', value: 'albumartists' },\n    { label: 'Album Comment', type: 'string', value: 'albumcomment' },\n    { label: 'Album Type', type: 'string', value: 'albumtype' },\n    { label: 'Album Version', type: 'string', value: 'albumversion' },\n    { label: 'Arranger', type: 'string', value: 'arranger' },\n    { label: 'Artist', type: 'string', value: 'artist' },\n    { label: 'Artists', type: 'string', value: 'artists' },\n    { label: 'ASIN', type: 'string', value: 'asin' },\n    { label: 'Barcode', type: 'string', value: 'barcode' },\n    { label: 'Bit Depth', type: 'number', value: 'bitdepth' },\n    { label: 'Bitrate', type: 'number', value: 'bitrate' },\n    { label: 'BPM', type: 'number', value: 'bpm' },\n    { label: 'Catalog Number', type: 'string', value: 'catalognumber' },\n    { label: 'Channels', type: 'number', value: 'channels' },\n    { label: 'Comment', type: 'string', value: 'comment' },\n    { label: 'Composer', type: 'string', value: 'composer' },\n    { label: 'Conductor', type: 'string', value: 'conductor' },\n    { label: 'Copyright', type: 'string', value: 'copyright' },\n    { label: 'Date Added', type: 'date', value: 'dateadded' },\n    { label: 'Date Favorited', type: 'date', value: 'dateloved' },\n    { label: 'Date Last Played', type: 'date', value: 'lastplayed' },\n    { label: 'Date Modified', type: 'date', value: 'datemodified' },\n    { label: 'DJ Mixer', type: 'string', value: 'djmixer' },\n    { label: 'Director', type: 'string', value: 'director' },\n    { label: 'Disc Number', type: 'number', value: 'discnumber' },\n    { label: 'Disc Subtitle', type: 'string', value: 'discsubtitle' },\n    { label: 'Disc Total', type: 'number', value: 'disctotal' },\n    { label: 'Duration', type: 'number', value: 'duration' },\n    { label: 'Encoded By', type: 'string', value: 'encodedby' },\n    { label: 'Encoder Settings', type: 'string', value: 'encodersettings' },\n    { label: 'Engineer', type: 'string', value: 'engineer' },\n    { label: 'Explicit Status', type: 'string', value: 'explicitstatus' },\n    { label: 'File Path', type: 'string', value: 'filepath' },\n    { label: 'File Type', type: 'string', value: 'filetype' },\n    { label: 'Genre', type: 'string', value: 'genre' },\n    { label: 'Grouping', type: 'string', value: 'grouping' },\n    { label: 'Has CoverArt', type: 'boolean', value: 'hascoverart' },\n    { label: 'Is Compilation', type: 'boolean', value: 'compilation' },\n    { label: 'Is Favorite', type: 'boolean', value: 'loved' },\n    { label: 'ISRC', type: 'string', value: 'isrc' },\n    { label: 'Key', type: 'string', value: 'key' },\n    { label: 'Language', type: 'string', value: 'language' },\n    { label: 'License', type: 'string', value: 'license' },\n    { label: 'Library Id', type: 'string', value: 'library_id' },\n    { label: 'Lyricist', type: 'string', value: 'lyricist' },\n    { label: 'Lyrics', type: 'string', value: 'lyrics' },\n    { label: 'Media', type: 'string', value: 'media' },\n    { label: 'Mixer', type: 'string', value: 'mixer' },\n    { label: 'Mood', type: 'string', value: 'mood' },\n    { label: 'Movement', type: 'string', value: 'movement' },\n    { label: 'Movement Name', type: 'string', value: 'movementname' },\n    { label: 'Movement Total', type: 'number', value: 'movementtotal' },\n    { label: 'MusicBrainz Album Artist Id', type: 'string', value: 'mbz_album_artist_id' },\n    { label: 'MusicBrainz Album Id', type: 'string', value: 'mbz_album_id' },\n    { label: 'MusicBrainz Artist Id', type: 'string', value: 'mbz_artist_id' },\n    { label: 'MusicBrainz Arranger Id', type: 'string', value: 'musicbrainz_arrangerid' },\n    { label: 'MusicBrainz Composer Id', type: 'string', value: 'musicbrainz_composerid' },\n    { label: 'MusicBrainz Conductor Id', type: 'string', value: 'musicbrainz_conductorid' },\n    { label: 'MusicBrainz Director Id', type: 'string', value: 'musicbrainz_directorid' },\n    { label: 'MusicBrainz Disc Id', type: 'string', value: 'musicbrainz_discid' },\n    { label: 'MusicBrainz DJ Mixer Id', type: 'string', value: 'musicbrainz_djmixerid' },\n    { label: 'MusicBrainz Engineer Id', type: 'string', value: 'musicbrainz_engineerid' },\n    { label: 'MusicBrainz Lyricist Id', type: 'string', value: 'musicbrainz_lyricistid' },\n    { label: 'MusicBrainz Mixer Id', type: 'string', value: 'musicbrainz_mixerid' },\n    { label: 'MusicBrainz Performer Id', type: 'string', value: 'musicbrainz_performerid' },\n    { label: 'MusicBrainz Producer Id', type: 'string', value: 'musicbrainz_producerid' },\n    { label: 'MusicBrainz Recording Id', type: 'string', value: 'mbz_recording_id' },\n    { label: 'MusicBrainz Release Group Id', type: 'string', value: 'mbz_release_group_id' },\n    { label: 'MusicBrainz Release Track Id', type: 'string', value: 'mbz_release_track_id' },\n    { label: 'MusicBrainz Remixer Id', type: 'string', value: 'musicbrainz_remixerid' },\n    { label: 'MusicBrainz Track Id', type: 'string', value: 'musicbrainz_trackid' },\n    { label: 'MusicBrainz Work Id', type: 'string', value: 'musicbrainz_workid' },\n    { label: 'Name', type: 'string', value: 'title' },\n    { label: 'Original Date', type: 'date', value: 'originaldate' },\n    { label: 'Original Year', type: 'date', value: 'originalyear' },\n    { label: 'Performer', type: 'string', value: 'performer' },\n    { label: 'Play Count', type: 'number', value: 'playcount' },\n    { label: 'Playlist', type: 'playlist', value: 'id' },\n    { label: 'Producer', type: 'string', value: 'producer' },\n    { label: 'R128 Album Gain', type: 'number', value: 'r128_album_gain' },\n    { label: 'R128 Track Gain', type: 'number', value: 'r128_track_gain' },\n    { label: 'Rating', type: 'number', value: 'rating' },\n    { label: 'Record Label', type: 'string', value: 'recordlabel' },\n    { label: 'Recording Date', type: 'date', value: 'recordingdate' },\n    { label: 'Release Country', type: 'string', value: 'releasecountry' },\n    { label: 'Release Date', type: 'date', value: 'releasedate' },\n    { label: 'Release Status', type: 'string', value: 'releasestatus' },\n    { label: 'Release Type', type: 'string', value: 'releasetype' },\n    { label: 'ReplayGain Album Gain', type: 'number', value: 'replaygain_album_gain' },\n    { label: 'ReplayGain Album Peak', type: 'number', value: 'replaygain_album_peak' },\n    { label: 'ReplayGain Track Gain', type: 'number', value: 'replaygain_track_gain' },\n    { label: 'ReplayGain Track Peak', type: 'number', value: 'replaygain_track_peak' },\n    { label: 'Remixer', type: 'string', value: 'remixer' },\n    { label: 'Script', type: 'string', value: 'script' },\n    { label: 'Size', type: 'number', value: 'size' },\n    { label: 'Sort Album', type: 'string', value: 'albumsort' },\n    { label: 'Sort Album Artist', type: 'string', value: 'albumartistsort' },\n    { label: 'Sort Album Artists', type: 'string', value: 'albumartistssort' },\n    { label: 'Sort Artist', type: 'string', value: 'artistsort' },\n    { label: 'Sort Artists', type: 'string', value: 'artistssort' },\n    { label: 'Sort Composer', type: 'string', value: 'composersort' },\n    { label: 'Sort Lyricist', type: 'string', value: 'lyricistsort' },\n    { label: 'Sort Name', type: 'string', value: 'titlesort' },\n    { label: 'Subtitle', type: 'string', value: 'subtitle' },\n    { label: 'Track Number', type: 'number', value: 'track' },\n    { label: 'Track Total', type: 'number', value: 'tracktotal' },\n    { label: 'Website', type: 'string', value: 'website' },\n    { label: 'Work', type: 'string', value: 'work' },\n    { label: 'Year', type: 'number', value: 'year' },\n];\n\nexport const NDSongQueryFieldsLabelMap: Record<string, string> = NDSongQueryFields.reduce(\n    (acc, field) => {\n        acc[field.value] = field.label;\n        return acc;\n    },\n    {} as Record<string, string>,\n);\n\nexport const NDSongQueryPlaylistOperators = [\n    {\n        label: i18n.t('filterOperator.inPlaylist', { postProcess: 'titleCase' }),\n        value: 'inPlaylist',\n    },\n    {\n        label: i18n.t('filterOperator.notInPlaylist', { postProcess: 'titleCase' }),\n        value: 'notInPlaylist',\n    },\n];\n\nexport const NDSongQueryDateOperators = [\n    {\n        label: i18n.t('filterOperator.is', { postProcess: 'titleCase' }),\n        value: 'is',\n    },\n    {\n        label: i18n.t('filterOperator.isNot', { postProcess: 'titleCase' }),\n        value: 'isNot',\n    },\n    {\n        label: i18n.t('filterOperator.before', { postProcess: 'titleCase' }),\n        value: 'before',\n    },\n    {\n        label: i18n.t('filterOperator.after', { postProcess: 'titleCase' }),\n        value: 'after',\n    },\n    {\n        label: i18n.t('filterOperator.inTheLast', { postProcess: 'titleCase' }),\n        value: 'inTheLast',\n    },\n    {\n        label: i18n.t('filterOperator.notInTheLast', { postProcess: 'titleCase' }),\n        value: 'notInTheLast',\n    },\n    {\n        label: i18n.t('filterOperator.inTheRange', { postProcess: 'titleCase' }),\n        value: 'inTheRange',\n    },\n    {\n        label: i18n.t('filterOperator.beforeDate', { postProcess: 'titleCase' }),\n        value: 'beforeDate',\n    },\n    {\n        label: i18n.t('filterOperator.afterDate', { postProcess: 'titleCase' }),\n        value: 'afterDate',\n    },\n    {\n        label: i18n.t('filterOperator.inTheRangeDate', { postProcess: 'titleCase' }),\n        value: 'inTheRangeDate',\n    },\n];\n\nexport const NDSongQueryStringOperators = [\n    {\n        label: i18n.t('filterOperator.is', { postProcess: 'titleCase' }),\n        value: 'is',\n    },\n    {\n        label: i18n.t('filterOperator.isNot', { postProcess: 'titleCase' }),\n        value: 'isNot',\n    },\n    {\n        label: i18n.t('filterOperator.contains', { postProcess: 'titleCase' }),\n        value: 'contains',\n    },\n    {\n        label: i18n.t('filterOperator.notContains', { postProcess: 'titleCase' }),\n        value: 'notContains',\n    },\n    {\n        label: i18n.t('filterOperator.startsWith', { postProcess: 'titleCase' }),\n        value: 'startsWith',\n    },\n    {\n        label: i18n.t('filterOperator.endsWith', { postProcess: 'titleCase' }),\n        value: 'endsWith',\n    },\n];\n\nexport const NDSongQueryBooleanOperators = [\n    {\n        label: i18n.t('filterOperator.is', { postProcess: 'titleCase' }),\n        value: 'is',\n    },\n    {\n        label: i18n.t('filterOperator.isNot', { postProcess: 'titleCase' }),\n        value: 'isNot',\n    },\n];\n\nexport const NDSongQueryNumberOperators = [\n    {\n        label: i18n.t('filterOperator.is', { postProcess: 'titleCase' }),\n        value: 'is',\n    },\n    {\n        label: i18n.t('filterOperator.isNot', { postProcess: 'titleCase' }),\n        value: 'isNot',\n    },\n    {\n        label: i18n.t('filterOperator.contains', { postProcess: 'titleCase' }),\n        value: 'contains',\n    },\n    {\n        label: i18n.t('filterOperator.notContains', { postProcess: 'titleCase' }),\n        value: 'notContains',\n    },\n    {\n        label: i18n.t('filterOperator.isGreaterThan', { postProcess: 'titleCase' }),\n        value: 'gt',\n    },\n    {\n        label: i18n.t('filterOperator.isLessThan', { postProcess: 'titleCase' }),\n        value: 'lt',\n    },\n    {\n        label: i18n.t('filterOperator.inTheRange', { postProcess: 'titleCase' }),\n        value: 'inTheRange',\n    },\n];\n\nexport enum NDUserListSort {\n    NAME = 'name',\n}\n\nconst sortOrderValues = ['ASC', 'DESC'] as const;\n\nconst error = z.string();\n\nconst paginationParameters = z.object({\n    _end: z.number().optional(),\n    _order: z.enum(sortOrderValues),\n    _start: z.number().optional(),\n});\n\nconst optionalPaginationParameters = paginationParameters.partial();\n\nconst authenticate = z.object({\n    id: z.string(),\n    isAdmin: z.boolean(),\n    name: z.string(),\n    subsonicSalt: z.string(),\n    subsonicToken: z.string(),\n    token: z.string(),\n    username: z.string(),\n});\n\nconst authenticateParameters = z.object({\n    password: z.string(),\n    username: z.string(),\n});\n\nconst user = z.object({\n    createdAt: z.string(),\n    email: z.string().optional(),\n    id: z.string(),\n    isAdmin: z.boolean(),\n    lastAccessAt: z.string(),\n    lastLoginAt: z.string(),\n    name: z.string(),\n    updatedAt: z.string(),\n    userName: z.string(),\n});\n\nconst userList = z.array(user);\n\nconst ndUserListSort = {\n    NAME: 'name',\n} as const;\n\nconst userListParameters = paginationParameters.extend({\n    _sort: z.nativeEnum(ndUserListSort).optional(),\n});\n\nconst genre = z.object({\n    id: z.string(),\n    name: z.string(),\n});\n\nconst genreListSort = {\n    NAME: 'name',\n    SONG_COUNT: 'songCount',\n} as const;\n\nconst genreListParameters = paginationParameters.extend({\n    _sort: z.nativeEnum(genreListSort).optional(),\n    library_id: z.array(z.string()).optional(),\n    name: z.string().optional(),\n});\n\nconst genreList = z.array(genre);\n\nconst stats = z.object({\n    albumCount: z.number(),\n    size: z.number(),\n    songCount: z.number(),\n});\n\nconst albumArtist = z.object({\n    albumCount: z.number(),\n    biography: z.string(),\n    createdAt: z.string().optional(),\n    externalInfoUpdatedAt: z.string(),\n    externalUrl: z.string(),\n    fullText: z.string(),\n    genres: z.array(genre).nullable(),\n    id: z.string(),\n    largeImageUrl: z.string().optional(),\n    mbzArtistId: z.string().optional(),\n    mediumImageUrl: z.string().optional(),\n    name: z.string(),\n    orderArtistName: z.string(),\n    playCount: z.number().optional(),\n    playDate: z.string().optional(),\n    rating: z.number(),\n    size: z.number(),\n    smallImageUrl: z.string().optional(),\n    songCount: z.number(),\n    starred: z.boolean(),\n    starredAt: z.string(),\n    stats: z.record(z.string(), stats).optional(),\n    updatedAt: z.string().optional(),\n});\n\nconst albumArtistList = z.array(albumArtist);\n\nconst albumArtistListParameters = paginationParameters.extend({\n    _sort: z.nativeEnum(NDAlbumArtistListSort).optional(),\n    genre_id: z.string().optional(),\n    library_id: z.array(z.string()).optional(),\n    missing: z.boolean().optional(),\n    name: z.string().optional(),\n    role: z.string().optional(),\n    starred: z.boolean().optional(),\n});\n\nconst participant = z.object({\n    id: z.string(),\n    name: z.string(),\n    subRole: z.string().optional(),\n});\n\nconst participants = z.record(z.string(), z.array(participant));\n\nconst album = z.object({\n    albumArtist: z.string(),\n    albumArtistId: z.string(),\n    allArtistIds: z.string(),\n    artist: z.string(),\n    artistId: z.string(),\n    catalogNum: z.string().optional(),\n    comment: z.string().optional(),\n    compilation: z.boolean(),\n    coverArtId: z.string().optional(), // Removed after v0.48.0\n    coverArtPath: z.string().optional(), // Removed after v0.48.0\n    createdAt: z.string(),\n    duration: z.number().optional(),\n    explicitStatus: z.string().optional(),\n    externalInfoUpdatedAt: z.string().optional(),\n    externalUrl: z.string().optional(),\n    fullText: z.string(),\n    genre: z.string(),\n    genres: z.array(genre).nullable(),\n    id: z.string(),\n    importedAt: z.string().optional(),\n    libraryId: z.number(),\n    libraryName: z.string(),\n    libraryPath: z.string(),\n    maxOriginalYear: z.number().optional(),\n    maxYear: z.number(),\n    mbzAlbumArtistId: z.string().optional(),\n    mbzAlbumId: z.string().optional(),\n    mbzAlbumType: z.string().optional(),\n    mbzReleaseGroupId: z.string().optional(),\n    minOriginalYear: z.number().optional(),\n    minYear: z.number(),\n    name: z.string(),\n    orderAlbumArtistName: z.string(),\n    orderAlbumName: z.string(),\n    originalDate: z.string().optional(),\n    participants: z.optional(participants),\n    playCount: z.number().optional(),\n    playDate: z.string().optional(),\n    rating: z.number().optional(),\n    releaseDate: z.string().optional(),\n    size: z.number(),\n    songCount: z.number(),\n    sortAlbumArtistName: z.string(),\n    sortArtistName: z.string(),\n    starred: z.boolean(),\n    starredAt: z.string().optional(),\n    tags: z.record(z.string(), z.array(z.string())).optional(),\n    updatedAt: z.string(),\n});\n\nconst albumList = z.array(album);\n\nconst albumListParameters = paginationParameters.extend({\n    _sort: z.nativeEnum(NDAlbumListSort).optional(),\n    album_id: z.string().optional(),\n    artist_id: z.union([z.string(), z.string().array()]).optional(),\n    compilation: z.boolean().optional(),\n    // in older versions, this was a single string. post BFR, you can repeat it multiple times\n    genre_id: z.union([z.string(), z.string().array()]).optional(),\n    has_rating: z.boolean().optional(),\n    id: z.string().optional(),\n    library_id: z.array(z.string()).optional(),\n    name: z.string().optional(),\n    recently_added: z.boolean().optional(),\n    recently_played: z.boolean().optional(),\n    starred: z.boolean().optional(),\n    year: z.number().optional(),\n});\n\nconst song = z.object({\n    album: z.string(),\n    albumArtist: z.string(),\n    albumArtistId: z.string(),\n    albumId: z.string(),\n    artist: z.string(),\n    artistId: z.string(),\n    bitDepth: z.number().optional(),\n    bitRate: z.number(),\n    bookmarkPosition: z.number(),\n    bpm: z.number().optional(),\n    catalogNum: z.string().optional(),\n    channels: z.number().optional(),\n    comment: z.string().optional(),\n    compilation: z.boolean(),\n    createdAt: z.string(),\n    discNumber: z.number(),\n    discSubtitle: z.string().optional(),\n    duration: z.number(),\n    embedArtPath: z.string().optional(),\n    explicitStatus: z.string().optional(),\n    externalInfoUpdatedAt: z.string().optional(),\n    externalUrl: z.string().optional(),\n    fullText: z.string(),\n    genre: z.string(),\n    genres: z.array(genre).nullable(),\n    hasCoverArt: z.boolean(),\n    id: z.string(),\n    imageFiles: z.string().optional(),\n    largeImageUrl: z.string().optional(),\n    libraryPath: z.string().optional(),\n    lyrics: z.string().optional(),\n    mbzAlbumArtistId: z.string().optional(),\n    mbzAlbumId: z.string().optional(),\n    mbzArtistId: z.string().optional(),\n    mbzReleaseTrackId: z.string().optional(),\n    mediumImageUrl: z.string().optional(),\n    orderAlbumArtistName: z.string(),\n    orderAlbumName: z.string(),\n    orderArtistName: z.string(),\n    orderTitle: z.string(),\n    participants: z.optional(participants),\n    path: z.string(),\n    playCount: z.number().optional(),\n    playDate: z.string().optional(),\n    rating: z.number().optional(),\n    releaseDate: z.string().optional(),\n    rgAlbumGain: z.number().optional(),\n    rgAlbumPeak: z.number().optional(),\n    rgTrackGain: z.number().optional(),\n    rgTrackPeak: z.number().optional(),\n    sampleRate: z.number(),\n    size: z.number(),\n    smallImageUrl: z.string().optional(),\n    sortAlbumArtistName: z.string(),\n    sortArtistName: z.string(),\n    starred: z.boolean(),\n    starredAt: z.string().optional(),\n    suffix: z.string(),\n    tags: z.record(z.string(), z.array(z.string())).optional(),\n    title: z.string(),\n    trackNumber: z.number(),\n    updatedAt: z.string(),\n    year: z.number(),\n});\n\nconst songList = z.array(song);\n\nconst songListParameters = paginationParameters.extend({\n    _sort: z.nativeEnum(NDSongListSort).optional(),\n    album_artist_id: z.array(z.string()).optional(),\n    album_id: z.array(z.string()).optional(),\n    artist_id: z.array(z.string()).optional(),\n    artists_id: z.array(z.string()).optional(),\n    genre_id: z.array(z.string()).optional(),\n    has_rating: z.boolean().optional(),\n    library_id: z.array(z.string()).optional(),\n    path: z.string().optional(),\n    starred: z.boolean().optional(),\n    title: z.string().optional(),\n    year: z.number().optional(),\n});\n\nconst playlist = z.object({\n    comment: z.string(),\n    createdAt: z.string(),\n    duration: z.number(),\n    evaluatedAt: z.string(),\n    id: z.string(),\n    name: z.string(),\n    ownerId: z.string(),\n    ownerName: z.string(),\n    path: z.string(),\n    public: z.boolean(),\n    rules: z.record(z.string(), z.any()),\n    size: z.number(),\n    songCount: z.number(),\n    sync: z.boolean(),\n    updatedAt: z.string(),\n});\n\nconst playlistList = z.array(playlist);\n\nconst playlistListParameters = paginationParameters.extend({\n    _sort: z.nativeEnum(NDPlaylistListSort).optional(),\n    owner_id: z.string().optional(),\n    q: z.string().optional(),\n    smart: z.boolean().optional(),\n});\n\nconst playlistSong = song.extend({\n    mediaFileId: z.string(),\n    playlistId: z.string(),\n});\n\nconst playlistSongList = z.array(playlistSong);\n\nconst createPlaylist = playlist.pick({\n    id: true,\n});\n\nconst createPlaylistParameters = z.object({\n    comment: z.string().optional(),\n    name: z.string(),\n    ownerId: z.string().optional(),\n    public: z.boolean().optional(),\n    rules: z.record(z.any()).optional(),\n    sync: z.boolean().optional(),\n});\n\nconst updatePlaylist = playlist;\n\nconst updatePlaylistParameters = createPlaylistParameters.partial();\n\nconst deletePlaylist = z.null();\n\nconst addToPlaylist = z.object({\n    added: z.number(),\n});\n\nconst addToPlaylistParameters = z.object({\n    ids: z.array(z.string()),\n});\n\nconst removeFromPlaylist = z.object({\n    ids: z.array(z.string()),\n});\n\nconst removeFromPlaylistParameters = z.object({\n    id: z.array(z.string()),\n});\n\nconst shareItem = z.object({\n    id: z.string(),\n});\n\nconst shareItemParameters = z.object({\n    description: z.string(),\n    downloadable: z.boolean(),\n    expires: z.number(),\n    resourceIds: z.string(),\n    resourceType: z.string(),\n});\n\nconst moveItemParameters = z.object({\n    insert_before: z.string(),\n});\n\nconst moveItem = z.null();\n\nconst tag = z.object({\n    albumCount: z.number().optional(),\n    id: z.string(),\n    songCount: z.number().optional(),\n    tagName: z.string(),\n    tagValue: z.string(),\n});\n\nconst tagList = z.array(tag);\n\nexport enum NDTagListSort {\n    TAG_VALUE = 'tagValue',\n}\n\nconst tagListParameters = optionalPaginationParameters.extend({\n    _sort: z.nativeEnum(NDTagListSort).optional(),\n    library_id: z.array(z.string()).optional(),\n    tag_name: z.string().optional(),\n    tag_value: z.string().optional(), // Search\n});\n\nconst saveQueueParameters = z.object({\n    current: z.number().optional(),\n    ids: z.array(z.string()).optional(),\n    position: z.number().optional(),\n});\n\nconst saveQueue = z.null();\n\nconst queue = z.object({\n    changedBy: z.string(),\n    createdAt: z.string(),\n    current: z.number(),\n    id: z.string(),\n    items: z.array(song).optional(),\n    position: z.number(),\n    updatedAt: z.string(),\n    userId: z.string(),\n});\n\nexport const ndType = {\n    _enum: {\n        albumArtistList: NDAlbumArtistListSort,\n        albumList: NDAlbumListSort,\n        genreList: genreListSort,\n        playlistList: NDPlaylistListSort,\n        songList: NDSongListSort,\n        tagList: NDTagListSort,\n        userList: ndUserListSort,\n    },\n    _parameters: {\n        addToPlaylist: addToPlaylistParameters,\n        albumArtistList: albumArtistListParameters,\n        albumList: albumListParameters,\n        authenticate: authenticateParameters,\n        createPlaylist: createPlaylistParameters,\n        genreList: genreListParameters,\n        moveItem: moveItemParameters,\n        playlistList: playlistListParameters,\n        removeFromPlaylist: removeFromPlaylistParameters,\n        saveQueue: saveQueueParameters,\n        shareItem: shareItemParameters,\n        songList: songListParameters,\n        tagList: tagListParameters,\n        updatePlaylist: updatePlaylistParameters,\n        userList: userListParameters,\n    },\n    _response: {\n        addToPlaylist,\n        album,\n        albumArtist,\n        albumArtistList,\n        albumList,\n        authenticate,\n        createPlaylist,\n        deletePlaylist,\n        error,\n        genre,\n        genreList,\n        moveItem,\n        playlist,\n        playlistList,\n        playlistSong,\n        playlistSongList,\n        queue,\n        removeFromPlaylist,\n        saveQueue,\n        shareItem,\n        song,\n        songList,\n        tagList,\n        updatePlaylist,\n        user,\n        userList,\n    },\n};\n"
  },
  {
    "path": "src/shared/api/subsonic/subsonic-normalize.ts",
    "content": "import { z } from 'zod';\n\nimport { ssType } from '/@/shared/api/subsonic/subsonic-types';\nimport { replacePathPrefix } from '/@/shared/api/utils';\nimport {\n    Album,\n    AlbumArtist,\n    ExplicitStatus,\n    Folder,\n    Genre,\n    InternetRadioStation,\n    LibraryItem,\n    Playlist,\n    RelatedArtist,\n    ServerListItemWithCredential,\n    ServerType,\n    Song,\n} from '/@/shared/types/domain-types';\n\nconst getArtistList = (\n    artists?: typeof ssType._response.song._type.artists,\n    artistId?: number | string,\n    artistName?: string,\n    participants?: null | Record<string, RelatedArtist[]>,\n) => {\n    if (!artists && !participants) {\n        return [\n            {\n                id: artistId?.toString() || '',\n                imageId: null,\n                imageUrl: null,\n                name: artistName || '',\n                userFavorite: false,\n                userRating: null,\n            },\n        ];\n    }\n\n    const result: RelatedArtist[] = [];\n\n    artists?.forEach((item) => {\n        result.push({\n            id: item.id.toString(),\n            imageId: null,\n            imageUrl: null,\n            name: item.name,\n            userFavorite: false,\n            userRating: null,\n        });\n    });\n\n    if (participants?.['remixer']) {\n        result.push(...participants['remixer']);\n    }\n\n    return result;\n};\n\nconst getParticipants = (\n    item:\n        | z.infer<typeof ssType._response.album>\n        | z.infer<typeof ssType._response.albumListEntry>\n        | z.infer<typeof ssType._response.song>,\n) => {\n    let participants: null | Record<string, RelatedArtist[]> = null;\n\n    if (item.contributors) {\n        participants = {};\n\n        for (const contributor of item.contributors) {\n            const artist = {\n                id: contributor.artist.id?.toString() || '',\n                imageId: null,\n                imageUrl: null,\n                name: contributor.artist.name || '',\n                userFavorite: false,\n                userRating: null,\n            };\n\n            const role = contributor.subRole\n                ? `${contributor.role} (${contributor.subRole})`\n                : contributor.role;\n\n            if (role in participants) {\n                participants[role].push(artist);\n            } else {\n                participants[role] = [artist];\n            }\n        }\n    }\n\n    return participants;\n};\n\nconst getGenres = (\n    item:\n        | z.infer<typeof ssType._response.album>\n        | z.infer<typeof ssType._response.albumListEntry>\n        | z.infer<typeof ssType._response.song>,\n    server?: null | ServerListItemWithCredential,\n): Genre[] => {\n    return item.genres\n        ? item.genres.map((genre) => ({\n              _itemType: LibraryItem.GENRE,\n              _serverId: server?.id || 'unknown',\n              _serverType: ServerType.SUBSONIC,\n              albumCount: null,\n              id: genre.name,\n              imageId: null,\n              imageUrl: null,\n              name: genre.name,\n              songCount: null,\n          }))\n        : item.genre\n          ? [\n                {\n                    _itemType: LibraryItem.GENRE,\n                    _serverId: server?.id || 'unknown',\n                    _serverType: ServerType.SUBSONIC,\n                    albumCount: null,\n                    id: item.genre,\n                    imageId: null,\n                    imageUrl: null,\n                    name: item.genre,\n                    songCount: null,\n                },\n            ]\n          : [];\n};\n\nconst normalizeSong = (\n    item: z.infer<typeof ssType._response.song>,\n    server?: null | ServerListItemWithCredential,\n    pathReplace?: string,\n    pathReplaceWith?: string,\n    playlistIndex?: number,\n    discTitleMap?: Map<number, string>,\n): Song => {\n    const participants = getParticipants(item);\n    const albumArtistsList = getArtistList(item.albumArtists, item.artistId, item.artist);\n    const albumArtistName =\n        item.albumArtists?.length > 0\n            ? item.albumArtists.map((a) => a.name).join(', ')\n            : item.artist || '';\n\n    return {\n        _itemType: LibraryItem.SONG,\n        _serverId: server?.id || 'unknown',\n        _serverType: ServerType.SUBSONIC,\n        album: item.album || '',\n        albumArtistName,\n        albumArtists: albumArtistsList,\n        albumId: item.albumId?.toString() || '',\n        artistName: item.artist || '',\n        artists: getArtistList(item.artists, item.artistId, item.artist, participants),\n        bitDepth: item.bitDepth || null,\n        bitRate: item.bitRate || 0,\n        bpm: item.bpm || null,\n        channels: item.channelCount || null,\n        comment: null,\n        compilation: null,\n        container: item.contentType.startsWith('audio/') ? item.contentType.split('/')[1] : null,\n        createdAt: item.created,\n        discNumber: item.discNumber || 1,\n        discSubtitle: discTitleMap?.get(item.discNumber ?? 1) ?? null,\n        duration: item.duration ? item.duration * 1000 : 0,\n        explicitStatus:\n            item.explicitStatus === 'explicit'\n                ? ExplicitStatus.EXPLICIT\n                : item.explicitStatus === 'clean'\n                  ? ExplicitStatus.CLEAN\n                  : null,\n        gain:\n            item.replayGain && (item.replayGain.albumGain || item.replayGain.trackGain)\n                ? {\n                      album: item.replayGain.albumGain,\n                      track: item.replayGain.trackGain,\n                  }\n                : null,\n        genres: getGenres(item, server),\n        id: item.id.toString(),\n        imageId: item.coverArt?.toString() || null,\n        imageUrl: null,\n        lastPlayedAt: null,\n        lyrics: null,\n        mbzRecordingId: item.musicBrainzId || null,\n        mbzTrackId: null,\n        name: item.title,\n        participants,\n        path: replacePathPrefix(item.path || '', pathReplace, pathReplaceWith),\n        peak:\n            item.replayGain && (item.replayGain.albumPeak || item.replayGain.trackPeak)\n                ? {\n                      album: item.replayGain.albumPeak,\n                      track: item.replayGain.trackPeak,\n                  }\n                : null,\n        playCount: item?.playCount || 0,\n        playlistItemId: playlistIndex !== undefined ? playlistIndex.toString() : undefined,\n        releaseDate: null,\n        releaseYear: item.year || null,\n        sampleRate: item.samplingRate || null,\n        size: item.size,\n        sortName: item.title,\n        tags: null,\n        trackNumber: item.track || 1,\n        trackSubtitle: null,\n        updatedAt: '',\n        userFavorite: Boolean(item.starred) || false,\n        userRating: item.userRating || null,\n    };\n};\n\nconst normalizeAlbumArtist = (\n    item:\n        | (z.infer<typeof ssType._response.albumArtist> & {\n              similarArtists?: z.infer<\n                  typeof ssType._response.artistInfo\n              >['artistInfo']['similarArtist'];\n          })\n        | (z.infer<typeof ssType._response.artistListEntry> & {\n              similarArtists?: z.infer<\n                  typeof ssType._response.artistInfo\n              >['artistInfo']['similarArtist'];\n          }),\n    server?: null | ServerListItemWithCredential,\n): AlbumArtist => {\n    return {\n        _itemType: LibraryItem.ALBUM_ARTIST,\n        _serverId: server?.id || 'unknown',\n        _serverType: ServerType.SUBSONIC,\n        albumCount: item.albumCount ? Number(item.albumCount) : 0,\n        biography: null,\n        duration: null,\n        genres: [],\n        id: item.id.toString(),\n        imageId: item.coverArt?.toString() || null,\n        imageUrl: null,\n        lastPlayedAt: null,\n        mbz: null,\n        name: item.name,\n        playCount: null,\n        similarArtists:\n            item.similarArtists?.map((artist) => ({\n                id: artist.id,\n                imageId: null,\n                imageUrl: null,\n                name: artist.name,\n                userFavorite: Boolean(artist.starred) || false,\n                userRating: artist.userRating || null,\n            })) || [],\n        songCount: null,\n        userFavorite: Boolean(item.starred) || false,\n        userRating: null,\n    };\n};\n\nconst PRIMARY_RELEASE_TYPES = ['album', 'ep', 'single', 'broadcast', 'other'];\n\nconst getReleaseType = (\n    item: z.infer<typeof ssType._response.album> | z.infer<typeof ssType._response.albumListEntry>,\n) => {\n    if (!item.releaseTypes) {\n        return null;\n    }\n\n    // Return the first primary release type\n    return item.releaseTypes.find((type) => PRIMARY_RELEASE_TYPES.includes(type)) || null;\n};\n\nconst normalizeAlbum = (\n    item: z.infer<typeof ssType._response.album> | z.infer<typeof ssType._response.albumListEntry>,\n    server?: null | ServerListItemWithCredential,\n    pathReplace?: string,\n    pathReplaceWith?: string,\n): Album => {\n    const discTitleMap = new Map<number, string>();\n\n    (item as z.infer<typeof ssType._response.album>).discTitles?.forEach((discTitle) => {\n        discTitleMap.set(discTitle.disc, discTitle.title);\n    });\n\n    const releaseDate =\n        item.releaseDate &&\n        typeof item.releaseDate.year === 'number' &&\n        typeof item.releaseDate.month === 'number' &&\n        typeof item.releaseDate.day === 'number'\n            ? `${item.releaseDate.year}-${item.releaseDate.month}-${item.releaseDate.day}`\n            : null;\n\n    return {\n        _itemType: LibraryItem.ALBUM,\n        _serverId: server?.id || 'unknown',\n        _serverType: ServerType.SUBSONIC,\n        albumArtistName: item.artist,\n        albumArtists: getArtistList(item.artists, item.artistId, item.artist),\n        artists: [],\n        comment: null,\n        createdAt: item.created,\n        duration: item.duration * 1000,\n        explicitStatus:\n            item.explicitStatus === 'explicit'\n                ? ExplicitStatus.EXPLICIT\n                : item.explicitStatus === 'clean'\n                  ? ExplicitStatus.CLEAN\n                  : null,\n        genres: getGenres(item, server),\n        id: item.id.toString(),\n        imageId: item.coverArt?.toString() || null,\n        imageUrl: null,\n        isCompilation: null,\n        lastPlayedAt: null,\n        mbzId: null,\n        mbzReleaseGroupId: null,\n        name: item.name,\n        originalDate: releaseDate,\n        originalYear: item.year || null,\n        participants: getParticipants(item),\n        playCount: null,\n        recordLabels: item.recordLabels?.map((item) => item.name) || [],\n        releaseDate,\n        releaseType: getReleaseType(item),\n        releaseTypes: item.releaseTypes || [],\n        releaseYear: item.year || null,\n        size: null,\n        songCount: item.songCount,\n        songs:\n            (item as z.infer<typeof ssType._response.album>).song?.map((song) =>\n                normalizeSong(song, server, pathReplace, pathReplaceWith, undefined, discTitleMap),\n            ) || [],\n        sortName: item.title,\n        tags: null,\n        updatedAt: item.created,\n        userFavorite: Boolean(item.starred) || false,\n        userRating: item.userRating || null,\n        version: item.version || null,\n    };\n};\n\nconst normalizePlaylist = (\n    item:\n        | z.infer<typeof ssType._response.playlist>\n        | z.infer<typeof ssType._response.playlistListEntry>,\n    server?: null | ServerListItemWithCredential,\n): Playlist => {\n    return {\n        _itemType: LibraryItem.PLAYLIST,\n        _serverId: server?.id || 'unknown',\n        _serverType: ServerType.SUBSONIC,\n        description: item.comment || null,\n        duration: item.duration * 1000,\n        genres: [],\n        id: item.id.toString(),\n        imageId: item.coverArt?.toString() || null,\n        imageUrl: null,\n        name: item.name,\n        owner: item.owner,\n        ownerId: item.owner,\n        public: item.public,\n        size: null,\n        songCount: item.songCount,\n    };\n};\n\nconst normalizeGenre = (\n    item: z.infer<typeof ssType._response.genre>,\n    server: null | ServerListItemWithCredential,\n): Genre => {\n    return {\n        _itemType: LibraryItem.GENRE,\n        _serverId: server?.id || 'unknown',\n        _serverType: ServerType.SUBSONIC,\n        albumCount: item.albumCount,\n        id: item.value,\n        imageId: null,\n        imageUrl: null,\n        name: item.value,\n        songCount: item.songCount,\n    };\n};\n\nconst normalizeFolder = (\n    item: z.infer<typeof ssType._response.directory>,\n    server?: null | ServerListItemWithCredential,\n    pathReplace?: string,\n    pathReplaceWith?: string,\n): Folder => {\n    const results = item.child?.reduce(\n        (acc: { folders: Folder[]; songs: Song[] }, item) => {\n            const isDirectory = item.isDir === true;\n\n            if (isDirectory) {\n                const folder = normalizeFolder(item, server);\n                acc.folders.push(folder);\n            } else {\n                const song = normalizeSong(item, server, pathReplace, pathReplaceWith);\n                acc.songs.push(song);\n            }\n\n            return acc;\n        },\n        {\n            folders: [],\n            songs: [],\n        },\n    );\n\n    return {\n        _itemType: LibraryItem.FOLDER,\n        _serverId: server?.id || 'unknown',\n        _serverType: ServerType.SUBSONIC,\n        children: {\n            folders: results?.folders || [],\n            songs: results?.songs || [],\n        },\n        id: item.id.toString(),\n        imageId: item.coverArt?.toString() || null,\n        imageUrl: null,\n        name: item.title,\n        parentId: item.parent,\n    };\n};\n\nconst normalizeInternetRadioStation = (\n    item: z.infer<typeof ssType._response.internetRadioStation>,\n): InternetRadioStation => {\n    return {\n        homepageUrl: item.homepageUrl || null,\n        id: item.id,\n        name: item.name,\n        streamUrl: item.streamUrl,\n    };\n};\n\nexport const ssNormalize = {\n    album: normalizeAlbum,\n    albumArtist: normalizeAlbumArtist,\n    folder: normalizeFolder,\n    genre: normalizeGenre,\n    internetRadioStation: normalizeInternetRadioStation,\n    playlist: normalizePlaylist,\n    song: normalizeSong,\n};\n"
  },
  {
    "path": "src/shared/api/subsonic/subsonic-types.ts",
    "content": "import { z } from 'zod';\n\nconst baseResponse = z.object({\n    'subsonic-response': z.object({\n        status: z.string(),\n        version: z.string(),\n    }),\n});\n\nconst userParameters = z.object({\n    username: z.string(),\n});\n\nconst user = z.object({\n    user: z.object({\n        adminRole: z.boolean(),\n        commentRole: z.boolean(),\n        coverArtRole: z.boolean(),\n        downloadRole: z.boolean(),\n        folder: z.string().array(),\n        jukeboxRole: z.boolean(),\n        playlistRole: z.boolean(),\n        podcastRole: z.boolean(),\n        scrobblingEnabled: z.boolean(),\n        settingsRole: z.boolean(),\n        shareRole: z.boolean(),\n        streamRole: z.boolean(),\n        uploadRole: z.boolean(),\n        username: z.string(),\n    }),\n});\n\nconst authenticate = user;\n\nconst authenticateParameters = z.object({\n    c: z.string(),\n    f: z.string(),\n    p: z.string().optional(),\n    s: z.string().optional(),\n    t: z.string().optional(),\n    u: z.string(),\n    username: z.string(),\n    v: z.string(),\n});\n\nconst id = z.number().or(z.string());\n\nconst createFavoriteParameters = z.object({\n    albumId: z.array(z.string()).optional(),\n    artistId: z.array(z.string()).optional(),\n    id: z.array(z.string()).optional(),\n});\n\nconst createFavorite = z.null();\n\nconst removeFavoriteParameters = z.object({\n    albumId: z.array(z.string()).optional(),\n    artistId: z.array(z.string()).optional(),\n    id: z.array(z.string()).optional(),\n});\n\nconst removeFavorite = z.null();\n\nconst setRatingParameters = z.object({\n    id: z.string(),\n    rating: z.number(),\n});\n\nconst setRating = z.null();\n\nconst musicFolder = z.object({\n    id,\n    name: z.string(),\n});\n\nconst musicFolderList = z.object({\n    musicFolders: z.object({\n        musicFolder: z.array(musicFolder),\n    }),\n});\n\nconst songGain = z.object({\n    albumGain: z.number().optional(),\n    albumPeak: z.number().optional(),\n    trackGain: z.number().optional(),\n    trackPeak: z.number().optional(),\n});\n\nconst genreItem = z.object({\n    name: z.string(),\n});\n\nconst simpleArtist = z.object({\n    id: z.string(),\n    name: z.string(),\n});\n\nconst contributor = z.object({\n    artist: simpleArtist,\n    role: z.string(),\n    subRole: z.string().optional(),\n});\n\nconst song = z.object({\n    album: z.string().optional(),\n    albumArtists: z.array(simpleArtist),\n    albumId: id.optional(),\n    artist: z.string().optional(),\n    artistId: id.optional(),\n    artists: z.array(simpleArtist),\n    averageRating: z.number().optional(),\n    bitDepth: z.number().optional(),\n    bitRate: z.number().optional(),\n    bpm: z.number().optional(),\n    channelCount: z.number().optional(),\n    contentType: z.string(),\n    contributors: z.array(contributor).optional(),\n    coverArt: z.string().optional(),\n    created: z.string(),\n    discNumber: z.number(),\n    duration: z.number().optional(),\n    explicitStatus: z.string().optional(),\n    genre: z.string().optional(),\n    genres: z.array(genreItem).optional(),\n    id,\n    isDir: z.boolean(),\n    isVideo: z.boolean(),\n    musicBrainzId: z.string().optional(),\n    parent: z.string(),\n    path: z.string(),\n    playCount: z.number().optional(),\n    replayGain: songGain.optional(),\n    samplingRate: z.number().optional(),\n    size: z.number(),\n    starred: z.boolean().optional(),\n    suffix: z.string(),\n    title: z.string(),\n    track: z.number().optional(),\n    type: z.string(),\n    userRating: z.number().optional(),\n    year: z.number().optional(),\n});\n\nconst recordLabel = z.object({\n    name: z.string(),\n});\n\nconst album = z.object({\n    album: z.string(),\n    artist: z.string(),\n    artistId: id,\n    artists: z.array(simpleArtist),\n    contributors: z.array(contributor).optional(),\n    coverArt: z.string(),\n    created: z.string(),\n    discTitles: z\n        .array(\n            z.object({\n                disc: z.number(),\n                title: z.string(),\n            }),\n        )\n        .optional(),\n    duration: z.number(),\n    explicitStatus: z.string().optional(),\n    genre: z.string().optional(),\n    genres: z.array(genreItem).optional(),\n    id,\n    isCompilation: z.boolean().optional(),\n    isDir: z.boolean(),\n    isVideo: z.boolean(),\n    name: z.string(),\n    parent: z.string(),\n    recordLabels: z.array(recordLabel).optional(),\n    releaseDate: z.object({ day: z.number(), month: z.number(), year: z.number() }).optional(),\n    releaseTypes: z.array(z.string()).optional(),\n    song: z.array(song),\n    songCount: z.number(),\n    starred: z.boolean().optional(),\n    title: z.string(),\n    userRating: z.number().optional(),\n    version: z.string().optional(),\n    year: z.number().optional(),\n});\n\nconst albumListEntry = album.omit({\n    song: true,\n});\n\nconst albumListParameters = z.object({\n    fromYear: z.number().optional(),\n    genre: z.string().optional(),\n    musicFolderId: z.string().optional(),\n    offset: z.number().optional(),\n    size: z.number().optional(),\n    toYear: z.number().optional(),\n    type: z.string().optional(),\n});\n\nconst albumList = z.array(album.omit({ song: true }));\n\nconst albumArtist = z.object({\n    album: z.array(album).optional(),\n    albumCount: z.string(),\n    artistImageUrl: z.string().optional(),\n    coverArt: z.string().optional(),\n    id,\n    name: z.string(),\n    roles: z.array(z.string()).optional(),\n    starred: z.string().optional(),\n});\n\nconst albumArtistList = z.object({\n    artist: z.array(albumArtist),\n    name: z.string(),\n});\n\nconst artistListEntry = albumArtist.pick({\n    albumCount: true,\n    coverArt: true,\n    id: true,\n    name: true,\n    roles: true,\n    starred: true,\n});\n\nconst artistInfoParameters = z.object({\n    count: z.number().optional(),\n    id: z.string(),\n    includeNotPresent: z.boolean().optional(),\n});\n\nconst artistInfo = z.object({\n    artistInfo: z.object({\n        biography: z.string().optional(),\n        largeImageUrl: z.string().optional(),\n        lastFmUrl: z.string().optional(),\n        mediumImageUrl: z.string().optional(),\n        musicBrainzId: z.string().optional(),\n        similarArtist: z.array(\n            z.object({\n                albumCount: z.string(),\n                artistImageUrl: z.string().optional(),\n                coverArt: z.string().optional(),\n                id: z.string(),\n                name: z.string(),\n                starred: z.string().optional(),\n                userRating: z.number().optional(),\n            }),\n        ),\n        smallImageUrl: z.string().optional(),\n    }),\n});\n\nconst topSongsListParameters = z.object({\n    artist: z.string(), // The name of the artist, not the artist ID\n    count: z.number().optional(),\n});\n\nconst topSongsList = z.object({\n    topSongs: z\n        .object({\n            song: z.array(song),\n        })\n        .optional(),\n});\n\nconst scrobbleParameters = z.object({\n    id: z.string(),\n    submission: z.boolean().optional(),\n    time: z.number().optional(), // The time (in milliseconds since 1 Jan 1970) at which the song was listened to.\n});\n\nconst scrobble = z.null();\n\nconst search3 = z.object({\n    searchResult3: z\n        .object({\n            album: z.array(album).optional(),\n            artist: z.array(albumArtist).optional(),\n            song: z.array(song).optional(),\n        })\n        .optional(),\n});\n\nconst search3Parameters = z.object({\n    albumCount: z.number().optional(),\n    albumOffset: z.number().optional(),\n    artistCount: z.number().optional(),\n    artistOffset: z.number().optional(),\n    musicFolderId: z.string().optional(),\n    query: z.string().optional(),\n    songCount: z.number().optional(),\n    songOffset: z.number().optional(),\n});\n\nconst randomSongListParameters = z.object({\n    fromYear: z.number().optional(),\n    genre: z.string().optional(),\n    musicFolderId: z.string().optional(),\n    size: z.number().optional(),\n    toYear: z.number().optional(),\n});\n\nconst randomSongList = z.object({\n    randomSongs: z\n        .object({\n            song: z.array(song),\n        })\n        .optional(),\n});\n\nconst ping = z.object({\n    openSubsonic: z.boolean().optional(),\n    serverVersion: z.string().optional(),\n    version: z.string(),\n});\n\nconst extension = z.object({\n    name: z.string(),\n    versions: z.number().array(),\n});\n\nconst serverInfo = z.object({\n    openSubsonicExtensions: z.array(extension).optional(),\n});\n\nconst structuredLyricsParameters = z.object({\n    id: z.string(),\n});\n\nconst lyricLine = z.object({\n    start: z.number().optional(),\n    value: z.string(),\n});\n\nconst structuredLyric = z.object({\n    displayArtist: z.string().optional(),\n    displayTitle: z.string().optional(),\n    lang: z.string(),\n    line: z.array(lyricLine),\n    offset: z.number().optional(),\n    synced: z.boolean(),\n});\n\nconst structuredLyrics = z.object({\n    lyricsList: z\n        .object({\n            structuredLyrics: z.array(structuredLyric).optional(),\n        })\n        .optional(),\n});\n\nconst similarSongsParameters = z.object({\n    count: z.number().optional(),\n    id: z.string(),\n});\n\nconst similarSongs = z.object({\n    similarSongs: z\n        .object({\n            song: z.array(song),\n        })\n        .optional(),\n});\n\nconst similarSongs2Parameters = z.object({\n    count: z.number().optional(),\n    id: z.string(),\n});\n\nconst similarSongs2 = z.object({\n    similarSongs2: z\n        .object({\n            song: z.array(song),\n        })\n        .optional(),\n});\n\nexport enum SubsonicExtensions {\n    FORM_POST = 'formPost',\n    INDEX_BASED_QUEUE = 'indexBasedQueue',\n    SONG_LYRICS = 'songLyrics',\n    TRANSCODE_OFFSET = 'transcodeOffset',\n}\n\nconst updatePlaylistParameters = z.object({\n    comment: z.string().optional(),\n    name: z.string().optional(),\n    playlistId: z.string(),\n    public: z.boolean().optional(),\n    songIdToAdd: z.array(z.string()).optional(),\n    songIndexToRemove: z.array(z.string()).optional(),\n});\n\nconst getStarredParameters = z.object({\n    musicFolderId: z.string().optional(),\n});\n\nconst getStarred = z.object({\n    starred: z\n        .object({\n            album: z.array(albumListEntry),\n            artist: z.array(artistListEntry),\n            song: z.array(song),\n        })\n        .optional(),\n});\n\nconst getSongsByGenreParameters = z.object({\n    count: z.number().optional(),\n    genre: z.string(),\n    musicFolderId: z.string().optional(),\n    offset: z.number().optional(),\n});\n\nconst getSongsByGenre = z.object({\n    songsByGenre: z\n        .object({\n            song: z.array(song),\n        })\n        .optional(),\n});\n\nconst getAlbumParameters = z.object({\n    id: z.string(),\n});\n\nconst getAlbum = z.object({\n    album,\n});\n\nconst getArtistParameters = z.object({\n    id: z.string(),\n});\n\nconst getArtist = z.object({\n    artist: albumArtist,\n});\n\nconst getSongParameters = z.object({\n    id: z.string(),\n});\n\nconst getSong = z.object({\n    song,\n});\n\nconst getArtistsParameters = z.object({\n    musicFolderId: z.string().optional(),\n});\n\nconst getArtists = z.object({\n    artists: z.object({\n        ignoredArticles: z.string(),\n        index: z.array(\n            z.object({\n                artist: z.array(artistListEntry),\n                name: z.string(),\n            }),\n        ),\n    }),\n});\n\nconst deletePlaylistParameters = z.object({\n    id: z.string(),\n});\n\nconst createPlaylistParameters = z.object({\n    name: z.string(),\n    playlistId: z.string().optional(),\n    songId: z.array(z.string()).optional(),\n});\n\nconst playlist = z.object({\n    changed: z.string().optional(),\n    comment: z.string().optional(),\n    coverArt: z.string().optional(),\n    created: z.string(),\n    duration: z.number(),\n    entry: z.array(song).optional(),\n    id,\n    name: z.string(),\n    owner: z.string(),\n    public: z.boolean(),\n    songCount: z.number(),\n});\n\nconst createPlaylist = z.object({\n    playlist,\n});\n\nconst getPlaylistsParameters = z.object({\n    username: z.string().optional(),\n});\n\nconst playlistListEntry = playlist.omit({\n    entry: true,\n});\n\nconst getPlaylists = z.object({\n    playlists: z\n        .object({\n            playlist: z.array(playlistListEntry),\n        })\n        .optional(),\n});\n\nconst getPlaylistParameters = z.object({\n    id: z.string(),\n});\n\nconst getPlaylist = z.object({\n    playlist,\n});\n\nconst genre = z.object({\n    albumCount: z.number(),\n    songCount: z.number(),\n    value: z.string(),\n});\n\nconst getGenresParameters = z.object({});\n\nconst getGenres = z.object({\n    genres: z\n        .object({\n            genre: z.array(genre),\n        })\n        .optional(),\n});\n\nexport enum AlbumListSortType {\n    ALPHABETICAL_BY_ARTIST = 'alphabeticalByArtist',\n    ALPHABETICAL_BY_NAME = 'alphabeticalByName',\n    BY_GENRE = 'byGenre',\n    BY_YEAR = 'byYear',\n    FREQUENT = 'frequent',\n    NEWEST = 'newest',\n    RANDOM = 'random',\n    RECENT = 'recent',\n    STARRED = 'starred',\n}\n\nconst getAlbumList2Parameters = z\n    .object({\n        fromYear: z.number().optional(),\n        genre: z.string().optional(),\n        musicFolderId: z.string().optional(),\n        offset: z.number().optional(),\n        size: z.number().optional(),\n        toYear: z.number().optional(),\n        type: z.nativeEnum(AlbumListSortType),\n    })\n    .refine(\n        (val) => {\n            if (val.type === AlbumListSortType.BY_YEAR) {\n                return val.fromYear !== undefined && val.toYear !== undefined;\n            }\n\n            return true;\n        },\n        {\n            message: 'Parameters \"fromYear\" and \"toYear\" are required when using sort \"byYear\"',\n        },\n    )\n    .refine(\n        (val) => {\n            if (val.type === AlbumListSortType.BY_GENRE) {\n                return val.genre !== undefined;\n            }\n\n            return true;\n        },\n        { message: 'Parameter \"genre\" is required when using sort \"byGenre\"' },\n    );\n\nconst getAlbumList2 = z.object({\n    albumList2: z.object({\n        album: z.array(albumListEntry),\n    }),\n});\n\nconst albumInfoParameters = z.object({\n    id: z.string(),\n});\n\nconst albumInfo = z.object({\n    albumInfo: z.object({\n        largeImageUrl: z.string().optional(),\n        lastFmUrl: z.string().optional(),\n        mediumImageUrl: z.string().optional(),\n        musicBrainzId: z.string().optional(),\n        notes: z.string().optional(),\n        smallImageUrl: z.string().optional(),\n    }),\n});\n\nconst getMusicDirectoryParameters = z.object({\n    id: z.string(),\n});\n\nconst directory = z.object({\n    artist: z.string().optional(),\n    child: z.array(song).optional(),\n    coverArt: z.string().optional(),\n    id,\n    isDir: z.boolean(),\n    parent: z.string().optional(),\n    title: z.string(),\n});\n\nconst getMusicDirectory = z.object({\n    directory,\n});\n\nconst getIndexes = z.object({\n    indexes: z.object({\n        child: z.array(song),\n        index: z\n            .object({\n                artist: z\n                    .object({\n                        coverArt: z.string().optional(),\n                        id: z.string(),\n                        name: z.string(),\n                    })\n                    .array(),\n            })\n            .array(),\n        shortcut: z\n            .object({\n                id: z.string(),\n                name: z.string(),\n            })\n            .array(),\n    }),\n});\n\nconst getIndexesParameters = z.object({\n    musicFolderId: z.string().optional(),\n});\n\nconst saveQueueParameters = z.object({\n    current: z.string().optional(),\n    id: z.string().array(),\n    position: z.number().optional(),\n});\n\nconst savePlayQueueByIndexParameters = z.object({\n    currentIndex: z.number().optional(),\n    id: z.string().array().optional(),\n    position: z.number().optional(),\n});\n\nconst saveQueue = z.null();\n\nconst playQueue = z.object({\n    playQueue: z.object({\n        changed: z.string(),\n        changedBy: z.string(),\n        current: z.string().optional(),\n        entry: song.array(),\n        position: z.number().optional(),\n        username: z.string(),\n    }),\n});\n\nconst playQueueByIndex = z.object({\n    playQueueByIndex: z\n        .object({\n            changed: z.string(),\n            changedBy: z.string(),\n            currentIndex: z.number().optional(),\n            entry: song.array().optional(),\n            position: z.number().optional(),\n            username: z.string(),\n        })\n        .optional(),\n});\n\nconst internetRadioStation = z.object({\n    homepageUrl: z.string().optional(),\n    id: z.string(),\n    name: z.string(),\n    streamUrl: z.string(),\n});\n\nconst deleteInternetRadioStationParameters = z.object({\n    id: z.string(),\n});\n\nconst deleteInternetRadioStation = z.null();\n\nconst createInternetRadioStationParameters = z.object({\n    homepageUrl: z.string().optional(),\n    name: z.string(),\n    streamUrl: z.string(),\n});\n\nconst createInternetRadioStation = z.null();\n\nconst updateInternetRadioStationParameters = z.object({\n    homepageUrl: z.string().optional(),\n    id: z.string(),\n    name: z.string(),\n    streamUrl: z.string(),\n});\n\nconst updateInternetRadioStation = z.null();\n\nconst getInternetRadioStations = z.object({\n    internetRadioStations: z\n        .object({\n            internetRadioStation: z.array(internetRadioStation),\n        })\n        .optional(),\n});\n\nexport const ssType = {\n    _parameters: {\n        albumInfo: albumInfoParameters,\n        albumList: albumListParameters,\n        artistInfo: artistInfoParameters,\n        authenticate: authenticateParameters,\n        createFavorite: createFavoriteParameters,\n        createInternetRadioStation: createInternetRadioStationParameters,\n        createPlaylist: createPlaylistParameters,\n        deleteInternetRadioStation: deleteInternetRadioStationParameters,\n        deletePlaylist: deletePlaylistParameters,\n        getAlbum: getAlbumParameters,\n        getAlbumList2: getAlbumList2Parameters,\n        getArtist: getArtistParameters,\n        getArtists: getArtistsParameters,\n        getGenre: getGenresParameters,\n        getGenres: getGenresParameters,\n        getIndexes: getIndexesParameters,\n        getMusicDirectory: getMusicDirectoryParameters,\n        getPlaylist: getPlaylistParameters,\n        getPlaylists: getPlaylistsParameters,\n        getSong: getSongParameters,\n        getSongsByGenre: getSongsByGenreParameters,\n        getStarred: getStarredParameters,\n        randomSongList: randomSongListParameters,\n        removeFavorite: removeFavoriteParameters,\n        savePlayQueueByIndex: savePlayQueueByIndexParameters,\n        saveQueue: saveQueueParameters,\n        scrobble: scrobbleParameters,\n        search3: search3Parameters,\n        setRating: setRatingParameters,\n        similarSongs: similarSongsParameters,\n        similarSongs2: similarSongs2Parameters,\n        structuredLyrics: structuredLyricsParameters,\n        topSongsList: topSongsListParameters,\n        updateInternetRadioStation: updateInternetRadioStationParameters,\n        updatePlaylist: updatePlaylistParameters,\n        user: userParameters,\n    },\n    _response: {\n        album,\n        albumArtist,\n        albumArtistList,\n        albumInfo,\n        albumList,\n        albumListEntry,\n        artistInfo,\n        artistListEntry,\n        authenticate,\n        baseResponse,\n        createFavorite,\n        createInternetRadioStation,\n        createPlaylist,\n        deleteInternetRadioStation,\n        directory,\n        genre,\n        getAlbum,\n        getAlbumList2,\n        getArtist,\n        getArtists,\n        getGenres,\n        getIndexes,\n        getInternetRadioStations,\n        getMusicDirectory,\n        getPlaylist,\n        getPlaylists,\n        getSong,\n        getSongsByGenre,\n        getStarred,\n        internetRadioStation,\n        musicFolderList,\n        ping,\n        playlist,\n        playlistListEntry,\n        playQueue,\n        playQueueByIndex,\n        randomSongList,\n        removeFavorite,\n        saveQueue,\n        scrobble,\n        search3,\n        serverInfo,\n        setRating,\n        similarSongs,\n        similarSongs2,\n        song,\n        structuredLyrics,\n        topSongsList,\n        updateInternetRadioStation,\n        user,\n    },\n};\n"
  },
  {
    "path": "src/shared/api/utils.ts",
    "content": "import { AxiosHeaders } from 'axios';\nimport isElectron from 'is-electron';\nimport orderBy from 'lodash/orderBy';\nimport shuffle from 'lodash/shuffle';\nimport semverCoerce from 'semver/functions/coerce';\nimport semverGte from 'semver/functions/gte';\nimport { z } from 'zod';\n\nimport {\n    Album,\n    AlbumArtist,\n    AlbumArtistListSort,\n    AlbumListSort,\n    ArtistListSort,\n    InternetRadioStation,\n    LibraryItem,\n    RadioListSort,\n    ServerListItem,\n    Song,\n    SongListSort,\n    SortOrder,\n} from '/@/shared/types/domain-types';\nimport { ServerFeature } from '/@/shared/types/features-types';\n\n// Since ts-rest client returns a strict response type, we need to add the headers to the body object\nexport const resultWithHeaders = <ItemType extends z.ZodTypeAny>(itemSchema: ItemType) => {\n    return z.object({\n        data: itemSchema,\n        headers: z.instanceof(AxiosHeaders),\n    });\n};\n\nexport const resultSubsonicBaseResponse = <ItemType extends z.ZodRawShape>(\n    itemSchema: ItemType,\n) => {\n    return z.object({\n        'subsonic-response': z\n            .object({\n                status: z.string(),\n                version: z.string(),\n            })\n            .extend(itemSchema),\n    });\n};\n\nexport const hasFeature = (server: null | ServerListItem, feature: ServerFeature): boolean => {\n    if (!server || !server.features) {\n        return false;\n    }\n\n    return (server.features[feature]?.length || 0) > 0;\n};\n\nexport const hasFeatureWithVersion = (\n    server: null | ServerListItem,\n    feature: ServerFeature,\n    version: number,\n): boolean => {\n    if (!server || !server.features) {\n        return false;\n    }\n\n    return (server.features[feature] ?? []).includes(version);\n};\n\nexport type VersionInfo = ReadonlyArray<\n    [string, Partial<Record<ServerFeature, readonly number[]>>]\n>;\n\n/**\n * Returns the available server features given the version string.\n * @param versionInfo a list, in DECREASING VERSION order, of the features supported by the server.\n *  The first version match will automatically consider the rest matched.\n * @example\n * ```\n * // The CORRECT way to order\n * const VERSION_INFO: VersionInfo = [\n *   ['0.49.3', { [ServerFeature.SHARING_ALBUM_SONG]: [1] }],\n *   ['0.48.0', { [ServerFeature.PLAYLISTS_SMART]: [1] }],\n * ];\n * // INCORRECT way to order\n * const VERSION_INFO: VersionInfo = [\n *   ['0.48.0', { [ServerFeature.PLAYLISTS_SMART]: [1] }],\n *   ['0.49.3', { [ServerFeature.SHARING_ALBUM_SONG]: [1] }],\n * ];\n *  ```\n * @param version the version string (SemVer)\n * @returns a Record containing the matched features (if any) and their versions\n */\nexport const getFeatures = (\n    versionInfo: VersionInfo,\n    version: string,\n): Partial<Record<ServerFeature, number[]>> => {\n    const cleanVersion = semverCoerce(version);\n    const features: Partial<Record<ServerFeature, number[]>> = {};\n    let matched = cleanVersion === null;\n\n    for (const [version, supportedFeatures] of versionInfo) {\n        if (!matched) {\n            matched = semverGte(cleanVersion!, version);\n        }\n\n        if (matched) {\n            for (const [feature, feat] of Object.entries(supportedFeatures)) {\n                if (feature in features) {\n                    features[feature].push(...feat);\n                } else {\n                    features[feature] = [...feat];\n                }\n            }\n        }\n    }\n\n    return features;\n};\n\nexport const getClientType = (): string => {\n    if (isElectron()) {\n        return 'Desktop Client';\n    }\n    const agent = navigator.userAgent;\n    switch (true) {\n        case agent.toLowerCase().indexOf('edge') > -1:\n            return 'Microsoft Edge';\n        case agent.toLowerCase().indexOf('edg/') > -1:\n            return 'Edge Chromium'; // Match also / to avoid matching for the older Edge\n        case agent.toLowerCase().indexOf('opr') > -1:\n            return 'Opera';\n        case agent.toLowerCase().indexOf('chrome') > -1:\n            return 'Chrome';\n        case agent.toLowerCase().indexOf('trident') > -1:\n            return 'Internet Explorer';\n        case agent.toLowerCase().indexOf('firefox') > -1:\n            return 'Firefox';\n        case agent.toLowerCase().indexOf('safari') > -1:\n            return 'Safari';\n        default:\n            return 'PC';\n    }\n};\n\nexport const SEPARATOR_STRING = ' • ';\n\nexport const sortSongList = (songs: Song[], sortBy: SongListSort, sortOrder: SortOrder) => {\n    let results: Song[] = songs;\n\n    const order = sortOrder === SortOrder.ASC ? 'asc' : 'desc';\n\n    switch (sortBy) {\n        case SongListSort.ALBUM:\n            results = orderBy(\n                results,\n                [(v) => v.album?.toLowerCase(), 'discNumber', 'trackNumber'],\n                [order, order, order],\n            );\n            break;\n\n        case SongListSort.ALBUM_ARTIST:\n            results = orderBy(\n                results,\n                [\n                    (v) => v.albumArtists[0]?.name.toLowerCase(),\n                    (v) => v.album?.toLowerCase(),\n                    'discNumber',\n                    'trackNumber',\n                ],\n                [order, order, order, order],\n            );\n            break;\n\n        case SongListSort.ARTIST:\n            results = orderBy(\n                results,\n                [(v) => v.artistName?.toLowerCase(), 'discNumber', 'trackNumber'],\n                [order, order, order, order],\n            );\n            break;\n\n        case SongListSort.BPM:\n            results = orderBy(\n                results,\n                ['bpm', (v) => v.album?.toLowerCase(), 'discNumber', 'trackNumber'],\n                [order, order, order, order],\n            );\n            break;\n\n        case SongListSort.CHANNELS:\n            results = orderBy(\n                results,\n                ['channels', (v) => v.album?.toLowerCase(), 'discNumber', 'trackNumber'],\n                [order, order, order, order],\n            );\n            break;\n\n        case SongListSort.COMMENT:\n            results = orderBy(\n                results,\n                ['comment', (v) => v.album?.toLowerCase(), 'discNumber', 'trackNumber'],\n                [order, order, order, order],\n            );\n            break;\n\n        case SongListSort.DURATION:\n            results = orderBy(\n                results,\n                ['duration', (v) => v.album?.toLowerCase(), 'discNumber', 'trackNumber'],\n                [order, order, order, order],\n            );\n            break;\n\n        case SongListSort.FAVORITED:\n            results = orderBy(\n                results,\n                [\n                    'userFavorite',\n                    (v) => v.name.toLowerCase(),\n                    (v) => v.album?.toLowerCase(),\n                    'discNumber',\n                    'trackNumber',\n                ],\n                [order, order, order, order, order],\n            );\n            break;\n\n        case SongListSort.GENRE:\n            results = orderBy(\n                results,\n                [\n                    (v) => v.genres?.[0]?.name.toLowerCase(),\n                    (v) => v.album?.toLowerCase(),\n                    'discNumber',\n                    'trackNumber',\n                ],\n                [order, order, order, order],\n            );\n            break;\n\n        case SongListSort.ID:\n            results = [...results];\n\n            if (order === 'desc') {\n                results.reverse();\n            }\n            break;\n\n        case SongListSort.NAME:\n            results = orderBy(\n                results,\n                [(v) => v.name.toLowerCase(), (v) => v.album?.toLowerCase()],\n                [order, order],\n            );\n            break;\n\n        case SongListSort.PLAY_COUNT:\n            results = orderBy(\n                results,\n                ['playCount', (v) => v.album?.toLowerCase(), 'discNumber', 'trackNumber'],\n                [order, order, order, order],\n            );\n            break;\n\n        case SongListSort.RANDOM:\n            results = shuffle(results);\n            break;\n\n        case SongListSort.RATING:\n            results = orderBy(\n                results,\n                [\n                    'userRating',\n                    (v) => v.name.toLowerCase(),\n                    (v) => v.album?.toLowerCase(),\n                    'discNumber',\n                    'trackNumber',\n                ],\n                [order, order, order, order, order],\n            );\n            break;\n\n        case SongListSort.RECENTLY_ADDED:\n            results = orderBy(\n                results,\n                [\n                    (v) => {\n                        const x = v.createdAt;\n                        if (x == null) return null;\n                        const d = new Date(x);\n                        return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();\n                    },\n                    (v) => v.album?.toLowerCase(),\n                    'discNumber',\n                    'trackNumber',\n                ],\n                [order, order, order, order],\n            );\n            break;\n\n        case SongListSort.RECENTLY_PLAYED:\n            results = orderBy(\n                results,\n                ['lastPlayedAt', (v) => v.album?.toLowerCase(), 'discNumber', 'trackNumber'],\n                [order, order, order, order],\n            );\n            break;\n\n        case SongListSort.RELEASE_DATE:\n            results = orderBy(\n                results,\n                ['releaseDate', (v) => v.album?.toLowerCase(), 'discNumber', 'trackNumber'],\n                [order, order, order, order],\n            );\n            break;\n\n        case SongListSort.SORT_NAME:\n            results = orderBy(results, [(v) => v.sortName ?? v.name], [order]);\n            break;\n\n        case SongListSort.YEAR:\n            results = orderBy(\n                results,\n                ['releaseYear', (v) => v.album?.toLowerCase(), 'discNumber', 'track'],\n                [order, order, order, order],\n            );\n            break;\n\n        default:\n            break;\n    }\n\n    return results;\n};\n\nexport const sortSongsByFetchedOrder = (\n    songs: Song[],\n    fetchedIds: string[],\n    itemType: LibraryItem,\n): Song[] => {\n    // For folders, songs are already in the correct order\n    if (itemType === LibraryItem.FOLDER) {\n        return songs;\n    }\n\n    // Group songs by the fetched ID they belong to\n    const songsByFetchedId = new Map<string, Song[]>();\n\n    for (const song of songs) {\n        let matchedId: string | undefined;\n\n        switch (itemType) {\n            case LibraryItem.ALBUM:\n                matchedId = fetchedIds.find((id) => song.albumId === id);\n                break;\n            case LibraryItem.ALBUM_ARTIST:\n                matchedId = fetchedIds.find((id) =>\n                    song.albumArtists.some((artist) => artist.id === id),\n                );\n                break;\n            case LibraryItem.ARTIST:\n                matchedId = fetchedIds.find((id) =>\n                    song.artists.some((artist) => artist.id === id),\n                );\n                break;\n            case LibraryItem.GENRE:\n                matchedId = fetchedIds.find((id) => song.genres.some((genre) => genre.id === id));\n                break;\n            case LibraryItem.PLAYLIST:\n                // For playlists, we might need to track which playlist each song came from\n                // This is a simplified approach - you may need to adjust based on your data structure\n                matchedId = fetchedIds.find((id) => song.playlistItemId === id);\n                break;\n            default:\n                break;\n        }\n\n        if (matchedId) {\n            if (!songsByFetchedId.has(matchedId)) {\n                songsByFetchedId.set(matchedId, []);\n            }\n            songsByFetchedId.get(matchedId)!.push(song);\n        }\n    }\n\n    // Sort each group by discNumber and trackNumber\n    // Skip sorting for ALBUM_ARTIST as songs are already sorted by the API\n    for (const [fetchedId, groupSongs] of songsByFetchedId.entries()) {\n        if (itemType === LibraryItem.ALBUM_ARTIST) {\n            continue;\n        }\n        const sortedGroup = orderBy(groupSongs, ['discNumber', 'trackNumber'], ['asc', 'asc']);\n        songsByFetchedId.set(fetchedId, sortedGroup);\n    }\n\n    // Combine groups in the order of fetchedIds\n    const result: Song[] = [];\n    for (const fetchedId of fetchedIds) {\n        const groupSongs = songsByFetchedId.get(fetchedId);\n        if (groupSongs) {\n            result.push(...groupSongs);\n        }\n    }\n\n    // Add any songs that didn't match any fetched ID at the end\n    const matchedIds = new Set(result.map((s) => s.id));\n    const unmatchedSongs = songs.filter((s) => !matchedIds.has(s.id));\n    if (unmatchedSongs.length > 0) {\n        // Skip sorting for ALBUM_ARTIST as songs are already sorted by the API\n        if (itemType === LibraryItem.ALBUM_ARTIST) {\n            result.push(...unmatchedSongs);\n        } else {\n            const sortedUnmatched = orderBy(\n                unmatchedSongs,\n                ['discNumber', 'trackNumber'],\n                ['asc', 'asc'],\n            );\n            result.push(...sortedUnmatched);\n        }\n    }\n\n    return result;\n};\n\nexport const sortAlbumArtistList = (\n    artists: AlbumArtist[],\n    sortBy: AlbumArtistListSort | ArtistListSort,\n    sortOrder: SortOrder,\n) => {\n    const order = sortOrder === SortOrder.ASC ? 'asc' : 'desc';\n\n    let results = artists;\n\n    switch (sortBy) {\n        case AlbumArtistListSort.ALBUM_COUNT:\n            results = orderBy(artists, ['albumCount', (v) => v.name.toLowerCase()], [order, 'asc']);\n            break;\n\n        case AlbumArtistListSort.FAVORITED:\n            results = orderBy(artists, ['starred'], [order]);\n            break;\n\n        case AlbumArtistListSort.NAME:\n            results = orderBy(artists, [(v) => v.name.toLowerCase()], [order]);\n            break;\n\n        case AlbumArtistListSort.RATING:\n            results = orderBy(artists, ['userRating'], [order]);\n            break;\n\n        default:\n            break;\n    }\n\n    return results;\n};\n\nexport const sortAlbumList = (albums: Album[], sortBy: AlbumListSort, sortOrder: SortOrder) => {\n    let results = albums;\n\n    const order = sortOrder === SortOrder.ASC ? 'asc' : 'desc';\n\n    switch (sortBy) {\n        case AlbumListSort.ALBUM_ARTIST:\n            results = orderBy(\n                results,\n                ['albumArtist', (v) => v.name.toLowerCase()],\n                [order, 'asc'],\n            );\n            break;\n        case AlbumListSort.DURATION:\n            results = orderBy(results, ['duration'], [order]);\n            break;\n        case AlbumListSort.FAVORITED:\n            results = orderBy(results, ['starred'], [order]);\n            break;\n        case AlbumListSort.ID:\n            results = sortOrder === SortOrder.DESC ? [...results].reverse() : results;\n            break;\n        case AlbumListSort.NAME:\n            results = orderBy(results, [(v) => v.name.toLowerCase()], [order]);\n            break;\n        case AlbumListSort.PLAY_COUNT:\n            results = orderBy(results, ['playCount'], [order]);\n            break;\n        case AlbumListSort.RANDOM:\n            results = shuffle(results);\n            break;\n        case AlbumListSort.RATING:\n            results = orderBy(results, ['userRating'], [order]);\n            break;\n        case AlbumListSort.RECENTLY_ADDED:\n            results = orderBy(results, ['createdAt'], [order]);\n            break;\n        case AlbumListSort.RECENTLY_PLAYED:\n            results = orderBy(results, ['lastPlayedAt'], [order]);\n            break;\n        case AlbumListSort.RELEASE_DATE:\n            results = orderBy(\n                results,\n                [\n                    (v) => {\n                        if (v.originalDate) {\n                            return new Date(v.originalDate).getTime();\n                        }\n\n                        // Fallback to the first day of the original year\n                        if (v.originalYear) {\n                            return new Date(v.originalYear, 0, 1).getTime();\n                        }\n                        return 0;\n                    },\n                    (v) => {\n                        if (v.releaseDate) {\n                            return new Date(v.releaseDate).getTime();\n                        }\n\n                        // Fallback to the first day of the release year\n                        if (v.releaseYear) {\n                            return new Date(v.releaseYear, 0, 1).getTime();\n                        }\n                        return 0;\n                    },\n                ],\n                [order, order],\n            );\n            break;\n        case AlbumListSort.SONG_COUNT:\n            results = orderBy(results, ['songCount'], [order]);\n            break;\n        case AlbumListSort.SORT_NAME:\n            results = orderBy(results, [(v) => v.sortName ?? v.name], [order]);\n            break;\n        case AlbumListSort.YEAR:\n            results = orderBy(results, ['releaseYear'], [order]);\n            break;\n        default:\n            break;\n    }\n\n    return results;\n};\n\nexport const sortRadioList = (\n    stations: InternetRadioStation[],\n    sortBy: RadioListSort,\n    sortOrder: SortOrder,\n) => {\n    let results = stations;\n\n    const order = sortOrder === SortOrder.ASC ? 'asc' : 'desc';\n\n    switch (sortBy) {\n        case RadioListSort.ID:\n            results = [...results];\n            if (order === 'desc') {\n                results.reverse();\n            }\n            break;\n        case RadioListSort.NAME:\n            results = orderBy(results, [(v) => v.name.toLowerCase()], [order]);\n            break;\n        default:\n            break;\n    }\n\n    return results;\n};\n\nexport const replacePathPrefix = (path: string, replacePrefix?: string, addPrefix?: string) => {\n    let newPath = path;\n    if (replacePrefix && newPath.startsWith(replacePrefix)) {\n        newPath = newPath.slice(replacePrefix.length);\n    }\n\n    return addPrefix ? addPrefix + newPath : newPath;\n};\n"
  },
  {
    "path": "src/shared/assets.d.ts",
    "content": "declare module '*.png' {\n    const content: string;\n    export default content;\n}\n\ndeclare module '*.svg' {\n    const content: string;\n    export default content;\n}\n"
  },
  {
    "path": "src/shared/components/accordion/accordion.module.css",
    "content": ".panel {\n    background: var(--theme-colors-background);\n}\n\n.control {\n    background: var(--theme-colors-background);\n\n    &:hover {\n        background: var(--theme-colors-background);\n    }\n}\n\n.chevron {\n    display: flex;\n    justify-content: center;\n}\n"
  },
  {
    "path": "src/shared/components/accordion/accordion.tsx",
    "content": "import {\n    Accordion as MantineAccordion,\n    AccordionProps as MantineAccordionProps,\n} from '@mantine/core';\n\nimport styles from './accordion.module.css';\n\nimport { Icon } from '/@/shared/components/icon/icon';\n\nexport interface AccordionProps\n    extends Omit<MantineAccordionProps, 'defaultValue' | 'multiple' | 'onChange'> {\n    defaultValue?: string | string[];\n    multiple?: boolean;\n    onChange?: (value: null | string | string[]) => void;\n}\n\nexport const Accordion = ({ children, classNames, ...props }: AccordionProps) => {\n    return (\n        <MantineAccordion\n            chevron={<Icon icon=\"arrowUpS\" size=\"lg\" />}\n            classNames={{\n                chevron: styles.chevron,\n                control: styles.control,\n                panel: styles.panel,\n                ...classNames,\n            }}\n            {...props}\n        >\n            {children}\n        </MantineAccordion>\n    );\n};\n\nAccordion.Control = MantineAccordion.Control;\nAccordion.Item = MantineAccordion.Item;\nAccordion.Panel = MantineAccordion.Panel;\n"
  },
  {
    "path": "src/shared/components/action-icon/action-icon.module.css",
    "content": ".root {\n    --ai-size-xs: calc(1.875rem * var(--mantine-scale));\n    --ai-size-sm: calc(2.25rem * var(--mantine-scale));\n    --ai-size-md: calc(2.625rem * var(--mantine-scale));\n    --ai-size-lg: calc(3.125rem * var(--mantine-scale));\n    --ai-size-xl: calc(3.75rem * var(--mantine-scale));\n    --ai-size-compact-xs: calc(1.5rem * var(--mantine-scale));\n    --ai-size-compact-sm: calc(1.75rem * var(--mantine-scale));\n    --ai-size-compact-md: calc(2rem * var(--mantine-scale));\n\n    font-weight: 500;\n    transition:\n        background-color 0.2s ease-in-out,\n        border-color 0.2s ease-in-out;\n\n    &[data-disabled='true'] {\n        opacity: 0.6;\n    }\n\n    &[data-variant='default'] {\n        color: var(--theme-colors-foreground);\n        background: var(--theme-colors-surface);\n        border: 1px solid transparent;\n\n        &:hover {\n            background: lighten(var(--theme-colors-surface), 5%);\n        }\n\n        &:focus-visible {\n            background: lighten(var(--theme-colors-surface), 10%);\n        }\n    }\n\n    &[data-variant='outline'] {\n        --button-border: var(--theme-colors-border);\n\n        color: var(--theme-colors-foreground);\n        border: 1px solid var(--theme-colors-border);\n\n        &:hover {\n            border: 1px solid lighten(var(--theme-colors-border), 10%);\n        }\n\n        &:focus-visible {\n            border: 1px solid lighten(var(--theme-colors-border), 10%);\n        }\n\n        svg {\n            color: var(--theme-colors-primary);\n            fill: var(--theme-colors-primary);\n        }\n    }\n\n    &[data-variant='filled'] {\n        color: var(--theme-colors-primary-contrast);\n        background: var(--theme-colors-primary-filled);\n        border: 1px solid transparent;\n        transition: background-color 0.2s ease-in-out;\n\n        &:hover,\n        &:focus-visible {\n            background: darken(var(--theme-colors-primary-filled), 10%);\n        }\n\n        svg {\n            color: var(--theme-colors-primary-contrast);\n            fill: var(--theme-colors-primary-contrast);\n        }\n    }\n\n    &[data-variant='subtle'] {\n        color: var(--theme-colors-foreground);\n        background: transparent;\n\n        &:hover,\n        &:active,\n        &:focus-visible {\n            @mixin dark {\n                background: lighten(var(--theme-colors-background), 5%);\n            }\n\n            @mixin light {\n                background: darken(var(--theme-colors-background), 5%);\n            }\n        }\n\n        &[data-disabled='true'] {\n            background: transparent;\n        }\n    }\n\n    &[data-variant='secondary'] {\n        border: 1px solid transparent;\n\n        &:hover {\n            background: darken(var(--theme-colors-surface), 5%);\n        }\n\n        &:focus-visible {\n            background: darken(var(--theme-colors-surface), 10%);\n        }\n    }\n\n    &[data-variant='transparent'] {\n        color: var(--theme-colors-foreground);\n        border: 1px solid transparent;\n\n        &:hover {\n            background: transparent;\n        }\n\n        &:focus-visible {\n            border: 1px solid lighten(var(--theme-colors-border), 10%);\n        }\n    }\n\n    &[data-size='compact-xs'] {\n        width: var(--ai-size-compact-xs);\n        min-width: var(--ai-size-compact-xs);\n        height: var(--ai-size-compact-xs);\n        min-height: var(--ai-size-compact-xs);\n    }\n\n    &[data-size='compact-sm'] {\n        width: var(--ai-size-compact-sm);\n        min-width: var(--ai-size-compact-sm);\n        height: var(--ai-size-compact-sm);\n        min-height: var(--ai-size-compact-sm);\n    }\n\n    &[data-size='compact-md'] {\n        width: var(--ai-size-compact-md);\n        min-width: var(--ai-size-compact-md);\n        height: var(--ai-size-compact-md);\n        min-height: var(--ai-size-compact-md);\n    }\n}\n"
  },
  {
    "path": "src/shared/components/action-icon/action-icon.tsx",
    "content": "import {\n    ElementProps,\n    ActionIcon as MantineActionIcon,\n    ActionIconProps as MantineActionIconProps,\n} from '@mantine/core';\nimport { forwardRef, useMemo } from 'react';\n\nimport styles from './action-icon.module.css';\n\nimport { AppIcon, Icon, IconProps } from '/@/shared/components/icon/icon';\nimport { Tooltip, TooltipProps } from '/@/shared/components/tooltip/tooltip';\nimport { createPolymorphicComponent } from '/@/shared/utils/create-polymorphic-component';\n\nconst COMPACT_SIZES = ['compact-xs', 'compact-sm', 'compact-md'] as const;\n\nconst isCompactSize = (size: number | string | undefined): boolean => {\n    return typeof size === 'string' && COMPACT_SIZES.includes(size as any);\n};\n\nexport interface ActionIconProps\n    extends ElementProps<'button', keyof MantineActionIconProps>,\n        MantineActionIconProps {\n    icon?: keyof typeof AppIcon;\n    iconProps?: Omit<IconProps, 'icon'>;\n    stopsPropagation?: boolean;\n    tooltip?: Omit<TooltipProps, 'children'>;\n}\n\nconst _ActionIcon = forwardRef<HTMLButtonElement, ActionIconProps>(\n    (\n        {\n            children,\n            classNames,\n            icon,\n            iconProps,\n            onClick,\n            size = 'sm',\n            stopsPropagation,\n            tooltip,\n            variant = 'default',\n            ...props\n        },\n        ref,\n    ) => {\n        const handleClick = (e: any) => {\n            if (stopsPropagation) e.stopPropagation();\n            if (onClick) onClick(e);\n        };\n\n        const memoizedClassNames = useMemo(\n            () => ({\n                root: styles.root,\n                ...classNames,\n            }),\n            [classNames],\n        );\n\n        const mantineSize = isCompactSize(size) ? 'sm' : size;\n        const compactSize = isCompactSize(size) ? (size as string) : undefined;\n\n        const actionIconProps: ActionIconProps & { 'data-size'?: string } = {\n            classNames: memoizedClassNames,\n            size: mantineSize,\n            variant,\n            ...props,\n            onClick: handleClick,\n            ...(compactSize && { 'data-size': compactSize }),\n        };\n\n        if (tooltip && icon) {\n            return (\n                <Tooltip withinPortal {...tooltip}>\n                    <MantineActionIcon ref={ref} {...actionIconProps}>\n                        <Icon icon={icon} size={actionIconProps.size} {...iconProps} />\n                    </MantineActionIcon>\n                </Tooltip>\n            );\n        }\n\n        if (icon) {\n            return (\n                <MantineActionIcon ref={ref} {...actionIconProps}>\n                    <Icon icon={icon} size={actionIconProps.size} {...iconProps} />\n                </MantineActionIcon>\n            );\n        }\n\n        if (tooltip) {\n            return (\n                <Tooltip withinPortal {...tooltip}>\n                    <MantineActionIcon ref={ref} {...actionIconProps}>\n                        {children}\n                    </MantineActionIcon>\n                </Tooltip>\n            );\n        }\n\n        return (\n            <MantineActionIcon ref={ref} {...actionIconProps}>\n                {children}\n            </MantineActionIcon>\n        );\n    },\n);\n\nexport const ActionIcon = createPolymorphicComponent<'button', ActionIconProps>(_ActionIcon);\nexport const ActionIconGroup = MantineActionIcon.Group;\nexport const ActionIconSection = MantineActionIcon.GroupSection;\n"
  },
  {
    "path": "src/shared/components/angle-slider/angle-slider.tsx",
    "content": "import {\n    AngleSlider as MantineAngleSlider,\n    AngleSliderProps as MantineAngleSliderProps,\n} from '@mantine/core';\nimport { forwardRef } from 'react';\n\nexport interface AngleSliderProps extends MantineAngleSliderProps {}\n\nexport const AngleSlider = forwardRef<HTMLDivElement, AngleSliderProps>((props, ref) => {\n    return <MantineAngleSlider {...props} ref={ref} />;\n});\n\nAngleSlider.displayName = 'AngleSlider';\n"
  },
  {
    "path": "src/shared/components/animations/animation-props.ts",
    "content": "import type { MotionProps } from 'motion/react';\n\nconst fadeIn: MotionProps = {\n    animate: 'show',\n    exit: 'hidden',\n    initial: 'hidden',\n    transition: { duration: 0.3 },\n    variants: {\n        hidden: { opacity: 0 },\n        show: { opacity: 1 },\n    },\n};\n\nconst fadeOut: MotionProps = {\n    animate: 'hidden',\n    exit: 'show',\n    initial: 'show',\n    transition: { duration: 0.3 },\n    variants: {\n        hidden: { opacity: 0 },\n        show: { opacity: 1 },\n    },\n};\n\nconst slideInLeft: MotionProps = {\n    animate: 'show',\n    exit: 'hidden',\n    initial: 'initial',\n    transition: { duration: 0.3 },\n    variants: {\n        hidden: { x: -100 },\n        initial: { x: -100 },\n        show: { x: 0 },\n    },\n};\n\nconst slideOutLeft: MotionProps = {\n    animate: 'hidden',\n    exit: 'show',\n    initial: 'initial',\n    transition: { duration: 0.3 },\n    variants: {\n        hidden: { x: -100 },\n        initial: { x: 0 },\n        show: { x: 0 },\n    },\n};\n\nconst slideInRight: MotionProps = {\n    animate: 'show',\n    exit: 'hidden',\n    initial: 'initial',\n    transition: { duration: 0.3 },\n    variants: {\n        hidden: { x: 100 },\n        initial: { x: 100 },\n        show: { x: 0 },\n    },\n};\n\nconst slideOutRight: MotionProps = {\n    animate: 'hidden',\n    exit: 'show',\n    initial: 'show',\n    transition: { duration: 0.3 },\n    variants: {\n        hidden: { x: 100 },\n        initial: { x: 0 },\n        show: { x: 0 },\n    },\n};\n\nconst slideInUp: MotionProps = {\n    animate: 'show',\n    exit: 'hidden',\n    initial: 'hidden',\n    transition: { duration: 0.3 },\n    variants: {\n        hidden: { y: 100 },\n        show: { y: 0 },\n    },\n};\n\nconst slideOutUp: MotionProps = {\n    animate: 'hidden',\n    exit: 'show',\n    initial: 'initial',\n    transition: { duration: 0.3 },\n    variants: {\n        hidden: { y: 100 },\n        initial: { y: 0 },\n        show: { y: 0 },\n    },\n};\n\nconst slideInDown: MotionProps = {\n    animate: 'show',\n    exit: 'hidden',\n    initial: 'initial',\n    transition: { duration: 0.3 },\n    variants: {\n        hidden: { y: -100 },\n        initial: { y: -100 },\n        show: { y: 0 },\n    },\n};\n\nconst slideOutDown: MotionProps = {\n    animate: 'hidden',\n    exit: 'show',\n    initial: 'show',\n    transition: { duration: 0.3 },\n    variants: {\n        hidden: { y: -10 },\n        show: { y: 0 },\n    },\n};\n\nconst scale: MotionProps = {\n    animate: { scale: 1 },\n    exit: 'hidden',\n    initial: 'hidden',\n    transition: { duration: 0.3 },\n    variants: {\n        hidden: { scale: 0 },\n        show: { scale: 1 },\n    },\n};\n\nconst rotate: MotionProps = {\n    animate: 'show',\n    exit: 'hidden',\n    initial: 'hidden',\n    transition: { duration: 0.3 },\n    variants: {\n        hidden: { rotate: 0 },\n        show: { rotate: 360 },\n    },\n};\n\nconst bounce: MotionProps = {\n    animate: 'show',\n    exit: 'hidden',\n    initial: 'hidden',\n    transition: { duration: 0.3, times: [0, 0.5, 1] },\n    variants: {\n        hidden: { y: [0, -30, 0] },\n        show: { y: 0 },\n    },\n};\n\nconst pulse: MotionProps = {\n    animate: 'show',\n    exit: 'hidden',\n    initial: 'hidden',\n    transition: { duration: 1, repeat: Infinity },\n    variants: {\n        hidden: { scale: [1, 1.1, 1] },\n        show: { scale: 1 },\n    },\n};\n\nconst shake: MotionProps = {\n    animate: 'show',\n    exit: 'hidden',\n    initial: 'hidden',\n    transition: { duration: 0.3 },\n    variants: {\n        hidden: { x: [-10, 10, -10, 10, 0] },\n        show: { x: 0 },\n    },\n};\n\nconst flip: MotionProps = {\n    animate: 'show',\n    exit: 'hidden',\n    initial: 'hidden',\n    transition: { duration: 0.3 },\n    variants: {\n        hidden: { rotateY: 0 },\n        show: { rotateY: 360 },\n    },\n};\n\nconst zoomIn: MotionProps = {\n    animate: 'show',\n    exit: 'hidden',\n    initial: 'hidden',\n    transition: { duration: 0.3 },\n    variants: {\n        hidden: { opacity: 0, scale: 0.5 },\n        show: { opacity: 1, scale: 1 },\n    },\n};\n\nconst zoomOut: MotionProps = {\n    animate: { opacity: 0, scale: 0.5 },\n    exit: 'hidden',\n    initial: 'hidden',\n    transition: { duration: 0.3 },\n    variants: {\n        hidden: { opacity: 0, scale: 0.5 },\n        show: { opacity: 1, scale: 1 },\n    },\n};\n\nconst rotateIn: MotionProps = {\n    animate: 'show',\n    exit: 'hidden',\n    initial: 'hidden',\n    transition: { duration: 0.3 },\n    variants: {\n        hidden: { opacity: 0, rotate: -180 },\n        show: { opacity: 1, rotate: 0 },\n    },\n};\n\nconst swing: MotionProps = {\n    animate: 'show',\n    exit: 'hidden',\n    initial: 'hidden',\n    transition: { duration: 1, repeat: Infinity },\n    variants: {\n        hidden: { rotate: [0, 15, -15, 0] },\n        show: { rotate: 0 },\n    },\n};\n\nconst rubberBand: MotionProps = {\n    animate: 'show',\n    exit: 'hidden',\n    initial: 'hidden',\n    transition: { duration: 0.8 },\n    variants: {\n        hidden: { scaleX: [1, 1.25, 0.75, 1.15, 0.95, 1] },\n        show: { scaleX: 1 },\n    },\n};\n\nconst fadeInUp: MotionProps = {\n    animate: 'show',\n    exit: 'hidden',\n    initial: 'hidden',\n    transition: { duration: 0.3 },\n    variants: {\n        hidden: { opacity: 0, y: 50 },\n        show: { opacity: 1, y: 0 },\n    },\n};\n\nconst fadeInDown: MotionProps = {\n    animate: 'show',\n    exit: 'hidden',\n    initial: 'hidden',\n    transition: { duration: 0.3 },\n    variants: {\n        hidden: { opacity: 0, y: -50 },\n        show: { opacity: 1, y: 0 },\n    },\n};\n\nconst rotateScale: MotionProps = {\n    animate: 'show',\n    exit: 'hidden',\n    initial: 'hidden',\n    transition: { duration: 0.7 },\n    variants: {\n        hidden: { rotate: 0, scale: 1 },\n        show: { rotate: 360, scale: 1.5 },\n    },\n};\n\nexport const animationProps = {\n    bounce,\n    fadeIn,\n    fadeInDown,\n    fadeInUp,\n    fadeOut,\n    flip,\n    pulse,\n    rotate,\n    rotateIn,\n    rotateScale,\n    rubberBand,\n    scale,\n    shake,\n    slideInDown,\n    slideInLeft,\n    slideInRight,\n    slideInUp,\n    slideOutDown,\n    slideOutLeft,\n    slideOutRight,\n    slideOutUp,\n    swing,\n    zoomIn,\n    zoomOut,\n};\n"
  },
  {
    "path": "src/shared/components/animations/animation-variants.ts",
    "content": "import type { Variants } from 'motion/react';\n\nimport merge from 'lodash/merge';\n\nconst fadeIn: Variants = {\n    hidden: { opacity: 0 },\n    show: { opacity: 1 },\n};\n\nconst fadeInUp: Variants = {\n    hidden: { opacity: 0, y: 10 },\n    show: { opacity: 1, y: 0 },\n};\n\nconst fadeInDown: Variants = {\n    hidden: { opacity: 0, y: -10 },\n    show: { opacity: 1, y: 0 },\n};\n\nconst fadeInLeft: Variants = {\n    hidden: { opacity: 0, x: 10 },\n    show: { opacity: 1, x: 0 },\n};\n\nconst fadeInRight: Variants = {\n    hidden: { opacity: 0, x: -10 },\n    show: { opacity: 1, x: 0 },\n};\n\nconst zoomIn: Variants = {\n    hidden: { scale: 0.5 },\n    show: { scale: 1 },\n};\n\nconst zoomOut: Variants = {\n    hidden: { scale: 1 },\n    show: { scale: 0.5 },\n};\n\nconst slideInUp: Variants = {\n    hidden: { y: 10 },\n    show: { y: 0 },\n};\n\nconst slideInDown: Variants = {\n    hidden: { y: -10 },\n    show: { y: 0 },\n};\n\nconst slideInLeft: Variants = {\n    hidden: { x: 10 },\n    show: { x: 0 },\n};\n\nconst slideInRight: Variants = {\n    hidden: { x: 10 },\n    show: { x: 0 },\n};\n\nconst scaleY: Variants = {\n    hidden: { height: 0, opacity: 0, overflow: 'hidden' },\n    show: { height: 'auto', opacity: 1 },\n};\n\nconst blurIn: Variants = {\n    hidden: { filter: 'blur(4px)' },\n    show: { filter: 'blur(0px)' },\n};\n\nconst flipHorizontal: Variants = {\n    hidden: { x: '-100%' },\n    show: { x: 0 },\n};\n\nconst flipVertical: Variants = {\n    hidden: { y: '-100%' },\n    show: { y: 0 },\n};\n\nfunction combine(...variants: Variants[]) {\n    const merged = merge({}, ...variants);\n\n    return merged as Variants;\n}\n\nfunction stagger(variants: Variants, delay?: number): Variants {\n    return {\n        ...variants,\n        show: {\n            ...variants.show,\n            transition: {\n                staggerChildren: delay ?? 0.1,\n            },\n        },\n    };\n}\n\nexport const animationVariants = {\n    blurIn,\n    combine,\n    fadeIn,\n    fadeInDown,\n    fadeInLeft,\n    fadeInRight,\n    fadeInUp,\n    flipHorizontal,\n    flipVertical,\n    scaleY,\n    slideInDown,\n    slideInLeft,\n    slideInRight,\n    slideInUp,\n    stagger,\n    zoomIn,\n    zoomOut,\n};\n"
  },
  {
    "path": "src/shared/components/badge/badge.module.css",
    "content": ".root[data-variant='filled'] {\n    background: var(--theme-colors-primary-filled);\n}\n\n.root[data-variant='outline'] {\n    background: transparent;\n}\n\n.root[data-variant='transparent'] {\n    background: transparent;\n}\n\n.root {\n    padding: var(--theme-spacing-xs) var(--theme-spacing-sm);\n    font-weight: 500;\n    color: var(--theme-colors-foreground);\n    background: var(--theme-colors-surface);\n}\n"
  },
  {
    "path": "src/shared/components/badge/badge.tsx",
    "content": "import {\n    ElementProps,\n    Badge as MantineBadge,\n    BadgeProps as MantineBadgeProps,\n} from '@mantine/core';\nimport { useMemo } from 'react';\n\nimport styles from './badge.module.css';\n\nimport { createPolymorphicComponent } from '/@/shared/utils/create-polymorphic-component';\n\nexport interface BadgeProps\n    extends ElementProps<'div', keyof MantineBadgeProps>,\n        MantineBadgeProps {}\n\nconst BaseBadge = ({ children, classNames, variant = 'default', ...props }: BadgeProps) => {\n    const memoizedClassNames = useMemo(\n        () => ({\n            root: styles.root,\n            ...classNames,\n        }),\n        [classNames],\n    );\n\n    return (\n        <MantineBadge classNames={memoizedClassNames} radius=\"md\" variant={variant} {...props}>\n            {children}\n        </MantineBadge>\n    );\n};\n\nexport const Badge = createPolymorphicComponent<'button', BadgeProps>(BaseBadge);\n"
  },
  {
    "path": "src/shared/components/box/box.tsx",
    "content": "import { ElementProps, Box as MantineBox, BoxProps as MantineBoxProps } from '@mantine/core';\nimport { memo } from 'react';\n\nexport interface BoxProps extends ElementProps<'div', keyof MantineBoxProps>, MantineBoxProps {}\n\nexport const Box = memo(({ children, ...props }: BoxProps) => {\n    return <MantineBox {...props}>{children}</MantineBox>;\n});\n\nBox.displayName = 'Box';\n"
  },
  {
    "path": "src/shared/components/breadcrumb/breadcrumb.tsx",
    "content": "import {\n    Breadcrumbs as MantineBreadcrumbs,\n    BreadcrumbsProps as MantineBreadcrumbsProps,\n} from '@mantine/core';\n\ninterface BreadcrumbProps extends MantineBreadcrumbsProps {}\n\nexport const Breadcrumb = ({ children, ...props }: BreadcrumbProps) => {\n    return <MantineBreadcrumbs {...props}>{children}</MantineBreadcrumbs>;\n};\n"
  },
  {
    "path": "src/shared/components/button/button.module.css",
    "content": ".root {\n    font-weight: 500;\n    border: 1px solid transparent;\n    transition:\n        opacity 0.2s ease,\n        background-color 0.2s ease-in-out,\n        border-color 0.2s ease-in-out;\n\n    &[data-loading='true'] {\n        transform: none;\n\n        &::before {\n            transition: none;\n            transition: opacity 0.1s ease;\n        }\n\n        .inner {\n            opacity: 0;\n            transform: none;\n        }\n    }\n\n    &[data-disabled='true'] {\n        opacity: 0.6;\n    }\n\n    &[data-variant='default'] {\n        color: var(--theme-colors-foreground);\n        background-color: var(--theme-colors-surface);\n\n        &:hover {\n            background: lighten(var(--theme-colors-surface), 5%);\n        }\n\n        &:focus-visible {\n            background: lighten(var(--theme-colors-surface), 10%);\n        }\n    }\n\n    &[data-variant='outline'] {\n        --button-border: var(--theme-colors-border);\n\n        color: var(--theme-colors-foreground);\n        border: 1px solid var(--theme-colors-border);\n\n        &:hover {\n            border: 1px solid lighten(var(--theme-colors-border), 10%);\n        }\n\n        &:focus-visible {\n            border: 1px solid lighten(var(--theme-colors-border), 10%);\n        }\n    }\n\n    &[data-variant='filled'] {\n        color: var(--theme-colors-primary-contrast);\n        background: var(--theme-colors-primary-filled);\n        border: 1px solid transparent;\n        transition: background-color 0.2s ease-in-out;\n\n        &:hover,\n        &:focus-visible {\n            background: darken(var(--theme-colors-primary-filled), 10%);\n        }\n\n        &:focus-visible {\n            outline: 1px solid var(--theme-colors-primary-filled);\n            outline-offset: 2px;\n        }\n    }\n\n    &[data-variant='state-error'] {\n        background: var(--theme-colors-state-error);\n\n        &:hover,\n        &:focus-visible {\n            background: darken(var(--theme-colors-state-error), 10%);\n        }\n    }\n\n    &[data-variant='state-info'] {\n        background: var(--theme-colors-state-info);\n\n        &:hover,\n        &:focus-visible {\n            background: darken(var(--theme-colors-state-info), 10%);\n        }\n    }\n\n    &[data-variant='state-success'] {\n        background: var(--theme-colors-state-success);\n\n        &:hover,\n        &:focus-visible {\n            background: darken(var(--theme-colors-state-success), 10%);\n        }\n    }\n\n    &[data-variant='state-warning'] {\n        background: var(--theme-colors-state-warning);\n\n        &:hover,\n        &:focus-visible {\n            background: darken(var(--theme-colors-state-warning), 10%);\n        }\n    }\n\n    &[data-variant='subtle'] {\n        color: var(--theme-colors-foreground);\n        background: transparent;\n\n        &:hover,\n        &:active,\n        &:focus-visible {\n            @mixin dark {\n                background-color: lighten(var(--theme-colors-background), 10%);\n            }\n\n            @mixin light {\n                background-color: darken(var(--theme-colors-background), 5%);\n            }\n        }\n    }\n\n    &[data-variant='secondary'] {\n        border: 1px solid transparent;\n\n        &:hover {\n            background-color: darken(var(--theme-colors-background), 5%);\n        }\n\n        &:focus-visible {\n            background-color: darken(var(--theme-colors-background), 10%);\n        }\n    }\n\n    &[data-variant='transparent'] {\n        color: var(--theme-colors-foreground);\n        border: 1px solid transparent;\n        transition: color 0.2s ease-in-out;\n\n        &:hover {\n            background-color: transparent;\n\n            @mixin dark {\n                color: darken(var(--theme-colors-foreground), 15%);\n            }\n\n            @mixin light {\n                color: lighten(var(--theme-colors-foreground), 10%);\n            }\n        }\n\n        &:focus-visible {\n            border: 1px solid lighten(var(--theme-colors-border), 10%);\n        }\n\n        &[data-disabled='true'] {\n            background-color: transparent;\n        }\n    }\n}\n\n.loader {\n    transition-duration: 0;\n}\n\n.section {\n    display: flex;\n    margin-inline-end: var(--theme-spacing-sm);\n}\n\n.button-inner.loading {\n    color: transparent;\n}\n\n.spinner {\n    position: absolute;\n    top: 50%;\n    left: 50%;\n    transform: translate3d(-50%, -50%, 0);\n}\n\n.uppercase {\n    text-transform: uppercase;\n}\n"
  },
  {
    "path": "src/shared/components/button/button.tsx",
    "content": "import type { ButtonVariant, ButtonProps as MantineButtonProps } from '@mantine/core';\n\nimport { ElementProps, Button as MantineButton } from '@mantine/core';\nimport clsx from 'clsx';\nimport { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react';\n\nimport styles from './button.module.css';\n\nimport { Tooltip, TooltipProps } from '/@/shared/components/tooltip/tooltip';\nimport { useTimeout } from '/@/shared/hooks/use-timeout';\nimport { createPolymorphicComponent } from '/@/shared/utils/create-polymorphic-component';\n\nexport interface ButtonProps\n    extends ElementProps<'button', keyof MantineButtonProps>,\n        MantineButtonProps,\n        MantineButtonProps {\n    tooltip?: Omit<TooltipProps, 'children'>;\n    uppercase?: boolean;\n    variant?: ExtendedButtonVariant;\n}\n\ntype ExtendedButtonVariant =\n    | 'state-error'\n    | 'state-info'\n    | 'state-success'\n    | 'state-warning'\n    | ButtonVariant;\n\nexport const _Button = forwardRef<HTMLButtonElement, ButtonProps>(\n    (\n        {\n            children,\n            classNames,\n            loading,\n            size = 'sm',\n            style,\n            tooltip,\n            uppercase,\n            variant = 'default',\n            ...props\n        }: ButtonProps,\n        ref,\n    ) => {\n        const memoizedClassNames = useMemo(\n            () => ({\n                inner: styles.inner,\n                label: clsx(styles.label, {\n                    [styles.uppercase]: uppercase,\n                }),\n                loader: styles.loader,\n                root: styles.root,\n                section: styles.section,\n                ...classNames,\n            }),\n            [classNames, uppercase],\n        );\n\n        if (tooltip) {\n            return (\n                <Tooltip withinPortal {...tooltip}>\n                    <MantineButton\n                        autoContrast\n                        classNames={memoizedClassNames}\n                        loading={loading}\n                        ref={ref}\n                        size={size}\n                        style={style}\n                        variant={variant}\n                        {...props}\n                    >\n                        {children}\n                    </MantineButton>\n                </Tooltip>\n            );\n        }\n\n        return (\n            <MantineButton\n                classNames={memoizedClassNames}\n                loading={loading}\n                ref={ref}\n                size={size}\n                style={style}\n                variant={variant}\n                {...props}\n            >\n                {children}\n            </MantineButton>\n        );\n    },\n);\n\nexport const Button = createPolymorphicComponent<'button', ButtonProps>(_Button);\n\nexport const ButtonGroup = MantineButton.Group;\n\nexport const ButtonGroupSection = MantineButton.GroupSection;\n\ninterface TimeoutButtonProps extends ButtonProps {\n    timeoutProps: {\n        callback: () => void;\n        duration: number;\n    };\n}\n\nexport const TimeoutButton = ({ timeoutProps, ...props }: TimeoutButtonProps) => {\n    const [, setTimeoutRemaining] = useState(timeoutProps.duration);\n    const [isRunning, setIsRunning] = useState(false);\n    const intervalRef = useRef<null | number>(null);\n\n    const callback = () => {\n        timeoutProps.callback();\n        setTimeoutRemaining(timeoutProps.duration);\n        if (intervalRef.current !== null) {\n            clearInterval(intervalRef.current);\n            intervalRef.current = null;\n        }\n        setIsRunning(false);\n    };\n\n    const { clear, start } = useTimeout(callback, timeoutProps.duration);\n\n    useEffect(() => {\n        return () => {\n            if (intervalRef.current !== null) {\n                clearInterval(intervalRef.current);\n                intervalRef.current = null;\n            }\n        };\n    }, []);\n\n    const startTimeout = useCallback(() => {\n        if (isRunning) {\n            if (intervalRef.current !== null) {\n                clearInterval(intervalRef.current);\n                intervalRef.current = null;\n            }\n            setIsRunning(false);\n            clear();\n        } else {\n            setIsRunning(true);\n            start();\n\n            const intervalId = window.setInterval(() => {\n                setTimeoutRemaining((prev) => prev - 100);\n            }, 100);\n\n            intervalRef.current = intervalId;\n        }\n    }, [clear, isRunning, start]);\n\n    return (\n        <Button onClick={startTimeout} {...props}>\n            {isRunning ? 'Cancel' : props.children}\n        </Button>\n    );\n};\n"
  },
  {
    "path": "src/shared/components/center/center.tsx",
    "content": "import { Center as MantineCenter, CenterProps as MantineCenterProps } from '@mantine/core';\nimport { forwardRef, memo, MouseEvent, useMemo } from 'react';\n\nexport interface CenterProps extends MantineCenterProps {\n    onClick?: (e: MouseEvent<HTMLDivElement>) => void;\n}\n\nconst _Center = forwardRef<HTMLDivElement, CenterProps>(\n    ({ children, classNames, onClick, style, ...props }, ref) => {\n        const memoizedClassNames = useMemo(() => ({ ...classNames }), [classNames]);\n        const memoizedStyle = useMemo(() => ({ ...style }), [style]);\n\n        return (\n            <MantineCenter\n                classNames={memoizedClassNames}\n                onClick={onClick}\n                ref={ref}\n                style={memoizedStyle}\n                {...props}\n            >\n                {children}\n            </MantineCenter>\n        );\n    },\n);\n\n_Center.displayName = 'Center';\n\nexport const Center = memo(_Center);\n"
  },
  {
    "path": "src/shared/components/checkbox/checkbox.module.css",
    "content": ".input {\n    background: var(--theme-colors-surface);\n    border: 1px solid var(--theme-colors-border);\n\n    &[data-variant='filled'] {\n        background: var(--theme-colors-background);\n    }\n}\n\n.body {\n    align-items: center;\n}\n\n.input:disabled {\n    border: 1px solid var(--theme-colors-border);\n    opacity: 0.6;\n}\n"
  },
  {
    "path": "src/shared/components/checkbox/checkbox.tsx",
    "content": "import { Checkbox as MantineCheckbox, CheckboxProps as MantineCheckboxProps } from '@mantine/core';\nimport { forwardRef } from 'react';\n\nimport styles from './checkbox.module.css';\n\ninterface CheckboxProps extends MantineCheckboxProps {}\n\nexport const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(\n    ({ classNames, ...props }: CheckboxProps, ref) => {\n        return (\n            <MantineCheckbox\n                classNames={{\n                    body: styles.body,\n                    input: styles.input,\n                    label: styles.label,\n                    ...classNames,\n                }}\n                ref={ref}\n                {...props}\n            />\n        );\n    },\n);\n"
  },
  {
    "path": "src/shared/components/checkbox-select/checkbox-select.module.css",
    "content": ".container {\n    display: flex;\n    flex-direction: column;\n    width: 100%;\n}\n\n.root {\n    padding: 0 var(--mantine-spacing-sm);\n}\n\n.body {\n    display: flex;\n    align-items: center;\n    width: 100%;\n    height: 100%;\n}\n\n.label-wrapper {\n    width: 100%;\n    height: 100%;\n}\n\n.label {\n    display: flex;\n    align-items: center;\n    width: 100%;\n    height: 100%;\n    overflow: hidden;\n    text-overflow: ellipsis;\n    font-size: var(--mantine-font-size-sm);\n    white-space: nowrap;\n    user-select: none;\n}\n\n.item {\n    display: grid;\n    grid-template-columns: auto minmax(0, 1fr);\n    align-items: center;\n    width: 100%;\n    padding: var(--mantine-spacing-xs);\n}\n\n.dragging {\n    opacity: 0.5;\n}\n\n.dragged-over-top {\n    box-shadow: inset 0 2px 0 0 var(--mantine-color-secondary-7);\n}\n\n.dragged-over-bottom {\n    box-shadow: inset 0 -2px 0 0 var(--mantine-color-secondary-7);\n}\n"
  },
  {
    "path": "src/shared/components/checkbox-select/checkbox-select.tsx",
    "content": "import type { Edge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge';\n\nimport {\n    attachClosestEdge,\n    extractClosestEdge,\n} from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge';\nimport { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';\nimport {\n    draggable,\n    dropTargetForElements,\n} from '@atlaskit/pragmatic-drag-and-drop/element/adapter';\nimport { disableNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/disable-native-drag-preview';\nimport clsx from 'clsx';\nimport { useEffect, useRef, useState } from 'react';\n\nimport styles from './checkbox-select.module.css';\n\nimport { ActionIcon } from '/@/shared/components/action-icon/action-icon';\nimport { Checkbox } from '/@/shared/components/checkbox/checkbox';\nimport { dndUtils, DragData, DragOperation, DragTarget } from '/@/shared/types/drag-and-drop';\n\ninterface CheckboxSelectProps {\n    data: { label: string; value: string }[];\n    enableDrag?: boolean;\n    onChange: (value: string[]) => void;\n    value: string[];\n}\n\nexport const CheckboxSelect = ({ data, enableDrag, onChange, value }: CheckboxSelectProps) => {\n    const handleChange = (values: string[]) => {\n        onChange(values);\n    };\n\n    return (\n        <div className={styles.container}>\n            {data.map((option) => (\n                <CheckboxSelectItem\n                    enableDrag={enableDrag}\n                    key={option.value}\n                    onChange={handleChange}\n                    option={option}\n                    values={value}\n                />\n            ))}\n        </div>\n    );\n};\n\ninterface CheckboxSelectItemProps {\n    enableDrag?: boolean;\n    onChange: (values: string[]) => void;\n    option: { label: string; value: string };\n    values: string[];\n}\n\nfunction CheckboxSelectItem({ enableDrag, onChange, option, values }: CheckboxSelectItemProps) {\n    const ref = useRef<HTMLInputElement | null>(null);\n    const dragHandleRef = useRef<HTMLButtonElement | null>(null);\n    const [isDragging, setIsDragging] = useState(false);\n    const [isDraggedOver, setIsDraggedOver] = useState<Edge | null>(null);\n\n    useEffect(() => {\n        if (!ref.current || !dragHandleRef.current || !enableDrag) {\n            return;\n        }\n\n        return combine(\n            draggable({\n                element: dragHandleRef.current,\n                getInitialData: () => {\n                    const data = dndUtils.generateDragData({\n                        id: [option.value],\n                        operation: [DragOperation.REORDER],\n                        type: DragTarget.GENERIC,\n                    });\n                    return data;\n                },\n                onDragStart: () => {\n                    setIsDragging(true);\n                },\n                onDrop: () => {\n                    setIsDragging(false);\n                },\n                onGenerateDragPreview: (data) => {\n                    disableNativeDragPreview({ nativeSetDragImage: data.nativeSetDragImage });\n                },\n            }),\n            dropTargetForElements({\n                canDrop: (args) => {\n                    const data = args.source.data as unknown as DragData;\n                    const isSelf = (args.source.data.id as string[])[0] === option.value;\n                    return dndUtils.isDropTarget(data.type, [DragTarget.GENERIC]) && !isSelf;\n                },\n                element: ref.current,\n                getData: ({ element, input }) => {\n                    const data = dndUtils.generateDragData({\n                        id: [option.value],\n                        operation: [DragOperation.REORDER],\n                        type: DragTarget.GENERIC,\n                    });\n\n                    return attachClosestEdge(data, {\n                        allowedEdges: ['top', 'bottom'],\n                        element,\n                        input,\n                    });\n                },\n                onDrag: (args) => {\n                    const closestEdgeOfTarget: Edge | null = extractClosestEdge(args.self.data);\n                    setIsDraggedOver(closestEdgeOfTarget);\n                },\n                onDragLeave: () => {\n                    setIsDraggedOver(null);\n                },\n                onDrop: (args) => {\n                    const closestEdgeOfTarget: Edge | null = extractClosestEdge(args.self.data);\n\n                    const from = args.source.data.id as string[];\n                    const to = args.self.data.id as string[];\n\n                    const newOrder = dndUtils.reorderById({\n                        edge: closestEdgeOfTarget,\n                        idFrom: from[0],\n                        idTo: to[0],\n                        list: values,\n                    });\n\n                    onChange(newOrder);\n                    setIsDraggedOver(null);\n                },\n            }),\n        );\n    }, [values, enableDrag, onChange, option.value]);\n\n    return (\n        <div\n            className={clsx(styles.item, {\n                [styles.draggedOverBottom]: isDraggedOver === 'bottom',\n                [styles.draggedOverTop]: isDraggedOver === 'top',\n                [styles.dragging]: isDragging,\n            })}\n            ref={ref}\n        >\n            {enableDrag && (\n                <ActionIcon\n                    className={styles.dragHandle}\n                    icon=\"dragVertical\"\n                    ref={dragHandleRef}\n                    size=\"xs\"\n                    variant=\"default\"\n                />\n            )}\n            <Checkbox\n                checked={values.includes(option.value)}\n                label={option.label}\n                onChange={(e) => {\n                    onChange(\n                        e.target.checked\n                            ? [...values, option.value]\n                            : values.filter((v) => v !== option.value),\n                    );\n                }}\n                variant=\"filled\"\n            />\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/shared/components/code/code.module.css",
    "content": ".root {\n    background: var(--theme-colors-surface);\n    border-radius: var(--theme-radius-md);\n}\n"
  },
  {
    "path": "src/shared/components/code/code.tsx",
    "content": "import { Code as MantineCode, CodeProps as MantineCodeProps } from '@mantine/core';\n\nimport styles from './code.module.css';\n\nexport interface CodeProps extends MantineCodeProps {}\n\nexport const Code = ({ classNames, ...props }: CodeProps) => {\n    return (\n        <MantineCode\n            {...props}\n            classNames={{\n                ...classNames,\n                root: styles.root,\n            }}\n            spellCheck={false}\n        />\n    );\n};\n"
  },
  {
    "path": "src/shared/components/color-input/color-input.module.css",
    "content": ".root {\n    & [data-disabled='true'] {\n        opacity: 0.6;\n    }\n}\n\n.input {\n    width: 100%;\n    border: 1px solid transparent;\n\n    &[data-variant='default'] {\n        color: var(--theme-colors-surface-foreground);\n        background: var(--theme-colors-surface);\n    }\n\n    &[data-variant='filled'] {\n        color: var(--theme-colors-foreground);\n        background: var(--theme-colors-background);\n    }\n}\n\n.input:focus,\n.input:focus-visible {\n    border-color: lighten(var(--theme-colors-border), 10%);\n}\n\n.label {\n    margin-bottom: var(--theme-spacing-sm);\n}\n\n.dropdown {\n    color: var(--theme-colors-surface-foreground);\n    background: var(--theme-colors-surface);\n    border: 1px solid transparent;\n    border-radius: var(--theme-radius-md);\n    box-shadow: 2px 2px 10px 2px rgb(0 0 0 / 40%);\n}\n"
  },
  {
    "path": "src/shared/components/color-input/color-input.tsx",
    "content": "import {\n    ColorInput as MantineColorInput,\n    ColorInputProps as MantineColorInputProps,\n} from '@mantine/core';\n\nimport styles from './color-input.module.css';\n\nexport interface ColorInputProps extends MantineColorInputProps {}\n\nexport const ColorInput = ({\n    classNames,\n    size = 'sm',\n    variant = 'default',\n    ...props\n}: ColorInputProps) => {\n    return (\n        <MantineColorInput\n            classNames={{\n                dropdown: styles.dropdown,\n                input: styles.input,\n                label: styles.label,\n                root: styles.root,\n                ...classNames,\n            }}\n            size={size}\n            variant={variant}\n            {...props}\n        />\n    );\n};\n"
  },
  {
    "path": "src/shared/components/context-menu/context-menu.module.css",
    "content": ".content {\n    z-index: 1000;\n    width: 16rem;\n    min-width: 16rem;\n    max-width: 16rem;\n    padding: var(--theme-spacing-xs);\n    color: var(--theme-colors-foreground);\n    background: var(--theme-colors-background);\n    border: 1px solid var(--theme-colors-border);\n    border-radius: var(--theme-radius-md);\n    filter: drop-shadow(0 0 5px rgb(0 0 0 / 50%));\n}\n\n.inner-content {\n    display: flex;\n    flex-direction: column;\n    gap: var(--base-gap-xs);\n    overflow: hidden;\n}\n\n.item {\n    position: relative;\n    display: flex;\n    align-items: center;\n    min-width: 8rem;\n    max-width: 100%;\n    padding: var(--theme-spacing-sm) var(--theme-spacing-md);\n    font-size: var(--theme-font-size-sm);\n    overflow-wrap: break-word;\n    white-space: normal;\n    cursor: default;\n    user-select: none;\n\n    &.has-left-icon {\n        padding-left: calc(var(--theme-spacing-md) + 1.5rem);\n    }\n\n    &.has-right-icon {\n        padding-right: calc(var(--theme-spacing-md) + 1.5rem);\n    }\n\n    & > *:not(.left-icon, .right-icon) {\n        flex: 1;\n        min-width: 0;\n        overflow-wrap: break-word;\n    }\n\n    &:hover {\n        background: var(--theme-colors-surface);\n    }\n}\n\n.item[data-highlighted] {\n    background: var(--theme-colors-surface);\n}\n\n.left-icon {\n    position: absolute;\n    top: 50%;\n    left: var(--theme-spacing-md);\n    z-index: 1;\n    display: flex;\n    align-items: center;\n    transform: translateY(-50%);\n}\n\n.right-icon {\n    position: absolute;\n    top: 50%;\n    right: var(--theme-spacing-md);\n    z-index: 1;\n    display: flex;\n    align-items: center;\n    transform: translateY(-50%);\n}\n\n.disabled {\n    pointer-events: none;\n    opacity: 0.6;\n}\n\n.item.selected {\n    &::before {\n        position: absolute;\n        top: 50%;\n        left: 2px;\n        width: 2px;\n        height: 50%;\n        content: '';\n        background-color: var(--theme-colors-primary-filled);\n        border-radius: var(--theme-radius-xl);\n        transform: translateY(-50%);\n    }\n}\n\n.divider {\n    height: 1px;\n    padding: 0;\n    margin: var(--theme-spacing-xs) 0;\n    background: none;\n    border: none;\n    border-top: 1px solid var(--theme-colors-border);\n}\n\n.max-height {\n    max-height: 36rem;\n}\n"
  },
  {
    "path": "src/shared/components/context-menu/context-menu.tsx",
    "content": "import type { Dispatch, SetStateAction } from 'react';\n\nimport * as RadixContextMenu from '@radix-ui/react-context-menu';\nimport clsx from 'clsx';\nimport { AnimatePresence, motion } from 'motion/react';\nimport {\n    createContext,\n    Fragment,\n    type ReactNode,\n    useContext,\n    useEffect,\n    useMemo,\n    useRef,\n    useState,\n} from 'react';\n\nimport styles from './context-menu.module.css';\n\nimport { animationVariants } from '/@/shared/components/animations/animation-variants';\nimport { AppIcon, Icon } from '/@/shared/components/icon/icon';\nimport { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';\n\ninterface ContextMenuContext {\n    open: boolean;\n    setOpen: Dispatch<SetStateAction<boolean>>;\n}\n\nexport const ContextMenuContext = createContext<ContextMenuContext | null>(null);\n\ninterface ContentProps {\n    bottomStickyContent?: ReactNode;\n    children: ReactNode;\n    onCloseAutoFocus?: (event: FocusEvent) => void;\n    onEscapeKeyDown?: (event: KeyboardEvent) => void;\n    onFocusOutside?: (event: FocusEvent) => void;\n    onPointerDownOutside?: (event: PointerEvent) => void;\n    stickyContent?: ReactNode;\n}\n\ninterface ContextMenuProps {\n    children: ReactNode;\n}\n\ninterface DividerProps {}\n\ninterface ItemProps {\n    children: ReactNode;\n    className?: string;\n    disabled?: boolean;\n    isSelected?: boolean;\n    leftIcon?: keyof typeof AppIcon;\n    onSelect?: (event: Event) => void;\n    rightIcon?: keyof typeof AppIcon;\n}\n\ninterface LabelProps extends React.ComponentPropsWithoutRef<'div'> {\n    children: ReactNode;\n}\n\ninterface SubmenuContext {\n    cancelCloseTimeout: () => void;\n    disabled?: boolean;\n    isCloseDisabled?: boolean;\n    open: boolean;\n    setCloseTimeout: (timeout: NodeJS.Timeout) => void;\n    setOpen: Dispatch<SetStateAction<boolean>>;\n}\n\ninterface TargetProps {\n    children: ReactNode;\n}\n\nexport function ContextMenu(props: ContextMenuProps) {\n    const { children } = props;\n\n    const [open, setOpen] = useState(false);\n    const context = useMemo(() => ({ open, setOpen }), [open]);\n\n    return (\n        <RadixContextMenu.Root onOpenChange={setOpen}>\n            <ContextMenuContext.Provider value={context}>{children}</ContextMenuContext.Provider>\n        </RadixContextMenu.Root>\n    );\n}\n\nfunction Content(props: ContentProps) {\n    const { bottomStickyContent, children, stickyContent } = props;\n    const { open } = useContext(ContextMenuContext) as ContextMenuContext;\n\n    return (\n        <AnimatePresence>\n            {open && (\n                <RadixContextMenu.Portal forceMount>\n                    <RadixContextMenu.Content asChild className={styles.content}>\n                        <motion.div\n                            animate=\"show\"\n                            className={styles.content}\n                            exit=\"hidden\"\n                            initial=\"hidden\"\n                        >\n                            {stickyContent}\n                            <ScrollArea className={styles.maxHeight}>{children}</ScrollArea>\n                            {bottomStickyContent}\n                        </motion.div>\n                    </RadixContextMenu.Content>\n                </RadixContextMenu.Portal>\n            )}\n        </AnimatePresence>\n    );\n}\n\nfunction Divider(props: DividerProps) {\n    return <RadixContextMenu.Separator {...props} className={styles.divider} />;\n}\n\nfunction Item(props: ItemProps) {\n    const { children, className, disabled, isSelected, leftIcon, onSelect, rightIcon } = props;\n\n    return (\n        <RadixContextMenu.Item\n            className={clsx(styles.item, className, {\n                [styles.disabled]: disabled,\n                [styles.selected]: isSelected,\n                [styles['has-left-icon']]: !!leftIcon,\n                [styles['has-right-icon']]: !!rightIcon,\n            })}\n            disabled={disabled}\n            onSelect={onSelect}\n        >\n            {leftIcon && <Icon className={styles.leftIcon} icon={leftIcon} />}\n            {children}\n            {rightIcon && <Icon className={styles.rightIcon} icon={rightIcon} />}\n        </RadixContextMenu.Item>\n    );\n}\n\nfunction Label(props: LabelProps) {\n    const { children, className, ...htmlProps } = props;\n\n    return (\n        <RadixContextMenu.Label className={clsx(styles.label, className)} {...htmlProps}>\n            {children}\n        </RadixContextMenu.Label>\n    );\n}\n\nfunction Target(props: TargetProps) {\n    const { children } = props;\n\n    return (\n        <RadixContextMenu.Trigger asChild className={styles.target}>\n            {children}\n        </RadixContextMenu.Trigger>\n    );\n}\n\nconst SubmenuContext = createContext<null | SubmenuContext>(null);\n\ninterface SubmenuContentProps {\n    children: ReactNode;\n    stickyContent?: ReactNode;\n}\n\ninterface SubmenuProps {\n    children: ReactNode;\n    disabled?: boolean;\n    isCloseDisabled?: boolean;\n    open?: boolean;\n}\n\ninterface SubmenuTargetProps {\n    children: ReactNode;\n}\n\nfunction Submenu(props: SubmenuProps) {\n    const { children, disabled, isCloseDisabled, open: isManuallyOpen } = props;\n    const [open, setOpen] = useState(isManuallyOpen ?? false);\n    const closeTimeoutRef = useRef<NodeJS.Timeout | null>(null);\n\n    useEffect(() => {\n        return () => {\n            if (closeTimeoutRef.current) {\n                clearTimeout(closeTimeoutRef.current);\n            }\n        };\n    }, []);\n\n    const cancelCloseTimeout = () => {\n        if (closeTimeoutRef.current) {\n            clearTimeout(closeTimeoutRef.current);\n            closeTimeoutRef.current = null;\n        }\n    };\n\n    const setCloseTimeout = (timeout: NodeJS.Timeout) => {\n        closeTimeoutRef.current = timeout;\n    };\n\n    const context = useMemo(\n        () => ({\n            cancelCloseTimeout,\n            disabled,\n            isCloseDisabled,\n            open,\n            setCloseTimeout,\n            setOpen,\n        }),\n        [disabled, isCloseDisabled, open],\n    );\n\n    return (\n        <RadixContextMenu.Sub open={open}>\n            <SubmenuContext.Provider value={context}>{children}</SubmenuContext.Provider>\n        </RadixContextMenu.Sub>\n    );\n}\n\nfunction SubmenuContent(props: SubmenuContentProps) {\n    const { children, stickyContent } = props;\n    const { cancelCloseTimeout, isCloseDisabled, open, setCloseTimeout, setOpen } = useContext(\n        SubmenuContext,\n    ) as SubmenuContext;\n\n    const handleMouseEnter = () => {\n        cancelCloseTimeout();\n        setOpen(true);\n    };\n\n    const handleMouseLeave = () => {\n        if (isCloseDisabled) {\n            const timeout = setTimeout(() => {\n                setOpen(false);\n            }, 150);\n            setCloseTimeout(timeout);\n        } else {\n            setOpen(false);\n        }\n    };\n\n    return (\n        <Fragment>\n            {open && (\n                <RadixContextMenu.Portal forceMount>\n                    <RadixContextMenu.SubContent\n                        className={styles.content}\n                        onMouseEnter={handleMouseEnter}\n                        onMouseLeave={handleMouseLeave}\n                    >\n                        <motion.div\n                            animate=\"show\"\n                            className={styles.innerContent}\n                            initial=\"hidden\"\n                            variants={animationVariants.fadeIn}\n                        >\n                            {stickyContent}\n                            <ScrollArea className={styles.maxHeight}>{children}</ScrollArea>\n                        </motion.div>\n                    </RadixContextMenu.SubContent>\n                </RadixContextMenu.Portal>\n            )}\n        </Fragment>\n    );\n}\n\nfunction SubmenuTarget(props: SubmenuTargetProps) {\n    const { children } = props;\n    const { cancelCloseTimeout, disabled, setCloseTimeout, setOpen } = useContext(\n        SubmenuContext,\n    ) as SubmenuContext;\n    const openTimeoutRef = useRef<NodeJS.Timeout | null>(null);\n\n    useEffect(() => {\n        return () => {\n            if (openTimeoutRef.current) {\n                clearTimeout(openTimeoutRef.current);\n            }\n        };\n    }, []);\n\n    const handleMouseEnter = () => {\n        if (disabled) return;\n\n        cancelCloseTimeout();\n\n        if (openTimeoutRef.current) {\n            clearTimeout(openTimeoutRef.current);\n        }\n\n        openTimeoutRef.current = setTimeout(() => {\n            setOpen(true);\n            openTimeoutRef.current = null;\n        }, 150);\n    };\n\n    const handleMouseLeave = () => {\n        if (openTimeoutRef.current) {\n            clearTimeout(openTimeoutRef.current);\n            openTimeoutRef.current = null;\n        }\n\n        const timeout = setTimeout(() => {\n            setOpen(false);\n        }, 150);\n        setCloseTimeout(timeout);\n    };\n\n    return (\n        <RadixContextMenu.SubTrigger\n            className={clsx({ [styles.disabled]: disabled })}\n            disabled={disabled}\n            onMouseEnter={handleMouseEnter}\n            onMouseLeave={handleMouseLeave}\n        >\n            {children}\n        </RadixContextMenu.SubTrigger>\n    );\n}\n\nContextMenu.Target = Target;\nContextMenu.Content = Content;\nContextMenu.Item = Item;\nContextMenu.Label = Label;\nContextMenu.Group = RadixContextMenu.Group;\nContextMenu.Submenu = Submenu;\nContextMenu.SubmenuTarget = SubmenuTarget;\nContextMenu.SubmenuContent = SubmenuContent;\nContextMenu.Divider = Divider;\nContextMenu.Arrow = RadixContextMenu.Arrow;\n"
  },
  {
    "path": "src/shared/components/copy-button/copy-button.tsx",
    "content": "import {\n    CopyButton as MantineCopyButton,\n    CopyButtonProps as MantineCopyButtonProps,\n} from '@mantine/core';\n\nexport interface CopyButtonProps extends MantineCopyButtonProps {}\n\nexport const CopyButton = ({ children, ...props }: CopyButtonProps) => {\n    return <MantineCopyButton {...props}>{children}</MantineCopyButton>;\n};\n"
  },
  {
    "path": "src/shared/components/date-picker/date-picker.module.css",
    "content": ".root {\n    & [data-disabled='true'] {\n        opacity: 0.6;\n    }\n}\n\n.input {\n    color: var(--theme-colors-surface-foreground);\n    background: var(--theme-colors-surface);\n    border: 1px solid transparent;\n}\n\n.section {\n    color: var(--theme-colors-foreground-muted);\n}\n\n.required {\n    color: var(--theme-colors-state-error);\n}\n"
  },
  {
    "path": "src/shared/components/date-picker/date-picker.tsx",
    "content": "import type {\n    DateInputProps as MantineDateInputProps,\n    DateTimePickerProps as MantineDateTimeInputProps,\n} from '@mantine/dates';\n\nimport {\n    DateInput as MantineDateInput,\n    DateTimePicker as MantineDateTimeInput,\n} from '@mantine/dates';\n\nimport styles from './date-picker.module.css';\n\ninterface DateInputProps extends MantineDateInputProps {\n    maxWidth?: number | string;\n    width?: number | string;\n}\n\nexport const DateInput = ({\n    classNames,\n    maxWidth,\n    size = 'sm',\n    style,\n    width,\n    ...props\n}: DateInputProps) => {\n    return (\n        <MantineDateInput\n            classNames={{\n                input: styles.input,\n                label: styles.label,\n                required: styles.required,\n                root: styles.root,\n                section: styles.section,\n                ...classNames,\n            }}\n            size={size}\n            style={{ maxWidth, width, ...style }}\n            {...props}\n        />\n    );\n};\n\ninterface DateTimeInputProps extends MantineDateTimeInputProps {\n    maxWidth?: number | string;\n    width?: number | string;\n}\n\nexport const DateTimeInput = ({\n    classNames,\n    maxWidth,\n    size = 'sm',\n    style,\n    width,\n    ...props\n}: DateTimeInputProps) => {\n    return (\n        <MantineDateTimeInput\n            classNames={{\n                input: styles.input,\n                label: styles.label,\n                required: styles.required,\n                root: styles.root,\n                section: styles.section,\n                ...classNames,\n            }}\n            size={size}\n            style={{ maxWidth, width, ...style }}\n            {...props}\n        />\n    );\n};\n"
  },
  {
    "path": "src/shared/components/date-time-picker/date-time-picker.module.css",
    "content": ".root {\n    & [data-disabled='true'] {\n        opacity: 0.6;\n    }\n}\n\n.input {\n    color: var(--theme-colors-surface-foreground);\n    background: var(--theme-colors-surface);\n    border: 1px solid transparent;\n}\n\n.section {\n    color: var(--theme-colors-foreground-muted);\n}\n\n.required {\n    color: var(--theme-colors-state-error);\n}\n"
  },
  {
    "path": "src/shared/components/date-time-picker/date-time-picker.tsx",
    "content": "import type { DateTimePickerProps as MantineDateTimePickerProps } from '@mantine/dates';\n\nimport { DateTimePicker as MantineDateTimePicker } from '@mantine/dates';\n\nimport styles from './date-time-picker.module.css';\n\ninterface DateTimePickerProps extends MantineDateTimePickerProps {\n    maxWidth?: number | string;\n    width?: number | string;\n}\n\nexport const DateTimePicker = ({\n    classNames,\n    maxWidth,\n    popoverProps,\n    size = 'sm',\n    style,\n    width,\n    ...props\n}: DateTimePickerProps) => {\n    return (\n        <MantineDateTimePicker\n            classNames={{\n                input: styles.input,\n                label: styles.label,\n                required: styles.required,\n                root: styles.root,\n                section: styles.section,\n                ...classNames,\n            }}\n            popoverProps={{ withinPortal: true, ...popoverProps }}\n            size={size}\n            style={{ maxWidth, width, ...style }}\n            {...props}\n        />\n    );\n};\n"
  },
  {
    "path": "src/shared/components/dialog/dialog.module.css",
    "content": ".root {\n    color: var(--theme-colors-surface-foreground);\n    background: var(--theme-colors-surface);\n    box-shadow: 2px 2px 10px 2px rgb(0 0 0 / 40%);\n}\n\n\n.close-button {\n    display: none;\n}\n"
  },
  {
    "path": "src/shared/components/dialog/dialog.tsx",
    "content": "import type { DialogProps as MantineDialogProps } from '@mantine/core';\n\nimport { Dialog as MantineDialog } from '@mantine/core';\n\nimport styles from './dialog.module.css';\n\ninterface DialogProps extends MantineDialogProps {}\n\nexport const Dialog = ({ classNames, style, ...props }: DialogProps) => {\n    return (\n        <MantineDialog\n            classNames={{ closeButton: styles.closeButton, root: styles.root, ...classNames }}\n            style={{\n                ...style,\n            }}\n            {...props}\n        />\n    );\n};\n"
  },
  {
    "path": "src/shared/components/divider/divider.module.css",
    "content": ".root {\n    --divider-color: var(--theme-colors-border);\n}\n"
  },
  {
    "path": "src/shared/components/divider/divider.tsx",
    "content": "import { Divider as MantineDivider, DividerProps as MantineDividerProps } from '@mantine/core';\nimport { forwardRef, memo, useMemo } from 'react';\n\nimport styles from './divider.module.css';\n\nexport interface DividerProps extends MantineDividerProps {}\n\nconst _Divider = forwardRef<HTMLDivElement, DividerProps>(\n    ({ classNames, style, ...props }, ref) => {\n        const memoizedClassNames = useMemo(\n            () => ({\n                root: styles.root,\n                ...classNames,\n            }),\n            [classNames],\n        );\n\n        const memoizedStyle = useMemo(() => ({ ...style }), [style]);\n\n        return (\n            <MantineDivider\n                classNames={memoizedClassNames}\n                ref={ref}\n                style={memoizedStyle}\n                {...props}\n            />\n        );\n    },\n);\n\n_Divider.displayName = 'Divider';\n\nexport const Divider = memo(_Divider);\n"
  },
  {
    "path": "src/shared/components/drag-drop-zone/drag-drop-zone.tsx",
    "content": "import { t } from 'i18next';\nimport { useCallback, useRef, useState } from 'react';\n\nimport { Flex } from '/@/shared/components/flex/flex';\nimport { AppIcon, Icon } from '/@/shared/components/icon/icon';\nimport { Text } from '/@/shared/components/text/text';\n\ninterface DragDropZoneProps {\n    icon: keyof typeof AppIcon;\n    onItemSelected: (contents: string) => void;\n    validateItem?: (contents: string) => { error?: string; isValid: boolean };\n}\n\nexport const DragDropZone = ({ icon, onItemSelected, validateItem }: DragDropZoneProps) => {\n    const zoneFileInput = useRef<HTMLInputElement | null>(null);\n    const [error, setError] = useState<string>('');\n\n    const processItem = useCallback(\n        (itemContents: string) => {\n            const { error: validationError, isValid } = validateItem\n                ? validateItem(itemContents)\n                : { isValid: true };\n\n            if (validationError || !isValid) {\n                setError(validationError!);\n                return;\n            }\n\n            onItemSelected(itemContents);\n        },\n        [onItemSelected, validateItem],\n    );\n\n    const onItemDropped = useCallback(\n        (event: React.DragEvent<HTMLDivElement>) => {\n            event.preventDefault();\n\n            const items = event.dataTransfer.items;\n\n            if (items.length > 1) {\n                setError(t('dragDropZone.error_oneFileOnly'));\n                return;\n            }\n\n            const file = items[0].getAsFile();\n\n            if (!file) {\n                return;\n            }\n\n            file.text()\n                .then((value) => processItem(value.toString()))\n                .catch((err) => {\n                    const error = err as Error;\n                    setError(\n                        t('dragDropZone.error_readingFile', {\n                            errorMessage: error.message,\n                        }),\n                    );\n                });\n        },\n        [processItem],\n    );\n\n    const onDragOver = useCallback((event: React.DragEvent<HTMLDivElement>) => {\n        event.stopPropagation();\n        event.preventDefault();\n    }, []);\n\n    const onZoneClick = useCallback(() => {\n        zoneFileInput.current?.click();\n    }, []);\n\n    const onZoneInputChange = useCallback(\n        (event: React.ChangeEvent<HTMLInputElement>) => {\n            const { files } = event.target;\n\n            if (!files || files.length > 1) {\n                setError(t('dragDropZone.error_oneFileOnly'));\n                return;\n            }\n\n            const reader = new FileReader();\n            reader.addEventListener('load', (event) => {\n                const contents = event.target?.result;\n\n                if (!contents) {\n                    return;\n                }\n\n                processItem(contents.toString());\n            });\n\n            reader.readAsText(files[0]);\n        },\n        [processItem],\n    );\n\n    const hasErrored = error.length > 0;\n    const borderColour = hasErrored ? 'red' : 'grey';\n\n    return (\n        <Flex\n            align=\"center\"\n            bd={`2px dashed ${borderColour}`}\n            bdrs={'sm'}\n            direction=\"column\"\n            gap={'sm'}\n            justify=\"center\"\n            onClick={onZoneClick}\n            onDragOver={onDragOver}\n            onDrop={onItemDropped}\n            p=\"sm\"\n            style={{ cursor: 'pointer' }}\n        >\n            <Icon icon={icon} size=\"3xl\" />\n            <Text>{t('dragDropZone.mainText').toString()}</Text>\n            {hasErrored ? (\n                <Text c=\"red\" ta=\"center\">\n                    {error}\n                </Text>\n            ) : null}\n            <input\n                onChange={onZoneInputChange}\n                ref={(self) => {\n                    zoneFileInput.current = self;\n                }}\n                style={{ display: 'none' }}\n                type=\"file\"\n            />\n        </Flex>\n    );\n};\n"
  },
  {
    "path": "src/shared/components/drawer/drawer.tsx",
    "content": "import { Drawer as MantineDrawer, DrawerProps as MantineDrawerProps } from '@mantine/core';\nimport { ReactNode } from 'react';\n\ninterface DrawerProps extends MantineDrawerProps {\n    children?: ReactNode;\n}\n\nexport const Drawer = ({ children, ...props }: DrawerProps) => {\n    return <MantineDrawer {...props}>{children}</MantineDrawer>;\n};\n"
  },
  {
    "path": "src/shared/components/dropdown-menu/dropdown-menu.module.css",
    "content": ".menu-item {\n    position: relative;\n    display: flex;\n    align-items: center;\n    padding: var(--theme-spacing-sm) var(--theme-spacing-md);\n    cursor: default;\n\n    &:hover {\n        background: var(--theme-colors-surface);\n    }\n}\n\n.menu-item:disabled {\n    opacity: 0.6;\n}\n\n.menu-item-label {\n    margin-right: var(--theme-spacing-md);\n    margin-left: var(--theme-spacing-md);\n    font-size: var(--theme-font-size-sm);\n    color: var(--theme-colors-surfaceforeground);\n}\n\n.selected {\n    &::before {\n        position: absolute;\n        top: 50%;\n        left: 2px;\n        width: 2px;\n        height: 50%;\n        content: '';\n        background-color: var(--theme-colors-primary-filled);\n        border-radius: var(--theme-radius-xl);\n        transform: translateY(-50%);\n    }\n}\n\n.menu-item-label-danger {\n    color: var(--theme-colors-state-error);\n}\n\n.menu-item-right-section {\n    display: flex;\n}\n\n.menu-dropdown {\n    padding: var(--theme-spacing-xs);\n    color: var(--theme-colors-foreground);\n    background: var(--theme-colors-background);\n    border: 1px solid var(--theme-colors-border);\n    filter: drop-shadow(0 0 5px rgb(0 0 0 / 50%));\n}\n\n.menu-divider {\n    padding: 0;\n    margin: 0;\n    border-color: var(--theme-colors-border);\n}\n\n.menu-item-section svg {\n    font-size: var(--theme-font-size-sm);\n}\n"
  },
  {
    "path": "src/shared/components/dropdown-menu/dropdown-menu.tsx",
    "content": "import type {\n    MenuDividerProps as MantineMenuDividerProps,\n    MenuDropdownProps as MantineMenuDropdownProps,\n    MenuItemProps as MantineMenuItemProps,\n    MenuLabelProps as MantineMenuLabelProps,\n    MenuProps as MantineMenuProps,\n} from '@mantine/core';\n\nimport { Menu as MantineMenu } from '@mantine/core';\nimport clsx from 'clsx';\nimport { ReactNode } from 'react';\n\nimport styles from './dropdown-menu.module.css';\n\nimport { createPolymorphicComponent } from '/@/shared/utils/create-polymorphic-component';\n\nexport interface MenuItemProps extends MantineMenuItemProps {\n    children: ReactNode;\n    isDanger?: boolean;\n    isSelected?: boolean;\n}\ntype MenuDividerProps = MantineMenuDividerProps;\ntype MenuDropdownProps = MantineMenuDropdownProps;\ntype MenuLabelProps = MantineMenuLabelProps;\ntype MenuProps = MantineMenuProps;\n\nconst getTransition = (position?: string) => {\n    if (position?.includes('top')) {\n        return 'fade-up';\n    }\n\n    if (position?.includes('bottom')) {\n        return 'fade-down';\n    }\n\n    if (position?.includes('left')) {\n        return 'fade-left';\n    }\n\n    if (position?.includes('right')) {\n        return 'fade-right';\n    }\n\n    return 'fade';\n};\n\nexport const DropdownMenu = ({ children, ...props }: MenuProps) => {\n    return (\n        <MantineMenu\n            classNames={{\n                dropdown: styles['menu-dropdown'],\n                itemSection: styles['menu-item-section'],\n            }}\n            offset={10}\n            transitionProps={{\n                transition: getTransition(props.position),\n            }}\n            withinPortal\n            {...props}\n        >\n            {children}\n        </MantineMenu>\n    );\n};\n\nconst MenuLabel = ({ children, ...props }: MenuLabelProps) => {\n    return (\n        <MantineMenu.Label className={styles['menu-label']} {...props}>\n            {children}\n        </MantineMenu.Label>\n    );\n};\n\nconst pMenuItem = ({ children, isDanger, isSelected, ...props }: MenuItemProps) => {\n    return (\n        <MantineMenu.Item\n            className={clsx(styles['menu-item'], {\n                [styles.selected]: isSelected,\n            })}\n            {...props}\n        >\n            <span\n                className={clsx(styles['menu-item-label'], {\n                    [styles['menu-item-label-danger']]: isDanger,\n                    [styles['menu-item-label-normal']]: !isDanger,\n                })}\n            >\n                {children}\n            </span>\n        </MantineMenu.Item>\n    );\n};\n\nconst MenuDropdown = ({ children, ...props }: MenuDropdownProps) => {\n    return (\n        <MantineMenu.Dropdown className={styles['menu-dropdown']} {...props}>\n            {children}\n        </MantineMenu.Dropdown>\n    );\n};\n\nconst MenuItem = createPolymorphicComponent<'button', MenuItemProps>(pMenuItem);\n\nconst MenuDivider = ({ ...props }: MenuDividerProps) => {\n    return <MantineMenu.Divider className={styles['menu-divider']} {...props} />;\n};\n\nDropdownMenu.Label = MenuLabel;\nDropdownMenu.Item = MenuItem;\nDropdownMenu.Target = MantineMenu.Target;\nDropdownMenu.Dropdown = MenuDropdown;\nDropdownMenu.Divider = MenuDivider;\n"
  },
  {
    "path": "src/shared/components/explicit-indicator/explicit-indicator.module.css",
    "content": ".root {\n    font-family:\n        Symbola,\n        'Segoe UI Emoji',\n        sans-serif;\n}\n\n.size-xs {\n    font-size: var(--theme-font-size-xs);\n}\n\n.size-sm {\n    font-size: var(--theme-font-size-sm);\n}\n\n.size-md {\n    font-size: var(--theme-font-size-md);\n}\n\n.size-lg {\n    font-size: var(--theme-font-size-lg);\n}\n\n.size-xl {\n    font-size: var(--theme-font-size-xl);\n}\n\n.size-2xl {\n    font-size: var(--theme-font-size-2xl);\n}\n\n.size-3xl {\n    font-size: var(--theme-font-size-3xl);\n}\n\n.size-4xl {\n    font-size: var(--theme-font-size-4xl);\n}\n\n.muted {\n    opacity: 0.85;\n}\n\n.with-space {\n    padding-right: var(--theme-spacing-xs);\n}\n"
  },
  {
    "path": "src/shared/components/explicit-indicator/explicit-indicator.tsx",
    "content": "import clsx from 'clsx';\nimport { ComponentPropsWithoutRef } from 'react';\n\nimport styles from './explicit-indicator.module.css';\n\nimport { ExplicitStatus } from '/@/shared/types/domain-types';\n\nconst EXPLICIT_SYMBOL = '🅴';\nconst CLEAN_SYMBOL = '🅲';\n\nexport interface ExplicitIndicatorProps extends ComponentPropsWithoutRef<'span'> {\n    explicitStatus: ExplicitStatus | null | undefined;\n    size?: ExplicitIndicatorSize;\n    withSpace?: boolean;\n}\n\nexport type ExplicitIndicatorSize = '2xl' | '3xl' | '4xl' | 'lg' | 'md' | 'sm' | 'xl' | 'xs';\n\nexport const ExplicitIndicator = ({\n    className,\n    explicitStatus,\n    size = 'lg',\n    withSpace = true,\n    ...rest\n}: ExplicitIndicatorProps) => {\n    if (explicitStatus !== ExplicitStatus.EXPLICIT && explicitStatus !== ExplicitStatus.CLEAN) {\n        return null;\n    }\n\n    const symbol = explicitStatus === ExplicitStatus.EXPLICIT ? EXPLICIT_SYMBOL : CLEAN_SYMBOL;\n\n    return (\n        <span\n            aria-label={explicitStatus === ExplicitStatus.EXPLICIT ? 'Explicit' : 'Clean'}\n            className={clsx(styles.root, styles[`size-${size}`], className, {\n                [styles.withSpace]: withSpace,\n            })}\n            {...rest}\n        >\n            {symbol}\n        </span>\n    );\n};\n"
  },
  {
    "path": "src/shared/components/fieldset/fieldset.module.css",
    "content": ".root {\n    &[data-variant='default'] {\n        background: none;\n    }\n}\n"
  },
  {
    "path": "src/shared/components/fieldset/fieldset.tsx",
    "content": "import { Fieldset as MantineFieldset, FieldsetProps as MantineFieldsetProps } from '@mantine/core';\nimport { CSSProperties, forwardRef } from 'react';\n\nimport styles from './fieldset.module.css';\n\nexport interface FieldsetProps extends MantineFieldsetProps {\n    maxWidth?: CSSProperties['maxWidth'];\n    width?: CSSProperties['width'];\n}\n\nexport const Fieldset = forwardRef<HTMLFieldSetElement, FieldsetProps>(\n    ({ children, ...props }, ref) => {\n        return (\n            <MantineFieldset classNames={{ root: styles.root }} {...props} ref={ref}>\n                {children}\n            </MantineFieldset>\n        );\n    },\n);\n\nFieldset.displayName = 'Fieldset';\n"
  },
  {
    "path": "src/shared/components/file-input/file-input.module.css",
    "content": ".root {\n    transition: width 0.3s ease-in-out;\n\n    &[data-disabled='true'] {\n        opacity: 0.6;\n    }\n}\n\n.input {\n    width: 100%;\n    border: 1px solid transparent;\n\n    &[data-variant='default'] {\n        color: var(--theme-colors-surface-foreground);\n        background: var(--theme-colors-surface);\n    }\n\n    &[data-variant='filled'] {\n        color: var(--theme-colors-foreground);\n        background: var(--theme-colors-background);\n    }\n}\n\n.input:focus,\n.input:focus-visible {\n    border-color: lighten(var(--theme-colors-border), 10%);\n}\n\n.section {\n    color: var(--theme-colors-foreground-muted);\n}\n\n.required {\n    color: var(--theme-colors-state-error);\n}\n\n.label {\n    margin-bottom: var(--theme-spacing-sm);\n    font-family: var(--theme-label-font-family);\n}\n"
  },
  {
    "path": "src/shared/components/file-input/file-input.tsx",
    "content": "import {\n    FileInput as MantineFileInput,\n    FileInputProps as MantineFileInputProps,\n} from '@mantine/core';\nimport { CSSProperties, forwardRef } from 'react';\n\nimport styles from './file-input.module.css';\n\nexport interface FileInputProps extends MantineFileInputProps {\n    maxWidth?: CSSProperties['maxWidth'];\n    width?: CSSProperties['width'];\n}\n\nexport const FileInput = forwardRef<HTMLButtonElement, FileInputProps>(\n    (\n        {\n            children,\n            classNames,\n            maxWidth,\n            size = 'sm',\n            style,\n            variant = 'default',\n            width,\n            ...props\n        },\n        ref,\n    ) => {\n        return (\n            <MantineFileInput\n                classNames={{\n                    input: styles.input,\n                    label: styles.label,\n                    required: styles.required,\n                    root: styles.root,\n                    section: styles.section,\n                    wrapper: styles.wrapper,\n                    ...classNames,\n                }}\n                ref={ref}\n                size={size}\n                style={{ maxWidth, width, ...style }}\n                variant={variant}\n                {...props}\n            >\n                {children}\n            </MantineFileInput>\n        );\n    },\n);\n"
  },
  {
    "path": "src/shared/components/flex/flex.tsx",
    "content": "import { Flex as MantineFlex, FlexProps as MantineFlexProps } from '@mantine/core';\nimport { forwardRef, memo, useMemo } from 'react';\n\nexport interface FlexProps extends MantineFlexProps {}\n\nconst _Flex = forwardRef<HTMLDivElement, FlexProps>(\n    ({ children, classNames, style, ...props }, ref) => {\n        const memoizedClassNames = useMemo(() => ({ ...classNames }), [classNames]);\n        const memoizedStyle = useMemo(() => ({ ...style }), [style]);\n\n        return (\n            <MantineFlex classNames={memoizedClassNames} ref={ref} style={memoizedStyle} {...props}>\n                {children}\n            </MantineFlex>\n        );\n    },\n);\n\n_Flex.displayName = 'Flex';\n\nexport const Flex = memo(_Flex);\n"
  },
  {
    "path": "src/shared/components/grid/grid.tsx",
    "content": "import { Grid as MantineGrid, GridProps as MantineGridProps } from '@mantine/core';\nimport { memo, useMemo } from 'react';\n\nexport interface GridProps extends MantineGridProps {}\n\nconst BaseGrid = ({ classNames, style, ...props }: GridProps) => {\n    const memoizedClassNames = useMemo(() => ({ ...classNames }), [classNames]);\n    const memoizedStyle = useMemo(() => ({ ...style }), [style]);\n\n    return <MantineGrid classNames={memoizedClassNames} style={memoizedStyle} {...props} />;\n};\n\nBaseGrid.displayName = 'Grid';\n\nexport const Grid = memo(BaseGrid) as unknown as typeof BaseGrid & { Col: typeof MantineGrid.Col };\n\n(Grid as typeof Grid & { Col: typeof MantineGrid.Col }).Col = MantineGrid.Col;\n"
  },
  {
    "path": "src/shared/components/group/group.tsx",
    "content": "import { Group as MantineGroup, GroupProps as MantineGroupProps } from '@mantine/core';\nimport { forwardRef, memo, useMemo } from 'react';\n\nexport interface GroupProps extends MantineGroupProps {}\n\nconst _Group = forwardRef<HTMLDivElement, GroupProps>(\n    ({ children, classNames, style, ...props }, ref) => {\n        const memoizedClassNames = useMemo(() => ({ ...classNames }), [classNames]);\n        const memoizedStyle = useMemo(() => ({ ...style }), [style]);\n\n        return (\n            <MantineGroup\n                classNames={memoizedClassNames}\n                ref={ref}\n                style={memoizedStyle}\n                {...props}\n            >\n                {children}\n            </MantineGroup>\n        );\n    },\n);\n\n_Group.displayName = 'Group';\n\nexport const Group = memo(_Group);\n"
  },
  {
    "path": "src/shared/components/hover-card/hover-card.module.css",
    "content": ".dropdown {\n    padding: var(--theme-spacing-xs);\n    color: var(--theme-colors-surface-foreground);\n    background: var(--theme-colors-surface);\n    border: 1px solid var(--theme-colors-border);\n    filter: drop-shadow(0 0 5px rgb(0 0 0 / 50%));\n}\n"
  },
  {
    "path": "src/shared/components/hover-card/hover-card.tsx",
    "content": "import {\n    HoverCard as MantineHoverCard,\n    HoverCardProps as MantineHoverCardProps,\n} from '@mantine/core';\n\nimport styles from './hover-card.module.css';\n\ninterface HoverCardProps extends MantineHoverCardProps {}\n\nexport const HoverCard = ({ children, classNames, ...props }: HoverCardProps) => {\n    return (\n        <MantineHoverCard\n            classNames={{\n                dropdown: styles.dropdown,\n                ...classNames,\n            }}\n            {...props}\n        >\n            {children}\n        </MantineHoverCard>\n    );\n};\n\nHoverCard.Target = MantineHoverCard.Target;\nHoverCard.Dropdown = MantineHoverCard.Dropdown;\n"
  },
  {
    "path": "src/shared/components/icon/icon.module.css",
    "content": ".size-xs {\n    font-size: var(--theme-font-size-xs);\n}\n\n.size-sm {\n    font-size: var(--theme-font-size-sm);\n}\n\n.size-md {\n    font-size: var(--theme-font-size-md);\n}\n\n.size-lg {\n    font-size: var(--theme-font-size-lg);\n}\n\n.size-xl {\n    font-size: var(--theme-font-size-xl);\n}\n\n.size-2xl {\n    font-size: var(--theme-font-size-2xl);\n}\n\n.size-3xl {\n    font-size: var(--theme-font-size-3xl);\n}\n\n.size-4xl {\n    font-size: var(--theme-font-size-4xl);\n}\n\n.size-5xl {\n    font-size: var(--theme-font-size-5xl);\n}\n\nimg.size-xs,\nimg.size-sm,\nimg.size-md,\nimg.size-lg,\nimg.size-xl,\nimg.size-2xl,\nimg.size-3xl,\nimg.size-4xl,\nimg.size-5xl {\n    width: 1em;\n    height: 1em;\n}\n\n.color-default {\n    color: var(--theme-colors-foreground);\n}\n\n.color-primary {\n    color: var(--theme-colors-primary-filled);\n}\n\n.color-muted {\n    color: var(--theme-colors-foreground-muted);\n}\n\n.color-success {\n    color: var(--theme-colors-state-success);\n}\n\n.color-error {\n    color: var(--theme-colors-state-error);\n}\n\n.color-info {\n    color: var(--theme-colors-state-info);\n}\n\n.color-warn {\n    color: var(--theme-colors-state-warn);\n}\n\n.color-favorite {\n    color: rgb(255 49 49);\n}\n\n.fill {\n    fill: transparent;\n}\n\n.fill-default {\n    fill: var(--theme-colors-foreground);\n}\n\n.fill-contrast {\n    fill: var(--theme-colors-primary-contrast);\n}\n\n.fill-inherit {\n    fill: inherit;\n}\n\n.fill-primary {\n    fill: var(--theme-colors-primary-filled);\n}\n\n.fill-muted {\n    fill: var(--theme-colors-foreground-muted);\n}\n\n.fill-success {\n    fill: var(--theme-colors-state-success);\n}\n\n.fill-error {\n    fill: var(--theme-colors-state-error);\n}\n\n.fill-info {\n    fill: var(--theme-colors-state-info);\n}\n\n.fill-warn {\n    fill: var(--theme-colors-state-warn);\n}\n\n.fill-favorite {\n    fill: rgb(255 49 49);\n}\n\n.spin {\n    animation: spin 1s linear infinite;\n}\n\n.pulse {\n    animation: pulse 1s linear infinite;\n}\n\n@keyframes spin {\n    from {\n        transform: rotate(0deg);\n    }\n\n    to {\n        transform: rotate(360deg);\n    }\n}\n\n@keyframes pulse {\n    0% {\n        opacity: 0.5;\n    }\n}\n"
  },
  {
    "path": "src/shared/components/icon/icon.tsx",
    "content": "import clsx from 'clsx';\nimport { motion } from 'motion/react';\nimport {\n    type ComponentType,\n    type CSSProperties,\n    forwardRef,\n    ImgHTMLAttributes,\n    memo,\n    useMemo,\n} from 'react';\nimport { IconBaseProps } from 'react-icons';\nimport {\n    LuAlignCenter,\n    LuAlignLeft,\n    LuAlignRight,\n    LuAppWindow,\n    LuArrowDown,\n    LuArrowDownToLine,\n    LuArrowDownWideNarrow,\n    LuArrowLeft,\n    LuArrowLeftRight,\n    LuArrowLeftToLine,\n    LuArrowRight,\n    LuArrowRightToLine,\n    LuArrowUp,\n    LuArrowUpDown,\n    LuArrowUpNarrowWide,\n    LuArrowUpToLine,\n    LuBookOpen,\n    LuBraces,\n    LuCheck,\n    LuChevronDown,\n    LuChevronLast,\n    LuChevronLeft,\n    LuChevronRight,\n    LuChevronUp,\n    LuCircleCheck,\n    LuCircleX,\n    LuClipboardCopy,\n    LuClock3,\n    LuCloudDownload,\n    LuCornerDownRight,\n    LuCornerUpRight,\n    LuDelete,\n    LuDisc,\n    LuDisc3,\n    LuDownload,\n    LuEllipsis,\n    LuEllipsisVertical,\n    LuExpand,\n    LuExternalLink,\n    LuFileJson,\n    LuFlag,\n    LuFolderOpen,\n    LuGauge,\n    LuGithub,\n    LuGripHorizontal,\n    LuGripVertical,\n    LuHardDrive,\n    LuHash,\n    LuHeadphones,\n    LuHeart,\n    LuHeartCrack,\n    LuImage,\n    LuInfinity,\n    LuInfo,\n    LuKeyboard,\n    LuLayoutGrid,\n    LuLayoutList,\n    LuLibrary,\n    LuList,\n    LuListFilter,\n    LuListMinus,\n    LuListMusic,\n    LuListPlus,\n    LuLoader,\n    LuLock,\n    LuLockOpen,\n    LuLogIn,\n    LuLogOut,\n    LuMenu,\n    LuMicVocal,\n    LuMinus,\n    LuMoon,\n    LuMusic,\n    LuMusic2,\n    LuPackage2,\n    LuPanelBottom,\n    LuPanelRight,\n    LuPanelRightClose,\n    LuPanelRightOpen,\n    LuPause,\n    LuPencilLine,\n    LuPin,\n    LuPinOff,\n    LuPlay,\n    LuPlus,\n    LuRadio,\n    LuRotateCw,\n    LuSave,\n    LuSearch,\n    LuSettings,\n    LuSettings2,\n    LuShare2,\n    LuShieldAlert,\n    LuShuffle,\n    LuSkipBack,\n    LuSkipForward,\n    LuSlidersHorizontal,\n    LuSquare,\n    LuSquareCheck,\n    LuSquareMenu,\n    LuStar,\n    LuStepBack,\n    LuStepForward,\n    LuSun,\n    LuTable,\n    LuTimer,\n    LuTimerOff,\n    LuTriangleAlert,\n    LuUpload,\n    LuUser,\n    LuUserPen,\n    LuUserRoundCog,\n    LuVolume1,\n    LuVolume2,\n    LuVolumeX,\n    LuWifi,\n    LuWifiOff,\n    LuWrench,\n    LuX,\n} from 'react-icons/lu';\nimport { MdOutlineVisibility, MdOutlineVisibilityOff } from 'react-icons/md';\nimport { PiMouseLeftClickFill, PiMouseRightClickFill } from 'react-icons/pi';\nimport { RiPlayListAddLine, RiRepeat2Line, RiRepeatOneLine } from 'react-icons/ri';\n\nimport styles from './icon.module.css';\nimport lastfmLogoIcon from './lastfm_logo_icon.png';\nimport listenbrainzLogoIcon from './listenbrainz_logo_icon.svg';\nimport musicbrainzLogoIcon from './musicbrainz_logo_icon.svg';\nimport qobuzLogoIcon from './qobuz_logo_icon.png';\nimport spotifyLogoIcon from './spotify_logo_icon.svg';\n\nexport type AppIconSelection = keyof typeof AppIcon;\n\ntype LogoImgProps = ImgHTMLAttributes<HTMLImageElement> & { size?: number | string };\n\nfunction logoImgStyle(size: number | string | undefined): CSSProperties | undefined {\n    if (size === undefined) return undefined;\n    const dim = typeof size === 'number' ? `${size}px` : size;\n    return { height: dim, width: dim };\n}\n\nconst ListenBrainzLogoIcon = forwardRef<HTMLImageElement, LogoImgProps>(\n    ({ className, size, style, ...props }, ref) => (\n        <img\n            alt=\"ListenBrainz\"\n            className={className}\n            ref={ref}\n            src={listenbrainzLogoIcon}\n            style={logoImgStyle(size) ?? style}\n            {...props}\n        />\n    ),\n);\n\nconst SpotifyLogoIcon = forwardRef<HTMLImageElement, LogoImgProps>(\n    ({ className, size, style, ...props }, ref) => (\n        <img\n            alt=\"Spotify\"\n            className={className}\n            ref={ref}\n            src={spotifyLogoIcon}\n            style={logoImgStyle(size) ?? style}\n            {...props}\n        />\n    ),\n);\n\nconst MusicBrainzLogoIcon = forwardRef<HTMLImageElement, LogoImgProps>(\n    ({ className, size, style, ...props }, ref) => (\n        <img\n            alt=\"MusicBrainz\"\n            className={className}\n            ref={ref}\n            src={musicbrainzLogoIcon}\n            style={logoImgStyle(size) ?? style}\n            {...props}\n        />\n    ),\n);\n\nconst QobuzLogoIcon = forwardRef<HTMLImageElement, LogoImgProps>(\n    ({ className, size, style, ...props }, ref) => (\n        <img\n            alt=\"Qobuz\"\n            className={className}\n            ref={ref}\n            src={qobuzLogoIcon}\n            style={logoImgStyle(size) ?? style}\n            {...props}\n        />\n    ),\n);\n\nconst LastfmLogoIcon = forwardRef<HTMLImageElement, LogoImgProps>(\n    ({ className, size, style, ...props }, ref) => (\n        <img\n            alt=\"Last.fm\"\n            className={className}\n            ref={ref}\n            src={lastfmLogoIcon}\n            style={logoImgStyle(size) ?? style}\n            {...props}\n        />\n    ),\n);\n\nexport const AppIcon = {\n    add: LuPlus,\n    album: LuDisc3,\n    alignCenter: LuAlignCenter,\n    alignLeft: LuAlignLeft,\n    alignRight: LuAlignRight,\n    appWindow: LuAppWindow,\n    arrowDown: LuArrowDown,\n    arrowDownS: LuChevronDown,\n    arrowDownToLine: LuArrowDownToLine,\n    arrowLeft: LuArrowLeft,\n    arrowLeftRight: LuArrowLeftRight,\n    arrowLeftS: LuChevronLeft,\n    arrowLeftToLine: LuArrowLeftToLine,\n    arrowRight: LuArrowRight,\n    arrowRightLast: LuChevronLast,\n    arrowRightS: LuChevronRight,\n    arrowRightToLine: LuArrowRightToLine,\n    arrowUp: LuArrowUp,\n    arrowUpS: LuChevronUp,\n    arrowUpToLine: LuArrowUpToLine,\n    artist: LuUserPen,\n    brandGitHub: LuGithub,\n    brandLastfm: LastfmLogoIcon,\n    brandListenBrainz: ListenBrainzLogoIcon,\n    brandMusicBrainz: MusicBrainzLogoIcon,\n    brandQobuz: QobuzLogoIcon,\n    brandSpotify: SpotifyLogoIcon,\n    cache: LuCloudDownload,\n    check: LuCheck,\n    clipboardCopy: LuClipboardCopy,\n    collection: LuPackage2,\n    delete: LuDelete,\n    disc: LuDisc,\n    download: LuDownload,\n    dragHorizontal: LuGripHorizontal,\n    dragVertical: LuGripVertical,\n    dropdown: LuChevronDown,\n    duration: LuClock3,\n    edit: LuPencilLine,\n    ellipsisHorizontal: LuEllipsis,\n    ellipsisVertical: LuEllipsisVertical,\n    emptyAlbumImage: LuDisc3,\n    emptyArtistImage: LuUser,\n    emptyGenreImage: LuFlag,\n    emptyImage: LuDisc3,\n    emptyPlaylistImage: LuListMusic,\n    emptySongImage: LuMusic,\n    error: LuShieldAlert,\n    expand: LuExpand,\n    externalLink: LuExternalLink,\n    favorite: LuHeart,\n    fileJson: LuFileJson,\n    filter: LuListFilter,\n    folder: LuFolderOpen,\n    genre: LuFlag,\n    goToItem: LuCornerDownRight,\n    hash: LuHash,\n    home: LuSquareMenu,\n    image: LuImage,\n    info: LuInfo,\n    itemAlbum: LuDisc3,\n    itemSong: LuMusic,\n    json: LuBraces,\n    keyboard: LuKeyboard,\n    lastPlayed: LuHeadphones,\n    layoutDetail: LuLayoutList,\n    layoutGrid: LuLayoutGrid,\n    layoutList: LuList,\n    layoutPanelBottom: LuPanelBottom,\n    layoutPanelRight: LuPanelRight,\n    layoutTable: LuTable,\n    library: LuLibrary,\n    list: LuList,\n    listInfinite: LuInfinity,\n    listPaginated: LuArrowRightToLine,\n    lock: LuLock,\n    lockOpen: LuLockOpen,\n    mediaNext: LuSkipForward,\n    mediaPause: LuPause,\n    mediaPlay: LuPlay,\n    mediaPlayLast: LuChevronLast,\n    mediaPlayNext: LuCornerUpRight,\n    mediaPrevious: LuSkipBack,\n    mediaRandom: RiPlayListAddLine,\n    mediaRepeat: RiRepeat2Line,\n    mediaRepeatOne: RiRepeatOneLine,\n    mediaSettings: LuSlidersHorizontal,\n    mediaShuffle: LuShuffle,\n    mediaSpeed: LuGauge,\n    mediaStepBackward: LuStepBack,\n    mediaStepForward: LuStepForward,\n    mediaStop: LuSquare,\n    menu: LuMenu,\n    metadata: LuBookOpen,\n    microphone: LuMicVocal,\n    minus: LuMinus,\n    mouseLeftClick: PiMouseLeftClickFill,\n    mouseRightClick: PiMouseRightClickFill,\n    panelRightClose: LuPanelRightClose,\n    panelRightOpen: LuPanelRightOpen,\n    pin: LuPin,\n    playlist: LuListMusic,\n    playlistAdd: LuListPlus,\n    playlistDelete: LuListMinus,\n    plus: LuPlus,\n    queryBuilder: LuWrench,\n    queue: LuList,\n    radio: LuRadio,\n    refresh: LuRotateCw,\n    remove: LuMinus,\n    save: LuSave,\n    search: LuSearch,\n    server: LuHardDrive,\n    settings: LuSettings2,\n    settings2: LuSettings,\n    share: LuShare2,\n    signIn: LuLogIn,\n    signOut: LuLogOut,\n    sleepTimer: LuTimer,\n    sleepTimerOff: LuTimerOff,\n    sort: LuArrowUpDown,\n    sortAsc: LuArrowUpNarrowWide,\n    sortDesc: LuArrowDownWideNarrow,\n    spinner: LuLoader,\n    square: LuSquare,\n    squareCheck: LuSquareCheck,\n    star: LuStar,\n    success: LuCircleCheck,\n    themeDark: LuMoon,\n    themeLight: LuSun,\n    track: LuMusic2,\n    unfavorite: LuHeartCrack,\n    unpin: LuPinOff,\n    upload: LuUpload,\n    user: LuUser,\n    userManage: LuUserRoundCog,\n    visibility: MdOutlineVisibility,\n    visibilityOff: MdOutlineVisibilityOff,\n    volumeMax: LuVolume2,\n    volumeMute: LuVolumeX,\n    volumeNormal: LuVolume1,\n    warn: LuTriangleAlert,\n    wifiOff: LuWifiOff,\n    wifiOn: LuWifi,\n    x: LuX,\n    xCircle: LuCircleX,\n} as const;\n\nexport interface IconProps extends Omit<IconBaseProps, 'color' | 'fill' | 'size'> {\n    animate?: 'pulse' | 'spin';\n    color?: IconColor;\n    fill?: IconColor;\n    icon: keyof typeof AppIcon;\n    size?: '2xl' | '3xl' | '4xl' | '5xl' | 'lg' | 'md' | 'sm' | 'xl' | 'xs' | number | string;\n}\ntype IconColor =\n    | 'contrast'\n    | 'default'\n    | 'error'\n    | 'favorite'\n    | 'info'\n    | 'inherit'\n    | 'muted'\n    | 'primary'\n    | 'success'\n    | 'warn';\n\nconst _Icon = forwardRef<HTMLDivElement, IconProps>((props, ref) => {\n    const { animate, className, color, fill, icon, size = 'md' } = props;\n\n    const IconComponent: ComponentType<any> = AppIcon[icon];\n\n    const classNames = useMemo(\n        () =>\n            clsx(className, {\n                [styles.fill]: true,\n                [styles.pulse]: animate === 'pulse',\n                [styles.spin]: animate === 'spin',\n                [styles[`color-${color || fill}`]]: color || fill,\n                [styles[`fill-${fill}`]]: fill,\n                [styles[`size-${size}`]]: true,\n            }),\n        [animate, className, color, fill, size],\n    );\n\n    return (\n        <IconComponent\n            className={classNames}\n            fill={fill}\n            ref={ref}\n            size={isPredefinedSize(size) ? undefined : size}\n        />\n    );\n});\n\n_Icon.displayName = 'Icon';\n\nexport const Icon = memo(_Icon);\n\nIcon.displayName = 'Icon';\n\nexport const MotionIcon: ComponentType = motion.create(Icon);\n\nfunction isPredefinedSize(size: IconProps['size']) {\n    return (\n        size === '2xl' ||\n        size === '3xl' ||\n        size === '4xl' ||\n        size === '5xl' ||\n        size === 'lg' ||\n        size === 'md' ||\n        size === 'sm' ||\n        size === 'xl' ||\n        size === 'xs'\n    );\n}\n"
  },
  {
    "path": "src/shared/components/image/image.module.css",
    "content": "@keyframes fade-in {\n    from {\n        opacity: 0;\n    }\n\n    to {\n        opacity: 1;\n    }\n}\n\n.image {\n    width: 100%;\n    height: 100%;\n    object-fit: var(--theme-image-fit);\n    border-radius: var(--theme-radius-md);\n}\n\n.image.animated {\n    opacity: 1;\n    animation: fade-in 0.2s ease-in;\n}\n\n.loader {\n    width: 100%;\n    height: 100%;\n    border-radius: var(--theme-radius-md);\n}\n\n.image-container {\n    position: relative;\n    display: flex;\n    width: 100%;\n    height: 100%;\n    aspect-ratio: 1 / 1;\n    overflow: hidden;\n}\n\n.censored .image {\n    filter: blur(10px);\n}\n\n.censored::after {\n    position: absolute;\n    inset: 0;\n    display: grid;\n    place-items: center;\n    font-weight: bold;\n    color: var(--theme-colors-background);\n    background-color: alpha(var(--theme-colors-background), 0.5);\n    border-radius: var(--theme-radius-md);\n}\n\n.unloader {\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    width: 100%;\n    height: 100%;\n    background-color: darken(var(--theme-colors-foreground), 40%);\n    border-radius: var(--theme-radius-md);\n    opacity: 0.3;\n}\n\n.skeleton {\n    width: 100%;\n    height: 100%;\n    border-radius: var(--theme-radius-md);\n}\n\n.view-wrapper {\n    width: 100%;\n    height: 100%;\n}\n"
  },
  {
    "path": "src/shared/components/image/image.tsx",
    "content": "import clsx from 'clsx';\nimport {\n    ForwardedRef,\n    forwardRef,\n    HTMLAttributes,\n    type ImgHTMLAttributes,\n    memo,\n    ReactNode,\n    useEffect,\n    useMemo,\n    useState,\n} from 'react';\n\nimport styles from './image.module.css';\nimport { useNativeImage } from './use-native-image';\n\nimport { AppIcon, Icon } from '/@/shared/components/icon/icon';\nimport { Skeleton } from '/@/shared/components/skeleton/skeleton';\nimport { useDebouncedValue } from '/@/shared/hooks/use-debounced-value';\nimport { useInViewport } from '/@/shared/hooks/use-in-viewport';\nimport { ImageRequest } from '/@/shared/types/domain-types';\n\nconst loadedImageCacheKeys = new Set<string>();\n\nexport interface ImageProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, 'src'> {\n    containerClassName?: string;\n    enableAnimation?: boolean;\n    enableDebounce?: boolean;\n    enableViewport?: boolean;\n    fetchPriority?: 'auto' | 'high' | 'low';\n    imageContainerProps?: Omit<ImageContainerProps, 'children'>;\n    imageRequest?: ImageRequest;\n    includeLoader?: boolean;\n    includeUnloader?: boolean;\n    isExplicit?: boolean;\n    src: string | undefined;\n    unloaderIcon?: keyof typeof AppIcon;\n}\n\ninterface ImageContainerProps extends HTMLAttributes<HTMLDivElement> {\n    children: ReactNode;\n    isExplicit?: boolean;\n}\n\ninterface ImageLoaderProps {\n    className?: string;\n}\n\ninterface ImageUnloaderProps {\n    className?: string;\n    icon?: keyof typeof AppIcon;\n}\n\nexport const FALLBACK_SVG =\n    'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMDAiIGhlaWdodD0iMzAwIj48ZmlsdGVyIGlkPSJhIiB4PSIwIiB5PSIwIj48ZmVUdXJidWxlbmNlIHR5cGU9ImZyYWN0YWxOb2lzZSIgYmFzZUZyZXF1ZW5jeT0iLjc1IiBzdGl0Y2hUaWxlcz0ic3RpdGNoIi8+PGZlQ29sb3JNYXRyaXggdHlwZT0ic2F0dXJhdGUiIHZhbHVlcz0iMCIvPjwvZmlsdGVyPjxwYXRoIGZpbHRlcj0idXJsKCNhKSIgb3BhY2l0eT0iLjA1IiBkPSJNMCAwaDMwMHYzMDBIMHoiLz48L3N2Zz4=';\n\nexport function BaseImage({\n    className,\n    containerClassName,\n    enableAnimation = false,\n    enableDebounce = false,\n    enableViewport = true,\n    fetchPriority,\n    imageContainerProps,\n    imageRequest,\n    includeLoader = true,\n    includeUnloader = true,\n    isExplicit = false,\n    onError,\n    onLoad,\n    src,\n    unloaderIcon = 'emptyImage',\n    ...props\n}: ImageProps) {\n    const viewport = useInViewport();\n    const { inViewport, ref } = enableViewport ? viewport : { inViewport: true, ref: undefined };\n    const { className: containerPropsClassName, ...restContainerProps } = imageContainerProps || {};\n\n    const rawImageRequest = useMemo(\n        () => imageRequest ?? (src ? { cacheKey: src, url: src } : undefined),\n        [imageRequest, src],\n    );\n    const isInSessionCache = Boolean(\n        rawImageRequest?.cacheKey && loadedImageCacheKeys.has(rawImageRequest.cacheKey),\n    );\n    const [debouncedImageRequest] = useDebouncedValue(rawImageRequest, 100, {\n        waitForInitial: true,\n    });\n    const effectiveImageRequest =\n        isInSessionCache || !enableDebounce ? rawImageRequest : debouncedImageRequest;\n\n    const [hasLoadedInInstance, setHasLoadedInInstance] = useState(false);\n\n    useEffect(() => {\n        setHasLoadedInInstance(false);\n    }, [effectiveImageRequest?.cacheKey]);\n\n    const shouldLoadImage = Boolean(\n        effectiveImageRequest &&\n            (!enableViewport || isInSessionCache || inViewport || hasLoadedInInstance),\n    );\n\n    const nativeImage = useNativeImage({\n        enabled: shouldLoadImage,\n        fetchPriority,\n        onFetchError: src\n            ? () => {\n                  (onError as ((event: undefined) => void) | undefined)?.(undefined);\n              }\n            : undefined,\n        request: effectiveImageRequest,\n    });\n\n    useEffect(() => {\n        if (!nativeImage.isLoaded || !effectiveImageRequest?.cacheKey) {\n            return;\n        }\n\n        loadedImageCacheKeys.add(effectiveImageRequest.cacheKey);\n        setHasLoadedInInstance(true);\n    }, [effectiveImageRequest?.cacheKey, nativeImage.isLoaded]);\n\n    return (\n        <ImageContainer\n            className={clsx(containerClassName, containerPropsClassName)}\n            isExplicit={isExplicit}\n            ref={ref}\n            {...restContainerProps}\n        >\n            {nativeImage.displaySrc ? (\n                <img\n                    className={clsx(styles.image, className, {\n                        [styles.animated]: enableAnimation,\n                    })}\n                    decoding=\"async\"\n                    fetchPriority={fetchPriority}\n                    onError={onError}\n                    onLoad={onLoad}\n                    src={nativeImage.displaySrc}\n                    {...props}\n                />\n            ) : !src ? (\n                <ImageUnloader className={className} icon={unloaderIcon} />\n            ) : nativeImage.isError ? (\n                includeUnloader ? (\n                    <ImageUnloader className={className} icon={unloaderIcon} />\n                ) : null\n            ) : includeLoader ? (\n                <ImageLoader className={className} />\n            ) : null}\n        </ImageContainer>\n    );\n}\n\nexport const Image = memo(BaseImage);\n\nconst ImageContainer = forwardRef(\n    (\n        { children, className, isExplicit, ...props }: ImageContainerProps,\n        ref: ForwardedRef<HTMLDivElement>,\n    ) => {\n        return (\n            <div\n                className={clsx(styles.imageContainer, className, {\n                    [styles.censored]: isExplicit,\n                })}\n                ref={ref}\n                {...props}\n            >\n                {children}\n            </div>\n        );\n    },\n);\n\nexport function ImageLoader({ className }: ImageLoaderProps) {\n    return (\n        <Skeleton\n            className={clsx(styles.skeleton, styles.loader, className)}\n            containerClassName={styles.skeletonContainer}\n        />\n    );\n}\n\nexport function ImageUnloader({ className, icon = 'emptyImage' }: ImageUnloaderProps) {\n    return (\n        <div className={clsx(styles.unloader, className)}>\n            <Icon color=\"default\" icon={icon} size=\"25%\" />\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/shared/components/image/use-native-image.ts",
    "content": "import { useEffect, useMemo, useRef, useState } from 'react';\n\nimport { ImageRequest } from '/@/shared/types/domain-types';\n\ntype FetchPriority = 'auto' | 'high' | 'low';\n\ninterface NativeImageState {\n    displaySrc?: string;\n    status: 'error' | 'idle' | 'loaded' | 'loading';\n}\n\ninterface UseNativeImageArgs {\n    enabled: boolean;\n    fetchPriority?: FetchPriority;\n    onFetchError?: () => void;\n    request?: ImageRequest | null;\n}\n\nexport function useNativeImage({\n    enabled,\n    fetchPriority,\n    onFetchError,\n    request,\n}: UseNativeImageArgs) {\n    const abortControllerRef = useRef<AbortController | null>(null);\n    const loadedRequestSignatureRef = useRef<null | string>(null);\n    const objectUrlRef = useRef<null | string>(null);\n    const onFetchErrorRef = useRef(onFetchError);\n    const [state, setState] = useState<NativeImageState>({ status: 'idle' });\n\n    const requestSignature = useMemo(() => {\n        if (!request) {\n            return null;\n        }\n\n        return JSON.stringify({\n            cacheKey: request.cacheKey,\n            credentials: request.credentials,\n            headers: request.headers,\n            url: request.url,\n        });\n    }, [request]);\n\n    onFetchErrorRef.current = onFetchError;\n\n    useEffect(() => {\n        const abortCurrentRequest = () => {\n            abortControllerRef.current?.abort();\n            abortControllerRef.current = null;\n        };\n\n        const revokeObjectUrl = () => {\n            if (!objectUrlRef.current) {\n                return;\n            }\n\n            URL.revokeObjectURL(objectUrlRef.current);\n            objectUrlRef.current = null;\n            loadedRequestSignatureRef.current = null;\n        };\n\n        if (!request || !requestSignature) {\n            abortCurrentRequest();\n            revokeObjectUrl();\n            setState({ status: 'idle' });\n            return;\n        }\n\n        if (!enabled) {\n            abortCurrentRequest();\n            setState((currentState) =>\n                currentState.displaySrc\n                    ? { ...currentState, status: 'loaded' }\n                    : { status: 'idle' },\n            );\n            return;\n        }\n\n        if (loadedRequestSignatureRef.current === requestSignature && objectUrlRef.current) {\n            setState({ displaySrc: objectUrlRef.current, status: 'loaded' });\n            return;\n        }\n\n        abortCurrentRequest();\n        revokeObjectUrl();\n        setState({ status: 'loading' });\n\n        const abortController = new AbortController();\n        abortControllerRef.current = abortController;\n\n        void (async () => {\n            try {\n                const init = {\n                    credentials: request.credentials,\n                    headers: request.headers,\n                    signal: abortController.signal,\n                } as RequestInit & { priority?: FetchPriority };\n\n                if (fetchPriority) {\n                    init.priority = fetchPriority;\n                }\n\n                const response = await fetch(request.url, init);\n\n                if (!response.ok) {\n                    throw new Error(`Failed to load image: ${response.status}`);\n                }\n\n                const blob = await response.blob();\n\n                if (abortController.signal.aborted) {\n                    return;\n                }\n\n                const objectUrl = URL.createObjectURL(blob);\n                objectUrlRef.current = objectUrl;\n                loadedRequestSignatureRef.current = requestSignature;\n                setState({ displaySrc: objectUrl, status: 'loaded' });\n            } catch {\n                if (abortController.signal.aborted) {\n                    return;\n                }\n\n                revokeObjectUrl();\n                setState({ status: 'error' });\n                onFetchErrorRef.current?.();\n            } finally {\n                if (abortControllerRef.current === abortController) {\n                    abortControllerRef.current = null;\n                }\n            }\n        })();\n\n        return () => {\n            abortController.abort();\n\n            if (abortControllerRef.current === abortController) {\n                abortControllerRef.current = null;\n            }\n        };\n    }, [enabled, fetchPriority, request, requestSignature]);\n\n    useEffect(() => {\n        return () => {\n            abortControllerRef.current?.abort();\n\n            if (objectUrlRef.current) {\n                URL.revokeObjectURL(objectUrlRef.current);\n            }\n        };\n    }, []);\n\n    return {\n        displaySrc: state.displaySrc,\n        isError: state.status === 'error',\n        isLoaded: state.status === 'loaded',\n        isLoading: state.status === 'loading',\n    };\n}\n"
  },
  {
    "path": "src/shared/components/json-input/json-input.module.css",
    "content": ".root {\n    transition: width 0.3s ease-in-out;\n\n    &[data-disabled='true'] {\n        opacity: 0.6;\n    }\n}\n\n.input {\n    width: 100%;\n    border: 1px solid transparent;\n\n    &[data-variant='default'] {\n        color: var(--theme-colors-surface-foreground);\n        background: var(--theme-colors-surface);\n    }\n\n    &[data-variant='filled'] {\n        color: var(--theme-colors-foreground);\n        background: var(--theme-colors-background);\n    }\n}\n\n.input:focus,\n.input:focus-visible {\n    border-color: lighten(var(--theme-colors-border), 10%);\n}\n\n.section {\n    color: var(--theme-colors-foreground-muted);\n}\n\n.required {\n    color: var(--theme-colors-state-error);\n}\n\n.label {\n    margin-bottom: var(--theme-spacing-sm);\n}\n"
  },
  {
    "path": "src/shared/components/json-input/json-input.tsx",
    "content": "import {\n    JsonInput as MantineJsonInput,\n    JsonInputProps as MantineJsonInputProps,\n} from '@mantine/core';\nimport { CSSProperties, forwardRef } from 'react';\n\nimport styles from './json-input.module.css';\n\nexport interface JsonInputProps extends MantineJsonInputProps {\n    maxWidth?: CSSProperties['maxWidth'];\n    width?: CSSProperties['width'];\n}\n\nexport const JsonInput = forwardRef<HTMLTextAreaElement, JsonInputProps>(\n    (\n        {\n            children,\n            classNames,\n            maxWidth,\n            size = 'sm',\n            style,\n            variant = 'default',\n            width,\n            ...props\n        },\n        ref,\n    ) => {\n        return (\n            <MantineJsonInput\n                classNames={{\n                    input: styles.input,\n                    label: styles.label,\n                    required: styles.required,\n                    root: styles.root,\n                    section: styles.section,\n                    wrapper: styles.wrapper,\n                    ...classNames,\n                }}\n                ref={ref}\n                size={size}\n                style={{ maxWidth, width, ...style }}\n                variant={variant}\n                {...props}\n            >\n                {children}\n            </MantineJsonInput>\n        );\n    },\n);\n"
  },
  {
    "path": "src/shared/components/kbd/kbd.tsx",
    "content": "import { Kbd as MantineKbd, KbdProps as MantineKbdProps } from '@mantine/core';\n\nexport interface KbdProps extends MantineKbdProps {}\n\nexport const Kbd = (props: KbdProps) => {\n    return <MantineKbd {...props} />;\n};\n"
  },
  {
    "path": "src/shared/components/loading-overlay/loading-overlay.tsx",
    "content": "import {\n    LoadingOverlay as MantineLoadingOverlay,\n    LoadingOverlayProps as MantineLoadingOverlayProps,\n} from '@mantine/core';\n\nimport { Spinner } from '/@/shared/components/spinner/spinner';\n\ninterface LoadingOverlayProps extends MantineLoadingOverlayProps {\n    color?: string;\n    opacity?: number;\n}\n\nexport const LoadingOverlay = ({ ...props }: LoadingOverlayProps) => {\n    return (\n        <MantineLoadingOverlay\n            loaderProps={{ children: <Spinner /> }}\n            overlayProps={{\n                color: 'var(--theme-colors-background)',\n                opacity: 0.5,\n            }}\n            styles={{\n                root: {\n                    zIndex: 150,\n                },\n            }}\n            transitionProps={{\n                duration: 0.5,\n                transition: 'fade',\n            }}\n            {...props}\n        />\n    );\n};\n"
  },
  {
    "path": "src/shared/components/modal/modal.module.css",
    "content": ".header {\n    display: flex;\n    justify-content: center;\n    margin-bottom: var(--theme-spacing-md);\n    background: var(--theme-colors-background);\n    border-radius: 0;\n}\n\n.header h2 {\n    width: 100%;\n    font-size: var(--theme-font-size-2xl);\n    font-weight: 700;\n    user-select: none;\n}\n\n.content {\n    padding: var(--theme-spacing-sm);\n    overflow: hidden;\n    background: var(--theme-colors-background);\n    border: 2px solid var(--theme-colors-border);\n    border-radius: var(--theme-radius-md);\n    box-shadow: 2px 2px 10px 2px rgb(0 0 0 / 40%);\n    filter: drop-shadow(0 0 5px rgb(0 0 0 / 50%));\n}\n\n.close {\n    position: absolute;\n    right: var(--theme-spacing-md);\n}\n"
  },
  {
    "path": "src/shared/components/modal/modal.tsx",
    "content": "import { Modal as MantineModal, ModalProps as MantineModalProps } from '@mantine/core';\nimport {\n    closeAllModals as closeAllModalsMantine,\n    ContextModalProps,\n    ModalsProvider as MantineModalsProvider,\n    ModalsProviderProps as MantineModalsProviderProps,\n    openModal as openModalMantine,\n} from '@mantine/modals';\nimport React, { ReactNode } from 'react';\n\nimport styles from './modal.module.css';\n\nimport { Button } from '/@/shared/components/button/button';\nimport { Flex } from '/@/shared/components/flex/flex';\nimport { Group } from '/@/shared/components/group/group';\nimport { Icon } from '/@/shared/components/icon/icon';\nimport { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';\nimport { Stack } from '/@/shared/components/stack/stack';\n\nexport const openModal = openModalMantine;\n\nexport const closeAllModals = closeAllModalsMantine;\n\nexport interface ModalProps extends Omit<MantineModalProps, 'onClose'> {\n    children?: ReactNode;\n    handlers: {\n        close: () => void;\n        open: () => void;\n        toggle: () => void;\n    };\n}\n\nexport const Modal = ({ children, classNames, handlers, ...rest }: ModalProps) => {\n    return (\n        <MantineModal\n            {...rest}\n            centered={true}\n            classNames={{\n                body: styles.body,\n                close: styles.close,\n                content: styles.content,\n                header: styles.header,\n                inner: styles.inner,\n                overlay: styles.overlay,\n                root: styles.root,\n                title: styles.title,\n                ...classNames,\n            }}\n            closeButtonProps={{\n                icon: <Icon icon=\"x\" size=\"xl\" />,\n            }}\n            onClose={handlers.close}\n            overlayProps={{\n                backgroundOpacity: 0.5,\n                blur: 1,\n            }}\n            radius=\"md\"\n            scrollAreaComponent={ScrollArea}\n            transitionProps={{\n                duration: 300,\n                exitDuration: 300,\n                transition: 'fade' as const,\n            }}\n        >\n            {children}\n        </MantineModal>\n    );\n};\n\nexport type ContextModalVars = {\n    context: ContextModalProps['context'];\n    id: ContextModalProps['id'];\n};\n\nexport const BaseContextModal = ({\n    context,\n    id,\n    innerProps,\n}: ContextModalProps<{\n    modalBody: (vars: ContextModalVars) => React.ReactNode;\n}>) => <>{innerProps.modalBody({ context, id })}</>;\n\ninterface ConfirmModalProps {\n    children: ReactNode;\n    disabled?: boolean;\n    labels?: {\n        cancel?: string;\n        confirm?: string;\n    };\n    loading?: boolean;\n    onCancel?: () => void;\n    onConfirm: () => void;\n}\n\nexport const ConfirmModal = ({\n    children,\n    disabled,\n    labels,\n    loading,\n    onCancel,\n    onConfirm,\n}: ConfirmModalProps) => {\n    const handleCancel = () => {\n        if (onCancel) {\n            onCancel();\n        } else {\n            closeAllModals();\n        }\n    };\n\n    return (\n        <Stack>\n            <Flex>{children}</Flex>\n            <Group justify=\"flex-end\">\n                <Button disabled={loading} onClick={handleCancel} variant=\"default\">\n                    {labels?.cancel ? labels.cancel : 'Cancel'}\n                </Button>\n                <Button\n                    data-autofocus\n                    disabled={disabled}\n                    loading={loading}\n                    onClick={onConfirm}\n                    variant=\"filled\"\n                >\n                    {labels?.confirm ? labels.confirm : 'Confirm'}\n                </Button>\n            </Group>\n        </Stack>\n    );\n};\n\nexport interface ModalsProviderProps extends MantineModalsProviderProps {}\n\nexport const ModalsProvider = ({ children, ...rest }: ModalsProviderProps) => {\n    return (\n        <MantineModalsProvider\n            modalProps={{\n                centered: true,\n                classNames: {\n                    body: styles.body,\n                    close: styles.close,\n                    content: styles.content,\n                    header: styles.header,\n                    inner: styles.inner,\n                    overlay: styles.overlay,\n                    root: styles.root,\n                    title: styles.title,\n                },\n                closeButtonProps: {\n                    icon: <Icon icon=\"x\" size=\"xl\" />,\n                },\n                overlayProps: {\n                    backgroundOpacity: 0.5,\n                    blur: 1,\n                },\n                radius: 'xl',\n                scrollAreaComponent: ScrollArea,\n                transitionProps: {\n                    duration: 300,\n                    exitDuration: 300,\n                    transition: 'fade',\n                },\n            }}\n            {...rest}\n        >\n            {children}\n        </MantineModalsProvider>\n    );\n};\n"
  },
  {
    "path": "src/shared/components/modal/model-shared.tsx",
    "content": "import { Button, ButtonProps } from '/@/shared/components/button/button';\n\nexport const ModalButton = ({ children, ...props }: ButtonProps) => {\n    return (\n        <Button px=\"2xl\" uppercase variant=\"subtle\" {...props}>\n            {children}\n        </Button>\n    );\n};\n"
  },
  {
    "path": "src/shared/components/multi-select/multi-select.module.css",
    "content": ".root {\n    & [data-disabled='true'] {\n        opacity: 0.6;\n    }\n}\n\n.label {\n    margin-bottom: var(--theme-spacing-sm);\n}\n\n.dropdown {\n    padding: var(--theme-spacing-xs);\n    color: var(--theme-colors-surface-foreground);\n    background: var(--theme-colors-surface);\n    border: 1px solid var(--theme-colors-border);\n}\n\n.input {\n    width: 100%;\n    border: 1px solid transparent;\n\n    &[data-variant='default'] {\n        color: var(--theme-colors-surface-foreground);\n        background: var(--theme-colors-surface);\n    }\n\n    &[data-variant='filled'] {\n        color: var(--theme-colors-foreground);\n        background: var(--theme-colors-background);\n    }\n}\n\n.input:focus,\n.input:focus-visible {\n    border-color: lighten(var(--theme-colors-border), 10%);\n}\n\n.option {\n    position: relative;\n    padding: var(--theme-spacing-sm) var(--theme-spacing-md);\n\n    &[data-checked='true'] {\n        &::before {\n            position: absolute;\n            top: 50%;\n            left: 2px;\n            width: 2px;\n            height: 50%;\n            content: '';\n            background-color: var(--theme-colors-primary-filled);\n            border-radius: var(--theme-radius-xl);\n            transform: translateY(-50%);\n        }\n    }\n}\n\n.option:hover {\n    background: lighten(var(--theme-colors-surface), 5%);\n}\n\n.pill {\n    font-size: var(--theme-font-size-sm);\n    background: var(--theme-colors-background);\n    border-radius: var(--theme-radius-sm);\n}\n\n.pills-list {\n    padding-right: var(--theme-spacing-lg);\n}\n\n.clear-button {\n    background: transparent !important;\n}\n"
  },
  {
    "path": "src/shared/components/multi-select/multi-select.tsx",
    "content": "import {\n    MultiSelect as MantineMultiSelect,\n    MultiSelectProps as MantineMultiSelectProps,\n} from '@mantine/core';\nimport { CSSProperties, useMemo } from 'react';\n\nimport styles from './multi-select.module.css';\n\nexport interface MultiSelectProps extends MantineMultiSelectProps {\n    maxWidth?: CSSProperties['maxWidth'];\n    width?: CSSProperties['width'];\n}\n\nconst defaultClassNames = {\n    dropdown: styles.dropdown,\n    input: styles.input,\n    label: styles.label,\n    option: styles.option,\n    pill: styles.pill,\n    pillsList: styles.pillsList,\n    root: styles.root,\n};\n\nconst defaultClearButtonProps = {\n    classNames: {\n        root: styles.clearButton,\n    },\n    variant: 'transparent' as const,\n};\n\nexport const MultiSelect = ({\n    classNames,\n    maxWidth,\n    variant = 'default',\n    width,\n    ...props\n}: MultiSelectProps) => {\n    const mergedClassNames = useMemo(\n        () => (classNames ? { ...defaultClassNames, ...classNames } : defaultClassNames),\n        [classNames],\n    );\n\n    const style = useMemo(\n        () => (maxWidth || width ? { maxWidth, width } : undefined),\n        [maxWidth, width],\n    );\n\n    return (\n        <MantineMultiSelect\n            classNames={mergedClassNames}\n            clearButtonProps={defaultClearButtonProps}\n            style={style}\n            variant={variant}\n            withCheckIcon={false}\n            {...props}\n        />\n    );\n};\n"
  },
  {
    "path": "src/shared/components/multi-select/virtual-multi-select.module.css",
    "content": ".container {\n    display: flex;\n    flex-direction: column;\n    gap: var(--theme-spacing-sm);\n    min-height: 0;\n}\n\n.container.disabled {\n    cursor: not-allowed;\n    user-select: none;\n}\n\n.list-container {\n    position: relative;\n    flex-shrink: 0;\n    overflow: hidden;\n    background-color: var(--theme-colors-surface);\n}\n\n.container.disabled .list-container {\n    opacity: 0.6;\n}\n\n\n.selected-option {\n    cursor: pointer;\n    transition: background-color 0.15s ease;\n}\n\n.selected-option:hover {\n    background-color: alpha(var(--theme-colors-surface), 0.6);\n}\n\n.selected-option.disabled {\n    cursor: not-allowed;\n}\n\n.selected-option.disabled:hover {\n    background-color: transparent;\n}\n"
  },
  {
    "path": "src/shared/components/multi-select/virtual-multi-select.tsx",
    "content": "import clsx from 'clsx';\nimport { useOverlayScrollbars } from 'overlayscrollbars-react';\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { List, RowComponentProps, useDynamicRowHeight, useListRef } from 'react-window-v2';\n\nimport styles from './virtual-multi-select.module.css';\n\nimport { ActionIcon } from '/@/shared/components/action-icon/action-icon';\nimport { Center } from '/@/shared/components/center/center';\nimport { Group } from '/@/shared/components/group/group';\nimport { Icon } from '/@/shared/components/icon/icon';\nimport { Spinner } from '/@/shared/components/spinner/spinner';\nimport { Stack } from '/@/shared/components/stack/stack';\nimport { TextInput } from '/@/shared/components/text-input/text-input';\nimport { Text } from '/@/shared/components/text/text';\n\nexport type VirtualMultiSelectOption<T> = T & { label: string; value: string };\n\ninterface VirtualMultiSelectProps<T> {\n    disabled?: boolean;\n    displayCountType?: 'album' | 'song';\n    height: number;\n    isLoading?: boolean;\n    label?: React.ReactNode | string;\n    onChange: (value: null | string[]) => void;\n    options: VirtualMultiSelectOption<T>[];\n    RowComponent: (\n        props: RowComponentProps<{\n            disabled?: boolean;\n            displayCountType?: 'album' | 'song';\n            focusedIndex: null | number;\n            onToggle: (value: string) => void;\n            options: VirtualMultiSelectOption<T>[];\n            value: string[];\n        }>,\n    ) => React.ReactElement;\n    singleSelect?: boolean;\n    value: string[];\n}\n\nexport function VirtualMultiSelect<T>({\n    disabled = false,\n    displayCountType = 'album',\n    height,\n    isLoading = false,\n    label,\n    onChange,\n    options,\n    RowComponent,\n    singleSelect = false,\n    value,\n}: VirtualMultiSelectProps<T>) {\n    const { t } = useTranslation();\n    const [search, setSearch] = useState('');\n    const [focusedIndex, setFocusedIndex] = useState<null | number>(null);\n    const listContainerRef = useRef<HTMLDivElement>(null);\n    const listRef = useListRef(null);\n\n    const rowHeight = useDynamicRowHeight({\n        defaultRowHeight: 50,\n    });\n\n    const [initialize, osInstance] = useOverlayScrollbars({\n        defer: false,\n        options: {\n            overflow: { x: 'hidden', y: 'scroll' },\n            paddingAbsolute: true,\n            scrollbars: {\n                autoHide: 'leave',\n                autoHideDelay: 500,\n                pointers: ['mouse', 'pen', 'touch'],\n                theme: 'feishin-os-scrollbar',\n            },\n        },\n    });\n\n    const selectedOptions = useMemo(\n        () => options.filter((option) => value.includes(option.value)),\n        [options, value],\n    );\n\n    const stableOptions = useMemo(\n        () =>\n            options.filter(\n                (option) =>\n                    !value.includes(option.value) &&\n                    option.label.toLowerCase().includes(search.toLowerCase()),\n            ),\n        [options, search, value],\n    );\n\n    useEffect(() => {\n        const { current: container } = listContainerRef;\n        if (!container) return;\n\n        const isListVisible = !isLoading && stableOptions.length > 0;\n\n        if (!isListVisible) {\n            osInstance()?.destroy();\n            return;\n        }\n\n        const viewport = container.firstElementChild as HTMLElement;\n        if (!viewport) return;\n\n        initialize({\n            elements: {\n                viewport,\n            },\n            target: container,\n        });\n\n        return () => osInstance()?.destroy();\n    }, [initialize, osInstance, isLoading, stableOptions.length]);\n\n    const handleToggle = useCallback(\n        (optionValue: string) => {\n            if (disabled) return;\n            if (value.includes(optionValue)) {\n                const newValue = value.filter((v) => v !== optionValue);\n                onChange(newValue.length > 0 ? newValue : null);\n            } else {\n                onChange(singleSelect ? [optionValue] : [...value, optionValue]);\n            }\n        },\n        [disabled, onChange, singleSelect, value],\n    );\n\n    const handleDeselect = useCallback(\n        (optionValue: string) => {\n            if (disabled) return;\n            const newValue = value.filter((v) => v !== optionValue);\n            onChange(newValue.length > 0 ? newValue : null);\n        },\n        [disabled, onChange, value],\n    );\n\n    const placeholder = useMemo(\n        () => (value.length > 0 ? t('common.countSelected', { count: value.length }) : undefined),\n        [t, value.length],\n    );\n\n    const labelWithClear = useMemo(() => {\n        if (!label) return undefined;\n        return label;\n    }, [label]);\n\n    const scrollToIndex = useCallback(\n        (index: number) => {\n            const list = listRef.current;\n            list?.scrollToRow({\n                align: 'auto',\n                behavior: 'auto',\n                index,\n            });\n        },\n        [listRef],\n    );\n\n    const handleKeyDown = useCallback(\n        (e: React.KeyboardEvent<HTMLDivElement>) => {\n            if (disabled || stableOptions.length === 0) return;\n\n            switch (e.key) {\n                case ' ':\n                case 'Enter': {\n                    e.preventDefault();\n                    e.stopPropagation();\n                    if (focusedIndex !== null && stableOptions[focusedIndex]) {\n                        handleToggle(stableOptions[focusedIndex].value);\n                    }\n                    break;\n                }\n                case 'ArrowDown': {\n                    e.preventDefault();\n                    e.stopPropagation();\n                    const newIndex =\n                        focusedIndex === null\n                            ? 0\n                            : Math.min(focusedIndex + 1, stableOptions.length - 1);\n                    setFocusedIndex(newIndex);\n                    scrollToIndex(newIndex);\n                    break;\n                }\n                case 'ArrowUp': {\n                    e.preventDefault();\n                    e.stopPropagation();\n                    const newIndex = focusedIndex === null ? 0 : Math.max(focusedIndex - 1, 0);\n                    setFocusedIndex(newIndex);\n                    scrollToIndex(newIndex);\n                    break;\n                }\n                case 'Tab': {\n                    setFocusedIndex(null);\n                    break;\n                }\n                default:\n                    break;\n            }\n        },\n        [disabled, focusedIndex, handleToggle, scrollToIndex, stableOptions],\n    );\n\n    return (\n        <div\n            className={clsx(styles.container, {\n                [styles.disabled]: disabled,\n            })}\n        >\n            <TextInput\n                disabled={disabled}\n                label={labelWithClear}\n                leftSection={\n                    value.length > 0 && !disabled ? (\n                        <ActionIcon\n                            icon=\"x\"\n                            iconProps={{ size: 'md' }}\n                            onClick={() => {\n                                onChange(null);\n                                setSearch('');\n                            }}\n                            size=\"xs\"\n                            variant=\"subtle\"\n                        />\n                    ) : undefined\n                }\n                onChange={(e) => {\n                    if (!disabled) {\n                        setSearch(e.currentTarget.value);\n                    }\n                }}\n                placeholder={placeholder}\n                rightSection={\n                    <Group gap=\"xs\" wrap=\"nowrap\">\n                        {search && !disabled ? (\n                            <ActionIcon\n                                icon=\"x\"\n                                iconProps={{ size: 'md' }}\n                                onClick={() => setSearch('')}\n                                size=\"xs\"\n                                variant=\"subtle\"\n                            />\n                        ) : (\n                            <Icon icon=\"search\" />\n                        )}\n                    </Group>\n                }\n                styles={{\n                    input: disabled ? { opacity: 0.6 } : undefined,\n                    label: { width: '100%' },\n                    section: disabled ? { opacity: 0.6 } : undefined,\n                    wrapper: disabled ? { opacity: 0.6 } : undefined,\n                }}\n                value={search}\n            />\n            <div\n                className={styles.listContainer}\n                onKeyDown={handleKeyDown}\n                onMouseDown={(e) => {\n                    if (disabled) return;\n                    const element = e.currentTarget as HTMLDivElement;\n                    if (element.focus) {\n                        element.focus({ preventScroll: true });\n                    }\n                }}\n                ref={listContainerRef}\n                style={{ height: `${height}px` }}\n                tabIndex={disabled ? -1 : 0}\n            >\n                {isLoading ? (\n                    <Center h=\"100%\" w=\"100%\">\n                        <Spinner container />\n                    </Center>\n                ) : stableOptions.length === 0 ? (\n                    <Center h=\"100%\" w=\"100%\">\n                        <Text isMuted isNoSelect size=\"sm\">\n                            {t('common.noResultsFromQuery', { postProcess: 'sentenceCase' })}\n                        </Text>\n                    </Center>\n                ) : (\n                    <List\n                        listRef={listRef}\n                        rowComponent={RowComponent}\n                        rowCount={stableOptions.length}\n                        rowHeight={rowHeight}\n                        rowProps={{\n                            disabled,\n                            displayCountType,\n                            focusedIndex,\n                            onToggle: handleToggle,\n                            options: stableOptions,\n                            value,\n                        }}\n                    />\n                )}\n            </div>\n            {selectedOptions.length > 0 && (\n                <Stack gap=\"xs\" mt=\"sm\">\n                    {selectedOptions.map((option) => (\n                        <Group\n                            className={clsx(styles.selectedOption, {\n                                [styles.disabled]: disabled,\n                            })}\n                            gap=\"sm\"\n                            key={option.value}\n                            onClick={() => handleDeselect(option.value)}\n                            wrap=\"nowrap\"\n                        >\n                            {!disabled && (\n                                <ActionIcon\n                                    icon=\"minus\"\n                                    iconProps={{ size: 'sm' }}\n                                    onClick={(e) => {\n                                        e.stopPropagation();\n                                        handleDeselect(option.value);\n                                    }}\n                                    size=\"xs\"\n                                    stopsPropagation\n                                    variant=\"transparent\"\n                                />\n                            )}\n                            <Text isNoSelect overflow=\"hidden\" size=\"sm\">\n                                {option.label}\n                            </Text>\n                        </Group>\n                    ))}\n                </Stack>\n            )}\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/shared/components/number-input/number-input.module.css",
    "content": ".root {\n    transition: width 0.3s ease-in-out;\n\n    &[data-disabled='true'] {\n        opacity: 0.6;\n    }\n}\n\n.input {\n    width: 100%;\n    border: 1px solid transparent;\n\n    &[data-variant='default'] {\n        color: var(--theme-colors-surface-foreground);\n        background: var(--theme-colors-surface);\n    }\n\n    &[data-variant='filled'] {\n        color: var(--theme-colors-foreground);\n        background: var(--theme-colors-background);\n    }\n}\n\n.input:focus,\n.input:focus-visible {\n    border-color: lighten(var(--theme-colors-border), 10%);\n}\n\n.control {\n    svg {\n        color: var(--theme-btn-default-fg);\n        fill: var(--theme-btn-default-fg);\n    }\n}\n\n.section {\n    color: var(--theme-colors-foreground-muted);\n}\n\n.required {\n    color: var(--theme-colors-state-error);\n}\n\n.label {\n    margin-bottom: var(--theme-spacing-sm);\n}\n"
  },
  {
    "path": "src/shared/components/number-input/number-input.tsx",
    "content": "import {\n    NumberInput as MantineNumberInput,\n    NumberInputProps as MantineNumberInputProps,\n} from '@mantine/core';\nimport { CSSProperties, forwardRef } from 'react';\n\nimport styles from './number-input.module.css';\n\nexport interface NumberInputProps extends MantineNumberInputProps {\n    maxWidth?: CSSProperties['maxWidth'];\n    width?: CSSProperties['width'];\n}\n\nexport const NumberInput = forwardRef<HTMLInputElement, NumberInputProps>(\n    (\n        {\n            children,\n            classNames,\n            defaultValue,\n            maxWidth,\n            onChange,\n            size = 'sm',\n            style,\n            variant = 'default',\n            width,\n            ...props\n        }: NumberInputProps,\n        ref,\n    ) => {\n        return (\n            <MantineNumberInput\n                classNames={{\n                    control: styles.control,\n                    input: styles.input,\n                    label: styles.label,\n                    required: styles.required,\n                    root: styles.root,\n                    section: styles.section,\n                    wrapper: styles.wrapper,\n                    ...classNames,\n                }}\n                defaultValue={defaultValue}\n                hideControls\n                onChange={\n                    onChange\n                        ? (e) => onChange(typeof e === 'number' ? e : defaultValue || e)\n                        : undefined\n                }\n                ref={ref}\n                size={size}\n                style={{ maxWidth, width, ...style }}\n                variant={variant}\n                {...props}\n            >\n                {children}\n            </MantineNumberInput>\n        );\n    },\n);\n"
  },
  {
    "path": "src/shared/components/option/option.module.css",
    "content": ".root {\n    padding: var(--theme-spacing-sm);\n}\n"
  },
  {
    "path": "src/shared/components/option/option.tsx",
    "content": "import { ReactNode, useMemo } from 'react';\n\nimport styles from './option.module.css';\n\nimport { Flex } from '/@/shared/components/flex/flex';\nimport { Group, GroupProps } from '/@/shared/components/group/group';\nimport { Text } from '/@/shared/components/text/text';\n\ninterface OptionProps extends GroupProps {\n    children: ReactNode;\n}\n\nconst defaultClassNames = { root: styles.root };\n\nexport const Option = ({ children, classNames, ...props }: OptionProps) => {\n    const mergedClassNames = useMemo(\n        () => (classNames ? { ...defaultClassNames, ...classNames } : defaultClassNames),\n        [classNames],\n    );\n\n    return (\n        <Group classNames={mergedClassNames} grow {...props}>\n            {children}\n        </Group>\n    );\n};\n\nOption.displayName = 'Option';\n\ninterface LabelProps {\n    children: ReactNode;\n}\n\nconst Label = ({ children }: LabelProps) => {\n    return <Text>{children}</Text>;\n};\n\ninterface ControlProps {\n    children: ReactNode;\n}\n\nconst Control = ({ children }: ControlProps) => {\n    return <Flex justify=\"flex-end\">{children}</Flex>;\n};\n\nOption.Label = Label;\nOption.Control = Control;\n"
  },
  {
    "path": "src/shared/components/pagination/pagination.module.css",
    "content": ".root {\n    justify-content: center;\n}\n\n.control {\n    color: var(--theme-colors-foreground);\n    background-color: var(--theme-colors-surface);\n    border: none;\n    transition:\n        background 0.2s ease-in-out,\n        color 0.2s ease-in-out;\n\n    &[data-active] {\n        color: var(--theme-colors-primary-contrast);\n        background-color: var(--theme-colors-primary-filled);\n    }\n\n    &[data-dots] {\n        background-color: transparent;\n    }\n\n    &:hover {\n        background-color: lighten(var(--theme-colors-surface), 10%);\n\n        &[data-active] {\n            background-color: darken(var(--theme-colors-primary-filled), 10%);\n        }\n\n        &[data-dots] {\n            background-color: transparent;\n        }\n    }\n}\n\n.container {\n    display: flex;\n    align-items: center;\n    justify-content: space-between;\n}\n"
  },
  {
    "path": "src/shared/components/pagination/pagination.tsx",
    "content": "import {\n    Pagination as MantinePagination,\n    PaginationProps as MantinePaginationProps,\n} from '@mantine/core';\nimport clsx from 'clsx';\nimport { useRef, useState } from 'react';\n\nimport styles from './pagination.module.css';\n\nimport { ActionIcon } from '/@/shared/components/action-icon/action-icon';\nimport { Group } from '/@/shared/components/group/group';\nimport { Icon } from '/@/shared/components/icon/icon';\nimport { NumberInput } from '/@/shared/components/number-input/number-input';\nimport { Popover } from '/@/shared/components/popover/popover';\nimport { Separator } from '/@/shared/components/separator/separator';\nimport { Text } from '/@/shared/components/text/text';\nimport { useContainerQuery } from '/@/shared/hooks/use-container-query';\n\ninterface PaginationProps extends MantinePaginationProps {\n    containerClassName?: string;\n    itemsPerPage: number;\n    totalItemCount: number;\n}\n\nexport const Pagination = ({\n    classNames,\n    containerClassName,\n    itemsPerPage,\n    style,\n    totalItemCount,\n    ...props\n}: PaginationProps) => {\n    const { ref: containerRef, ...containerQuery } = useContainerQuery();\n\n    const paginationRef = useRef<HTMLDivElement>(null);\n\n    // !IMPORTANT: Mantine Pagination is 1-indexed\n    const currentPageIndex = props.value || 0;\n    const currentPageValue = currentPageIndex + 1;\n\n    const handleChange = (e: number) => {\n        props.onChange?.(e - 1);\n    };\n\n    const currentPageStartIndex = itemsPerPage * currentPageIndex + 1;\n    const currentPageEndIndex = Math.min(currentPageValue * itemsPerPage, totalItemCount);\n\n    const [goToPage, setGoToPage] = useState(currentPageValue);\n\n    const handleGoToPage = () => {\n        handleChange(Math.max(1, Math.min(goToPage, props.total)));\n    };\n\n    const handleGoToKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {\n        if (e.key === 'Enter') {\n            handleGoToPage();\n        }\n    };\n\n    return (\n        <div className={clsx(styles.container, containerClassName)} ref={containerRef}>\n            <Group gap=\"xs\">\n                <MantinePagination\n                    boundaries={1}\n                    classNames={{\n                        control: styles.control,\n                        root: styles.root,\n                        ...classNames,\n                    }}\n                    nextIcon={() => <Icon icon=\"arrowRightS\" />}\n                    previousIcon={() => <Icon icon=\"arrowLeftS\" />}\n                    radius=\"md\"\n                    ref={paginationRef}\n                    siblings={containerQuery.isXl ? 3 : containerQuery.isMd ? 2 : 1}\n                    size=\"md\"\n                    style={{\n                        ...style,\n                    }}\n                    {...props}\n                    onChange={handleChange}\n                    value={currentPageValue}\n                />\n                <Popover position=\"top\">\n                    <Popover.Target>\n                        <ActionIcon\n                            className={styles.control}\n                            icon=\"ellipsisHorizontal\"\n                            size=\"xs\"\n                            style={{\n                                height: 'calc(2rem * 1)',\n                                minWidth: 'calc(2rem * 1)',\n                            }}\n                        />\n                    </Popover.Target>\n                    <Popover.Dropdown>\n                        <Group gap={0}>\n                            <NumberInput\n                                autoFocus\n                                hideControls={false}\n                                max={props.total}\n                                min={1}\n                                onChange={(e) => setGoToPage(Number(e))}\n                                onKeyDown={handleGoToKeyDown}\n                                value={currentPageValue}\n                                width={120}\n                            />\n                            <ActionIcon\n                                icon=\"arrowRight\"\n                                onClick={handleGoToPage}\n                                variant=\"default\"\n                            />\n                        </Group>\n                    </Popover.Dropdown>\n                </Popover>\n            </Group>\n            {containerQuery.isSm && totalItemCount && (\n                <Text isNoSelect weight={500}>\n                    {currentPageStartIndex} - {currentPageEndIndex} <Separator /> {totalItemCount}\n                </Text>\n            )}\n        </div>\n    );\n};\n"
  },
  {
    "path": "src/shared/components/paper/paper.module.css",
    "content": ".root {\n    color: var(--theme-colors-surface-foreground);\n    background: var(--theme-colors-surface);\n}\n"
  },
  {
    "path": "src/shared/components/paper/paper.tsx",
    "content": "import type { PaperProps as MantinePaperProps } from '@mantine/core';\n\nimport { Paper as MantinePaper } from '@mantine/core';\nimport { memo, ReactNode, useMemo } from 'react';\n\nimport styles from './paper.module.css';\n\nexport interface PaperProps extends MantinePaperProps {\n    children?: ReactNode;\n}\n\nconst BasePaper = ({ children, classNames, style, ...props }: PaperProps) => {\n    const memoizedClassNames = useMemo(\n        () => ({\n            root: styles.root,\n            ...classNames,\n        }),\n        [classNames],\n    );\n\n    const memoizedStyle = useMemo(() => ({ ...style }), [style]);\n\n    return (\n        <MantinePaper classNames={memoizedClassNames} style={memoizedStyle} {...props}>\n            {children}\n        </MantinePaper>\n    );\n};\n\nBasePaper.displayName = 'Paper';\n\nexport const Paper = memo(BasePaper);\n"
  },
  {
    "path": "src/shared/components/password-input/password-input.module.css",
    "content": ".root {\n    &[data-disabled='true'] {\n        opacity: 0.6;\n    }\n\n    transition: width 0.3s ease-in-out;\n}\n\n.input {\n    width: 100%;\n    border: 1px solid transparent;\n\n    &[data-variant='default'] {\n        color: var(--theme-colors-surface-foreground);\n        background: var(--theme-colors-surface);\n    }\n\n    &[data-variant='filled'] {\n        color: var(--theme-colors-foreground);\n        background: var(--theme-colors-background);\n    }\n}\n\n.input:focus,\n.input:focus-visible {\n    border-color: lighten(var(--theme-colors-border), 10%);\n}\n\n.section {\n    color: var(--theme-colors-foreground-muted);\n}\n\n.required {\n    color: var(--theme-colors-state-error);\n}\n\n.label {\n    margin-bottom: var(--theme-spacing-sm);\n}\n"
  },
  {
    "path": "src/shared/components/password-input/password-input.tsx",
    "content": "import {\n    PasswordInput as MantinePasswordInput,\n    PasswordInputProps as MantinePasswordInputProps,\n} from '@mantine/core';\nimport { CSSProperties, forwardRef } from 'react';\n\nimport styles from './password-input.module.css';\n\nexport interface PasswordInputProps extends MantinePasswordInputProps {\n    maxWidth?: CSSProperties['maxWidth'];\n    width?: CSSProperties['width'];\n}\n\nexport const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(\n    ({ children, classNames, maxWidth, style, variant = 'default', width, ...props }, ref) => {\n        return (\n            <MantinePasswordInput\n                classNames={{\n                    input: styles.input,\n                    label: styles.label,\n                    required: styles.required,\n                    root: styles.root,\n                    section: styles.section,\n                    ...classNames,\n                }}\n                ref={ref}\n                style={{ maxWidth, width, ...style }}\n                variant={variant}\n                {...props}\n            >\n                {children}\n            </MantinePasswordInput>\n        );\n    },\n);\n"
  },
  {
    "path": "src/shared/components/pill/pill.module.css",
    "content": ".root {\n    box-sizing: border-box;\n    user-select: auto;\n    background: alpha(var(--theme-colors-background), 0.5);\n    border: 1px solid var(--theme-colors-border);\n    transition: background-color 0.2s ease-in-out;\n\n    &[data-variant='outline'] {\n        background: transparent;\n        border: 1px solid var(--theme-colors-border);\n    }\n}\n\n.root.link:hover {\n    background: alpha(var(--theme-colors-surface), 0.5);\n}\n\n.label {\n    font-family: var(--theme-content-font-family);\n}\n\n.label.sm {\n    font-size: var(--theme-font-size-sm);\n}\n\n.label.md {\n    font-size: var(--theme-font-size-md);\n}\n\n.label.lg {\n    font-size: var(--theme-font-size-lg);\n}\n\n.label.xl {\n    font-size: var(--theme-font-size-xl);\n}\n\n.label.xs {\n    font-size: var(--theme-font-size-xs);\n}\n\n.remove {\n    transition: color 0.1s ease-in-out;\n\n    &:hover {\n        color: var(--theme-colors-foreground-muted);\n    }\n}\n\n.group.sm {\n    --pg-gap: var(--theme-spacing-sm) !important;\n}\n\n.group.md {\n    --pg-gap: var(--theme-spacing-md) !important;\n}\n\n.group.lg {\n    --pg-gap: var(--theme-spacing-lg) !important;\n}\n\n.group.xl {\n    --pg-gap: var(--theme-spacing-xl) !important;\n}\n\n.group.xs {\n    --pg-gap: var(--theme-spacing-xs) !important;\n}\n"
  },
  {
    "path": "src/shared/components/pill/pill.tsx",
    "content": "import {\n    Pill as MantinePill,\n    PillGroupProps as MantinePillGroupProps,\n    PillProps as MantinePillProps,\n} from '@mantine/core';\nimport clsx from 'clsx';\nimport { forwardRef } from 'react';\nimport { Link } from 'react-router';\n\nimport styles from './pill.module.css';\n\ninterface PillProps extends MantinePillProps {}\n\nexport const Pill = ({ children, classNames, radius = 'md', size = 'md', ...props }: PillProps) => {\n    return (\n        <MantinePill\n            classNames={{\n                label: clsx({\n                    [styles.label]: true,\n                    [styles.lg]: size === 'lg',\n                    [styles.md]: size === 'md',\n                    [styles.sm]: size === 'sm',\n                    [styles.xl]: size === 'xl',\n                    [styles.xs]: size === 'xs',\n                }),\n                remove: styles.remove,\n                root: styles.root,\n                ...classNames,\n            }}\n            radius={radius}\n            size={size}\n            {...props}\n        >\n            {children}\n        </MantinePill>\n    );\n};\n\ninterface PillGroupProps extends MantinePillGroupProps {}\n\nconst PillGroup = ({ children, classNames, gap = 'sm', ...props }: PillGroupProps) => {\n    return (\n        <MantinePill.Group\n            classNames={{\n                group: clsx(styles.group, {\n                    [styles.lg]: gap === 'lg',\n                    [styles.md]: gap === 'md',\n                    [styles.sm]: gap === 'sm',\n                    [styles.xl]: gap === 'xl',\n                    [styles.xs]: gap === 'xs',\n                }),\n                ...classNames,\n            }}\n            gap={gap}\n            {...props}\n        >\n            {children}\n        </MantinePill.Group>\n    );\n};\n\nPill.Group = PillGroup;\n\ninterface PillLinkProps\n    extends Omit<React.ComponentPropsWithoutRef<typeof Link>, keyof PillProps>,\n        PillProps {}\n\nexport const PillLink = forwardRef<HTMLDivElement, PillLinkProps>(({ children, ...props }, ref) => {\n    const { classNames, radius = 'md', size = 'md', ...rest } = props;\n\n    return (\n        <MantinePill\n            classNames={{\n                label: clsx({\n                    [styles.label]: true,\n                    [styles.lg]: size === 'lg',\n                    [styles.md]: size === 'md',\n                    [styles.sm]: size === 'sm',\n                    [styles.xl]: size === 'xl',\n                    [styles.xs]: size === 'xs',\n                }),\n                remove: styles.remove,\n                root: clsx(styles.root, styles.link),\n                ...classNames,\n            }}\n            component={Link}\n            radius={radius}\n            ref={ref}\n            size={size}\n            {...rest}\n        >\n            {children}\n        </MantinePill>\n    );\n});\n"
  },
  {
    "path": "src/shared/components/popover/popover.module.css",
    "content": ".dropdown {\n    padding: var(--theme-spacing-sm);\n    color: var(--theme-colors-foreground);\n    background: var(--theme-colors-background);\n    border: 2px solid var(--theme-colors-border);\n    border-radius: var(--theme-radius-md);\n    box-shadow: 2px 2px 10px 2px rgb(0 0 0 / 40%);\n    filter: drop-shadow(0 0 5px rgb(0 0 0 / 50%));\n}\n"
  },
  {
    "path": "src/shared/components/popover/popover.tsx",
    "content": "import type {\n    PopoverDropdownProps as MantinePopoverDropdownProps,\n    PopoverProps as MantinePopoverProps,\n} from '@mantine/core';\n\nimport { Popover as MantinePopover } from '@mantine/core';\n\nimport styles from './popover.module.css';\n\nexport interface PopoverDropdownProps extends MantinePopoverDropdownProps {}\nexport interface PopoverProps extends MantinePopoverProps {}\n\nconst getTransition = (position?: string) => {\n    if (position?.includes('top')) {\n        return 'fade-up';\n    }\n\n    if (position?.includes('bottom')) {\n        return 'fade-down';\n    }\n\n    if (position?.includes('left')) {\n        return 'fade-left';\n    }\n\n    if (position?.includes('right')) {\n        return 'fade-right';\n    }\n\n    return 'fade';\n};\n\nexport const Popover = ({ children, ...props }: PopoverProps) => {\n    return (\n        <MantinePopover\n            classNames={{\n                dropdown: styles.dropdown,\n            }}\n            closeOnClickOutside={true}\n            closeOnEscape={true}\n            offset={10}\n            transitionProps={{ transition: getTransition(props.position) }}\n            withArrow={false}\n            withinPortal\n            {...props}\n        >\n            {children}\n        </MantinePopover>\n    );\n};\n\nPopover.Target = MantinePopover.Target;\nPopover.Dropdown = MantinePopover.Dropdown;\n"
  },
  {
    "path": "src/shared/components/portal/portal.tsx",
    "content": "import { Portal as MantinePortal, PortalProps as MantinePortalProps } from '@mantine/core';\n\nexport interface PortalProps extends MantinePortalProps {}\n\nexport const Portal = ({ children, ...props }: PortalProps) => {\n    return <MantinePortal {...props}>{children}</MantinePortal>;\n};\n"
  },
  {
    "path": "src/shared/components/rating/rating.module.css",
    "content": ".root.xs {\n    --rating-size: var(--theme-font-size-xs) !important;\n\n    padding-right: var(--theme-spacing-xs);\n    padding-left: var(--theme-spacing-xs);\n}\n\n.root.sm {\n    --rating-size: var(--theme-font-size-sm) !important;\n\n    padding-right: var(--theme-spacing-sm);\n    padding-left: var(--theme-spacing-sm);\n}\n\n.root.md {\n    --rating-size: var(--theme-font-size-md) !important;\n\n    padding-right: var(--theme-spacing-md);\n    padding-left: var(--theme-spacing-md);\n}\n\n.root.lg {\n    --rating-size: var(--theme-font-size-lg) !important;\n\n    padding-right: var(--theme-spacing-md);\n    padding-left: var(--theme-spacing-md);\n}\n\n.root.xl {\n    --rating-size: var(--theme-font-size-xl) !important;\n\n    padding-right: var(--theme-spacing-md);\n    padding-left: var(--theme-spacing-md);\n}\n\n.symbol-body {\n    svg {\n        stroke: var(--theme-colors-foreground);\n        stroke-width: 1px;\n\n        &:not([data-filled='true']) {\n            fill: transparent;\n        }\n    }\n}\n"
  },
  {
    "path": "src/shared/components/rating/rating.tsx",
    "content": "import { Rating as MantineRating, RatingProps as MantineRatingProps } from '@mantine/core';\nimport clsx from 'clsx';\nimport debounce from 'lodash/debounce';\nimport { useCallback } from 'react';\n\nimport styles from './rating.module.css';\n\ninterface RatingProps extends MantineRatingProps {\n    preventDefault?: boolean;\n    stopPropagation?: boolean;\n}\n\nexport const Rating = ({\n    classNames,\n    onChange,\n    preventDefault = true,\n    size,\n    stopPropagation = true,\n    style,\n    ...props\n}: RatingProps) => {\n    const valueChange = useCallback(\n        (rating: number) => {\n            if (onChange) {\n                if (rating === props.value) {\n                    onChange(0);\n                } else {\n                    onChange(rating);\n                }\n            }\n        },\n        [onChange, props.value],\n    );\n\n    const debouncedOnChange = debounce(valueChange, 100);\n\n    return (\n        <MantineRating\n            classNames={{\n                root: clsx(styles.root, {\n                    [styles.lg]: size === 'lg',\n                    [styles.md]: size === 'md',\n                    [styles.sm]: size === 'sm',\n                    [styles.xl]: size === 'xl',\n                    [styles.xs]: size === 'xs',\n                }),\n                symbolBody: styles.symbolBody,\n                ...classNames,\n            }}\n            style={{\n                ...style,\n            }}\n            {...props}\n            onChange={(e) => {\n                debouncedOnChange(e);\n            }}\n            onClick={(e) => {\n                if (preventDefault) {\n                    e.preventDefault();\n                }\n                if (stopPropagation) {\n                    e.stopPropagation();\n                }\n            }}\n        />\n    );\n};\n"
  },
  {
    "path": "src/shared/components/read-only-rating/read-only-rating.module.css",
    "content": ".root {\n    display: inline-flex;\n    gap: 0.125rem;\n    align-items: center;\n    line-height: 1;\n}\n\n.root.interactive {\n    cursor: pointer;\n}\n\n.root.xs {\n    font-size: var(--theme-font-size-xs);\n}\n\n.root.sm {\n    font-size: var(--theme-font-size-sm);\n}\n\n.root.md {\n    font-size: var(--theme-font-size-md);\n}\n\n.filled {\n    color: var(--theme-colors-primary);\n}\n\n.empty {\n    color: var(--theme-colors-foreground-muted);\n    opacity: 0.6;\n}\n"
  },
  {
    "path": "src/shared/components/read-only-rating/read-only-rating.tsx",
    "content": "import clsx from 'clsx';\nimport { memo, useCallback, useState } from 'react';\n\nimport styles from './read-only-rating.module.css';\n\nconst MAX_STARS = 5;\n\ninterface ReadOnlyRatingProps {\n    className?: string;\n    onChange?: (value: number) => void;\n    size?: 'md' | 'sm' | 'xs';\n    value?: null | number;\n}\n\nfunction ReadOnlyRatingComponent({ className, onChange, size = 'sm', value }: ReadOnlyRatingProps) {\n    const [hoverIndex, setHoverIndex] = useState<null | number>(null);\n    const rating = Math.min(MAX_STARS, Math.max(0, value ?? 0));\n    const displayCount = hoverIndex !== null ? hoverIndex : Math.floor(rating);\n\n    const handlePointerMove = useCallback(\n        (e: React.PointerEvent) => {\n            if (!onChange) return;\n            const el = e.currentTarget;\n            const width = (el as HTMLElement).offsetWidth;\n            if (width <= 0) return;\n            const x = e.clientX - el.getBoundingClientRect().left;\n            const segment = Math.floor((x / width) * MAX_STARS);\n            const filled = segment < 0 ? 0 : Math.min(MAX_STARS, segment + 1);\n            setHoverIndex(filled);\n        },\n        [onChange],\n    );\n\n    const handlePointerLeave = useCallback(() => {\n        setHoverIndex(null);\n    }, []);\n\n    const handleClick = useCallback(\n        (e: React.MouseEvent) => {\n            if (!onChange) return;\n            e.preventDefault();\n            e.stopPropagation();\n            const el = e.currentTarget;\n            const width = (el as HTMLElement).offsetWidth;\n            if (width <= 0) return;\n            const x = e.clientX - el.getBoundingClientRect().left;\n            const segment = Math.floor((x / width) * MAX_STARS);\n            const clicked = segment < 0 ? 0 : Math.min(MAX_STARS, segment + 1);\n            onChange(clicked === rating ? 0 : clicked);\n        },\n        [onChange, rating],\n    );\n\n    const isInteractive = typeof onChange === 'function';\n\n    return (\n        <span\n            aria-label={isInteractive ? undefined : `${rating} out of ${MAX_STARS} stars`}\n            className={clsx(\n                styles.root,\n                size && styles[size],\n                isInteractive && styles.interactive,\n                className,\n            )}\n            onClick={isInteractive ? handleClick : undefined}\n            onPointerLeave={isInteractive ? handlePointerLeave : undefined}\n            onPointerMove={isInteractive ? handlePointerMove : undefined}\n            role={isInteractive ? undefined : 'img'}\n        >\n            {Array.from({ length: MAX_STARS }, (_, i) => (\n                <span className={i < displayCount ? styles.filled : styles.empty} key={i}>\n                    ★\n                </span>\n            ))}\n        </span>\n    );\n}\n\nexport const ReadOnlyRating = memo(ReadOnlyRatingComponent);\n\nReadOnlyRating.displayName = 'ReadOnlyRating';\n"
  },
  {
    "path": "src/shared/components/scroll-area/scroll-area.css",
    "content": ".feishin-os-scrollbar {\n    --os-size: var(--theme-scrollbar-size);\n    --os-track-bg: var(--theme-scrollbar-track-background);\n    --os-track-bg-hover: var(--theme-scrollbar-track-hover-background);\n    --os-track-border-radius: var(--theme-scrollbar-track-border-radius);\n    --os-handle-bg: var(--theme-scrollbar-handle-background);\n    --os-handle-bg-hover: var(--theme-scrollbar-handle-hover-background);\n    --os-handle-bg-active: var(--theme-scrollbar-handle-active-background);\n    --os-handle-border-radius: var(--theme-scrollbar-handle-border-radius);\n    --os-handle-max-size: 200px;\n}\n"
  },
  {
    "path": "src/shared/components/scroll-area/scroll-area.module.css",
    "content": "/* .thumb {\n    background: var(--theme-scrollbar-handle-background);\n    border-radius: var(--theme-scrollbar-handle-border-radius);\n\n    &:hover {\n        background: var(--theme-scrollbar-handle-hover-background);\n    }\n\n    &[data-state='visible'] {\n        animation: fade-in 0.3s forwards;\n    }\n\n    &[data-state='hidden'] {\n        animation: fade-out 0.2s forwards;\n    }\n}\n\n.scrollbar {\n    padding: 0;\n    background: var(--theme-scrollbar-track-background);\n    border-radius: var(--theme-scrollbar-track-border-radius);\n\n    &:hover {\n        background: var(--theme-scrollbar-track-hover-background);\n    }\n}\n\n.viewport > div {\n    display: block !important;\n} */\n\n.scroll-area {\n    width: 100%;\n    height: 100%;\n}\n"
  },
  {
    "path": "src/shared/components/scroll-area/scroll-area.tsx",
    "content": "import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element';\nimport clsx from 'clsx';\nimport { useOverlayScrollbars } from 'overlayscrollbars-react';\nimport { forwardRef, Ref, useEffect, useRef, useState } from 'react';\n\nimport styles from './scroll-area.module.css';\nimport './scroll-area.css';\n\nimport { useMergedRef } from '/@/shared/hooks/use-merged-ref';\nimport { DragData, DragTarget } from '/@/shared/types/drag-and-drop';\n\ninterface ScrollAreaProps extends React.ComponentPropsWithoutRef<'div'> {\n    allowDragScroll?: boolean;\n    debugScrollPosition?: boolean;\n    scrollHideDelay?: number;\n    scrollX?: boolean;\n    scrollY?: boolean;\n}\n\nexport const ScrollArea = forwardRef((props: ScrollAreaProps, ref: Ref<HTMLDivElement>) => {\n    const {\n        allowDragScroll,\n        children,\n        className,\n        scrollHideDelay,\n        scrollX = false,\n        scrollY = true,\n        ...htmlProps\n    } = props;\n\n    const containerRef = useRef(null);\n    const [scroller, setScroller] = useState<HTMLElement | null | Window>(null);\n\n    const [initialize, osInstance] = useOverlayScrollbars({\n        defer: false,\n        options: {\n            overflow: { x: scrollX ? 'scroll' : 'hidden', y: scrollY ? 'scroll' : 'hidden' },\n            scrollbars: {\n                autoHide: 'leave',\n                autoHideDelay: scrollHideDelay || 500,\n                pointers: ['mouse', 'pen', 'touch'],\n                theme: 'feishin-os-scrollbar',\n                visibility: 'visible',\n            },\n        },\n    });\n\n    useEffect(() => {\n        const { current: root } = containerRef;\n\n        let autoScrollCleanup: (() => void) | null = null;\n\n        if (scroller && root) {\n            initialize({\n                elements: { viewport: scroller as HTMLElement },\n                target: root,\n            });\n\n            if (allowDragScroll) {\n                autoScrollCleanup = autoScrollForElements({\n                    canScroll: (args) => {\n                        const data = args.source.data as unknown as DragData<unknown>;\n                        if (data.type === DragTarget.TABLE_COLUMN) return false;\n                        return true;\n                    },\n                    element: scroller as HTMLElement,\n                    getAllowedAxis: () => 'vertical',\n                    getConfiguration: () => ({ maxScrollSpeed: 'standard' }),\n                });\n            }\n        }\n\n        return () => {\n            if (autoScrollCleanup) {\n                autoScrollCleanup();\n            }\n\n            osInstance()?.destroy();\n        };\n    }, [allowDragScroll, initialize, osInstance, scroller]);\n\n    const mergedRef = useMergedRef(ref, containerRef);\n\n    return (\n        <div\n            className={clsx(styles.scrollArea, className)}\n            ref={(el) => {\n                setScroller(el);\n                mergedRef(el);\n            }}\n            {...htmlProps}\n        >\n            {children}\n        </div>\n    );\n});\n"
  },
  {
    "path": "src/shared/components/segmented-control/segmented-control.module.css",
    "content": ".root {\n    background: var(--theme-colors-surface);\n}\n\n.label {\n    &[data-active='true'] {\n        @mixin dark {\n            background-color: lighten(var(--theme-colors-surface), 10%);\n        }\n\n        @mixin light {\n            background-color: darken(var(--theme-colors-surface), 10%);\n        }\n    }\n}\n\n.indicator {\n    display: none;\n}\n"
  },
  {
    "path": "src/shared/components/segmented-control/segmented-control.tsx",
    "content": "import type { SegmentedControlProps as MantineSegmentedControlProps } from '@mantine/core';\n\nimport { SegmentedControl as MantineSegmentedControl } from '@mantine/core';\nimport { forwardRef } from 'react';\n\nimport styles from './segmented-control.module.css';\n\ntype SegmentedControlProps = MantineSegmentedControlProps;\n\nexport const SegmentedControl = forwardRef<HTMLDivElement, SegmentedControlProps>(\n    ({ classNames, size = 'sm', ...props }: SegmentedControlProps, ref) => {\n        return (\n            <MantineSegmentedControl\n                classNames={{\n                    control: styles.control,\n                    indicator: styles.indicator,\n                    label: styles.label,\n                    root: styles.root,\n                    ...classNames,\n                }}\n                ref={ref}\n                size={size}\n                transitionDuration={250}\n                transitionTimingFunction=\"linear\"\n                {...props}\n            />\n        );\n    },\n);\n"
  },
  {
    "path": "src/shared/components/select/select.module.css",
    "content": ".root {\n    & [data-disabled='true'] {\n        opacity: 0.6;\n    }\n}\n\n.input {\n    width: 100%;\n    border: 1px solid transparent;\n\n    &[data-variant='default'] {\n        color: var(--theme-colors-surface-foreground);\n        background: var(--theme-colors-surface);\n    }\n\n    &[data-variant='filled'] {\n        color: var(--theme-colors-foreground);\n        background: var(--theme-colors-background);\n    }\n}\n\n.input:focus,\n.input:focus-visible {\n    border-color: lighten(var(--theme-colors-border), 10%);\n}\n\n.label {\n    margin-bottom: var(--theme-spacing-sm);\n}\n\n.dropdown {\n    padding: var(--theme-spacing-xs);\n    color: var(--theme-colors-surface-foreground);\n    background: var(--theme-colors-surface);\n    border: 1px solid var(--theme-colors-border);\n}\n\n.option {\n    position: relative;\n    padding: var(--theme-spacing-sm) var(--theme-spacing-lg);\n\n    &[data-checked='true'] {\n        &::before {\n            position: absolute;\n            top: 50%;\n            left: 2px;\n            width: 2px;\n            height: 50%;\n            content: '';\n            background-color: var(--theme-colors-primary-filled);\n            border-radius: var(--theme-radius-xl);\n            transform: translateY(-50%);\n        }\n    }\n}\n\n.option:hover {\n    background: lighten(var(--theme-colors-surface), 5%);\n}\n"
  },
  {
    "path": "src/shared/components/select/select.tsx",
    "content": "import type { SelectProps as MantineSelectProps } from '@mantine/core';\n\nimport { Select as MantineSelect } from '@mantine/core';\nimport { CSSProperties } from 'react';\n\nimport styles from './select.module.css';\n\nexport interface SelectProps extends MantineSelectProps {\n    maxWidth?: CSSProperties['maxWidth'];\n    width?: CSSProperties['width'];\n}\n\nexport const Select = ({\n    allowDeselect = false,\n    classNames,\n    clearable = false,\n    maxWidth,\n    variant = 'default',\n    width,\n    ...props\n}: SelectProps) => {\n    return (\n        <MantineSelect\n            allowDeselect={allowDeselect || clearable}\n            classNames={{\n                dropdown: styles.dropdown,\n                input: styles.input,\n                label: styles.label,\n                option: styles.option,\n                root: styles.root,\n                section: styles.section,\n                ...classNames,\n            }}\n            clearable={clearable}\n            spellCheck={false}\n            style={{ maxWidth, width }}\n            variant={variant}\n            withCheckIcon={false}\n            {...props}\n        />\n    );\n};\n"
  },
  {
    "path": "src/shared/components/separator/separator.module.css",
    "content": ""
  },
  {
    "path": "src/shared/components/separator/separator.tsx",
    "content": "import { SEPARATOR_STRING } from '/@/shared/api/utils';\n\nexport const Separator = () => {\n    return <>{SEPARATOR_STRING}</>;\n};\n"
  },
  {
    "path": "src/shared/components/skeleton/skeleton.module.css",
    "content": ".skeleton {\n    @mixin dark {\n        --base-color: lighten(var(--theme-colors-surface), 10%) !important;\n        --highlight-color: lighten(var(--theme-colors-surface), 15%) !important;\n    }\n\n    @mixin light {\n        --base-color: darken(var(--theme-colors-surface), 10%) !important;\n        --highlight-color: darken(var(--theme-colors-surface), 15%) !important;\n    }\n\n    --animation-duration: 1.5s !important;\n\n    width: 100%;\n    height: 100%;\n    background: linear-gradient(\n        90deg,\n        var(--base-color) 0%,\n        var(--highlight-color) 50%,\n        var(--base-color) 100%\n    );\n    background-size: 200% 100%;\n    border-radius: var(--skeleton-border-radius, 0.25rem);\n}\n\n.skeleton.animated {\n    animation: shimmer var(--animation-duration) ease-in-out infinite;\n}\n\n@keyframes shimmer {\n    0% {\n        background-position: 200% 0;\n    }\n\n    100% {\n        background-position: -200% 0;\n    }\n}\n\n.skeleton-container {\n    display: flex;\n    align-items: center;\n    width: 100%;\n    height: 100%;\n}\n\n.skeleton-container.inline {\n    display: inline-flex;\n}\n\n.skeleton-container.centered {\n    justify-content: center;\n}\n\n.skeleton-container.rtl {\n    flex-direction: row-reverse;\n}\n\n.skeleton-wrapper {\n    display: flex;\n    flex-direction: column;\n    gap: 0.5rem;\n    align-items: stretch;\n    width: 100%;\n    height: 100%;\n}\n\n.skeleton-wrapper.inline {\n    display: inline-flex;\n}\n\n.skeleton-wrapper.rtl {\n    direction: rtl;\n}\n"
  },
  {
    "path": "src/shared/components/skeleton/skeleton.tsx",
    "content": "import clsx from 'clsx';\nimport { type CSSProperties, memo, useEffect, useRef, useState } from 'react';\n\nimport styles from './skeleton.module.css';\n\ninterface SkeletonProps {\n    baseColor?: string;\n    borderRadius?: string;\n    className?: string;\n    containerClassName?: string;\n    count?: number;\n    direction?: 'ltr' | 'rtl';\n    enableAnimation?: boolean;\n    height?: number | string;\n    inline?: boolean;\n    isCentered?: boolean;\n    style?: CSSProperties;\n    width?: number | string;\n}\n\nexport function BaseSkeleton({\n    baseColor,\n    borderRadius,\n    className,\n    containerClassName,\n    count = 1,\n    direction = 'ltr',\n    enableAnimation = true,\n    height,\n    inline,\n    isCentered,\n    style,\n    width,\n}: SkeletonProps) {\n    const containerRef = useRef<HTMLDivElement>(null);\n    const [isInViewport, setIsInViewport] = useState(false);\n    const [isDocumentVisible, setIsDocumentVisible] = useState(\n        typeof document === 'undefined' ? true : document.visibilityState === 'visible',\n    );\n\n    useEffect(() => {\n        if (!enableAnimation || typeof document === 'undefined') {\n            return;\n        }\n\n        const handleVisibilityChange = () => {\n            setIsDocumentVisible(document.visibilityState === 'visible');\n        };\n\n        document.addEventListener('visibilitychange', handleVisibilityChange);\n\n        return () => {\n            document.removeEventListener('visibilitychange', handleVisibilityChange);\n        };\n    }, [enableAnimation]);\n\n    useEffect(() => {\n        if (!enableAnimation) {\n            setIsInViewport(false);\n\n            return;\n        }\n\n        const element = containerRef.current;\n\n        if (!element) {\n            return;\n        }\n\n        if (typeof IntersectionObserver === 'undefined') {\n            setIsInViewport(true);\n\n            return;\n        }\n\n        const observer = new IntersectionObserver(\n            (entries) => {\n                const [entry] = entries;\n                setIsInViewport(Boolean(entry?.isIntersecting));\n            },\n            { threshold: 0.01 },\n        );\n\n        observer.observe(element);\n\n        return () => {\n            observer.disconnect();\n        };\n    }, [enableAnimation, count, inline, isCentered, direction]);\n\n    const shouldAnimate = enableAnimation && isDocumentVisible && isInViewport;\n\n    const skeletonStyle: CSSProperties = {\n        ...style,\n        ...(baseColor && { ['--base-color' as string]: baseColor }),\n        ...(borderRadius && { ['--skeleton-border-radius' as string]: borderRadius }),\n        ...(height !== undefined && {\n            height: typeof height === 'number' ? `${height}px` : height,\n        }),\n        ...(width !== undefined && { width: typeof width === 'number' ? `${width}px` : width }),\n    };\n\n    const containerClasses = clsx(styles.skeletonContainer, containerClassName, {\n        [styles.centered]: isCentered,\n        [styles.inline]: inline,\n        [styles.rtl]: direction === 'rtl',\n    });\n\n    const skeletonClasses = clsx(styles.skeleton, className, {\n        [styles.animated]: shouldAnimate,\n    });\n\n    if (count <= 1) {\n        return (\n            <div className={containerClasses} ref={containerRef}>\n                <div className={skeletonClasses} style={skeletonStyle} />\n            </div>\n        );\n    }\n\n    return (\n        <div\n            className={clsx(containerClasses, styles.skeletonWrapper)}\n            dir={direction}\n            ref={containerRef}\n        >\n            {Array.from({ length: count }, (_, i) => (\n                <div className={skeletonClasses} key={i} style={skeletonStyle} />\n            ))}\n        </div>\n    );\n}\n\nexport const Skeleton = memo(BaseSkeleton);\n\nSkeleton.displayName = 'Skeleton';\n"
  },
  {
    "path": "src/shared/components/slider/slider.module.css",
    "content": ".track {\n    height: 0.5rem;\n}\n\n.thumb {\n    background: var(--theme-colors-foreground);\n}\n\n.label {\n    max-width: 200px;\n    padding: var(--theme-spacing-sm) var(--theme-spacing-md);\n    font-size: var(--theme-font-size-md);\n    font-weight: 550;\n    color: var(--theme-colors-surface-foreground);\n    background: var(--theme-colors-surface);\n    box-shadow: 4px 4px 10px 0 rgb(0 0 0 / 20%);\n}\n"
  },
  {
    "path": "src/shared/components/slider/slider.tsx",
    "content": "import type { SliderProps as MantineSliderProps } from '@mantine/core';\n\nimport { Slider as MantineSlider } from '@mantine/core';\nimport { forwardRef } from 'react';\n\nimport styles from './slider.module.css';\n\nexport interface SliderProps extends MantineSliderProps {}\n\nexport const Slider = forwardRef<HTMLDivElement, SliderProps>(\n    ({ classNames, style, ...props }, ref) => {\n        return (\n            <MantineSlider\n                classNames={{\n                    bar: styles.bar,\n                    label: styles.label,\n                    thumb: styles.thumb,\n                    track: styles.track,\n                    ...classNames,\n                }}\n                ref={ref}\n                style={{\n                    ...style,\n                }}\n                {...props}\n            />\n        );\n    },\n);\n"
  },
  {
    "path": "src/shared/components/spinner/spinner.module.css",
    "content": ".container {\n    width: 100%;\n    height: 100%;\n}\n\n.icon {\n    animation: rotating 1s linear infinite;\n}\n\n@keyframes rotating {\n    from {\n        transform: rotate(0deg);\n    }\n\n    to {\n        transform: rotate(360deg);\n    }\n}\n"
  },
  {
    "path": "src/shared/components/spinner/spinner.tsx",
    "content": "import { Center } from '@mantine/core';\nimport { memo } from 'react';\nimport { IconBaseProps } from 'react-icons';\nimport { CgSpinnerTwo } from 'react-icons/cg';\n\nimport styles from './spinner.module.css';\n\ninterface SpinnerProps extends IconBaseProps {\n    color?: string;\n    container?: boolean;\n    size?: number;\n}\n\nexport const SpinnerIcon = CgSpinnerTwo;\n\nconst _Spinner = ({ ...props }: SpinnerProps) => {\n    if (props.container) {\n        return (\n            <Center className={styles.container}>\n                <SpinnerIcon className={styles.icon} color={props.color} size={props.size} />\n            </Center>\n        );\n    }\n\n    return <SpinnerIcon className={styles.icon} color={props.color} size={props.size} />;\n};\n\n_Spinner.displayName = 'Spinner';\n\nexport const Spinner = memo(_Spinner);\n"
  },
  {
    "path": "src/shared/components/spoiler/spoiler.module.css",
    "content": ".control:hover {\n    color: var(--theme-btn-subtle-fg-hover);\n    text-decoration: none;\n}\n\n.spoiler {\n    position: relative;\n    width: 100%;\n    height: 100%;\n    overflow: hidden;\n    white-space: pre-wrap;\n    cursor: pointer;\n}\n\n.control {\n    width: 100%;\n    padding: var(--theme-spacing-md) 0;\n    color: var(--theme-colors-foreground);\n}\n\n.spoiler.can-expand {\n    cursor: pointer;\n}\n\n.spoiler.is-expanded {\n    max-height: 2500px !important;\n}\n"
  },
  {
    "path": "src/shared/components/spoiler/spoiler.tsx",
    "content": "import { Spoiler as MantineSpoiler, SpoilerProps as MantineSpoilerProps } from '@mantine/core';\nimport { ReactNode, useState } from 'react';\n\nimport styles from './spoiler.module.css';\n\nimport { Icon } from '/@/shared/components/icon/icon';\n\ninterface SpoilerProps extends Omit<MantineSpoilerProps, 'hideLabel' | 'showLabel'> {\n    children?: ReactNode;\n}\n\nexport const Spoiler = ({ children, maxHeight = 56, ...props }: SpoilerProps) => {\n    const [expanded, setExpanded] = useState(false);\n\n    return (\n        <MantineSpoiler\n            classNames={{ content: styles.spoiler, control: styles.control }}\n            expanded={expanded}\n            maxHeight={maxHeight}\n            {...props}\n            hideLabel={<Icon icon=\"arrowUpS\" size=\"lg\" />}\n            onClick={() => setExpanded(!expanded)}\n            showLabel={<Icon icon=\"arrowDownS\" size=\"lg\" />}\n        >\n            {children}\n        </MantineSpoiler>\n    );\n};\n"
  },
  {
    "path": "src/shared/components/stack/stack.tsx",
    "content": "import { Stack as MantineStack, StackProps as MantineStackProps } from '@mantine/core';\nimport { forwardRef, memo, useMemo } from 'react';\n\nexport interface StackProps extends MantineStackProps {}\n\nconst _Stack = forwardRef<HTMLDivElement, StackProps>(\n    ({ children, classNames, style, ...props }, ref) => {\n        const memoizedClassNames = useMemo(() => ({ ...classNames }), [classNames]);\n        const memoizedStyle = useMemo(() => ({ ...style }), [style]);\n\n        return (\n            <MantineStack\n                classNames={memoizedClassNames}\n                ref={ref}\n                style={memoizedStyle}\n                {...props}\n            >\n                {children}\n            </MantineStack>\n        );\n    },\n);\n\n_Stack.displayName = 'Stack';\n\nexport const Stack = memo(_Stack);\n"
  },
  {
    "path": "src/shared/components/switch/switch.module.css",
    "content": ".thumb {\n    background: var(--theme-colors-foreground);\n}\n\n.track {\n    background-color: var(--theme-colors-surface);\n    border: 1px solid var(--theme-colors-border);\n\n    input:checked + & {\n        background-color: var(--theme-colors-primary-filled);\n\n        & > .thumb {\n            background: var(--theme-colors-primary-contrast);\n        }\n    }\n}\n"
  },
  {
    "path": "src/shared/components/switch/switch.tsx",
    "content": "import type { SwitchProps as MantineSwitchProps } from '@mantine/core';\n\nimport { Switch as MantineSwitch } from '@mantine/core';\nimport { forwardRef, Ref } from 'react';\n\nimport styles from './switch.module.css';\n\ntype SwitchProps = MantineSwitchProps;\n\nexport const Switch = forwardRef(\n    ({ classNames, ...props }: SwitchProps, ref: Ref<HTMLInputElement>) => {\n        return (\n            <MantineSwitch\n                classNames={{\n                    input: styles.input,\n                    root: styles.root,\n                    thumb: styles.thumb,\n                    track: styles.track,\n                    ...classNames,\n                }}\n                ref={ref}\n                withThumbIndicator={false}\n                {...props}\n            />\n        );\n    },\n);\n"
  },
  {
    "path": "src/shared/components/table/table.module.css",
    "content": ".td {\n    padding: var(--theme-spacing-xs) var(--theme-spacing-sm);\n}\n\n.th {\n    padding: var(--theme-spacing-xs) var(--theme-spacing-sm);\n    background-color: initial;\n}\n"
  },
  {
    "path": "src/shared/components/table/table.tsx",
    "content": "import { Table as MantineTable, TableProps as MantineTableProps } from '@mantine/core';\n\nimport styles from './table.module.css';\n\nexport interface TableProps extends MantineTableProps {}\n\nexport const Table = ({ classNames, ...props }: TableProps) => {\n    return (\n        <MantineTable\n            classNames={{\n                td: styles.td,\n                th: styles.th,\n                ...classNames,\n            }}\n            {...props}\n        />\n    );\n};\n\nTable.Thead = MantineTable.Thead;\nTable.Tr = MantineTable.Tr;\nTable.Td = MantineTable.Td;\nTable.Th = MantineTable.Th;\nTable.Tbody = MantineTable.Tbody;\n"
  },
  {
    "path": "src/shared/components/tabs/tabs.module.css",
    "content": ".root {\n    height: 100%;\n}\n\n.list {\n    padding-right: var(--theme-spacing-md);\n\n    &::before {\n        border: 1px solid var(--theme-colors-border);\n    }\n}\n\n.tab {\n    padding: var(--theme-spacing-md);\n    font-weight: 500;\n    color: var(--theme-btn-subtle-fg);\n    transition: color 0.2s ease-in-out;\n\n    &:hover {\n        color: var(--theme-btn-subtle-fg-hover);\n        background: var(--theme-btn-subtle-bg-hover);\n    }\n}\n\n.panel {\n    padding: var(--theme-spacing-lg) var(--theme-spacing-sm);\n}\n\n.tab[data-active] {\n    color: var(--theme-btn-subtle-fg);\n    background: none;\n    border-color: var(--theme-colors-primary-filled);\n\n    &:hover {\n        background: none;\n    }\n}\n"
  },
  {
    "path": "src/shared/components/tabs/tabs.tsx",
    "content": "import { Tabs as MantineTabs, TabsProps as MantineTabsProps, TabsPanelProps } from '@mantine/core';\nimport { Suspense } from 'react';\n\nimport styles from './tabs.module.css';\n\ntype TabsProps = MantineTabsProps;\n\nexport const Tabs = ({ children, ...props }: TabsProps) => {\n    return (\n        <MantineTabs\n            classNames={{\n                list: styles.list,\n                panel: styles.panel,\n                root: styles.root,\n                tab: styles.tab,\n            }}\n            {...props}\n        >\n            {children}\n        </MantineTabs>\n    );\n};\n\nconst Panel = ({ children, ...props }: TabsPanelProps) => {\n    return (\n        <MantineTabs.Panel {...props}>\n            <Suspense fallback={<></>}>{children}</Suspense>\n        </MantineTabs.Panel>\n    );\n};\n\nTabs.List = MantineTabs.List;\nTabs.Panel = Panel;\nTabs.Tab = MantineTabs.Tab;\n"
  },
  {
    "path": "src/shared/components/text/text.module.css",
    "content": ".root {\n    font-family: var(--font-family);\n    color: var(--theme-colors-foreground);\n    user-select: auto;\n}\n\n.muted {\n    color: var(--theme-colors-foreground-muted);\n}\n\n.root.link {\n    cursor: pointer;\n}\n\n.root.link:hover {\n    color: var(--theme-colors-foreground);\n    text-decoration: underline;\n}\n\n.root.overflow-hidden {\n    overflow: hidden;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n}\n\n.no-select {\n    user-select: none;\n}\n"
  },
  {
    "path": "src/shared/components/text/text.tsx",
    "content": "import { Text as MantineText, TextProps as MantineTextProps } from '@mantine/core';\nimport clsx from 'clsx';\nimport { ComponentPropsWithoutRef, ReactNode, useMemo } from 'react';\n\nimport styles from './text.module.css';\n\nimport { createPolymorphicComponent } from '/@/shared/utils/create-polymorphic-component';\n\nexport interface TextProps extends MantineTextDivProps {\n    children?: ReactNode;\n    font?: Font;\n    isLink?: boolean;\n    isMuted?: boolean;\n    isNoSelect?: boolean;\n    overflow?: 'hidden' | 'visible';\n    to?: string;\n    weight?: number;\n}\n\ntype Font = 'Epilogue' | 'Gotham' | 'Inter' | 'Poppins';\n\ntype MantineTextDivProps = ComponentPropsWithoutRef<'div'> & MantineTextProps;\n\nexport const BaseText = ({\n    children,\n    font,\n    isLink,\n    isMuted,\n    isNoSelect,\n    overflow,\n    weight,\n    ...rest\n}: TextProps) => {\n    const classNames = useMemo(\n        () => ({\n            root: clsx(styles.root, {\n                [styles.link]: isLink,\n                [styles.muted]: isMuted,\n                [styles.noSelect]: isNoSelect,\n                [styles.overflowHidden]: overflow === 'hidden',\n            }),\n        }),\n        [isLink, isMuted, isNoSelect, overflow],\n    );\n\n    const style = useMemo(\n        () =>\n            ({\n                '--font-family': font,\n            }) as React.CSSProperties,\n        [font],\n    );\n\n    return (\n        <MantineText classNames={classNames} component=\"div\" fw={weight} style={style} {...rest}>\n            {children}\n        </MantineText>\n    );\n};\n\nexport const Text = createPolymorphicComponent<'div', TextProps>(BaseText);\n"
  },
  {
    "path": "src/shared/components/text-input/text-input.module.css",
    "content": ".root {\n    transition: width 0.3s ease-in-out;\n}\n\n.input {\n    width: 100%;\n    border: 1px solid transparent;\n\n    &[data-variant='default'] {\n        color: var(--theme-colors-surface-foreground);\n        background: var(--theme-colors-surface);\n    }\n\n    &[data-variant='filled'] {\n        color: var(--theme-colors-foreground);\n        background: var(--theme-colors-background);\n    }\n}\n\n.input:focus,\n.input:focus-visible {\n    border-color: lighten(var(--theme-colors-border), 10%);\n}\n\n.label {\n    margin-bottom: var(--theme-spacing-sm);\n}\n\n.section {\n    color: var(--theme-colors-foreground-muted);\n}\n\n.required {\n    color: var(--theme-colors-state-error);\n}\n\n.disabled {\n    opacity: 0.6;\n}\n"
  },
  {
    "path": "src/shared/components/text-input/text-input.tsx",
    "content": "import {\n    TextInput as MantineTextInput,\n    TextInputProps as MantineTextInputProps,\n} from '@mantine/core';\nimport { CSSProperties, forwardRef } from 'react';\n\nimport styles from './text-input.module.css';\n\nexport interface TextInputProps extends MantineTextInputProps {\n    maxWidth?: CSSProperties['maxWidth'];\n    width?: CSSProperties['width'];\n}\n\nexport const TextInput = forwardRef<HTMLInputElement, TextInputProps>(\n    (\n        {\n            children,\n            classNames,\n            maxWidth,\n            size = 'sm',\n            style,\n            variant = 'default',\n            width,\n            ...props\n        }: TextInputProps,\n        ref,\n    ) => {\n        return (\n            <MantineTextInput\n                classNames={{\n                    input: styles.input,\n                    label: styles.label,\n                    required: styles.required,\n                    root: styles.root,\n                    section: styles.section,\n                    wrapper: styles.wrapper,\n                    ...classNames,\n                }}\n                ref={ref}\n                size={size}\n                spellCheck={false}\n                style={{ maxWidth, width, ...style }}\n                variant={variant}\n                {...props}\n            >\n                {children}\n            </MantineTextInput>\n        );\n    },\n);\n"
  },
  {
    "path": "src/shared/components/text-title/text-title.module.css",
    "content": ".root {\n    color: var(--theme-colors-foreground);\n    transition: color 0.2s ease-in-out;\n}\n\n.muted {\n    color: var(--theme-colors-foreground-muted);\n}\n\n.link {\n    cursor: pointer;\n}\n\n.link:hover {\n    color: var(--theme-colors-foreground);\n    text-decoration: underline;\n}\n\n.no-select {\n    user-select: none;\n}\n\n.overflow-hidden {\n    overflow: hidden;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n}\n"
  },
  {
    "path": "src/shared/components/text-title/text-title.tsx",
    "content": "import type { TitleProps as MantineTitleProps } from '@mantine/core';\nimport type { ComponentPropsWithoutRef, ReactNode } from 'react';\n\nimport { createPolymorphicComponent, Title as MantineHeader } from '@mantine/core';\nimport clsx from 'clsx';\n\nimport styles from './text-title.module.css';\n\ntype MantineTextTitleDivProps = ComponentPropsWithoutRef<'div'> & MantineTitleProps;\n\ninterface TextTitleProps extends MantineTextTitleDivProps {\n    children?: ReactNode;\n    isLink?: boolean;\n    isMuted?: boolean;\n    isNoSelect?: boolean;\n    overflow?: 'hidden' | 'visible';\n    to?: string;\n    weight?: number;\n}\n\nconst _TextTitle = ({\n    children,\n    className,\n    isLink,\n    isMuted,\n    isNoSelect,\n    overflow,\n    weight,\n    ...rest\n}: TextTitleProps) => {\n    return (\n        <MantineHeader\n            className={clsx(\n                styles.root,\n                {\n                    [styles.link]: isLink,\n                    [styles.muted]: isMuted,\n                    [styles.noSelect]: isNoSelect,\n                    [styles.overflowHidden]: overflow === 'hidden' && !rest.lineClamp,\n                },\n                className,\n            )}\n            fw={weight}\n            {...rest}\n        >\n            {children}\n        </MantineHeader>\n    );\n};\n\nexport const TextTitle = createPolymorphicComponent<'div', TextTitleProps>(_TextTitle);\n"
  },
  {
    "path": "src/shared/components/textarea/textarea.module.css",
    "content": ".root {\n    transition: width 0.3s ease-in-out;\n\n    &[data-disabled='true'] {\n        opacity: 0.6;\n    }\n}\n\n.input {\n    color: var(--theme-colors-surface-foreground);\n    background: var(--theme-colors-surface);\n    border: 1px solid transparent;\n}\n\n.input:focus,\n.input:focus-visible {\n    border-color: lighten(var(--theme-colors-border), 10%);\n}\n\n.label {\n    margin-bottom: var(--theme-spacing-sm);\n}\n"
  },
  {
    "path": "src/shared/components/textarea/textarea.tsx",
    "content": "import { Textarea as MantineTextarea, TextareaProps as MantineTextareaProps } from '@mantine/core';\nimport { CSSProperties, forwardRef } from 'react';\n\nimport styles from './textarea.module.css';\n\nexport interface TextareaProps extends MantineTextareaProps {\n    maxWidth?: CSSProperties['maxWidth'];\n    width?: CSSProperties['width'];\n}\n\nexport const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(\n    ({ children, classNames, maxWidth, style, width, ...props }: TextareaProps, ref) => {\n        return (\n            <MantineTextarea\n                classNames={{\n                    input: styles.input,\n                    label: styles.label,\n                    required: styles.required,\n                    root: styles.root,\n                    wrapper: styles.wrapper,\n                    ...classNames,\n                }}\n                ref={ref}\n                style={{ maxWidth, width, ...style }}\n                {...props}\n            >\n                {children}\n            </MantineTextarea>\n        );\n    },\n);\n"
  },
  {
    "path": "src/shared/components/toast/toast.module.css",
    "content": ".root {\n    background-color: var(--theme-colors-surface);\n}\n\n.root.error {\n    --notification-color: var(--theme-colors-state-error);\n}\n\n.root.info {\n    --notification-color: var(--theme-colors-state-info);\n}\n\n.root.success {\n    --notification-color: var(--theme-colors-state-success);\n}\n\n.root.warning {\n    --notification-color: var(--theme-colors-state-warning);\n}\n\n.title {\n    font-size: var(--theme-font-size-md);\n}\n\n.body {\n    padding: var(--theme-spacing-md);\n}\n\n.loader {\n    margin: var(--theme-spacing-md);\n}\n\n.description {\n    font-size: var(--theme-font-size-md);\n}\n\n.close-button {\n    background-color: var(--theme-colors-surface);\n}\n"
  },
  {
    "path": "src/shared/components/toast/toast.tsx",
    "content": "import type { NotificationData } from '@mantine/notifications';\n\nimport {\n    cleanNotifications,\n    cleanNotificationsQueue,\n    hideNotification,\n    notifications,\n    updateNotification,\n} from '@mantine/notifications';\nimport clsx from 'clsx';\n\nimport styles from './toast.module.css';\n\ninterface NotificationProps extends Omit<NotificationData, 'message'> {\n    message?: string;\n    onClose?: () => void;\n    type?: 'error' | 'info' | 'success' | 'warning';\n}\n\nconst getTitle = (type: NotificationProps['type']) => {\n    if (type === 'success') return 'Success';\n    if (type === 'warning') return 'Warning';\n    if (type === 'error') return 'Error';\n    return 'Info';\n};\n\nconst showToast = ({ message, onClose, type, ...props }: NotificationProps) => {\n    return notifications.show({\n        ...props,\n        classNames: {\n            body: styles.body,\n            closeButton: styles.closeButton,\n            description: styles.description,\n            loader: styles.loader,\n            root: clsx(styles.root, {\n                [styles.error]: type === 'error',\n                [styles.info]: type === 'info',\n                [styles.success]: type === 'success',\n                [styles.warning]: type === 'warning',\n            }),\n            title: styles.title,\n        },\n        message: message ?? '',\n        onClose,\n        title: getTitle(type),\n        withBorder: true,\n        withCloseButton: true,\n    });\n};\n\nexport const toast = {\n    clean: cleanNotifications,\n    cleanQueue: cleanNotificationsQueue,\n    error: (props: NotificationProps) => showToast({ type: 'error', ...props }),\n    hide: hideNotification,\n    info: (props: NotificationProps) => showToast({ type: 'info', ...props }),\n    show: showToast,\n    success: (props: NotificationProps) => showToast({ type: 'success', ...props }),\n    update: updateNotification,\n    warn: (props: NotificationProps) => showToast({ type: 'warning', ...props }),\n};\n"
  },
  {
    "path": "src/shared/components/tooltip/tooltip.module.css",
    "content": ".tooltip {\n    padding: var(--theme-spacing-sm) var(--theme-spacing-md);\n    font-size: var(--theme-font-size-lg);\n    font-weight: 500;\n    color: var(--theme-colors-surface-foreground);\n    background: var(--theme-colors-surface);\n    box-shadow: 2px 2px 10px 0 rgb(0 0 0 / 60%);\n}\n"
  },
  {
    "path": "src/shared/components/tooltip/tooltip.tsx",
    "content": "import { Tooltip as MantineTooltip, TooltipProps as MantineTooltipProps } from '@mantine/core';\nimport clsx from 'clsx';\nimport { memo, useMemo } from 'react';\n\nimport styles from './tooltip.module.css';\n\nexport interface TooltipProps extends MantineTooltipProps {}\n\nconst DEFAULT_TRANSITION_PROPS = {\n    duration: 250,\n    transition: 'fade',\n} as const;\n\nconst TooltipComponent = memo(\n    ({\n        children,\n        classNames,\n        openDelay = 500,\n        transitionProps = DEFAULT_TRANSITION_PROPS,\n        withinPortal = true,\n        ...props\n    }: TooltipProps) => {\n        const memoizedClassNames = useMemo(\n            () => ({\n                ...classNames,\n                tooltip: clsx(styles.tooltip, classNames?.['tooltip']),\n            }),\n            [classNames],\n        );\n\n        const memoizedTransitionProps = useMemo(\n            () => transitionProps ?? DEFAULT_TRANSITION_PROPS,\n            [transitionProps],\n        );\n\n        return (\n            <MantineTooltip\n                arrowSize={10}\n                classNames={memoizedClassNames}\n                multiline\n                openDelay={openDelay}\n                transitionProps={memoizedTransitionProps}\n                withArrow\n                withinPortal={withinPortal}\n                {...props}\n            >\n                {children}\n            </MantineTooltip>\n        );\n    },\n);\n\nTooltipComponent.displayName = 'Tooltip';\n\nexport const Tooltip = TooltipComponent as typeof TooltipComponent & {\n    Group: typeof MantineTooltip.Group;\n};\n\nTooltip.Group = MantineTooltip.Group;\n\nTooltip.Group = MantineTooltip.Group;\n"
  },
  {
    "path": "src/shared/components/yes-no-select/yes-no-select.tsx",
    "content": "import { useTranslation } from 'react-i18next';\n\nimport { Select, SelectProps } from '/@/shared/components/select/select';\n\nexport interface YesNoSelectProps extends SelectProps {}\n\nexport const YesNoSelect = ({ ...props }: YesNoSelectProps) => {\n    const { t } = useTranslation();\n\n    return (\n        <Select\n            clearable\n            data={[\n                {\n                    label: t('common.no', { postProcess: 'sentenceCase' }),\n                    value: 'false',\n                },\n                {\n                    label: t('common.yes', { postProcess: 'sentenceCase' }),\n                    value: 'true',\n                },\n            ]}\n            {...props}\n        />\n    );\n};\n"
  },
  {
    "path": "src/shared/constants/playback-selectors.ts",
    "content": "// Defines the selectors used to identify playback-related elements in the UI.\n// Can be used by browser extensions for accessing meta data around currently playing media.\n\nexport const PlaybackSelectors = {\n    elapsedTime: 'elapsed-time',\n    mediaPlayer: 'media-player',\n    playerCoverArt: 'player-cover-art',\n    playerStatePaused: 'player-state-paused',\n    playerStatePlaying: 'player-state-playing',\n    songAlbum: 'song-album',\n    songArtist: 'song-artist',\n    songTitle: 'song-title',\n    totalDuration: 'total-duration',\n} as const;\n"
  },
  {
    "path": "src/shared/hooks/use-click-outside.ts",
    "content": "import { useClickOutside as useMantineClickOutside } from '@mantine/hooks';\n\nexport const useClickOutside = useMantineClickOutside;\n"
  },
  {
    "path": "src/shared/hooks/use-container-query.ts",
    "content": "import { useElementSize } from '@mantine/hooks';\n\ninterface UseContainerQueryProps {\n    '2xl'?: number;\n    '3xl'?: number;\n    lg?: number;\n    md?: number;\n    sm?: number;\n    xl?: number;\n}\n\nexport const useContainerQuery = (props?: UseContainerQueryProps) => {\n    const { '2xl': xxl, '3xl': xxxl, lg, md, sm, xl } = props || {};\n    const { height, ref, width } = useElementSize();\n\n    const isXs = width >= 0;\n    const isSm = width >= (sm || 600);\n    const isMd = width >= (md || 768);\n    const isLg = width >= (lg || 1200);\n    const isXl = width >= (xl || 1500);\n    const is2xl = width >= (xxl || 1920);\n    const is3xl = width >= (xxxl || 2560);\n\n    return { height, is2xl, is3xl, isLg, isMd, isSm, isXl, isXs, ref, width };\n};\n"
  },
  {
    "path": "src/shared/hooks/use-debounced-callback.ts",
    "content": "import { useDebouncedCallback as useMantineDebouncedCallback } from '@mantine/hooks';\n\nexport const useDebouncedCallback = useMantineDebouncedCallback;\n"
  },
  {
    "path": "src/shared/hooks/use-debounced-state.ts",
    "content": "import { useDebouncedState as useMantineDebouncedState } from '@mantine/hooks';\n\nexport const useDebouncedState = useMantineDebouncedState;\n"
  },
  {
    "path": "src/shared/hooks/use-debounced-value.ts",
    "content": "import { useEffect, useRef, useState } from 'react';\n\ninterface UseDebouncedValueOptions {\n    waitForInitial?: boolean;\n}\n\nexport function useDebouncedValue<T>(\n    value: T,\n    delay: number,\n    options?: UseDebouncedValueOptions,\n): [T | undefined] {\n    const { waitForInitial = false } = options || {};\n    const [debouncedValue, setDebouncedValue] = useState<T | undefined>(\n        waitForInitial ? undefined : value,\n    );\n    const timeoutRef = useRef<NodeJS.Timeout | null>(null);\n\n    useEffect(() => {\n        // Clear any existing timeout\n        if (timeoutRef.current) {\n            clearTimeout(timeoutRef.current);\n        }\n\n        // Set up a new timeout to update the debounced value\n        timeoutRef.current = setTimeout(() => {\n            setDebouncedValue(value);\n        }, delay);\n\n        // Cleanup function to clear the timeout if the component unmounts or value changes\n        return () => {\n            if (timeoutRef.current) {\n                clearTimeout(timeoutRef.current);\n            }\n        };\n    }, [value, delay]);\n\n    // Cleanup on unmount\n    useEffect(() => {\n        return () => {\n            if (timeoutRef.current) {\n                clearTimeout(timeoutRef.current);\n            }\n        };\n    }, []);\n\n    return [debouncedValue];\n}\n"
  },
  {
    "path": "src/shared/hooks/use-disclosure.ts",
    "content": "import { useDisclosure as useMantineDisclosure } from '@mantine/hooks';\n\nexport const useDisclosure = useMantineDisclosure;\n"
  },
  {
    "path": "src/shared/hooks/use-double-click.ts",
    "content": "import { useCallback, useEffect, useRef } from 'react';\n\nexport const useDoubleClick = ({\n    doubleClickLatency = 300,\n    onDoubleClick = () => null,\n    onSingleClick = () => null,\n    singleClickLatency = 20,\n}: {\n    doubleClickLatency?: number;\n    onDoubleClick?: (e: any) => void;\n    onSingleClick?: (e: any) => void;\n    singleClickLatency?: number;\n}) => {\n    const clickCountRef = useRef(0);\n    const singleClickTimeoutRef = useRef<NodeJS.Timeout | null>(null);\n    const doubleClickTimeoutRef = useRef<NodeJS.Timeout | null>(null);\n    const singleClickFiredRef = useRef(false);\n    const lastClickEventRef = useRef<any>(null);\n\n    // Use latency for backward compatibility, but prefer doubleClickLatency\n    const effectiveDoubleClickLatency = doubleClickLatency;\n    const effectiveSingleClickLatency = singleClickLatency ?? 50;\n\n    const clearSingleClick = useCallback(() => {\n        if (singleClickTimeoutRef.current) {\n            clearTimeout(singleClickTimeoutRef.current);\n            singleClickTimeoutRef.current = null;\n        }\n    }, []);\n\n    const handleClick = useCallback(\n        (e: any) => {\n            clickCountRef.current += 1;\n            lastClickEventRef.current = e;\n\n            if (clickCountRef.current === 1) {\n                // First click: fire single click optimistically after short delay\n                singleClickFiredRef.current = false;\n\n                // Set double-click detection window first\n                doubleClickTimeoutRef.current = setTimeout(() => {\n                    clickCountRef.current = 0;\n                    singleClickFiredRef.current = false;\n                }, effectiveDoubleClickLatency);\n\n                // Fire single click after delay (defaults to 0 for immediate response)\n                if (effectiveSingleClickLatency > 0) {\n                    singleClickTimeoutRef.current = setTimeout(() => {\n                        // Only fire if still a single click and double click hasn't been detected\n                        if (clickCountRef.current === 1 && !singleClickFiredRef.current) {\n                            singleClickFiredRef.current = true;\n                            onSingleClick(lastClickEventRef.current);\n                        }\n                    }, effectiveSingleClickLatency);\n                } else {\n                    // Fire immediately if latency is 0\n                    // Note: If double click comes immediately after, both may fire\n                    // For best UX, use a small delay (e.g., 50ms) instead of 0\n                    singleClickFiredRef.current = true;\n                    onSingleClick(lastClickEventRef.current);\n                }\n            } else if (clickCountRef.current === 2) {\n                // Second click detected within double-click latency\n                // Cancel single click if it hasn't fired yet\n                if (!singleClickFiredRef.current) {\n                    clearSingleClick();\n                }\n\n                // Fire double click\n                onDoubleClick(e);\n\n                // Reset state\n                clickCountRef.current = 0;\n                singleClickFiredRef.current = false;\n\n                // Clear double-click timeout\n                if (doubleClickTimeoutRef.current) {\n                    clearTimeout(doubleClickTimeoutRef.current);\n                    doubleClickTimeoutRef.current = null;\n                }\n            }\n        },\n        [\n            effectiveDoubleClickLatency,\n            effectiveSingleClickLatency,\n            onDoubleClick,\n            onSingleClick,\n            clearSingleClick,\n        ],\n    );\n\n    // Cleanup timeouts on unmount\n    useEffect(() => {\n        return () => {\n            if (singleClickTimeoutRef.current) {\n                clearTimeout(singleClickTimeoutRef.current);\n            }\n            if (doubleClickTimeoutRef.current) {\n                clearTimeout(doubleClickTimeoutRef.current);\n            }\n        };\n    }, []);\n\n    return handleClick;\n};\n"
  },
  {
    "path": "src/shared/hooks/use-element-size.ts",
    "content": "import { useElementSize as useElementSizeMantine } from '@mantine/hooks';\n\nexport const useElementSize = useElementSizeMantine;\n"
  },
  {
    "path": "src/shared/hooks/use-focus-trap.ts",
    "content": "import { useFocusTrap as useMantineFocusTrap } from '@mantine/hooks';\n\nexport const useFocusTrap = useMantineFocusTrap;\n"
  },
  {
    "path": "src/shared/hooks/use-focus-within.ts",
    "content": "import { useFocusWithin as useMantineFocusWithin } from '@mantine/hooks';\n\nexport const useFocusWithin = useMantineFocusWithin;\n"
  },
  {
    "path": "src/shared/hooks/use-form.ts",
    "content": "import { useForm as useMantineForm } from '@mantine/form';\n\nexport const useForm = useMantineForm;\n"
  },
  {
    "path": "src/shared/hooks/use-hotkeys.ts",
    "content": "import {\n    type HotkeyItem as MantineHotkeyItem,\n    useHotkeys as useMantineHotkeys,\n} from '@mantine/hooks';\n\nexport const useHotkeys = useMantineHotkeys;\n\nexport type HotkeyItem = MantineHotkeyItem;\n"
  },
  {
    "path": "src/shared/hooks/use-in-viewport.ts",
    "content": "import { useInViewport as useMantineInViewport } from '@mantine/hooks';\n\nexport const useInViewport = useMantineInViewport;\n"
  },
  {
    "path": "src/shared/hooks/use-intersection.ts",
    "content": "import { useIntersection as useMantineIntersection } from '@mantine/hooks';\n\nexport const useIntersection = useMantineIntersection;\n"
  },
  {
    "path": "src/shared/hooks/use-is-overflow.ts",
    "content": "import { MutableRefObject, useLayoutEffect, useState } from 'react';\n\nexport const useIsOverflow = (ref: MutableRefObject<HTMLDivElement | null>) => {\n    const [isOverflow, setIsOverflow] = useState<boolean | undefined>(undefined);\n\n    useLayoutEffect(() => {\n        const { current } = ref;\n\n        const trigger = () => {\n            const hasOverflow = (current?.scrollHeight || 0) > (current?.clientHeight || 0);\n            setIsOverflow(hasOverflow);\n        };\n\n        if (current) {\n            trigger();\n        }\n    }, [ref]);\n\n    return isOverflow;\n};\n"
  },
  {
    "path": "src/shared/hooks/use-local-storage.ts",
    "content": "import { useLocalStorage as useMantineLocalStorage } from '@mantine/hooks';\n\nexport const useLocalStorage = useMantineLocalStorage;\n"
  },
  {
    "path": "src/shared/hooks/use-long-press.ts",
    "content": "import { useCallback, useRef } from 'react';\n\ninterface UseLongPressOptions<T extends HTMLElement = HTMLElement> {\n    delay?: number;\n    onClick?: (event: React.MouseEvent<T> | React.TouchEvent<T>) => void;\n    onFinish?: (event: null | React.MouseEvent<T> | React.TouchEvent<T>) => void;\n    onLongPress?: (event: React.MouseEvent<T> | React.TouchEvent<T>) => void;\n    onStart?: (event: React.MouseEvent<T> | React.TouchEvent<T>) => void;\n}\n\ninterface UseLongPressReturn {\n    onMouseDown: (event: React.MouseEvent) => void;\n    onMouseLeave: (event: React.MouseEvent) => void;\n    onMouseUp: (event: React.MouseEvent) => void;\n    onTouchCancel: (event: React.TouchEvent) => void;\n    onTouchEnd: (event: React.TouchEvent) => void;\n    onTouchStart: (event: React.TouchEvent) => void;\n}\n\nexport const useLongPress = <T extends HTMLElement = HTMLElement>({\n    delay = 500,\n    onClick,\n    onFinish,\n    onLongPress,\n    onStart,\n}: UseLongPressOptions<T>): UseLongPressReturn => {\n    const timeoutRef = useRef<NodeJS.Timeout | null>(null);\n    const targetRef = useRef<EventTarget | null>(null);\n    const longPressTriggeredRef = useRef(false);\n    const eventRef = useRef<null | React.MouseEvent<T> | React.TouchEvent<T>>(null);\n\n    const start = useCallback(\n        (event: React.MouseEvent<T> | React.TouchEvent<T>) => {\n            longPressTriggeredRef.current = false;\n            targetRef.current = event.target;\n            eventRef.current = event;\n\n            onStart?.(event);\n\n            timeoutRef.current = setTimeout(() => {\n                longPressTriggeredRef.current = true;\n                if (eventRef.current) {\n                    onLongPress?.(eventRef.current);\n                }\n            }, delay);\n        },\n        [onLongPress, onStart, delay],\n    );\n\n    const clear = useCallback(() => {\n        if (timeoutRef.current) {\n            clearTimeout(timeoutRef.current);\n            timeoutRef.current = null;\n        }\n    }, []);\n\n    const handleMouseDown = useCallback(\n        (event: React.MouseEvent) => {\n            if (event.button !== 0) {\n                return;\n            }\n            event.preventDefault();\n            start(event as React.MouseEvent<T>);\n        },\n        [start],\n    );\n\n    const handleMouseUp = useCallback(() => {\n        const event = eventRef.current;\n        clear();\n        if (!longPressTriggeredRef.current && onClick && event) {\n            onClick(event);\n        }\n        onFinish?.(event || null);\n        longPressTriggeredRef.current = false;\n        eventRef.current = null;\n    }, [clear, onClick, onFinish]);\n\n    const handleMouseLeave = useCallback(() => {\n        const event = eventRef.current;\n        clear();\n        onFinish?.(event || null);\n        longPressTriggeredRef.current = false;\n        eventRef.current = null;\n    }, [clear, onFinish]);\n\n    const handleTouchStart = useCallback(\n        (event: React.TouchEvent) => {\n            start(event as React.TouchEvent<T>);\n        },\n        [start],\n    );\n\n    const handleTouchEnd = useCallback(() => {\n        const event = eventRef.current;\n        clear();\n        if (!longPressTriggeredRef.current && onClick && event) {\n            onClick(event);\n        }\n        onFinish?.(event || null);\n        longPressTriggeredRef.current = false;\n        eventRef.current = null;\n    }, [clear, onClick, onFinish]);\n\n    const handleTouchCancel = useCallback(() => {\n        const event = eventRef.current;\n        clear();\n        onFinish?.(event || null);\n        longPressTriggeredRef.current = false;\n        eventRef.current = null;\n    }, [clear, onFinish]);\n\n    return {\n        onMouseDown: handleMouseDown,\n        onMouseLeave: handleMouseLeave,\n        onMouseUp: handleMouseUp,\n        onTouchCancel: handleTouchCancel,\n        onTouchEnd: handleTouchEnd,\n        onTouchStart: handleTouchStart,\n    };\n};\n"
  },
  {
    "path": "src/shared/hooks/use-media-query.ts",
    "content": "import { useMediaQuery as useMantineMediaQuery } from '@mantine/hooks';\n\nexport const useMediaQuery = useMantineMediaQuery;\n"
  },
  {
    "path": "src/shared/hooks/use-merged-ref.ts",
    "content": "import { useMergedRef as useMergedRefMantine } from '@mantine/hooks';\n\nexport const useMergedRef = useMergedRefMantine;\n"
  },
  {
    "path": "src/shared/hooks/use-session-storage.ts",
    "content": "import { useSessionStorage as useMantineSessionStorage } from '@mantine/hooks';\n\nexport const useSessionStorage = useMantineSessionStorage;\n"
  },
  {
    "path": "src/shared/hooks/use-set-state.ts",
    "content": "import { useSetState as useMantineSetState } from '@mantine/hooks';\n\nexport const useSetState = useMantineSetState;\n"
  },
  {
    "path": "src/shared/hooks/use-throttled-callback.ts",
    "content": "import { useThrottledCallback as useMantineThrottledCallback } from '@mantine/hooks';\n\nexport const useThrottledCallback = useMantineThrottledCallback;\n"
  },
  {
    "path": "src/shared/hooks/use-throttled-value.ts",
    "content": "import { useThrottledValue as useMantineThrottledValue } from '@mantine/hooks';\n\nexport const useThrottledValue = useMantineThrottledValue;\n"
  },
  {
    "path": "src/shared/hooks/use-timeout.ts",
    "content": "import { useTimeout as useMantineTimeout } from '@mantine/hooks';\n\nexport const useTimeout = useMantineTimeout;\n"
  },
  {
    "path": "src/shared/styles/ag-grid.css",
    "content": ".ag-header-fixed {\n    position: fixed !important;\n    top: 65px;\n    z-index: 15;\n    padding: 0 2rem;\n    margin: 0 -2rem;\n    box-shadow: 0 -1px 0 0 #181818;\n    transition: position 0.2s ease-in-out;\n}\n\n.ag-header-window-bar {\n    top: 95px;\n}\n\n.ag-header {\n    z-index: 5;\n}\n\n.window-frame {\n    top: 95px;\n}\n\n.ag-header-transparent {\n    --ag-header-background-color: rgb(0 0 0 / 0%) !important;\n}\n\n.ag-header-fixed-margin {\n    margin-top: 36px !important;\n}\n\n.ag-header-cell-comp-wrapper {\n    margin: 0 0.5rem;\n}\n\n.ag-header-cell,\n.ag-header-group-cell {\n    padding-right: 0.5rem;\n    padding-left: 0.5rem;\n}\n\n.ag-header-cell-resize {\n    background-color: transparent;\n}\n"
  },
  {
    "path": "src/shared/styles/global.css",
    "content": "* {\n    box-sizing: border-box;\n    outline: none;\n}\n\n*,\n*::before,\n*::after {\n    box-sizing: border-box;\n    text-rendering: optimizelegibility;\n    -webkit-tap-highlight-color: rgb(0 0 0 / 0%);\n    text-size-adjust: none;\n    -webkit-font-smoothing: antialiased;\n    -moz-osx-font-smoothing: grayscale;\n}\n\nbody {\n    margin: 0;\n}\n\nbody,\nhtml {\n    position: absolute;\n    display: block;\n    width: 100%;\n    height: 100%;\n    overflow: hidden;\n    font-family: var(--theme-content-font-family);\n    font-size: var(--theme-root-font-size);\n    font-variant-numeric: tabular-nums;\n    color: var(--theme-colors-foreground);\n    background: var(--theme-colors-background);\n}\n\ninput,\nbutton,\ntextarea,\nselect {\n    font: inherit;\n}\n\nbutton,\nselect {\n    text-transform: none;\n}\n\nimg {\n    user-select: none;\n    -webkit-user-drag: none;\n}\n\n@media only screen and (width < 640px) {\n    body,\n    html {\n        overflow-x: auto;\n    }\n}\n\n#app {\n    height: inherit;\n}\n\n::-webkit-scrollbar {\n    width: 12px;\n    height: 12px;\n}\n\n::-webkit-scrollbar-corner {\n    background: var(--theme-scrollbar-track-background);\n}\n\n::-webkit-scrollbar-track {\n    background: var(--theme-scrollbar-track-background);\n}\n\n::-webkit-scrollbar-thumb {\n    background: var(--theme-scrollbar-handle-background);\n}\n\n::-webkit-scrollbar-thumb:hover {\n    background: var(--theme-scrollbar-handle-hover-background);\n}\n\na {\n    text-decoration: none;\n}\n\nbutton {\n    -webkit-app-region: no-drag;\n}\n\n.hide-scrollbar {\n    &::-webkit-scrollbar-track {\n        background: transparent;\n    }\n\n    &::-webkit-scrollbar-thumb {\n        background-color: transparent;\n    }\n}\n\n@keyframes fade-in {\n    from {\n        opacity: 0;\n    }\n\n    to {\n        opacity: 1;\n    }\n}\n\n@keyframes fade-out {\n    from {\n        opacity: 1;\n    }\n\n    to {\n        opacity: 0;\n    }\n}\n\n@font-face {\n    font-family: Symbola;\n    src: url('../../../assets/fonts/Symbola.woff2');\n    font-display: swap;\n}\n\n\n@font-face {\n    font-family: Inter;\n    font-weight: 100 1000;\n    src: url('../../../assets/fonts/Inter-VariableFont_opsz,wght.woff2');\n    font-display: swap;\n}\n\n@font-face {\n    font-family: Poppins;\n    font-style: normal;\n    font-weight: 400;\n    src: url('../../../assets/fonts/Poppins-Regular.woff2');\n    font-display: swap;\n}\n\n@font-face {\n    font-family: Poppins;\n    font-style: normal;\n    font-weight: 500;\n    src: url('../../../assets/fonts/Poppins-Medium.woff2');\n    font-display: swap;\n}\n\n@font-face {\n    font-family: Poppins;\n    font-style: normal;\n    font-weight: 600;\n    src: url('../../../assets/fonts/Poppins-SemiBold.woff2');\n    font-display: swap;\n}\n\n@font-face {\n    font-family: Poppins;\n    font-style: normal;\n    font-weight: 700;\n    src: url('../../../assets/fonts/Poppins-Bold.woff2');\n    font-display: swap;\n}\n\n@font-face {\n    font-family: Poppins;\n    font-style: normal;\n    font-weight: 800;\n    src: url('../../../assets/fonts/Poppins-ExtraBold.woff2');\n    font-display: swap;\n}\n\n@font-face {\n    font-family: Poppins;\n    font-style: normal;\n    font-weight: 900;\n    src: url('../../../assets/fonts/Poppins-Black.woff2');\n    font-display: swap;\n}\n\n@font-face {\n    font-family: 'Noto Sans JP';\n    font-variation-settings: 'wght' 500;\n    font-weight: 400;\n    src: url('../../../assets/fonts/NotoSansJP-VariableFont_wght.woff2');\n    unicode-range: U+3000-9FFF, U+FF00-FFEF; /* Japanese characters */\n}\n\n@font-face {\n    font-family: 'Noto Sans JP';\n    font-variation-settings: 'wght' 600;\n    font-weight: 500;\n    src: url('../../../assets/fonts/NotoSansJP-VariableFont_wght.woff2');\n    unicode-range: U+3000-9FFF, U+FF00-FFEF; /* Japanese characters */\n}\n\n@font-face {\n    font-family: 'Noto Sans JP';\n    font-variation-settings: 'wght' 700;\n    font-weight: 600;\n    src: url('../../../assets/fonts/NotoSansJP-VariableFont_wght.woff2');\n    unicode-range: U+3000-9FFF, U+FF00-FFEF; /* Japanese characters */\n}\n\n@font-face {\n    font-family: 'Noto Sans JP';\n    font-variation-settings: 'wght' 800;\n    font-weight: 700;\n    src: url('../../../assets/fonts/NotoSansJP-VariableFont_wght.woff2');\n    unicode-range: U+3000-9FFF, U+FF00-FFEF; /* Japanese characters */\n}\n\n@font-face {\n    font-family: 'Noto Sans JP';\n    font-variation-settings: 'wght' 900;\n    font-weight: 800 1000;\n    src: url('../../../assets/fonts/NotoSansJP-VariableFont_wght.woff2');\n    unicode-range: U+3000-9FFF, U+FF00-FFEF; /* Japanese characters */\n}\n\n@font-face {\n    font-family: 'Noto Sans Hebrew';\n    font-variation-settings: 'wght' 500;\n    font-weight: 400;\n    src: url('../../../assets/fonts/NotoSansHebrew-VariableFont_wdth,wght.woff2');\n    unicode-range: U+0590-05FF, U+FB1D-FB4F; /* Hebrew characters */\n}\n\n@font-face {\n    font-family: 'Noto Sans Hebrew';\n    font-variation-settings: 'wght' 600;\n    font-weight: 500;\n    src: url('../../../assets/fonts/NotoSansHebrew-VariableFont_wdth,wght.woff2');\n    unicode-range: U+0590-05FF, U+FB1D-FB4F; /* Hebrew characters */\n}\n\n@font-face {\n    font-family: 'Noto Sans Hebrew';\n    font-variation-settings: 'wght' 700;\n    font-weight: 600;\n    src: url('../../../assets/fonts/NotoSansHebrew-VariableFont_wdth,wght.woff2');\n    unicode-range: U+0590-05FF, U+FB1D-FB4F; /* Hebrew characters */\n}\n\n@font-face {\n    font-family: 'Noto Sans Hebrew';\n    font-variation-settings: 'wght' 800;\n    font-weight: 700;\n    src: url('../../../assets/fonts/NotoSansHebrew-VariableFont_wdth,wght.woff2');\n    unicode-range: U+0590-05FF, U+FB1D-FB4F; /* Hebrew characters */\n}\n\n@font-face {\n    font-family: 'Noto Sans Hebrew';\n    font-variation-settings: 'wght' 900;\n    font-weight: 800 1000;\n    src: url('../../../assets/fonts/NotoSansHebrew-VariableFont_wdth,wght.woff2');\n    unicode-range: U+0590-05FF, U+FB1D-FB4F; /* Hebrew characters */\n}\n\n:root {\n    --theme-background-noise: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMDAiIGhlaWdodD0iMzAwIj48ZmlsdGVyIGlkPSJhIiB4PSIwIiB5PSIwIj48ZmVUdXJidWxlbmNlIHR5cGU9ImZyYWN0YWxOb2lzZSIgYmFzZUZyZXF1ZW5jeT0iLjc1IiBzdGl0Y2hUaWxlcz0ic3RpdGNoIi8+PGZlQ29sb3JNYXRyaXggdHlwZT0ic2F0dXJhdGUiIHZhbHVlcz0iMCIvPjwvZmlsdGVyPjxwYXRoIGZpbHRlcj0idXJsKCNhKSIgb3BhY2l0eT0iLjA1IiBkPSJNMCAwaDMwMHYzMDBIMHoiLz48L3N2Zz4=');\n    --theme-fullscreen-player-text-shadow: black 0px 0px 10px;\n    --theme-font-size-xs: var(--mantine-font-size-xs);\n    --theme-font-size-sm: var(--mantine-font-size-sm);\n    --theme-font-size-md: var(--mantine-font-size-md);\n    --theme-font-size-lg: var(--mantine-font-size-lg);\n    --theme-font-size-xl: var(--mantine-font-size-xl);\n    --theme-font-size-2xl: var(--mantine-font-size-2xl);\n    --theme-font-size-3xl: var(--mantine-font-size-3xl);\n    --theme-font-size-4xl: var(--mantine-font-size-4xl);\n    --theme-font-size-5xl: var(--mantine-font-size-5xl);\n    --theme-breakpoint-xs: var(--mantine-breakpoint-xs);\n    --theme-breakpoint-sm: var(--mantine-breakpoint-sm);\n    --theme-breakpoint-md: var(--mantine-breakpoint-md);\n    --theme-breakpoint-lg: var(--mantine-breakpoint-lg);\n    --theme-breakpoint-xl: var(--mantine-breakpoint-xl);\n    --theme-breakpoint-2xl: var(--mantine-breakpoint-2xl);\n    --theme-breakpoint-3xl: var(--mantine-breakpoint-3xl);\n    --theme-spacing-xs: var(--mantine-spacing-xs);\n    --theme-spacing-sm: var(--mantine-spacing-sm);\n    --theme-spacing-md: var(--mantine-spacing-md);\n    --theme-spacing-lg: var(--mantine-spacing-lg);\n    --theme-spacing-xl: var(--mantine-spacing-xl);\n    --theme-spacing-2xl: var(--mantine-spacing-2xl);\n    --theme-spacing-3xl: var(--mantine-spacing-3xl);\n    --theme-spacing-4xl: var(--mantine-spacing-4xl);\n    --theme-shadow-xs: var(--mantine-shadow-xs);\n    --theme-shadow-sm: var(--mantine-shadow-sm);\n    --theme-shadow-md: var(--mantine-shadow-md);\n    --theme-shadow-lg: var(--mantine-shadow-lg);\n    --theme-shadow-xl: var(--mantine-shadow-xl);\n    --theme-radius-xs: var(--mantine-radius-xs);\n    --theme-radius-sm: var(--mantine-radius-sm);\n    --theme-radius-md: var(--mantine-radius-md);\n    --theme-radius-lg: var(--mantine-radius-lg);\n    --theme-radius-xl: var(--mantine-radius-xl);\n    --theme-line-height-xs: var(--mantine-line-height-xs);\n    --theme-line-height-sm: var(--mantine-line-height-sm);\n    --theme-line-height-md: var(--mantine-line-height-md);\n    --theme-line-height-lg: var(--mantine-line-height-lg);\n    --theme-line-height-xl: var(--mantine-line-height-xl);\n    --theme-colors-dark-1: var(--mantine-color-dark-1);\n    --theme-colors-dark-2: var(--mantine-color-dark-2);\n    --theme-colors-dark-3: var(--mantine-color-dark-3);\n    --theme-colors-dark-4: var(--mantine-color-dark-4);\n    --theme-colors-dark-5: var(--mantine-color-dark-5);\n    --theme-colors-dark-6: var(--mantine-color-dark-6);\n    --theme-colors-dark-7: var(--mantine-color-dark-7);\n    --theme-colors-dark-8: var(--mantine-color-dark-8);\n    --theme-colors-dark-9: var(--mantine-color-dark-9);\n    --theme-colors-dark-10: var(--mantine-color-dark-10);\n    --theme-colors-light-1: var(--mantine-color-light-1);\n    --theme-colors-light-2: var(--mantine-color-light-2);\n    --theme-colors-light-3: var(--mantine-color-light-3);\n    --theme-colors-light-4: var(--mantine-color-light-4);\n    --theme-colors-light-5: var(--mantine-color-light-5);\n    --theme-colors-light-6: var(--mantine-color-light-6);\n    --theme-colors-light-7: var(--mantine-color-light-7);\n    --theme-colors-light-8: var(--mantine-color-light-8);\n    --theme-colors-light-9: var(--mantine-color-light-9);\n    --theme-colors-light-10: var(--mantine-color-light-10);\n    --theme-colors-background: var(--mantine-color-body);\n    --theme-colors-foreground: var(--mantine-color-text);\n    --theme-colors-primary-filled: var(--mantine-primary-color-filled);\n    --theme-colors-primary-contrast: var(--mantine-primary-color-contrast);\n\n    @mixin light-root {\n        --theme-colors-border: rgb(0 0 0 / 5%);\n    }\n\n    @mixin dark-root {\n        --theme-colors-border: rgb(255 255 255 / 10%);\n    }\n}\n"
  },
  {
    "path": "src/shared/themes/app-theme-types.ts",
    "content": "import type { MantineThemeOverride } from '@mantine/core';\n\nimport { CSSProperties } from 'react';\n\nexport enum AppTheme {\n    AYU_DARK = 'ayuDark',\n    AYU_LIGHT = 'ayuLight',\n    CATPPUCCIN_LATTE = 'catppuccinLatte',\n    CATPPUCCIN_MOCHA = 'catppuccinMocha',\n    DEFAULT_DARK = 'defaultDark',\n    DEFAULT_LIGHT = 'defaultLight',\n    DRACULA = 'dracula',\n    GITHUB_DARK = 'githubDark',\n    GITHUB_LIGHT = 'githubLight',\n    GLASSY_DARK = 'glassyDark',\n    GRUVBOX_DARK = 'gruvboxDark',\n    GRUVBOX_LIGHT = 'gruvboxLight',\n    HIGH_CONTRAST_DARK = 'highContrastDark',\n    HIGH_CONTRAST_LIGHT = 'highContrastLight',\n    MATERIAL_DARK = 'materialDark',\n    MATERIAL_LIGHT = 'materialLight',\n    MONOKAI = 'monokai',\n    NIGHT_OWL = 'nightOwl',\n    NORD = 'nord',\n    ONE_DARK = 'oneDark',\n    ROSE_PINE = 'rosePine',\n    ROSE_PINE_DAWN = 'rosePineDawn',\n    ROSE_PINE_MOON = 'rosePineMoon',\n    SHADES_OF_PURPLE = 'shadesOfPurple',\n    SOLARIZED_DARK = 'solarizedDark',\n    SOLARIZED_LIGHT = 'solarizedLight',\n    TOKYO_NIGHT = 'tokyoNight',\n    VSCODE_DARK_PLUS = 'vscodeDarkPlus',\n    VSCODE_LIGHT_PLUS = 'vscodeLightPlus',\n}\n\nexport type AppThemeConfiguration = Partial<BaseAppThemeConfiguration>;\n\nexport interface BaseAppThemeConfiguration {\n    app: {\n        'content-max-width'?: CSSProperties['maxWidth'];\n        'overlay-header'?: CSSProperties['background'];\n        'overlay-subheader'?: CSSProperties['background'];\n        'root-font-size'?: CSSProperties['fontSize'];\n        'scrollbar-handle-active-background'?: CSSProperties['background'];\n        'scrollbar-handle-background'?: CSSProperties['background'];\n        'scrollbar-handle-border-radius'?: CSSProperties['borderRadius'];\n        'scrollbar-handle-hover-background'?: CSSProperties['background'];\n        'scrollbar-size'?: CSSProperties['width'];\n        'scrollbar-track-active-background'?: CSSProperties['background'];\n        'scrollbar-track-background'?: CSSProperties['background'];\n        'scrollbar-track-border-radius'?: CSSProperties['borderRadius'];\n        'scrollbar-track-hover-background'?: CSSProperties['background'];\n    };\n    colors: {\n        background?: CSSProperties['background'];\n        'background-alternate'?: CSSProperties['background'];\n        black?: CSSProperties['color'];\n        foreground?: CSSProperties['color'];\n        'foreground-muted'?: CSSProperties['color'];\n        primary?: CSSProperties['color'];\n        'state-error'?: CSSProperties['color'];\n        'state-info'?: CSSProperties['color'];\n        'state-success'?: CSSProperties['color'];\n        'state-warning'?: CSSProperties['color'];\n        surface?: CSSProperties['background'];\n        'surface-foreground'?: CSSProperties['color'];\n        white?: CSSProperties['color'];\n    };\n    mantineOverride?: MantineThemeOverride;\n    mode: 'dark' | 'light';\n    stylesheets?: string[];\n}\n"
  },
  {
    "path": "src/shared/themes/app-theme.ts",
    "content": "import merge from 'lodash/merge';\n\nimport { AppThemeConfiguration } from './app-theme-types';\nimport { AppTheme } from './app-theme-types';\n\nimport { ayuDark } from '/@/shared/themes/ayu-dark/ayu-dark';\nimport { ayuLight } from '/@/shared/themes/ayu-light/ayu-light';\nimport { catppuccinLatte } from '/@/shared/themes/catppuccin-latte/catppuccin-latte';\nimport { catppuccinMocha } from '/@/shared/themes/catppuccin-mocha/catppuccin-mocha';\nimport { defaultTheme } from '/@/shared/themes/default';\nimport { defaultDark } from '/@/shared/themes/default-dark/default-dark';\nimport { defaultLight } from '/@/shared/themes/default-light/default-light';\nimport { dracula } from '/@/shared/themes/dracula/dracula';\nimport { githubDark } from '/@/shared/themes/github-dark/github-dark';\nimport { githubLight } from '/@/shared/themes/github-light/github-light';\nimport { glassyDark } from '/@/shared/themes/glassy-dark/glassy-dark';\nimport { gruvboxDark } from '/@/shared/themes/gruvbox-dark/gruvbox-dark';\nimport { gruvboxLight } from '/@/shared/themes/gruvbox-light/gruvbox-light';\nimport { highContrastDark } from '/@/shared/themes/high-contrast-dark/high-contrast-dark';\nimport { highContrastLight } from '/@/shared/themes/high-contrast-light/high-contrast-light';\nimport { materialDark } from '/@/shared/themes/material-dark/material-dark';\nimport { materialLight } from '/@/shared/themes/material-light/material-light';\nimport { monokai } from '/@/shared/themes/monokai/monokai';\nimport { nightOwl } from '/@/shared/themes/night-owl/night-owl';\nimport { nord } from '/@/shared/themes/nord/nord';\nimport { oneDark } from '/@/shared/themes/one-dark/one-dark';\nimport { rosePineDawn } from '/@/shared/themes/rose-pine-dawn/rose-pine-dawn';\nimport { rosePineMoon } from '/@/shared/themes/rose-pine-moon/rose-pine-moon';\nimport { rosePine } from '/@/shared/themes/rose-pine/rose-pine';\nimport { shadesOfPurple } from '/@/shared/themes/shades-of-purple/shades-of-purple';\nimport { solarizedDark } from '/@/shared/themes/solarized-dark/solarized-dark';\nimport { solarizedLight } from '/@/shared/themes/solarized-light/solarized-light';\nimport { tokyoNight } from '/@/shared/themes/tokyo-night/tokyo-night';\nimport { vscodeDarkPlus } from '/@/shared/themes/vscode-dark-plus/vscode-dark-plus';\nimport { vscodeLightPlus } from '/@/shared/themes/vscode-light-plus/vscode-light-plus';\n\nexport const appTheme: Record<AppTheme, AppThemeConfiguration> = {\n    [AppTheme.AYU_DARK]: ayuDark,\n    [AppTheme.AYU_LIGHT]: ayuLight,\n    [AppTheme.CATPPUCCIN_LATTE]: catppuccinLatte,\n    [AppTheme.CATPPUCCIN_MOCHA]: catppuccinMocha,\n    [AppTheme.DEFAULT_DARK]: defaultDark,\n    [AppTheme.DEFAULT_LIGHT]: defaultLight,\n    [AppTheme.DRACULA]: dracula,\n    [AppTheme.GITHUB_DARK]: githubDark,\n    [AppTheme.GITHUB_LIGHT]: githubLight,\n    [AppTheme.GLASSY_DARK]: glassyDark,\n    [AppTheme.GRUVBOX_DARK]: gruvboxDark,\n    [AppTheme.GRUVBOX_LIGHT]: gruvboxLight,\n    [AppTheme.HIGH_CONTRAST_DARK]: highContrastDark,\n    [AppTheme.HIGH_CONTRAST_LIGHT]: highContrastLight,\n    [AppTheme.MATERIAL_DARK]: materialDark,\n    [AppTheme.MATERIAL_LIGHT]: materialLight,\n    [AppTheme.MONOKAI]: monokai,\n    [AppTheme.NIGHT_OWL]: nightOwl,\n    [AppTheme.NORD]: nord,\n    [AppTheme.ONE_DARK]: oneDark,\n    [AppTheme.ROSE_PINE]: rosePine,\n    [AppTheme.ROSE_PINE_DAWN]: rosePineDawn,\n    [AppTheme.ROSE_PINE_MOON]: rosePineMoon,\n    [AppTheme.SHADES_OF_PURPLE]: shadesOfPurple,\n    [AppTheme.SOLARIZED_DARK]: solarizedDark,\n    [AppTheme.SOLARIZED_LIGHT]: solarizedLight,\n    [AppTheme.TOKYO_NIGHT]: tokyoNight,\n    [AppTheme.VSCODE_DARK_PLUS]: vscodeDarkPlus,\n    [AppTheme.VSCODE_LIGHT_PLUS]: vscodeLightPlus,\n};\n\nexport const getAppTheme = (theme: AppTheme): AppThemeConfiguration => {\n    return {\n        app: merge({}, defaultTheme.app, appTheme[theme].app),\n        colors: merge({}, defaultTheme.colors, appTheme[theme].colors),\n        mantineOverride: merge({}, defaultTheme.mantineOverride, appTheme[theme].mantineOverride),\n        mode: appTheme[theme].mode,\n        stylesheets: appTheme[theme].stylesheets,\n    };\n};\n"
  },
  {
    "path": "src/shared/themes/ayu-dark/ayu-dark.ts",
    "content": "import { AppThemeConfiguration } from '/@/shared/themes/app-theme-types';\n\nexport const ayuDark: AppThemeConfiguration = {\n    app: {\n        'overlay-header':\n            'linear-gradient(transparent 0%, rgb(31 36 48 / 85%) 100%), var(--theme-background-noise)',\n        'overlay-subheader':\n            'linear-gradient(180deg, rgb(31 36 48 / 5%) 0%, var(--theme-colors-background) 100%), var(--theme-background-noise)',\n        'scrollbar-handle-background': 'rgba(160, 160, 160, 20%)',\n        'scrollbar-handle-hover-background': 'rgba(160, 160, 160, 40%)',\n    },\n    colors: {\n        background: 'rgb(31, 36, 48)',\n        'background-alternate': 'rgb(23, 27, 36)',\n        black: 'rgb(0, 0, 0)',\n        foreground: 'rgb(203, 204, 198)',\n        'foreground-muted': 'rgb(170, 173, 166)',\n        primary: 'rgb(115, 192, 203)',\n        'state-error': 'rgb(255, 51, 51)',\n        'state-info': 'rgb(115, 192, 203)',\n        'state-success': 'rgb(186, 230, 126)',\n        'state-warning': 'rgb(255, 204, 102)',\n        surface: 'rgb(39, 46, 57)',\n        'surface-foreground': 'rgb(203, 204, 198)',\n        white: 'rgb(255, 255, 255)',\n    },\n    mode: 'dark',\n};\n"
  },
  {
    "path": "src/shared/themes/ayu-light/ayu-light.ts",
    "content": "import { AppThemeConfiguration } from '/@/shared/themes/app-theme-types';\n\nexport const ayuLight: AppThemeConfiguration = {\n    app: {\n        'overlay-header':\n            'linear-gradient(rgb(253 253 253 / 50%) 0%, rgb(253 253 253 / 80%)), var(--theme-background-noise)',\n        'overlay-subheader':\n            'linear-gradient(180deg, rgba(253, 253, 253, 5%) 0%, var(--theme-colors-background)), var(--theme-background-noise)',\n        'scrollbar-handle-background': 'rgba(140, 140, 140, 30%)',\n        'scrollbar-handle-hover-background': 'rgba(140, 140, 140, 60%)',\n    },\n    colors: {\n        background: 'rgb(253, 253, 253)',\n        'background-alternate': 'rgb(250, 250, 250)',\n        black: 'rgb(0, 0, 0)',\n        foreground: 'rgb(57, 58, 52)',\n        'foreground-muted': 'rgb(128, 128, 128)',\n        primary: 'rgb(86, 156, 214)',\n        'state-error': 'rgb(255, 51, 51)',\n        'state-info': 'rgb(55, 118, 171)',\n        'state-success': 'rgb(86, 171, 47)',\n        'state-warning': 'rgb(255, 153, 0)',\n        surface: 'rgb(255, 255, 255)',\n        'surface-foreground': 'rgb(57, 58, 52)',\n        white: 'rgb(255, 255, 255)',\n    },\n    mantineOverride: {\n        primaryShade: {\n            light: 4,\n        },\n    },\n    mode: 'light',\n};\n"
  },
  {
    "path": "src/shared/themes/catppuccin-latte/catppuccin-latte.ts",
    "content": "import { AppThemeConfiguration } from '/@/shared/themes/app-theme-types';\n\nexport const catppuccinLatte: AppThemeConfiguration = {\n    app: {\n        'overlay-header':\n            'linear-gradient(rgb(239 241 245 / 50%) 0%, rgb(239 241 245 / 80%)), var(--theme-background-noise)',\n        'overlay-subheader':\n            'linear-gradient(180deg, rgba(239, 241, 245, 5%) 0%, var(--theme-colors-background)), var(--theme-background-noise)',\n        'scrollbar-handle-background': 'rgba(140, 140, 140, 30%)',\n        'scrollbar-handle-hover-background': 'rgba(140, 140, 140, 60%)',\n    },\n    colors: {\n        background: 'rgb(239, 241, 245)',\n        'background-alternate': 'rgb(230, 233, 239)',\n        black: 'rgb(0, 0, 0)',\n        foreground: 'rgb(76, 79, 105)',\n        'foreground-muted': 'rgb(108, 111, 133)',\n        primary: 'rgb(30, 102, 245)',\n        'state-error': 'rgb(210, 15, 57)',\n        'state-info': 'rgb(30, 102, 245)',\n        'state-success': 'rgb(64, 160, 43)',\n        'state-warning': 'rgb(223, 142, 29)',\n        surface: 'rgb(220, 224, 232)',\n        'surface-foreground': 'rgb(76, 79, 105)',\n        white: 'rgb(255, 255, 255)',\n    },\n    mantineOverride: {\n        primaryShade: {\n            light: 4,\n        },\n    },\n    mode: 'light',\n};\n"
  },
  {
    "path": "src/shared/themes/catppuccin-mocha/catppuccin-mocha.ts",
    "content": "import { AppThemeConfiguration } from '/@/shared/themes/app-theme-types';\n\nexport const catppuccinMocha: AppThemeConfiguration = {\n    app: {\n        'overlay-header':\n            'linear-gradient(transparent 0%, rgb(24 24 37 / 85%) 100%), var(--theme-background-noise)',\n        'overlay-subheader':\n            'linear-gradient(180deg, rgb(24 24 37 / 5%) 0%, var(--theme-colors-background) 100%), var(--theme-background-noise)',\n        'scrollbar-handle-background': 'rgba(160, 160, 160, 20%)',\n        'scrollbar-handle-hover-background': 'rgba(160, 160, 160, 40%)',\n    },\n    colors: {\n        background: 'rgb(24, 24, 37)',\n        'background-alternate': 'rgb(17, 17, 27)',\n        black: 'rgb(0, 0, 0)',\n        foreground: 'rgb(205, 214, 244)',\n        'foreground-muted': 'rgb(186, 194, 222)',\n        primary: 'rgb(137, 180, 250)',\n        'state-error': 'rgb(243, 139, 168)',\n        'state-info': 'rgb(137, 180, 250)',\n        'state-success': 'rgb(166, 227, 161)',\n        'state-warning': 'rgb(250, 179, 135)',\n        surface: 'rgb(30, 30, 46)',\n        'surface-foreground': 'rgb(205, 214, 244)',\n        white: 'rgb(255, 255, 255)',\n    },\n    mode: 'dark',\n};\n"
  },
  {
    "path": "src/shared/themes/default-dark/default-dark.ts",
    "content": "import { AppThemeConfiguration } from '/@/shared/themes/app-theme-types';\n\nexport const defaultDark: AppThemeConfiguration = {\n    app: {},\n    colors: {\n        primary: 'rgb(53, 116, 252)',\n    },\n    mode: 'dark',\n};\n"
  },
  {
    "path": "src/shared/themes/default-light/default-light.ts",
    "content": "import { AppThemeConfiguration } from '/@/shared/themes/app-theme-types';\n\nexport const defaultLight: AppThemeConfiguration = {\n    app: {\n        'overlay-header':\n            'linear-gradient(rgb(255 255 255 / 50%) 0%, rgb(255 255 255 / 80%)), var(--theme-background-noise)',\n        'overlay-subheader':\n            'linear-gradient(180deg, rgba(255, 255, 255, 5%) 0%, var(--theme-colors-background)), var(--theme-background-noise)',\n        'scrollbar-handle-background': 'rgba(140, 140, 140, 30%)',\n        'scrollbar-handle-hover-background': 'rgba(140, 140, 140, 60%)',\n        'scrollbar-track-background': 'transparent',\n    },\n    colors: {\n        background: 'rgb(235, 235, 235)',\n        'background-alternate': 'rgb(240, 240, 240)',\n        black: 'rgb(0, 0, 0)',\n        foreground: 'rgb(25, 25, 25)',\n        'foreground-muted': 'rgb(80, 80, 80)',\n        primary: 'rgb(0, 122, 255)',\n        'state-error': 'rgb(255, 59, 48)',\n        'state-info': 'rgb(0, 122, 255)',\n        'state-success': 'rgb(48, 209, 88)',\n        'state-warning': 'rgb(255, 214, 0)',\n        surface: 'rgb(225, 225, 225)',\n        'surface-foreground': 'rgb(0, 0, 0)',\n        white: 'rgb(255, 255, 255)',\n    },\n    mantineOverride: {\n        primaryShade: {\n            light: 4,\n        },\n    },\n    mode: 'light',\n};\n"
  },
  {
    "path": "src/shared/themes/default.ts",
    "content": "import { AppThemeConfiguration } from './app-theme-types';\n\nexport const defaultTheme: AppThemeConfiguration = {\n    app: {\n        'content-max-width': '1800px',\n        'overlay-header':\n            'linear-gradient(transparent 0%, rgb(0 0 0 / 85%) 100%), var(--theme-background-noise)',\n        'overlay-subheader':\n            'linear-gradient(180deg, rgb(0 0 0 / 5%) 0%, var(--theme-colors-background) 100%), var(--theme-background-noise)',\n        'root-font-size': '16px',\n        'scrollbar-handle-active-background': 'rgba(160, 160, 160, 40%)',\n        'scrollbar-handle-background': 'rgba(160, 160, 160, 20%)',\n        'scrollbar-handle-border-radius': '0',\n        'scrollbar-handle-hover-background': 'rgba(160, 160, 160, 60%)',\n        'scrollbar-size': '9px',\n        'scrollbar-track-active-background': 'transparent',\n        'scrollbar-track-background': 'transparent',\n        'scrollbar-track-border-radius': '0',\n        'scrollbar-track-hover-background': 'transparent',\n    },\n    colors: {\n        background: 'rgb(12, 12, 12)',\n        'background-alternate': 'rgb(8, 8, 8)',\n        black: 'rgb(0, 0, 0)',\n        foreground: 'rgb(225, 225, 225)',\n        'foreground-muted': 'rgb(150, 150, 150)',\n        primary: 'rgb(53, 116, 252)',\n        'state-error': 'rgb(204, 50, 50)',\n        'state-info': 'rgb(53, 116, 252)',\n        'state-success': 'rgb(50, 204, 50)',\n        'state-warning': 'rgb(255, 120, 120)',\n        surface: 'rgb(20, 20, 20)',\n        'surface-foreground': 'rgb(215, 215, 215)',\n        white: 'rgb(255, 255, 255)',\n    },\n    mode: 'dark',\n};\n"
  },
  {
    "path": "src/shared/themes/dracula/dracula.ts",
    "content": "import { AppThemeConfiguration } from '/@/shared/themes/app-theme-types';\n\nexport const dracula: AppThemeConfiguration = {\n    app: {\n        'overlay-header':\n            'linear-gradient(transparent 0%, rgb(40 42 54 / 85%) 100%), var(--theme-background-noise)',\n        'overlay-subheader':\n            'linear-gradient(180deg, rgb(40 42 54 / 5%) 0%, var(--theme-colors-background) 100%), var(--theme-background-noise)',\n        'scrollbar-handle-background': 'rgba(160, 160, 160, 20%)',\n        'scrollbar-handle-hover-background': 'rgba(160, 160, 160, 40%)',\n    },\n    colors: {\n        background: 'rgb(40, 42, 54)',\n        'background-alternate': 'rgb(30, 31, 41)',\n        black: 'rgb(0, 0, 0)',\n        foreground: 'rgb(248, 248, 242)',\n        'foreground-muted': 'rgb(189, 147, 249)',\n        primary: 'rgb(189, 147, 249)',\n        'state-error': 'rgb(255, 85, 85)',\n        'state-info': 'rgb(139, 233, 253)',\n        'state-success': 'rgb(80, 250, 123)',\n        'state-warning': 'rgb(255, 184, 108)',\n        surface: 'rgb(68, 71, 90)',\n        'surface-foreground': 'rgb(248, 248, 242)',\n        white: 'rgb(255, 255, 255)',\n    },\n    mode: 'dark',\n};\n"
  },
  {
    "path": "src/shared/themes/github-dark/github-dark.ts",
    "content": "import { AppThemeConfiguration } from '/@/shared/themes/app-theme-types';\n\nexport const githubDark: AppThemeConfiguration = {\n    app: {\n        'overlay-header':\n            'linear-gradient(transparent 0%, rgb(13 17 23 / 85%) 100%), var(--theme-background-noise)',\n        'overlay-subheader':\n            'linear-gradient(180deg, rgb(13 17 23 / 5%) 0%, var(--theme-colors-background) 100%), var(--theme-background-noise)',\n        'scrollbar-handle-background': 'rgba(160, 160, 160, 20%)',\n        'scrollbar-handle-hover-background': 'rgba(160, 160, 160, 40%)',\n    },\n    colors: {\n        background: 'rgb(13, 17, 23)',\n        'background-alternate': 'rgb(22, 27, 34)',\n        black: 'rgb(0, 0, 0)',\n        foreground: 'rgb(201, 209, 217)',\n        'foreground-muted': 'rgb(139, 148, 158)',\n        primary: 'rgb(88, 166, 255)',\n        'state-error': 'rgb(248, 81, 73)',\n        'state-info': 'rgb(88, 166, 255)',\n        'state-success': 'rgb(56, 211, 145)',\n        'state-warning': 'rgb(251, 188, 5)',\n        surface: 'rgb(46, 57, 72)',\n        'surface-foreground': 'rgb(201, 209, 217)',\n        white: 'rgb(255, 255, 255)',\n    },\n    mode: 'dark',\n};\n"
  },
  {
    "path": "src/shared/themes/github-light/github-light.ts",
    "content": "import { AppThemeConfiguration } from '/@/shared/themes/app-theme-types';\n\nexport const githubLight: AppThemeConfiguration = {\n    app: {\n        'overlay-header':\n            'linear-gradient(rgb(255 255 255 / 50%) 0%, rgb(255 255 255 / 80%)), var(--theme-background-noise)',\n        'overlay-subheader':\n            'linear-gradient(180deg, rgba(255, 255, 255, 5%) 0%, var(--theme-colors-background)), var(--theme-background-noise)',\n        'scrollbar-handle-background': 'rgba(140, 140, 140, 30%)',\n        'scrollbar-handle-hover-background': 'rgba(140, 140, 140, 60%)',\n    },\n    colors: {\n        background: 'rgb(255, 255, 255)',\n        'background-alternate': 'rgb(246, 248, 250)',\n        black: 'rgb(0, 0, 0)',\n        foreground: 'rgb(31, 35, 40)',\n        'foreground-muted': 'rgb(101, 109, 118)',\n        primary: 'rgb(9, 105, 218)',\n        'state-error': 'rgb(212, 5, 17)',\n        'state-info': 'rgb(9, 105, 218)',\n        'state-success': 'rgb(26, 127, 100)',\n        'state-warning': 'rgb(191, 136, 0)',\n        surface: 'rgb(250, 252, 254)',\n        'surface-foreground': 'rgb(31, 35, 40)',\n        white: 'rgb(255, 255, 255)',\n    },\n    mantineOverride: {\n        primaryShade: {\n            light: 4,\n        },\n    },\n    mode: 'light',\n};\n"
  },
  {
    "path": "src/shared/themes/glassy-dark/glassy-dark.ts",
    "content": "import glassyOverridesCss from './glassy_overrides.css?inline';\n\nimport { AppThemeConfiguration } from '/@/shared/themes/app-theme-types';\n\nexport const glassyDark: AppThemeConfiguration = {\n    app: {\n        'overlay-header':\n            'linear-gradient(transparent 0%, rgb(13 17 23 / 85%) 100%), var(--theme-background-noise)',\n        'overlay-subheader':\n            'linear-gradient(180deg, rgb(13 17 23 / 5%) 0%, var(--theme-colors-background) 100%), var(--theme-background-noise)',\n        'scrollbar-handle-background': 'rgba(88, 166, 255, 20%)',\n        'scrollbar-handle-hover-background': 'rgba(88, 166, 255, 40%)',\n    },\n    colors: {\n        background: 'rgb(2, 2, 6)',\n        'background-alternate': 'rgb(0, 0, 0)',\n        black: 'rgb(0, 0, 0)',\n        foreground: 'rgb(225, 225, 225)',\n        'foreground-muted': 'rgb(150, 150, 150)',\n        'state-error': 'rgb(204, 50, 50)',\n        'state-info': 'rgb(53, 116, 252)',\n        'state-success': 'rgb(50, 204, 50)',\n        'state-warning': 'rgb(255, 120, 120)',\n        surface: 'rgb(4, 4, 9)',\n        'surface-foreground': 'rgb(215, 215, 215)',\n        white: 'rgb(255, 255, 255)',\n    },\n    mode: 'dark',\n    stylesheets: [glassyOverridesCss],\n};\n"
  },
  {
    "path": "src/shared/themes/glassy-dark/glassy_overrides.css",
    "content": ".fs-player-bar-module-container {\n    background: rgb(0 0 0 / 40%) !important;\n    backdrop-filter: blur(2rem);\n}\n\n.fs-poster-card-module-image {\n    border-radius: 18px !important;\n}\n\n.fs-grid-card-controls-module-grid-card-controls-container {\n    border-radius: 18px;\n    backdrop-filter: blur(5px);\n}\n\n.fsplayer-text {\n    font-size: 45px;\n    text-align: left;\n}\n\n.fs-full-screen-player-image-module-metadata-container {\n    width: 100%;\n}\n\n.fs-full-screen-player-queue-module-grid-container::before {\n    border-radius: 18px !important;\n}\n\n/* stylelint-disable selector-class-pattern */\n.mantine-Modal-overlay {\n    backdrop-filter: blur(3px);\n}\n/* stylelint-enable selector-class-pattern */\n\n.fs-modal-module-content,\n.fs-select-module-dropdown,\n.fs-popover-module-dropdown,\n.fs-dialog-module-root,\n.fs-context-menu-module-content,\n.fs-dropdown-menu-module-menu-dropdown,\n.fs-accordion-module-panel {\n    background: rgb(4 4 9 / 50%) !important;\n    backdrop-filter: blur(2rem);\n\n    button,\n    input,\n    .fs-multi-select-module-input,\n    a {\n        border-radius: 12px;\n    }\n}\n\n.fs-context-menu-module-content,\n.fs-dropdown-menu-module-menu-dropdown {\n    border-radius: 18px;\n}\n\n.fs-accordion-module-panel {\n    border-radius: 18px;\n    backdrop-filter: none !important;\n}\n\n.fs-accordion-module-control {\n    background-color: transparent;\n}\n\n.fs-modal-module-header {\n    background: rgb(4 4 9 / 80%) !important;\n    border-radius: 18px;\n}\n\n.fs-select-module-dropdown,\n.fs-popover-module-dropdown {\n    border-radius: 18px;\n}\n\n/* stylelint-disable selector-class-pattern */\n.mantine-Center-root img {\n    border-radius: 18px;\n}\n/* stylelint-enable selector-class-pattern */\n\n.ag-header {\n    background-color: transparent !important;\n    border-radius: 8px 8px 0 0;\n}\n\n.fs-left-controls-module-playerbar-image {\n    border-radius: 8px !important;\n}\n\n/* stylelint-disable selector-class-pattern */\n.favorite_icon .mantine-ActionIcon-icon {\n    justify-content: left;\n}\n/* stylelint-enable selector-class-pattern */\n\n.fork-header svg {\n    padding-left: 2px;\n    margin-left: 5px;\n}\n\n.fs-button-module-root[data-variant='outline'] {\n    border-radius: 1rem;\n}\n\n.fs-poster-card-module-image-container {\n    border-radius: 8px !important;\n}\n\n/* stylelint-disable selector-class-pattern */\n.mantine-Table-th {\n    background-color: transparent !important;\n}\n/* stylelint-enable selector-class-pattern */\n\ntable {\n    border: 0 !important;\n}\n\n.fs-sidebar-module-accordion-content a {\n    border-radius: 8px;\n}\n\n.fs-main-content-module-main-content-container {\n    height: 100vh;\n}\n\n/* stylelint-disable selector-class-pattern */\n.mantine-Tabs-root {\n    input {\n        border-radius: 18px;\n    }\n\n    button:not(.mantine-focus-never) {\n        border-radius: 8px;\n    }\n}\n/* stylelint-enable selector-class-pattern */\n\n/* stylelint-disable selector-class-pattern */\n.mantine-Slider-track::before {\n    background-color: var(--theme-colors-surface);\n}\n/* stylelint-enable selector-class-pattern */\n\n/* stylelint-disable selector-not-notation */\n.fs-image-module-image:not(.ag-cell *)\n:not(.fs-left-controls-module-image *)\n:not(.fs-sidebar-playlist-list-module-row-group *) {\n    border-radius: 18px !important;\n}\n/* stylelint-enable selector-not-notation */\n\n.fs-left-controls-module-image {\n    border-radius: 12px;\n}\n\n.fork-server-selector {\n    /* stylelint-disable selector-class-pattern */\n    .mantine-SegmentedControl-indicator,\n    /* stylelint-enable selector-class-pattern */\n    .fs-segmented-control-module-root,\n    input,\n    button {\n        border-radius: 12px;\n    }\n}\n\n.fs-text-input-module-input,\n[cmdk-item][data-selected] {\n    background: rgb(4 4 9 / 50%) !important;\n    border: 0;\n    border-radius: 18px;\n}\n\n.fs-modal-module-content [cmdk-separator] {\n    display: none;\n}\n\n/* Button fixes */\n.fs-button-module-root[data-variant='filled'] {\n    border-radius: 12px;\n}\n\n/* stylelint-disable selector-class-pattern */\n.mantine-Accordion-label {\n    button,\n    a {\n        border-radius: 8px;\n    }\n}\n/* stylelint-enable selector-class-pattern */\n\n/* stylelint-disable selector-class-pattern */\n.mantine-Grid-col button {\n    border-radius: 8px;\n}\n/* stylelint-enable selector-class-pattern */\n\n/* share dialog */\n.fs-modal-module-body {\n    .fs-textarea-module-input {\n        border-radius: 12px;\n    }\n\n    .fs-accordion-module-panel {\n        background-color: transparent;\n    }\n}\n\n.fs-feature-carousel-module-image-column {\n    align-items: normal !important;\n}\n\n/* stylelint-disable selector-class-pattern */\n.mantine-Badge-root {\n    background: rgb(1 1 5 / 45%);\n}\n/* stylelint-enable selector-class-pattern */\n\n.fs-sidebar-module-image-container img {\n    border-radius: 18px;\n}\n\n.fs-expanded-list-item-module-container {\n    position: relative;\n    bottom: 90px;\n    border-radius: 18px;\n}\n\n.fs-sidebar-play-queue-module-lyrics-section {\n    bottom: 90px;\n}\n\n.fs-page-header-module-container {\n    background-color: transparent;\n}\n\n.fs-tabs-module-tab {\n    border-radius: 0 !important;\n}\n\n/* stylelint-disable selector-class-pattern */\n.fs-full-screen-player-module-container .mantine-Group-root button {\n    border-radius: 100%;\n}\n/* stylelint-enable selector-class-pattern */\n\n.fs-full-screen-player-image-module-image {\n    border-radius: 18px;\n}\n\n.fs-segmented-control-module-root {\n    border-radius: 18px;\n}\n\n.fs-segmented-control-module-label[data-active='true'],\n/* stylelint-disable selector-class-pattern */\n.mantine-SegmentedControl-control {\n    border-radius: 8px;\n}\n/* stylelint-enable selector-class-pattern */\n\n.fs-table-config-module-group {\n    border-radius: 8px;\n}\n\n.fs-server-selector-module-button-group {\n    border-radius: 18px;\n}\n"
  },
  {
    "path": "src/shared/themes/gruvbox-dark/gruvbox-dark.ts",
    "content": "import { AppThemeConfiguration } from '/@/shared/themes/app-theme-types';\n\nexport const gruvboxDark: AppThemeConfiguration = {\n    app: {\n        'overlay-header':\n            'linear-gradient(transparent 0%, rgb(40 40 40 / 85%) 100%), var(--theme-background-noise)',\n        'overlay-subheader':\n            'linear-gradient(180deg, rgb(40 40 40 / 5%) 0%, var(--theme-colors-background) 100%), var(--theme-background-noise)',\n        'scrollbar-handle-background': 'rgba(160, 160, 160, 20%)',\n        'scrollbar-handle-hover-background': 'rgba(160, 160, 160, 40%)',\n    },\n    colors: {\n        background: 'rgb(40, 40, 40)',\n        'background-alternate': 'rgb(29, 32, 33)',\n        black: 'rgb(0, 0, 0)',\n        foreground: 'rgb(235, 219, 178)',\n        'foreground-muted': 'rgb(189, 174, 147)',\n        primary: 'rgb(250, 189, 47)',\n        'state-error': 'rgb(251, 73, 52)',\n        'state-info': 'rgb(131, 165, 152)',\n        'state-success': 'rgb(184, 187, 38)',\n        'state-warning': 'rgb(250, 189, 47)',\n        surface: 'rgb(50, 48, 47)',\n        'surface-foreground': 'rgb(235, 219, 178)',\n        white: 'rgb(255, 255, 255)',\n    },\n    mode: 'dark',\n};\n"
  },
  {
    "path": "src/shared/themes/gruvbox-light/gruvbox-light.ts",
    "content": "import { AppThemeConfiguration } from '/@/shared/themes/app-theme-types';\n\nexport const gruvboxLight: AppThemeConfiguration = {\n    app: {\n        'overlay-header':\n            'linear-gradient(rgb(251 241 199 / 50%) 0%, rgb(251 241 199 / 80%)), var(--theme-background-noise)',\n        'overlay-subheader':\n            'linear-gradient(180deg, rgba(251, 241, 199, 5%) 0%, var(--theme-colors-background)), var(--theme-background-noise)',\n        'scrollbar-handle-background': 'rgba(140, 140, 140, 30%)',\n        'scrollbar-handle-hover-background': 'rgba(140, 140, 140, 60%)',\n    },\n    colors: {\n        background: 'rgb(251, 241, 199)',\n        'background-alternate': 'rgb(242, 229, 188)',\n        black: 'rgb(0, 0, 0)',\n        foreground: 'rgb(60, 56, 54)',\n        'foreground-muted': 'rgb(124, 111, 100)',\n        primary: 'rgb(214, 93, 14)',\n        'state-error': 'rgb(204, 36, 29)',\n        'state-info': 'rgb(7, 102, 120)',\n        'state-success': 'rgb(121, 116, 14)',\n        'state-warning': 'rgb(214, 93, 14)',\n        surface: 'rgb(235, 219, 178)',\n        'surface-foreground': 'rgb(60, 56, 54)',\n        white: 'rgb(255, 255, 255)',\n    },\n    mantineOverride: {\n        primaryShade: {\n            light: 4,\n        },\n    },\n    mode: 'light',\n};\n"
  },
  {
    "path": "src/shared/themes/high-contrast-dark/high-contrast-dark.ts",
    "content": "import { AppThemeConfiguration } from '/@/shared/themes/app-theme-types';\n\nexport const highContrastDark: AppThemeConfiguration = {\n    app: {\n        'overlay-header':\n            'linear-gradient(transparent 0%, rgb(0 0 0 / 95%) 100%), var(--theme-background-noise)',\n        'overlay-subheader':\n            'linear-gradient(180deg, rgb(0 0 0 / 5%) 0%, var(--theme-colors-background) 100%), var(--theme-background-noise)',\n        'scrollbar-handle-background': 'rgba(255, 255, 255, 40%)',\n        'scrollbar-handle-hover-background': 'rgba(255, 255, 255, 60%)',\n    },\n    colors: {\n        background: 'rgb(0, 0, 0)',\n        'background-alternate': 'rgb(0, 0, 0)',\n        black: 'rgb(0, 0, 0)',\n        foreground: 'rgb(255, 255, 255)',\n        'foreground-muted': 'rgb(200, 200, 200)',\n        primary: 'rgb(0, 191, 255)',\n        'state-error': 'rgb(255, 0, 0)',\n        'state-info': 'rgb(0, 191, 255)',\n        'state-success': 'rgb(0, 255, 0)',\n        'state-warning': 'rgb(255, 255, 0)',\n        surface: 'rgb(20, 20, 20)',\n        'surface-foreground': 'rgb(255, 255, 255)',\n        white: 'rgb(255, 255, 255)',\n    },\n    mode: 'dark',\n};\n"
  },
  {
    "path": "src/shared/themes/high-contrast-light/high-contrast-light.ts",
    "content": "import { AppThemeConfiguration } from '/@/shared/themes/app-theme-types';\n\nexport const highContrastLight: AppThemeConfiguration = {\n    app: {\n        'overlay-header':\n            'linear-gradient(rgb(255 255 255 / 95%) 0%, rgb(255 255 255 / 100%)), var(--theme-background-noise)',\n        'overlay-subheader':\n            'linear-gradient(180deg, rgba(255, 255, 255, 5%) 0%, var(--theme-colors-background)), var(--theme-background-noise)',\n        'scrollbar-handle-background': 'rgba(0, 0, 0, 40%)',\n        'scrollbar-handle-hover-background': 'rgba(0, 0, 0, 60%)',\n    },\n    colors: {\n        background: 'rgb(255, 255, 255)',\n        'background-alternate': 'rgb(255, 255, 255)',\n        black: 'rgb(0, 0, 0)',\n        foreground: 'rgb(0, 0, 0)',\n        'foreground-muted': 'rgb(50, 50, 50)',\n        primary: 'rgb(0, 0, 255)',\n        'state-error': 'rgb(255, 0, 0)',\n        'state-info': 'rgb(0, 0, 255)',\n        'state-success': 'rgb(0, 128, 0)',\n        'state-warning': 'rgb(255, 140, 0)',\n        surface: 'rgb(240, 240, 240)',\n        'surface-foreground': 'rgb(0, 0, 0)',\n        white: 'rgb(255, 255, 255)',\n    },\n    mantineOverride: {\n        primaryShade: {\n            light: 4,\n        },\n    },\n    mode: 'light',\n};\n"
  },
  {
    "path": "src/shared/themes/material-dark/material-dark.ts",
    "content": "import { AppThemeConfiguration } from '/@/shared/themes/app-theme-types';\n\nexport const materialDark: AppThemeConfiguration = {\n    app: {\n        'overlay-header':\n            'linear-gradient(transparent 0%, rgb(33 33 33 / 85%) 100%), var(--theme-background-noise)',\n        'overlay-subheader':\n            'linear-gradient(180deg, rgb(33 33 33 / 5%) 0%, var(--theme-colors-background) 100%), var(--theme-background-noise)',\n        'scrollbar-handle-background': 'rgba(160, 160, 160, 20%)',\n        'scrollbar-handle-hover-background': 'rgba(160, 160, 160, 40%)',\n    },\n    colors: {\n        background: 'rgb(33, 33, 33)',\n        'background-alternate': 'rgb(18, 18, 18)',\n        black: 'rgb(0, 0, 0)',\n        foreground: 'rgb(255, 255, 255)',\n        'foreground-muted': 'rgb(189, 189, 189)',\n        primary: 'rgb(33, 150, 243)',\n        'state-error': 'rgb(244, 67, 54)',\n        'state-info': 'rgb(33, 150, 243)',\n        'state-success': 'rgb(76, 175, 80)',\n        'state-warning': 'rgb(255, 152, 0)',\n        surface: 'rgb(48, 48, 48)',\n        'surface-foreground': 'rgb(255, 255, 255)',\n        white: 'rgb(255, 255, 255)',\n    },\n    mode: 'dark',\n};\n"
  },
  {
    "path": "src/shared/themes/material-light/material-light.ts",
    "content": "import { AppThemeConfiguration } from '/@/shared/themes/app-theme-types';\n\nexport const materialLight: AppThemeConfiguration = {\n    app: {\n        'overlay-header':\n            'linear-gradient(rgb(250 250 250 / 50%) 0%, rgb(250 250 250 / 80%)), var(--theme-background-noise)',\n        'overlay-subheader':\n            'linear-gradient(180deg, rgba(250, 250, 250, 5%) 0%, var(--theme-colors-background)), var(--theme-background-noise)',\n        'scrollbar-handle-background': 'rgba(140, 140, 140, 30%)',\n        'scrollbar-handle-hover-background': 'rgba(140, 140, 140, 60%)',\n    },\n    colors: {\n        background: 'rgb(250, 250, 250)',\n        'background-alternate': 'rgb(255, 255, 255)',\n        black: 'rgb(0, 0, 0)',\n        foreground: 'rgb(33, 33, 33)',\n        'foreground-muted': 'rgb(117, 117, 117)',\n        primary: 'rgb(33, 150, 243)',\n        'state-error': 'rgb(244, 67, 54)',\n        'state-info': 'rgb(33, 150, 243)',\n        'state-success': 'rgb(76, 175, 80)',\n        'state-warning': 'rgb(255, 152, 0)',\n        surface: 'rgb(245, 245, 245)',\n        'surface-foreground': 'rgb(33, 33, 33)',\n        white: 'rgb(255, 255, 255)',\n    },\n    mantineOverride: {\n        primaryShade: {\n            light: 4,\n        },\n    },\n    mode: 'light',\n};\n"
  },
  {
    "path": "src/shared/themes/monokai/monokai.ts",
    "content": "import { AppThemeConfiguration } from '/@/shared/themes/app-theme-types';\n\nexport const monokai: AppThemeConfiguration = {\n    app: {\n        'overlay-header':\n            'linear-gradient(transparent 0%, rgb(39 40 34 / 85%) 100%), var(--theme-background-noise)',\n        'overlay-subheader':\n            'linear-gradient(180deg, rgb(39 40 34 / 5%) 0%, var(--theme-colors-background) 100%), var(--theme-background-noise)',\n        'scrollbar-handle-background': 'rgba(160, 160, 160, 20%)',\n        'scrollbar-handle-hover-background': 'rgba(160, 160, 160, 40%)',\n    },\n    colors: {\n        background: 'rgb(39, 40, 34)',\n        'background-alternate': 'rgb(30, 31, 28)',\n        black: 'rgb(0, 0, 0)',\n        foreground: 'rgb(248, 248, 242)',\n        'foreground-muted': 'rgb(117, 113, 94)',\n        primary: 'rgb(174, 129, 255)',\n        'state-error': 'rgb(249, 38, 114)',\n        'state-info': 'rgb(102, 217, 239)',\n        'state-success': 'rgb(166, 226, 46)',\n        'state-warning': 'rgb(253, 151, 31)',\n        surface: 'rgb(50, 51, 45)',\n        'surface-foreground': 'rgb(248, 248, 242)',\n        white: 'rgb(255, 255, 255)',\n    },\n    mode: 'dark',\n};\n"
  },
  {
    "path": "src/shared/themes/night-owl/night-owl.ts",
    "content": "import { AppThemeConfiguration } from '/@/shared/themes/app-theme-types';\n\nexport const nightOwl: AppThemeConfiguration = {\n    app: {\n        'overlay-header':\n            'linear-gradient(transparent 0%, rgb(1 22 39 / 85%) 100%), var(--theme-background-noise)',\n        'overlay-subheader':\n            'linear-gradient(180deg, rgb(1 22 39 / 5%) 0%, var(--theme-colors-background) 100%), var(--theme-background-noise)',\n        'scrollbar-handle-background': 'rgba(160, 160, 160, 20%)',\n        'scrollbar-handle-hover-background': 'rgba(160, 160, 160, 40%)',\n    },\n    colors: {\n        background: 'rgb(1, 22, 39)',\n        'background-alternate': 'rgb(0, 16, 28)',\n        black: 'rgb(0, 0, 0)',\n        foreground: 'rgb(214, 222, 235)',\n        'foreground-muted': 'rgb(171, 178, 191)',\n        primary: 'rgb(130, 170, 255)',\n        'state-error': 'rgb(255, 123, 172)',\n        'state-info': 'rgb(130, 170, 255)',\n        'state-success': 'rgb(173, 219, 103)',\n        'state-warning': 'rgb(255, 184, 108)',\n        surface: 'rgb(11, 41, 66)',\n        'surface-foreground': 'rgb(214, 222, 235)',\n        white: 'rgb(255, 255, 255)',\n    },\n    mode: 'dark',\n};\n"
  },
  {
    "path": "src/shared/themes/nord/nord.ts",
    "content": "import { AppThemeConfiguration } from '/@/shared/themes/app-theme-types';\n\nexport const nord: AppThemeConfiguration = {\n    app: {\n        'overlay-header':\n            'linear-gradient(transparent 0%, rgb(46 52 64 / 85%) 100%), var(--theme-background-noise)',\n        'overlay-subheader':\n            'linear-gradient(180deg, rgb(46 52 64 / 5%) 0%, var(--theme-colors-background) 100%), var(--theme-background-noise)',\n        'scrollbar-handle-background': 'rgba(160, 160, 160, 20%)',\n        'scrollbar-handle-hover-background': 'rgba(160, 160, 160, 40%)',\n    },\n    colors: {\n        background: 'rgb(46, 52, 64)',\n        'background-alternate': 'rgb(37, 41, 54)',\n        black: 'rgb(0, 0, 0)',\n        foreground: 'rgb(236, 239, 244)',\n        'foreground-muted': 'rgb(216, 222, 233)',\n        primary: 'rgb(136, 192, 208)',\n        'state-error': 'rgb(191, 97, 106)',\n        'state-info': 'rgb(136, 192, 208)',\n        'state-success': 'rgb(163, 190, 140)',\n        'state-warning': 'rgb(235, 203, 139)',\n        surface: 'rgb(59, 66, 82)',\n        'surface-foreground': 'rgb(236, 239, 244)',\n        white: 'rgb(255, 255, 255)',\n    },\n    mode: 'dark',\n};\n"
  },
  {
    "path": "src/shared/themes/one-dark/one-dark.ts",
    "content": "import { AppThemeConfiguration } from '/@/shared/themes/app-theme-types';\n\nexport const oneDark: AppThemeConfiguration = {\n    app: {\n        'overlay-header':\n            'linear-gradient(transparent 0%, rgb(40 44 52 / 85%) 100%), var(--theme-background-noise)',\n        'overlay-subheader':\n            'linear-gradient(180deg, rgb(40 44 52 / 5%) 0%, var(--theme-colors-background) 100%), var(--theme-background-noise)',\n        'scrollbar-handle-background': 'rgba(160, 160, 160, 20%)',\n        'scrollbar-handle-hover-background': 'rgba(160, 160, 160, 40%)',\n    },\n    colors: {\n        background: 'rgb(40, 44, 52)',\n        'background-alternate': 'rgb(30, 33, 40)',\n        black: 'rgb(0, 0, 0)',\n        foreground: 'rgb(171, 178, 191)',\n        'foreground-muted': 'rgb(152, 161, 178)',\n        primary: 'rgb(97, 175, 239)',\n        'state-error': 'rgb(224, 108, 117)',\n        'state-info': 'rgb(97, 175, 239)',\n        'state-success': 'rgb(152, 195, 121)',\n        'state-warning': 'rgb(229, 192, 123)',\n        surface: 'rgb(55, 59, 70)',\n        'surface-foreground': 'rgb(171, 178, 191)',\n        white: 'rgb(255, 255, 255)',\n    },\n    mode: 'dark',\n};\n"
  },
  {
    "path": "src/shared/themes/rose-pine/rose-pine.ts",
    "content": "import { AppThemeConfiguration } from '/@/shared/themes/app-theme-types';\n\nexport const rosePine: AppThemeConfiguration = {\n    app: {\n        'scrollbar-handle-active-background': 'rgba(82, 79, 103, 0.7)',\n        'scrollbar-handle-background': 'rgba(33, 32, 46, 0.5)',\n    },\n    colors: {\n        background: '#191724', // base\n        'background-alternate': '#191724', // base\n        foreground: '#e0def4', // text\n        'foreground-muted': '#6e6a86', // muted\n        primary: '#ebbcba', // rose\n        'state-error': '#eb6f92', // love\n        'state-info': '#9ccfd8', // foam\n        'state-success': '#31748f', // pine\n        'state-warning': '#f6c177', // gold\n        surface: '#1f1d2e', // surface\n        'surface-foreground': '#908caa', // subtle\n    },\n    mode: 'dark',\n};\n"
  },
  {
    "path": "src/shared/themes/rose-pine-dawn/rose-pine-dawn.ts",
    "content": "import { AppThemeConfiguration } from '/@/shared/themes/app-theme-types';\n\nexport const rosePineDawn: AppThemeConfiguration = {\n    app: {\n        'scrollbar-handle-active-background': 'rgba(206, 202, 205, 0.7)',\n        'scrollbar-handle-background': 'rgba(244, 237, 232, 0.5)',\n    },\n    colors: {\n        background: '#faf4ed', // base\n        'background-alternate': '#faf4ed', // base\n        foreground: '#575279', // text\n        'foreground-muted': '#9893a5', // muted\n        primary: '#d7827e', // rose\n        'state-error': '#b4637a', // love\n        'state-info': '#56949f', // foam\n        'state-success': '#286983', // pine\n        'state-warning': '#ea9d34', // gold\n        surface: '#fffaf3', // surface\n        'surface-foreground': '#797593', // subtle\n    },\n    mode: 'light',\n};\n"
  },
  {
    "path": "src/shared/themes/rose-pine-moon/rose-pine-moon.ts",
    "content": "import { AppThemeConfiguration } from '/@/shared/themes/app-theme-types';\n\nexport const rosePineMoon: AppThemeConfiguration = {\n    app: {\n        'scrollbar-handle-active-background': 'rgba(86, 82, 110, 0.7)',\n        'scrollbar-handle-background': 'rgba(42, 40, 62, 0.5)',\n    },\n    colors: {\n        background: '#232136', // base\n        'background-alternate': '#232136', // base\n        foreground: '#e0def4', // text\n        'foreground-muted': '#6e6a86', // muted\n        primary: '#ea9a97', // rose\n        'state-error': '#eb6f92', // love\n        'state-info': '#9ccfd8', // foam\n        'state-success': '#3e8fb0', // pine\n        'state-warning': '#f6c177', // gold\n        surface: '#191724', // surface\n        'surface-foreground': '#908caa', // subtle\n    },\n    mode: 'dark',\n};\n"
  },
  {
    "path": "src/shared/themes/shades-of-purple/shades-of-purple.ts",
    "content": "import { AppThemeConfiguration } from '/@/shared/themes/app-theme-types';\n\nexport const shadesOfPurple: AppThemeConfiguration = {\n    app: {\n        'overlay-header':\n            'linear-gradient(transparent 0%, rgb(45 43 85 / 85%) 100%), var(--theme-background-noise)',\n        'overlay-subheader':\n            'linear-gradient(180deg, rgb(45 43 85 / 5%) 0%, var(--theme-colors-background) 100%), var(--theme-background-noise)',\n        'scrollbar-handle-background': 'rgba(160, 160, 160, 20%)',\n        'scrollbar-handle-hover-background': 'rgba(160, 160, 160, 40%)',\n    },\n    colors: {\n        background: 'rgb(45, 43, 85)',\n        'background-alternate': 'rgb(37, 35, 69)',\n        black: 'rgb(0, 0, 0)',\n        foreground: 'rgb(255, 255, 255)',\n        'foreground-muted': 'rgb(255, 255, 255)',\n        primary: 'rgb(167, 129, 255)',\n        'state-error': 'rgb(255, 99, 99)',\n        'state-info': 'rgb(130, 170, 255)',\n        'state-success': 'rgb(10, 255, 157)',\n        'state-warning': 'rgb(255, 184, 108)',\n        surface: 'rgb(58, 56, 102)',\n        'surface-foreground': 'rgb(255, 255, 255)',\n        white: 'rgb(255, 255, 255)',\n    },\n    mode: 'dark',\n};\n"
  },
  {
    "path": "src/shared/themes/solarized-dark/solarized-dark.ts",
    "content": "import { AppThemeConfiguration } from '/@/shared/themes/app-theme-types';\n\nexport const solarizedDark: AppThemeConfiguration = {\n    app: {\n        'overlay-header':\n            'linear-gradient(transparent 0%, rgb(0 43 54 / 85%) 100%), var(--theme-background-noise)',\n        'overlay-subheader':\n            'linear-gradient(180deg, rgb(0 43 54 / 5%) 0%, var(--theme-colors-background) 100%), var(--theme-background-noise)',\n        'scrollbar-handle-background': 'rgba(160, 160, 160, 20%)',\n        'scrollbar-handle-hover-background': 'rgba(160, 160, 160, 40%)',\n    },\n    colors: {\n        background: 'rgb(0, 43, 54)',\n        'background-alternate': 'rgb(7, 54, 66)',\n        black: 'rgb(0, 0, 0)',\n        foreground: 'rgb(131, 148, 150)',\n        'foreground-muted': 'rgb(88, 110, 117)',\n        primary: 'rgb(38, 139, 210)',\n        'state-error': 'rgb(220, 50, 47)',\n        'state-info': 'rgb(38, 139, 210)',\n        'state-success': 'rgb(133, 153, 0)',\n        'state-warning': 'rgb(181, 137, 0)',\n        surface: 'rgb(14, 65, 78)',\n        'surface-foreground': 'rgb(131, 148, 150)',\n        white: 'rgb(255, 255, 255)',\n    },\n    mode: 'dark',\n};\n"
  },
  {
    "path": "src/shared/themes/solarized-light/solarized-light.ts",
    "content": "import { AppThemeConfiguration } from '/@/shared/themes/app-theme-types';\n\nexport const solarizedLight: AppThemeConfiguration = {\n    app: {\n        'overlay-header':\n            'linear-gradient(rgb(253 246 227 / 50%) 0%, rgb(253 246 227 / 80%)), var(--theme-background-noise)',\n        'overlay-subheader':\n            'linear-gradient(180deg, rgba(253, 246, 227, 5%) 0%, var(--theme-colors-background)), var(--theme-background-noise)',\n        'scrollbar-handle-background': 'rgba(140, 140, 140, 30%)',\n        'scrollbar-handle-hover-background': 'rgba(140, 140, 140, 60%)',\n    },\n    colors: {\n        background: 'rgb(253, 246, 227)',\n        'background-alternate': 'rgb(238, 232, 213)',\n        black: 'rgb(0, 0, 0)',\n        foreground: 'rgb(101, 123, 131)',\n        'foreground-muted': 'rgb(147, 161, 161)',\n        primary: 'rgb(38, 139, 210)',\n        'state-error': 'rgb(220, 50, 47)',\n        'state-info': 'rgb(38, 139, 210)',\n        'state-success': 'rgb(133, 153, 0)',\n        'state-warning': 'rgb(181, 137, 0)',\n        surface: 'rgb(247, 240, 220)',\n        'surface-foreground': 'rgb(0, 43, 54)',\n        white: 'rgb(255, 255, 255)',\n    },\n    mantineOverride: {\n        primaryShade: {\n            light: 4,\n        },\n    },\n    mode: 'light',\n};\n"
  },
  {
    "path": "src/shared/themes/tokyo-night/tokyo-night.ts",
    "content": "import { AppThemeConfiguration } from '/@/shared/themes/app-theme-types';\n\nexport const tokyoNight: AppThemeConfiguration = {\n    app: {\n        'overlay-header':\n            'linear-gradient(transparent 0%, rgb(26 27 38 / 85%) 100%), var(--theme-background-noise)',\n        'overlay-subheader':\n            'linear-gradient(180deg, rgb(26 27 38 / 5%) 0%, var(--theme-colors-background) 100%), var(--theme-background-noise)',\n        'scrollbar-handle-background': 'rgba(160, 160, 160, 20%)',\n        'scrollbar-handle-hover-background': 'rgba(160, 160, 160, 40%)',\n    },\n    colors: {\n        background: 'rgb(26, 27, 38)',\n        'background-alternate': 'rgb(20, 21, 30)',\n        black: 'rgb(0, 0, 0)',\n        foreground: 'rgb(192, 202, 245)',\n        'foreground-muted': 'rgb(169, 177, 214)',\n        primary: 'rgb(125, 207, 255)',\n        'state-error': 'rgb(247, 118, 142)',\n        'state-info': 'rgb(125, 207, 255)',\n        'state-success': 'rgb(158, 206, 106)',\n        'state-warning': 'rgb(255, 158, 100)',\n        surface: 'rgb(35, 36, 51)',\n        'surface-foreground': 'rgb(192, 202, 245)',\n        white: 'rgb(255, 255, 255)',\n    },\n    mode: 'dark',\n};\n"
  },
  {
    "path": "src/shared/themes/vscode-dark-plus/vscode-dark-plus.ts",
    "content": "import { AppThemeConfiguration } from '/@/shared/themes/app-theme-types';\n\nexport const vscodeDarkPlus: AppThemeConfiguration = {\n    app: {\n        'overlay-header':\n            'linear-gradient(transparent 0%, rgb(30 30 30 / 85%) 100%), var(--theme-background-noise)',\n        'overlay-subheader':\n            'linear-gradient(180deg, rgb(30 30 30 / 5%) 0%, var(--theme-colors-background) 100%), var(--theme-background-noise)',\n        'scrollbar-handle-background': 'rgba(160, 160, 160, 20%)',\n        'scrollbar-handle-hover-background': 'rgba(160, 160, 160, 40%)',\n    },\n    colors: {\n        background: 'rgb(30, 30, 30)',\n        'background-alternate': 'rgb(24, 24, 24)',\n        black: 'rgb(0, 0, 0)',\n        foreground: 'rgb(212, 212, 212)',\n        'foreground-muted': 'rgb(170, 170, 170)',\n        primary: 'rgb(0, 122, 204)',\n        'state-error': 'rgb(244, 63, 94)',\n        'state-info': 'rgb(0, 122, 204)',\n        'state-success': 'rgb(89, 185, 89)',\n        'state-warning': 'rgb(255, 184, 108)',\n        surface: 'rgb(37, 37, 38)',\n        'surface-foreground': 'rgb(212, 212, 212)',\n        white: 'rgb(255, 255, 255)',\n    },\n    mode: 'dark',\n};\n"
  },
  {
    "path": "src/shared/themes/vscode-light-plus/vscode-light-plus.ts",
    "content": "import { AppThemeConfiguration } from '/@/shared/themes/app-theme-types';\n\nexport const vscodeLightPlus: AppThemeConfiguration = {\n    app: {\n        'overlay-header':\n            'linear-gradient(rgb(255 255 255 / 50%) 0%, rgb(255 255 255 / 80%)), var(--theme-background-noise)',\n        'overlay-subheader':\n            'linear-gradient(180deg, rgba(255, 255, 255, 5%) 0%, var(--theme-colors-background)), var(--theme-background-noise)',\n        'scrollbar-handle-background': 'rgba(140, 140, 140, 30%)',\n        'scrollbar-handle-hover-background': 'rgba(140, 140, 140, 60%)',\n    },\n    colors: {\n        background: 'rgb(255, 255, 255)',\n        'background-alternate': 'rgb(250, 250, 250)',\n        black: 'rgb(0, 0, 0)',\n        foreground: 'rgb(0, 0, 0)',\n        'foreground-muted': 'rgb(113, 113, 113)',\n        primary: 'rgb(0, 122, 204)',\n        'state-error': 'rgb(229, 20, 0)',\n        'state-info': 'rgb(0, 122, 204)',\n        'state-success': 'rgb(16, 124, 16)',\n        'state-warning': 'rgb(191, 136, 0)',\n        surface: 'rgb(243, 243, 243)',\n        'surface-foreground': 'rgb(0, 0, 0)',\n        white: 'rgb(255, 255, 255)',\n    },\n    mantineOverride: {\n        primaryShade: {\n            light: 9,\n        },\n    },\n    mode: 'light',\n};\n"
  },
  {
    "path": "src/shared/types/css-modules.d.ts",
    "content": "declare module '*.module.css' {\n    const classes: { [key: string]: string };\n    export default classes;\n}\n\ndeclare module '*.css?raw' {\n    const content: string;\n    export default content;\n}\n\ndeclare module '*.css?inline' {\n    const content: string;\n    export default content;\n}\n"
  },
  {
    "path": "src/shared/types/domain-types.ts",
    "content": "import {\n    JFAlbumArtistListSort,\n    JFAlbumListSort,\n    JFArtistListSort,\n    JFGenreListSort,\n    JFPlaylistListSort,\n    JFSongListSort,\n    JFSortOrder,\n} from '/@/shared/api/jellyfin/jellyfin-types';\nimport {\n    NDAlbumArtistListSort,\n    NDAlbumListSort,\n    NDGenreListSort,\n    NDPlaylistListSort,\n    NDSongListSort,\n    NDSortOrder,\n    NDTagListSort,\n    NDUserListSort,\n} from '/@/shared/api/navidrome/navidrome-types';\nimport { ServerFeatures } from '/@/shared/types/features-types';\nimport { PlayerStatus } from '/@/shared/types/types';\n\nexport enum LibraryItem {\n    ALBUM = 'album',\n    ALBUM_ARTIST = 'albumArtist',\n    ARTIST = 'artist',\n    FOLDER = 'folder',\n    GENRE = 'genre',\n    PLAYLIST = 'playlist',\n    PLAYLIST_SONG = 'playlistSong',\n    QUEUE_SONG = 'queueSong',\n    RADIO_STATION = 'radioStation',\n    SONG = 'song',\n}\n\nexport enum ServerType {\n    JELLYFIN = 'jellyfin',\n    NAVIDROME = 'navidrome',\n    SUBSONIC = 'subsonic',\n}\n\nexport enum SortOrder {\n    ASC = 'ASC',\n    DESC = 'DESC',\n}\n\nexport type AnyLibraryItem = Album | AlbumArtist | Artist | Playlist | QueueSong | Song;\n\nexport type AnyLibraryItems =\n    | Album[]\n    | AlbumArtist[]\n    | Artist[]\n    | Playlist[]\n    | QueueSong[]\n    | Song[];\n\nexport interface PlayerData {\n    currentSong: QueueSong | undefined;\n    index: number;\n    nextSong: QueueSong | undefined;\n    num: 1 | 2;\n    player1: QueueSong | undefined;\n    player2: QueueSong | undefined;\n    previousSong: QueueSong | undefined;\n    queueLength: number;\n    status: PlayerStatus;\n}\n\nexport interface QueueData {\n    default: string[];\n    shuffled: number[];\n    songs: Record<string, QueueSong>;\n}\n\nexport type QueueSong = Song & {\n    _uniqueId: string;\n};\n\nexport interface SavedCollection {\n    filterQueryString: string;\n    id: string;\n    name: string;\n    type: LibraryItem.ALBUM | LibraryItem.SONG;\n}\n\nexport type ServerListItem = {\n    features?: ServerFeatures;\n    id: string;\n    isAdmin?: boolean;\n    musicFolderId?: string[];\n    name: string;\n    preferInstantMix?: boolean;\n    preferRemoteUrl?: boolean;\n    remoteUrl?: string;\n    savePassword?: boolean;\n    type: ServerType;\n    url: string;\n    userId: null | string;\n    username: string;\n    version?: string;\n};\n\nexport type ServerListItemWithCredential = ServerListItem & {\n    credential: string;\n    ndCredential?: string;\n};\n\nexport type User = {\n    createdAt: null | string;\n    email: null | string;\n    id: string;\n    isAdmin: boolean | null;\n    lastLoginAt: null | string;\n    name: string;\n    updatedAt: null | string;\n};\n\ntype SortOrderMap = {\n    jellyfin: Record<SortOrder, JFSortOrder>;\n    navidrome: Record<SortOrder, NDSortOrder>;\n    subsonic: Record<SortOrder, undefined>;\n};\n\nexport const sortOrderMap: SortOrderMap = {\n    jellyfin: {\n        ASC: JFSortOrder.ASC,\n        DESC: JFSortOrder.DESC,\n    },\n    navidrome: {\n        ASC: NDSortOrder.ASC,\n        DESC: NDSortOrder.DESC,\n    },\n    subsonic: {\n        ASC: undefined,\n        DESC: undefined,\n    },\n};\n\nexport enum ExplicitStatus {\n    CLEAN = 'CLEAN',\n    EXPLICIT = 'EXPLICIT',\n}\n\nexport enum ExternalSource {\n    LASTFM = 'LASTFM',\n    MUSICBRAINZ = 'MUSICBRAINZ',\n    SPOTIFY = 'SPOTIFY',\n    THEAUDIODB = 'THEAUDIODB',\n}\n\nexport enum ExternalType {\n    ID = 'ID',\n    LINK = 'LINK',\n}\n\nexport enum GenreListSort {\n    NAME = 'name',\n}\n\nexport enum ImageType {\n    BACKDROP = 'BACKDROP',\n    LOGO = 'LOGO',\n    PRIMARY = 'PRIMARY',\n    SCREENSHOT = 'SCREENSHOT',\n}\n\nexport enum TagListSort {\n    NAME = 'name',\n}\n\nexport type Album = {\n    _itemType: LibraryItem.ALBUM;\n    _serverId: string;\n    _serverType: ServerType;\n    albumArtistName: string;\n    albumArtists: RelatedArtist[];\n    artists: RelatedArtist[];\n    comment: null | string;\n    createdAt: string;\n    duration: null | number;\n    explicitStatus: ExplicitStatus | null;\n    genres: Genre[];\n    id: string;\n    imageId: null | string;\n    imageUrl: null | string;\n    isCompilation: boolean | null;\n    lastPlayedAt: null | string;\n    mbzId: null | string;\n    mbzReleaseGroupId: null | string;\n    name: string;\n    originalDate: null | string;\n    originalYear: null | number;\n    participants: null | Record<string, RelatedArtist[]>;\n    playCount: null | number;\n    recordLabels: string[];\n    releaseDate: null | string;\n    releaseType: null | string;\n    releaseTypes: string[];\n    releaseYear: null | number;\n    size: null | number;\n    songCount: null | number;\n    songs?: Song[];\n    sortName: string;\n    tags: null | Record<string, string[]>;\n    updatedAt: string;\n    userFavorite: boolean;\n    userRating: null | number;\n    version: null | string;\n} & { songs?: Song[] };\n\nexport type AlbumArtist = {\n    _itemType: LibraryItem.ALBUM_ARTIST;\n    _serverId: string;\n    _serverType: ServerType;\n    albumCount: null | number;\n    biography: null | string;\n    duration: null | number;\n    genres: Genre[];\n    id: string;\n    imageId: null | string;\n    imageUrl: null | string;\n    lastPlayedAt: null | string;\n    mbz: null | string;\n    name: string;\n    playCount: null | number;\n    similarArtists: null | RelatedArtist[];\n    songCount: null | number;\n    userFavorite: boolean;\n    userRating: null | number;\n};\n\nexport type Artist = Omit<AlbumArtist, '_itemType'> & {\n    _itemType: LibraryItem.ARTIST;\n};\n\nexport type AuthenticationResponse = {\n    credential: string;\n    isAdmin?: boolean;\n    ndCredential?: string;\n    userId: null | string;\n    username: string;\n};\n\nexport interface BasePaginatedResponse<T> {\n    error?: any | string;\n    items: T;\n    startIndex: number;\n    totalRecordCount: null | number;\n}\n\nexport interface BaseQuery<T> {\n    sortBy: T;\n    sortOrder: SortOrder;\n}\n\nexport type EndpointDetails = {\n    server: ServerListItem;\n};\n\nexport type Folder = {\n    _itemType: LibraryItem.FOLDER;\n    _serverId: string;\n    _serverType: ServerType;\n    children?: {\n        folders: Folder[];\n        songs: Song[];\n    };\n    id: string;\n    imageId?: null | string;\n    imageUrl?: null | string;\n    name: string;\n    parentId?: string;\n};\n\nexport type FolderArgs = BaseEndpointArgs & { query: FolderQuery };\n\nexport interface FolderQuery extends BaseQuery<SongListSort> {\n    id: string;\n    musicFolderId?: string | string[];\n    searchTerm?: string;\n}\n\nexport type FolderResponse = Folder;\n\nexport type GainInfo = {\n    album?: number;\n    track?: number;\n};\n\nexport type Genre = {\n    _itemType: LibraryItem.GENRE;\n    _serverId: string;\n    _serverType: ServerType;\n    albumCount: null | number;\n    id: string;\n    imageId: null | string;\n    imageUrl: null | string;\n    name: string;\n    songCount: null | number;\n};\n\nexport type GenreListArgs = BaseEndpointArgs & { query: GenreListQuery };\n\nexport interface GenreListQuery extends BaseQuery<GenreListSort> {\n    _custom?: {\n        jellyfin?: null;\n        navidrome?: null;\n    };\n    limit?: number;\n    musicFolderId?: string | string[];\n    searchTerm?: string;\n    startIndex: number;\n}\n\n// Genre List\nexport type GenreListResponse = BasePaginatedResponse<Genre[]>;\n\nexport type GenresResponse = Genre[];\n\nexport type ListSortOrder = 'asc' | 'desc';\n\nexport type MusicFolder = {\n    id: string;\n    name: string;\n};\n\nexport type MusicFoldersResponse = MusicFolder[];\n\nexport type Playlist = {\n    _itemType: LibraryItem.PLAYLIST;\n    _serverId: string;\n    _serverType: ServerType;\n    description: null | string;\n    duration: null | number;\n    genres: Genre[];\n    id: string;\n    imageId: null | string;\n    imageUrl: null | string;\n    name: string;\n    owner: null | string;\n    ownerId: null | string;\n    public: boolean | null;\n    rules?: null | Record<string, any>;\n    size: null | number;\n    songCount: null | number;\n    sync?: boolean | null;\n};\n\nexport type RelatedAlbumArtist = {\n    id: string;\n    name: string;\n};\n\nexport type RelatedArtist = {\n    id: string;\n    imageId: null | string;\n    imageUrl: null | string;\n    name: string;\n    userFavorite: boolean;\n    userRating: null | number;\n};\n\nexport type Song = {\n    _itemType: LibraryItem.SONG;\n    _serverId: string;\n    _serverType: ServerType;\n    album: null | string;\n    albumArtistName: string;\n    albumArtists: RelatedArtist[];\n    albumId: string;\n    artistName: string;\n    artists: RelatedArtist[];\n    bitDepth: null | number;\n    bitRate: number;\n    bpm: null | number;\n    channels: null | number;\n    comment: null | string;\n    compilation: boolean | null;\n    container: null | string;\n    createdAt: string;\n    discNumber: number;\n    discSubtitle: null | string;\n    duration: number;\n    explicitStatus: ExplicitStatus | null;\n    gain: GainInfo | null;\n    genres: Genre[];\n    id: string;\n    imageId: null | string;\n    imageUrl: null | string;\n    lastPlayedAt: null | string;\n    lyrics: null | string;\n    mbzRecordingId: null | string;\n    mbzTrackId: null | string;\n    name: string;\n    participants: null | Record<string, RelatedArtist[]>;\n    path: null | string;\n    peak: GainInfo | null;\n    playCount: number;\n    playlistItemId?: string;\n    releaseDate: null | string;\n    releaseYear: null | number;\n    sampleRate: null | number;\n    size: number;\n    sortName: string;\n    tags: null | Record<string, string[]>;\n    trackNumber: number;\n    trackSubtitle: null | string;\n    updatedAt: string;\n    userFavorite: boolean;\n    userRating: null | number;\n};\n\ntype BaseEndpointArgs = {\n    apiClientProps: {\n        server?: null | ServerListItemWithCredential;\n        serverId: string;\n        signal?: AbortSignal;\n    };\n    context?: {\n        pathReplace?: string;\n        pathReplaceWith?: string;\n    };\n};\n\ntype GenreListSortMap = {\n    jellyfin: Record<GenreListSort, JFGenreListSort | undefined>;\n    navidrome: Record<GenreListSort, NDGenreListSort | undefined>;\n    subsonic: Record<UserListSort, undefined>;\n};\n\nexport const genreListSortMap: GenreListSortMap = {\n    jellyfin: {\n        name: JFGenreListSort.NAME,\n    },\n    navidrome: {\n        name: NDGenreListSort.NAME,\n    },\n    subsonic: {\n        name: undefined,\n    },\n};\n\ntype TagListSortMap = {\n    jellyfin: Record<TagListSort, undefined>;\n    navidrome: Record<TagListSort, NDTagListSort | undefined>;\n    subsonic: Record<TagListSort, undefined>;\n};\n\nexport const tagListSortMap: TagListSortMap = {\n    jellyfin: {\n        name: undefined,\n    },\n    navidrome: {\n        name: NDTagListSort.TAG_VALUE,\n    },\n    subsonic: {\n        name: undefined,\n    },\n};\n\nexport enum AlbumListSort {\n    ALBUM_ARTIST = 'albumArtist',\n    ARTIST = 'artist',\n    COMMUNITY_RATING = 'communityRating',\n    CRITIC_RATING = 'criticRating',\n    DURATION = 'duration',\n    EXPLICIT_STATUS = 'explicitStatus',\n    FAVORITED = 'favorited',\n    ID = 'id',\n    NAME = 'name',\n    PLAY_COUNT = 'playCount',\n    RANDOM = 'random',\n    RATING = 'rating',\n    RECENTLY_ADDED = 'recentlyAdded',\n    RECENTLY_PLAYED = 'recentlyPlayed',\n    RELEASE_DATE = 'releaseDate',\n    SONG_COUNT = 'songCount',\n    SORT_NAME = 'sortName',\n    YEAR = 'year',\n}\n\nexport type AlbumListArgs = BaseEndpointArgs & { query: AlbumListQuery };\n\nexport type AlbumListCountArgs = BaseEndpointArgs & { query: ListCountQuery<AlbumListQuery> };\n\nexport interface AlbumListQuery extends AlbumListNavidromeQuery, BaseQuery<AlbumListSort> {\n    _custom?: Record<string, any>;\n    artistIds?: string[];\n    compilation?: boolean;\n    favorite?: boolean;\n    genreIds?: string[];\n    limit?: number;\n    maxYear?: number;\n    minYear?: number;\n    musicFolderId?: string | string[];\n    searchTerm?: string;\n    startIndex: number;\n}\n\n// Album List\nexport type AlbumListResponse = BasePaginatedResponse<Album[]>;\n\nexport type ListCountQuery<TQuery> = Omit<TQuery, 'startIndex'>;\n\ninterface AlbumListNavidromeQuery {\n    hasRating?: boolean;\n    isRecentlyPlayed?: boolean;\n}\n\ntype AlbumListSortMap = {\n    jellyfin: Record<AlbumListSort, JFAlbumListSort | undefined>;\n    navidrome: Record<AlbumListSort, NDAlbumListSort | undefined>;\n    subsonic: Record<AlbumListSort, undefined>;\n};\n\nexport const albumListSortMap: AlbumListSortMap = {\n    jellyfin: {\n        albumArtist: JFAlbumListSort.ALBUM_ARTIST,\n        artist: undefined,\n        communityRating: JFAlbumListSort.COMMUNITY_RATING,\n        criticRating: JFAlbumListSort.CRITIC_RATING,\n        duration: undefined,\n        explicitStatus: undefined,\n        favorited: undefined,\n        id: undefined,\n        name: JFAlbumListSort.NAME,\n        playCount: JFAlbumListSort.PLAY_COUNT,\n        random: JFAlbumListSort.RANDOM,\n        rating: undefined,\n        recentlyAdded: JFAlbumListSort.RECENTLY_ADDED,\n        recentlyPlayed: undefined,\n        releaseDate: JFAlbumListSort.RELEASE_DATE,\n        songCount: undefined,\n        sortName: JFAlbumListSort.NAME,\n        year: undefined,\n    },\n    navidrome: {\n        albumArtist: NDAlbumListSort.ALBUM_ARTIST,\n        artist: NDAlbumListSort.ARTIST,\n        communityRating: undefined,\n        criticRating: undefined,\n        duration: NDAlbumListSort.DURATION,\n        explicitStatus: NDAlbumListSort.EXPLICIT_STATUS,\n        favorited: NDAlbumListSort.STARRED,\n        id: undefined,\n        name: NDAlbumListSort.NAME,\n        playCount: NDAlbumListSort.PLAY_COUNT,\n        random: NDAlbumListSort.RANDOM,\n        rating: NDAlbumListSort.RATING,\n        recentlyAdded: NDAlbumListSort.RECENTLY_ADDED,\n        recentlyPlayed: NDAlbumListSort.PLAY_DATE,\n        // Recent versions of Navidrome support release date, but fallback to year for now\n        releaseDate: NDAlbumListSort.YEAR,\n        songCount: NDAlbumListSort.SONG_COUNT,\n        sortName: NDAlbumListSort.NAME,\n        year: NDAlbumListSort.YEAR,\n    },\n    subsonic: {\n        albumArtist: undefined,\n        artist: undefined,\n        communityRating: undefined,\n        criticRating: undefined,\n        duration: undefined,\n        explicitStatus: undefined,\n        favorited: undefined,\n        id: undefined,\n        name: undefined,\n        playCount: undefined,\n        random: undefined,\n        rating: undefined,\n        recentlyAdded: undefined,\n        recentlyPlayed: undefined,\n        releaseDate: undefined,\n        songCount: undefined,\n        sortName: undefined,\n        year: undefined,\n    },\n};\n\nexport enum SongListSort {\n    ALBUM = 'album',\n    ALBUM_ARTIST = 'albumArtist',\n    ARTIST = 'artist',\n    BPM = 'bpm',\n    CHANNELS = 'channels',\n    COMMENT = 'comment',\n    DURATION = 'duration',\n    EXPLICIT_STATUS = 'explicitStatus',\n    FAVORITED = 'favorited',\n    GENRE = 'genre',\n    ID = 'id',\n    NAME = 'name',\n    PLAY_COUNT = 'playCount',\n    RANDOM = 'random',\n    RATING = 'rating',\n    RECENTLY_ADDED = 'recentlyAdded',\n    RECENTLY_PLAYED = 'recentlyPlayed',\n    RELEASE_DATE = 'releaseDate',\n    SORT_NAME = 'sortName',\n    YEAR = 'year',\n}\n\nexport type AlbumDetailArgs = BaseEndpointArgs & { query: AlbumDetailQuery };\n\nexport type AlbumDetailQuery = { id: string };\n\n// Album Detail\nexport type AlbumDetailResponse = Album;\n\nexport type AlbumInfo = {\n    imageUrl: null | string;\n    notes: null | string;\n};\n\nexport type SongListArgs = BaseEndpointArgs & { query: SongListQuery };\n\nexport type SongListCountArgs = BaseEndpointArgs & { query: ListCountQuery<SongListQuery> };\n\nexport interface SongListQuery extends BaseQuery<SongListSort> {\n    _custom?: Record<string, any>;\n    albumArtistIds?: string[];\n    albumIds?: string[];\n    artistIds?: string[];\n    favorite?: boolean;\n    genreIds?: string[];\n    hasRating?: boolean;\n    imageSize?: number;\n    limit?: number;\n    maxYear?: number;\n    minYear?: number;\n    musicFolderId?: string | string[];\n    searchTerm?: string;\n    startIndex: number;\n}\n\n// Song List\nexport type SongListResponse = BasePaginatedResponse<Song[]>;\n\ntype SongListSortMap = {\n    jellyfin: Record<SongListSort, JFSongListSort | undefined>;\n    navidrome: Record<SongListSort, NDSongListSort | undefined>;\n    subsonic: Record<SongListSort, undefined>;\n};\n\nexport const songListSortMap: SongListSortMap = {\n    jellyfin: {\n        album: JFSongListSort.ALBUM,\n        albumArtist: JFSongListSort.ALBUM_ARTIST,\n        artist: JFSongListSort.ARTIST,\n        bpm: undefined,\n        channels: undefined,\n        comment: undefined,\n        duration: JFSongListSort.DURATION,\n        explicitStatus: undefined,\n        favorited: undefined,\n        genre: undefined,\n        id: undefined,\n        name: JFSongListSort.NAME,\n        playCount: JFSongListSort.PLAY_COUNT,\n        random: JFSongListSort.RANDOM,\n        rating: undefined,\n        recentlyAdded: JFSongListSort.RECENTLY_ADDED,\n        recentlyPlayed: JFSongListSort.RECENTLY_PLAYED,\n        releaseDate: JFSongListSort.RELEASE_DATE,\n        sortName: JFSongListSort.NAME,\n        year: undefined,\n    },\n    navidrome: {\n        album: NDSongListSort.ALBUM_SONGS,\n        albumArtist: NDSongListSort.ALBUM_ARTIST,\n        artist: NDSongListSort.ARTIST,\n        bpm: NDSongListSort.BPM,\n        channels: NDSongListSort.CHANNELS,\n        comment: NDSongListSort.COMMENT,\n        duration: NDSongListSort.DURATION,\n        explicitStatus: NDSongListSort.EXPLICIT_STATUS,\n        favorited: NDSongListSort.FAVORITED,\n        genre: NDSongListSort.GENRE,\n        id: NDSongListSort.ID,\n        name: NDSongListSort.TITLE,\n        playCount: NDSongListSort.PLAY_COUNT,\n        random: NDSongListSort.RANDOM,\n        rating: NDSongListSort.RATING,\n        recentlyAdded: NDSongListSort.RECENTLY_ADDED,\n        recentlyPlayed: NDSongListSort.PLAY_DATE,\n        releaseDate: undefined,\n        sortName: NDSongListSort.TITLE,\n        year: NDSongListSort.YEAR,\n    },\n    subsonic: {\n        album: undefined,\n        albumArtist: undefined,\n        artist: undefined,\n        bpm: undefined,\n        channels: undefined,\n        comment: undefined,\n        duration: undefined,\n        explicitStatus: undefined,\n        favorited: undefined,\n        genre: undefined,\n        id: undefined,\n        name: undefined,\n        playCount: undefined,\n        random: undefined,\n        rating: undefined,\n        recentlyAdded: undefined,\n        recentlyPlayed: undefined,\n        releaseDate: undefined,\n        sortName: undefined,\n        year: undefined,\n    },\n};\n\nexport enum AlbumArtistListSort {\n    ALBUM = 'album',\n    ALBUM_COUNT = 'albumCount',\n    DURATION = 'duration',\n    FAVORITED = 'favorited',\n    NAME = 'name',\n    PLAY_COUNT = 'playCount',\n    RANDOM = 'random',\n    RATING = 'rating',\n    RECENTLY_ADDED = 'recentlyAdded',\n    RELEASE_DATE = 'releaseDate',\n    SONG_COUNT = 'songCount',\n}\n\nexport type AlbumArtistListArgs = BaseEndpointArgs & { query: AlbumArtistListQuery };\n\nexport type AlbumArtistListCountArgs = BaseEndpointArgs & {\n    query: ListCountQuery<AlbumArtistListQuery>;\n};\n\nexport interface AlbumArtistListQuery extends BaseQuery<AlbumArtistListSort> {\n    _custom?: Record<string, any>;\n    favorite?: boolean;\n    limit?: number;\n    musicFolderId?: string | string[];\n    searchTerm?: string;\n    startIndex: number;\n}\n\n// Album Artist List\nexport type AlbumArtistListResponse = BasePaginatedResponse<AlbumArtist[]>;\n\nexport type SongDetailArgs = BaseEndpointArgs & { query: SongDetailQuery };\n\nexport type SongDetailQuery = { id: string };\n\n// Song Detail\nexport type SongDetailResponse = Song;\n\ntype AlbumArtistListSortMap = {\n    jellyfin: Record<AlbumArtistListSort, JFAlbumArtistListSort | undefined>;\n    navidrome: Record<AlbumArtistListSort, NDAlbumArtistListSort | undefined>;\n    subsonic: Record<AlbumArtistListSort, undefined>;\n};\n\nexport const albumArtistListSortMap: AlbumArtistListSortMap = {\n    jellyfin: {\n        album: JFAlbumArtistListSort.ALBUM,\n        albumCount: undefined,\n        duration: JFAlbumArtistListSort.DURATION,\n        favorited: undefined,\n        name: JFAlbumArtistListSort.NAME,\n        playCount: undefined,\n        random: JFAlbumArtistListSort.RANDOM,\n        rating: undefined,\n        recentlyAdded: JFAlbumArtistListSort.RECENTLY_ADDED,\n        releaseDate: undefined,\n        songCount: undefined,\n    },\n    navidrome: {\n        album: undefined,\n        albumCount: NDAlbumArtistListSort.ALBUM_COUNT,\n        duration: undefined,\n        favorited: NDAlbumArtistListSort.FAVORITED,\n        name: NDAlbumArtistListSort.NAME,\n        playCount: NDAlbumArtistListSort.PLAY_COUNT,\n        random: undefined,\n        rating: NDAlbumArtistListSort.RATING,\n        recentlyAdded: undefined,\n        releaseDate: undefined,\n        songCount: NDAlbumArtistListSort.SONG_COUNT,\n    },\n    subsonic: {\n        album: undefined,\n        albumCount: undefined,\n        duration: undefined,\n        favorited: undefined,\n        name: undefined,\n        playCount: undefined,\n        random: undefined,\n        rating: undefined,\n        recentlyAdded: undefined,\n        releaseDate: undefined,\n        songCount: undefined,\n    },\n};\n\n// Album Artist Detail\n\nexport enum ArtistListSort {\n    ALBUM = 'album',\n    ALBUM_COUNT = 'albumCount',\n    DURATION = 'duration',\n    FAVORITED = 'favorited',\n    NAME = 'name',\n    PLAY_COUNT = 'playCount',\n    RANDOM = 'random',\n    RATING = 'rating',\n    RECENTLY_ADDED = 'recentlyAdded',\n    RELEASE_DATE = 'releaseDate',\n    SONG_COUNT = 'songCount',\n}\n\nexport type AlbumArtistDetailArgs = BaseEndpointArgs & { query: AlbumArtistDetailQuery };\n\nexport type AlbumArtistDetailQuery = { id: string };\n\nexport type AlbumArtistDetailResponse = AlbumArtist | null;\n\nexport type AlbumArtistInfoArgs = BaseEndpointArgs & { query: AlbumArtistInfoQuery };\n\nexport type AlbumArtistInfoQuery = { id: string; limit?: number };\n\nexport type AlbumArtistInfoResponse = {\n    biography?: null | string;\n    imageUrl?: null | string;\n    similarArtists: null | RelatedArtist[];\n};\n\nexport type ArtistListArgs = BaseEndpointArgs & { query: ArtistListQuery };\n\nexport type ArtistListCountArgs = BaseEndpointArgs & { query: ListCountQuery<ArtistListQuery> };\n\nexport interface ArtistListQuery extends BaseQuery<ArtistListSort> {\n    _custom?: Record<string, any>;\n    favorite?: boolean;\n    limit?: number;\n    musicFolderId?: string | string[];\n    role?: string;\n    searchTerm?: string;\n    startIndex: number;\n}\n\n// Artist List\nexport type ArtistListResponse = BasePaginatedResponse<AlbumArtist[]>;\n\ntype ArtistListSortMap = {\n    jellyfin: Record<ArtistListSort, JFArtistListSort | undefined>;\n    navidrome: Record<ArtistListSort, undefined>;\n    subsonic: Record<ArtistListSort, undefined>;\n};\n\nexport const artistListSortMap: ArtistListSortMap = {\n    jellyfin: {\n        album: JFArtistListSort.ALBUM,\n        albumCount: undefined,\n        duration: JFArtistListSort.DURATION,\n        favorited: undefined,\n        name: JFArtistListSort.NAME,\n        playCount: undefined,\n        random: JFArtistListSort.RANDOM,\n        rating: undefined,\n        recentlyAdded: JFArtistListSort.RECENTLY_ADDED,\n        releaseDate: undefined,\n        songCount: undefined,\n    },\n    navidrome: {\n        album: undefined,\n        albumCount: undefined,\n        duration: undefined,\n        favorited: undefined,\n        name: undefined,\n        playCount: undefined,\n        random: undefined,\n        rating: undefined,\n        recentlyAdded: undefined,\n        releaseDate: undefined,\n        songCount: undefined,\n    },\n    subsonic: {\n        album: undefined,\n        albumCount: undefined,\n        duration: undefined,\n        favorited: undefined,\n        name: undefined,\n        playCount: undefined,\n        random: undefined,\n        rating: undefined,\n        recentlyAdded: undefined,\n        releaseDate: undefined,\n        songCount: undefined,\n    },\n};\n\nexport enum PlaylistListSort {\n    DURATION = 'duration',\n    NAME = 'name',\n    OWNER = 'owner',\n    PUBLIC = 'public',\n    SONG_COUNT = 'songCount',\n    UPDATED_AT = 'updatedAt',\n}\n\nexport enum RadioListSort {\n    ID = 'id',\n    NAME = 'name',\n}\n\nexport type AddToPlaylistArgs = BaseEndpointArgs & {\n    body: AddToPlaylistBody;\n    query: AddToPlaylistQuery;\n};\n\nexport type AddToPlaylistBody = {\n    songId: string[];\n};\n\nexport type AddToPlaylistQuery = {\n    id: string;\n};\n\n// Add to playlist\nexport type AddToPlaylistResponse = null | undefined;\n\nexport type CreateInternetRadioStationArgs = BaseEndpointArgs & {\n    body: CreateInternetRadioStationBody;\n};\n\nexport type CreateInternetRadioStationBody = {\n    homepageUrl?: string;\n    name: string;\n    streamUrl: string;\n};\n\nexport type CreateInternetRadioStationResponse = null | undefined;\n\nexport type CreatePlaylistArgs = BaseEndpointArgs & { body: CreatePlaylistBody };\n\nexport type CreatePlaylistBody = {\n    _custom?: Record<string, any>;\n    comment?: string;\n    name: string;\n    ownerId?: string;\n    public?: boolean;\n    queryBuilderRules?: Record<string, any>;\n    sync?: boolean;\n};\n\n// Create Playlist\nexport type CreatePlaylistResponse = undefined | { id: string };\n\nexport type DeleteInternetRadioStationArgs = BaseEndpointArgs & {\n    query: DeleteInternetRadioStationQuery;\n};\n\nexport type DeleteInternetRadioStationQuery = {\n    id: string;\n};\n\nexport type DeleteInternetRadioStationResponse = null | undefined;\n\nexport type DeletePlaylistArgs = BaseEndpointArgs & {\n    query: DeletePlaylistQuery;\n};\n\nexport type DeletePlaylistQuery = { id: string };\n\n// Delete Playlist\nexport type DeletePlaylistResponse = null | undefined;\n\nexport type FavoriteArgs = BaseEndpointArgs & { query: FavoriteQuery };\n\nexport type FavoriteQuery = {\n    id: string[];\n    type: LibraryItem;\n};\n\n// Favorite\nexport type FavoriteResponse = null | undefined;\n\nexport type GetInternetRadioStationsArgs = BaseEndpointArgs;\n\nexport type GetInternetRadioStationsResponse = InternetRadioStation[];\n\nexport type InternetRadioStation = {\n    homepageUrl?: null | string;\n    id: string;\n    name: string;\n    streamUrl: string;\n};\n\nexport type PlaylistListArgs = BaseEndpointArgs & { query: PlaylistListQuery };\n\nexport type PlaylistListCountArgs = BaseEndpointArgs & { query: ListCountQuery<PlaylistListQuery> };\n\nexport interface PlaylistListQuery extends BaseQuery<PlaylistListSort> {\n    _custom?: Record<string, any>;\n    excludeSmartPlaylists?: boolean;\n    limit?: number;\n    searchTerm?: string;\n    startIndex: number;\n}\n\n// Playlist List\nexport type PlaylistListResponse = BasePaginatedResponse<Playlist[]>;\n\nexport type RatingQuery = {\n    id: string[];\n    rating: number;\n    type: LibraryItem;\n};\n\n// Rating\nexport type RatingResponse = null | undefined;\n\nexport type RemoveFromPlaylistArgs = BaseEndpointArgs & {\n    query: RemoveFromPlaylistQuery;\n};\n\nexport type RemoveFromPlaylistQuery = {\n    id: string;\n    songId: string[];\n};\n\n// Remove from playlist\nexport type RemoveFromPlaylistResponse = null | undefined;\n\nexport type ReplacePlaylistArgs = BaseEndpointArgs & {\n    body: ReplacePlaylistBody;\n    query: ReplacePlaylistQuery;\n};\n\nexport type ReplacePlaylistBody = {\n    songId: string[];\n};\n\nexport type ReplacePlaylistQuery = {\n    id: string;\n};\n\n// Replace playlist\nexport type ReplacePlaylistResponse = null | undefined;\n\nexport type SetRatingArgs = BaseEndpointArgs & { query: RatingQuery };\n\nexport type ShareItemArgs = BaseEndpointArgs & { body: ShareItemBody };\n\nexport type ShareItemBody = {\n    description: string;\n    downloadable: boolean;\n    expires: number;\n    resourceIds: string;\n    resourceType: string;\n};\n\n// Sharing\nexport type ShareItemResponse = undefined | { id: string };\n\nexport type UpdateInternetRadioStationArgs = BaseEndpointArgs & {\n    body: UpdateInternetRadioStationBody;\n    query: UpdateInternetRadioStationQuery;\n};\n\nexport type UpdateInternetRadioStationBody = {\n    homepageUrl?: string;\n    name: string;\n    streamUrl: string;\n};\n\nexport type UpdateInternetRadioStationQuery = {\n    id: string;\n};\n\nexport type UpdateInternetRadioStationResponse = null | undefined;\n\nexport type UpdatePlaylistArgs = BaseEndpointArgs & {\n    body: UpdatePlaylistBody;\n    query: UpdatePlaylistQuery;\n};\n\nexport type UpdatePlaylistBody = {\n    _custom?: Record<string, any>;\n    comment?: string;\n    genres?: Genre[];\n    name: string;\n    ownerId?: string;\n    public?: boolean;\n    queryBuilderRules?: Record<string, any>;\n    sync?: boolean;\n};\n\nexport type UpdatePlaylistQuery = {\n    id: string;\n};\n\n// Update Playlist\nexport type UpdatePlaylistResponse = null | undefined;\n\ntype PlaylistListSortMap = {\n    jellyfin: Record<PlaylistListSort, JFPlaylistListSort | undefined>;\n    navidrome: Record<PlaylistListSort, NDPlaylistListSort | undefined>;\n    subsonic: Record<PlaylistListSort, undefined>;\n};\n\nexport const playlistListSortMap: PlaylistListSortMap = {\n    jellyfin: {\n        duration: JFPlaylistListSort.DURATION,\n        name: JFPlaylistListSort.NAME,\n        owner: undefined,\n        public: undefined,\n        songCount: JFPlaylistListSort.SONG_COUNT,\n        updatedAt: undefined,\n    },\n    navidrome: {\n        duration: NDPlaylistListSort.DURATION,\n        name: NDPlaylistListSort.NAME,\n        owner: NDPlaylistListSort.OWNER,\n        public: NDPlaylistListSort.PUBLIC,\n        songCount: NDPlaylistListSort.SONG_COUNT,\n        updatedAt: NDPlaylistListSort.UPDATED_AT,\n    },\n    subsonic: {\n        duration: undefined,\n        name: undefined,\n        owner: undefined,\n        public: undefined,\n        songCount: undefined,\n        updatedAt: undefined,\n    },\n};\n\nexport enum UserListSort {\n    NAME = 'name',\n}\n\nexport type MusicFolderListArgs = BaseEndpointArgs;\n\nexport type MusicFolderListQuery = null;\n\n// Music Folder List\nexport type MusicFolderListResponse = BasePaginatedResponse<MusicFolder[]>;\n\nexport type PlaylistDetailArgs = BaseEndpointArgs & { query: PlaylistDetailQuery };\n\nexport type PlaylistDetailQuery = {\n    id: string;\n};\n\n// Playlist Detail\nexport type PlaylistDetailResponse = Playlist;\n\nexport type PlaylistSongListArgs = BaseEndpointArgs & { query: PlaylistSongListQuery };\n\nexport type PlaylistSongListCountArgs = BaseEndpointArgs & {\n    query: ListCountQuery<PlaylistSongListQuery>;\n};\n\nexport type PlaylistSongListQuery = {\n    id: string;\n};\n\nexport type PlaylistSongListQueryClientSide = {\n    sortBy?: SongListSort;\n    sortOrder?: SortOrder;\n};\n\n// Playlist Songs\nexport type PlaylistSongListResponse = BasePaginatedResponse<Song[]>;\n\nexport type UserListArgs = BaseEndpointArgs & { query: UserListQuery };\n\nexport interface UserListQuery extends BaseQuery<UserListSort> {\n    _custom?: Record<string, any>;\n    limit?: number;\n    searchTerm?: string;\n    startIndex: number;\n}\n\n// User list\n// Playlist List\nexport type UserListResponse = BasePaginatedResponse<User[]>;\n\ntype UserListSortMap = {\n    jellyfin: Record<UserListSort, undefined>;\n    navidrome: Record<UserListSort, NDUserListSort | undefined>;\n    subsonic: Record<UserListSort, undefined>;\n};\n\nexport const userListSortMap: UserListSortMap = {\n    jellyfin: {\n        name: undefined,\n    },\n    navidrome: {\n        name: NDUserListSort.NAME,\n    },\n    subsonic: {\n        name: undefined,\n    },\n};\n\nexport enum Played {\n    All = 'all',\n    Never = 'never',\n    Played = 'played',\n}\n\nexport type ArtistInfoArgs = BaseEndpointArgs & { query: ArtistInfoQuery };\n\n// Artist Info\nexport type ArtistInfoQuery = {\n    artistId: string;\n    limit: number;\n    musicFolderId?: string | string[];\n};\n\nexport type FullLyricsMetadata = Omit<InternetProviderLyricResponse, 'id' | 'lyrics' | 'source'> & {\n    lyrics: LyricsResponse;\n    offsetMs?: number;\n    remote: boolean;\n    source: string;\n};\n\nexport type InternetProviderLyricResponse = {\n    artist: string;\n    id: string;\n    lyrics: string;\n    name: string;\n    source: LyricSource;\n};\n\nexport type InternetProviderLyricSearchResponse = {\n    artist: string;\n    id: string;\n    isSync: boolean | null;\n    name: string;\n    score?: number;\n    source: LyricSource;\n};\n\nexport type LyricOverride = Omit<InternetProviderLyricResponse, 'lyrics'>;\n\nexport type LyricsArgs = BaseEndpointArgs & {\n    query: LyricsQuery;\n};\n\nexport type LyricsQuery = {\n    songId: string;\n};\n\nexport type LyricsResponse = string | SynchronizedLyricsArray;\n\nexport type RandomSongListArgs = BaseEndpointArgs & {\n    query: RandomSongListQuery;\n};\n\nexport type RandomSongListQuery = {\n    genre?: string;\n    limit?: number;\n    maxYear?: number;\n    minYear?: number;\n    musicFolderId?: string | string[];\n    played: Played;\n};\n\nexport type RandomSongListResponse = SongListResponse;\n\nexport type ScrobbleArgs = BaseEndpointArgs & {\n    query: ScrobbleQuery;\n};\n\nexport type ScrobbleQuery = {\n    albumId?: string;\n    event?: 'pause' | 'start' | 'timeupdate' | 'unpause';\n    id: string;\n    position?: number;\n    submission: boolean;\n};\n\n// Scrobble\nexport type ScrobbleResponse = null;\n\nexport type SearchAlbumArtistsQuery = {\n    albumArtistLimit?: number;\n    albumArtistStartIndex?: number;\n    musicFolderId?: string | string[];\n    query?: string;\n};\n\nexport type SearchAlbumsQuery = {\n    albumLimit?: number;\n    albumStartIndex?: number;\n    musicFolderId?: string | string[];\n    query?: string;\n};\n\nexport type SearchArgs = BaseEndpointArgs & {\n    query: SearchQuery;\n};\n\nexport type SearchQuery = {\n    albumArtistLimit?: number;\n    albumArtistStartIndex?: number;\n    albumLimit?: number;\n    albumStartIndex?: number;\n    musicFolderId?: string | string[];\n    query?: string;\n    songLimit?: number;\n    songStartIndex?: number;\n};\n\nexport type SearchResponse = {\n    albumArtists: AlbumArtist[];\n    albums: Album[];\n    songs: Song[];\n};\n\nexport type SearchSongsQuery = {\n    musicFolderId?: string | string[];\n    query?: string;\n    songLimit?: number;\n    songStartIndex?: number;\n};\n\nexport type SynchronizedLyricsArray = Array<[number, string]>;\n\nexport type TopSongListArgs = BaseEndpointArgs & { query: TopSongListQuery };\n\nexport type TopSongListQuery = {\n    artist: string;\n    artistId: string;\n    limit?: number;\n    type?: 'community' | 'personal';\n};\n\n// Top Songs List\nexport type TopSongListResponse = BasePaginatedResponse<Song[]>;\n\nexport const instanceOfCancellationError = (error: any) => {\n    return 'revert' in error;\n};\n\nexport enum LyricSource {\n    GENIUS = 'Genius',\n    LRCLIB = 'lrclib.net',\n    NETEASE = 'NetEase',\n    SIMPMUSIC = 'SimpMusic',\n}\n\nexport type AlbumRadioArgs = BaseEndpointArgs & {\n    query: AlbumRadioQuery;\n};\n\nexport type AlbumRadioQuery = {\n    albumId: string;\n    count?: number;\n};\n\nexport type ArtistRadioArgs = BaseEndpointArgs & {\n    query: ArtistRadioQuery;\n};\n\nexport type ArtistRadioQuery = {\n    artistId: string;\n    count?: number;\n};\n\nexport type ControllerEndpoint = {\n    addToPlaylist: (args: AddToPlaylistArgs) => Promise<AddToPlaylistResponse>;\n    authenticate: (\n        url: string,\n        body: { legacy?: boolean; password: string; username: string },\n    ) => Promise<AuthenticationResponse>;\n    createFavorite: (args: FavoriteArgs) => Promise<FavoriteResponse>;\n    createInternetRadioStation: (\n        args: CreateInternetRadioStationArgs,\n    ) => Promise<CreateInternetRadioStationResponse>;\n    createPlaylist: (args: CreatePlaylistArgs) => Promise<CreatePlaylistResponse>;\n    deleteFavorite: (args: FavoriteArgs) => Promise<FavoriteResponse>;\n    deleteInternetRadioStation: (\n        args: DeleteInternetRadioStationArgs,\n    ) => Promise<DeleteInternetRadioStationResponse>;\n    deletePlaylist: (args: DeletePlaylistArgs) => Promise<DeletePlaylistResponse>;\n    getAlbumArtistDetail: (args: AlbumArtistDetailArgs) => Promise<AlbumArtistDetailResponse>;\n    getAlbumArtistInfo?: (args: AlbumArtistInfoArgs) => Promise<AlbumArtistInfoResponse | null>;\n    getAlbumArtistList: (args: AlbumArtistListArgs) => Promise<AlbumArtistListResponse>;\n    getAlbumArtistListCount: (args: AlbumArtistListCountArgs) => Promise<number>;\n    getAlbumDetail: (args: AlbumDetailArgs) => Promise<AlbumDetailResponse>;\n    getAlbumInfo?: (args: AlbumDetailArgs) => Promise<AlbumInfo>;\n    getAlbumList: (args: AlbumListArgs) => Promise<AlbumListResponse>;\n    getAlbumListCount: (args: AlbumListCountArgs) => Promise<number>;\n    getAlbumRadio: (args: AlbumRadioArgs) => Promise<Song[]>;\n    getArtistList: (args: ArtistListArgs) => Promise<ArtistListResponse>;\n    getArtistListCount: (args: ArtistListCountArgs) => Promise<number>;\n    getArtistRadio: (args: ArtistRadioArgs) => Promise<Song[]>;\n    getDownloadUrl: (args: DownloadArgs) => string;\n    getFolder: (args: FolderArgs) => Promise<FolderResponse>;\n    getGenreList: (args: GenreListArgs) => Promise<GenreListResponse>;\n    getImageRequest: (args: ImageArgs) => ImageRequest | null;\n    getImageUrl: (args: ImageArgs) => null | string;\n    getInternetRadioStations: (\n        args: GetInternetRadioStationsArgs,\n    ) => Promise<GetInternetRadioStationsResponse>;\n    getLyrics?: (args: LyricsArgs) => Promise<LyricsResponse>;\n    getMusicFolderList: (args: MusicFolderListArgs) => Promise<MusicFolderListResponse>;\n    getPlaylistDetail: (args: PlaylistDetailArgs) => Promise<PlaylistDetailResponse>;\n    getPlaylistList: (args: PlaylistListArgs) => Promise<PlaylistListResponse>;\n    getPlaylistListCount: (args: PlaylistListCountArgs) => Promise<number>;\n    getPlaylistSongList: (args: PlaylistSongListArgs) => Promise<SongListResponse>;\n    getPlayQueue: (args: GetQueueArgs) => Promise<GetQueueResponse>;\n    getRandomSongList: (args: RandomSongListArgs) => Promise<SongListResponse>;\n    getRoles: (args: BaseEndpointArgs) => Promise<Array<string | { label: string; value: string }>>;\n    getServerInfo: (args: ServerInfoArgs) => Promise<ServerInfo>;\n    getSimilarSongs: (args: SimilarSongsArgs) => Promise<Song[]>;\n    getSongDetail: (args: SongDetailArgs) => Promise<SongDetailResponse>;\n    getSongList: (args: SongListArgs) => Promise<SongListResponse>;\n    getSongListCount: (args: SongListCountArgs) => Promise<number>;\n    getStreamUrl: (args: StreamArgs) => string;\n    getStructuredLyrics?: (args: StructuredLyricsArgs) => Promise<StructuredLyric[]>;\n    getTagList?: (args: TagListArgs) => Promise<TagListResponse>;\n    getTopSongs: (args: TopSongListArgs) => Promise<TopSongListResponse>;\n    // getArtistInfo?: (args: any) => void;\n    getUserInfo: (args: UserInfoArgs) => Promise<UserInfoResponse>;\n    getUserList?: (args: UserListArgs) => Promise<UserListResponse>;\n    movePlaylistItem?: (args: MoveItemArgs) => Promise<void>;\n    removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RemoveFromPlaylistResponse>;\n    replacePlaylist: (args: ReplacePlaylistArgs) => Promise<ReplacePlaylistResponse>;\n    savePlayQueue: (args: SaveQueueArgs) => Promise<void>;\n    scrobble: (args: ScrobbleArgs) => Promise<ScrobbleResponse>;\n    search: (args: SearchArgs) => Promise<SearchResponse>;\n    setRating?: (args: SetRatingArgs) => Promise<RatingResponse>;\n    shareItem?: (args: ShareItemArgs) => Promise<ShareItemResponse>;\n    updateInternetRadioStation: (\n        args: UpdateInternetRadioStationArgs,\n    ) => Promise<UpdateInternetRadioStationResponse>;\n    updatePlaylist: (args: UpdatePlaylistArgs) => Promise<UpdatePlaylistResponse>;\n};\n\nexport type DownloadArgs = BaseEndpointArgs & {\n    query: DownloadQuery;\n};\n\nexport type DownloadQuery = {\n    id: string;\n};\n\n// This type from https://wicg.github.io/local-font-access/#fontdata\n// NOTE: it is still experimental, so this should be updates as appropriate\nexport type FontData = {\n    family: string;\n    fullName: string;\n    postscriptName: string;\n    style: string;\n};\n\nexport type GetQueueArgs = BaseEndpointArgs;\n\nexport interface GetQueueQuery {}\n\nexport type GetQueueResponse = {\n    changed: string;\n    changedBy: string;\n    currentIndex: number;\n    entry: Song[];\n    positionMs: number;\n    username: string;\n};\n\nexport type ImageArgs = BaseEndpointArgs & {\n    baseUrl?: string;\n    query: ImageQuery;\n};\n\nexport type ImageQuery = {\n    id: string;\n    itemType: LibraryItem;\n    size?: number;\n};\n\nexport type ImageRequest = {\n    cacheKey: string;\n    credentials?: RequestCredentials;\n    headers?: Record<string, string>;\n    url: string;\n};\n\nexport type InternalControllerEndpoint = {\n    addToPlaylist: (\n        args: ReplaceApiClientProps<AddToPlaylistArgs>,\n    ) => Promise<AddToPlaylistResponse>;\n    authenticate: (\n        url: string,\n        body: { legacy?: boolean; password: string; username: string },\n    ) => Promise<AuthenticationResponse>;\n    createFavorite: (args: ReplaceApiClientProps<FavoriteArgs>) => Promise<FavoriteResponse>;\n    createInternetRadioStation: (\n        args: ReplaceApiClientProps<CreateInternetRadioStationArgs>,\n    ) => Promise<CreateInternetRadioStationResponse>;\n    createPlaylist: (\n        args: ReplaceApiClientProps<CreatePlaylistArgs>,\n    ) => Promise<CreatePlaylistResponse>;\n    deleteFavorite: (args: ReplaceApiClientProps<FavoriteArgs>) => Promise<FavoriteResponse>;\n    deleteInternetRadioStation: (\n        args: ReplaceApiClientProps<DeleteInternetRadioStationArgs>,\n    ) => Promise<DeleteInternetRadioStationResponse>;\n    deletePlaylist: (\n        args: ReplaceApiClientProps<DeletePlaylistArgs>,\n    ) => Promise<DeletePlaylistResponse>;\n    getAlbumArtistDetail: (\n        args: ReplaceApiClientProps<AlbumArtistDetailArgs>,\n    ) => Promise<AlbumArtistDetailResponse>;\n    getAlbumArtistInfo?: (\n        args: ReplaceApiClientProps<AlbumArtistInfoArgs>,\n    ) => Promise<AlbumArtistInfoResponse | null>;\n    getAlbumArtistList: (\n        args: ReplaceApiClientProps<AlbumArtistListArgs>,\n    ) => Promise<AlbumArtistListResponse>;\n    getAlbumArtistListCount: (\n        args: ReplaceApiClientProps<AlbumArtistListCountArgs>,\n    ) => Promise<number>;\n    getAlbumDetail: (args: ReplaceApiClientProps<AlbumDetailArgs>) => Promise<AlbumDetailResponse>;\n    getAlbumInfo?: (args: ReplaceApiClientProps<AlbumDetailArgs>) => Promise<AlbumInfo>;\n    getAlbumList: (args: ReplaceApiClientProps<AlbumListArgs>) => Promise<AlbumListResponse>;\n    getAlbumListCount: (args: ReplaceApiClientProps<AlbumListCountArgs>) => Promise<number>;\n    getAlbumRadio: (args: ReplaceApiClientProps<AlbumRadioArgs>) => Promise<Song[]>;\n    // getArtistInfo?: (args: any) => void;\n    getArtistList: (args: ReplaceApiClientProps<ArtistListArgs>) => Promise<ArtistListResponse>;\n    getArtistListCount: (args: ReplaceApiClientProps<ArtistListCountArgs>) => Promise<number>;\n    getArtistRadio: (args: ReplaceApiClientProps<ArtistRadioArgs>) => Promise<Song[]>;\n    getDownloadUrl: (args: ReplaceApiClientProps<DownloadArgs>) => string;\n    getFolder: (args: ReplaceApiClientProps<FolderArgs>) => Promise<FolderResponse>;\n    getGenreList: (args: ReplaceApiClientProps<GenreListArgs>) => Promise<GenreListResponse>;\n    getImageRequest: (args: ReplaceApiClientProps<ImageArgs>) => ImageRequest | null;\n    getImageUrl: (args: ReplaceApiClientProps<ImageArgs>) => null | string;\n    getInternetRadioStations: (\n        args: ReplaceApiClientProps<GetInternetRadioStationsArgs>,\n    ) => Promise<GetInternetRadioStationsResponse>;\n    getLyrics?: (args: ReplaceApiClientProps<LyricsArgs>) => Promise<LyricsResponse>;\n    getMusicFolderList: (\n        args: ReplaceApiClientProps<MusicFolderListArgs>,\n    ) => Promise<MusicFolderListResponse>;\n    getPlaylistDetail: (\n        args: ReplaceApiClientProps<PlaylistDetailArgs>,\n    ) => Promise<PlaylistDetailResponse>;\n    getPlaylistList: (\n        args: ReplaceApiClientProps<PlaylistListArgs>,\n    ) => Promise<PlaylistListResponse>;\n    getPlaylistListCount: (args: ReplaceApiClientProps<PlaylistListCountArgs>) => Promise<number>;\n    getPlaylistSongList: (\n        args: ReplaceApiClientProps<PlaylistSongListArgs>,\n    ) => Promise<SongListResponse>;\n    getPlayQueue: (args: ReplaceApiClientProps<GetQueueArgs>) => Promise<GetQueueResponse>;\n    getRandomSongList: (\n        args: ReplaceApiClientProps<RandomSongListArgs>,\n    ) => Promise<SongListResponse>;\n    getRoles: (\n        args: ReplaceApiClientProps<BaseEndpointArgs>,\n    ) => Promise<Array<string | { label: string; value: string }>>;\n    getServerInfo: (args: ReplaceApiClientProps<ServerInfoArgs>) => Promise<ServerInfo>;\n    getSimilarSongs: (args: ReplaceApiClientProps<SimilarSongsArgs>) => Promise<Song[]>;\n    getSongDetail: (args: ReplaceApiClientProps<SongDetailArgs>) => Promise<SongDetailResponse>;\n    getSongList: (args: ReplaceApiClientProps<SongListArgs>) => Promise<SongListResponse>;\n    getSongListCount: (args: ReplaceApiClientProps<SongListCountArgs>) => Promise<number>;\n    getStreamUrl: (args: ReplaceApiClientProps<StreamArgs>) => string;\n    getStructuredLyrics?: (\n        args: ReplaceApiClientProps<StructuredLyricsArgs>,\n    ) => Promise<StructuredLyric[]>;\n    getTagList?: (args: ReplaceApiClientProps<TagListArgs>) => Promise<TagListResponse>;\n    getTopSongs: (args: ReplaceApiClientProps<TopSongListArgs>) => Promise<TopSongListResponse>;\n    getUserInfo: (args: ReplaceApiClientProps<UserInfoArgs>) => Promise<UserInfoResponse>;\n    getUserList?: (args: ReplaceApiClientProps<UserListArgs>) => Promise<UserListResponse>;\n    movePlaylistItem?: (args: ReplaceApiClientProps<MoveItemArgs>) => Promise<void>;\n    removeFromPlaylist: (\n        args: ReplaceApiClientProps<RemoveFromPlaylistArgs>,\n    ) => Promise<RemoveFromPlaylistResponse>;\n    replacePlaylist: (\n        args: ReplaceApiClientProps<ReplacePlaylistArgs>,\n    ) => Promise<ReplacePlaylistResponse>;\n    savePlayQueue: (args: ReplaceApiClientProps<SaveQueueArgs>) => Promise<void>;\n    scrobble: (args: ReplaceApiClientProps<ScrobbleArgs>) => Promise<ScrobbleResponse>;\n    search: (args: ReplaceApiClientProps<SearchArgs>) => Promise<SearchResponse>;\n    setRating?: (args: ReplaceApiClientProps<SetRatingArgs>) => Promise<RatingResponse>;\n    shareItem?: (args: ReplaceApiClientProps<ShareItemArgs>) => Promise<ShareItemResponse>;\n    updateInternetRadioStation: (\n        args: ReplaceApiClientProps<UpdateInternetRadioStationArgs>,\n    ) => Promise<UpdateInternetRadioStationResponse>;\n    updatePlaylist: (\n        args: ReplaceApiClientProps<UpdatePlaylistArgs>,\n    ) => Promise<UpdatePlaylistResponse>;\n};\n\nexport type LyricGetQuery = {\n    remoteSongId: string;\n    remoteSource: LyricSource;\n    song: Song;\n};\n\nexport type LyricSearchQuery = {\n    album?: string;\n    artist?: string;\n    duration?: number;\n    name?: string;\n};\n\nexport type LyricsOverride = Omit<FullLyricsMetadata, 'lyrics'> & { id: string };\n\nexport type MoveItemArgs = BaseEndpointArgs & {\n    query: MoveItemQuery;\n};\n\nexport type MoveItemQuery = {\n    endingIndex: number;\n    playlistId: string;\n    startingIndex: number;\n    trackId: string;\n};\n\nexport type ReplaceApiClientProps<T> = BaseEndpointArgsWithServer & Omit<T, 'apiClientProps'>;\n\nexport type SaveQueueArgs = BaseEndpointArgs & {\n    query: SaveQueueQuery;\n};\n\nexport type SaveQueueQuery = {\n    currentIndex?: number;\n    positionMs?: number;\n    songs: string[];\n};\n\nexport type ServerInfo = {\n    features: ServerFeatures;\n    id?: string;\n    version: string;\n};\n\nexport type ServerInfoArgs = BaseEndpointArgs;\n\nexport type SimilarSongsArgs = BaseEndpointArgs & {\n    query: SimilarSongsQuery;\n};\n\nexport type SimilarSongsQuery = {\n    count?: number;\n    musicFolderId?: string | string[];\n    songId: string;\n};\n\nexport type StreamArgs = BaseEndpointArgs & {\n    query: StreamQuery;\n};\n\nexport type StreamQuery = {\n    bitrate?: number;\n    format?: string;\n    id: string;\n    transcode: boolean;\n};\n\nexport type StructuredLyric = (StructuredSyncedLyric | StructuredUnsyncedLyric) & {\n    lang: string;\n};\n\nexport type StructuredLyricsArgs = BaseEndpointArgs & {\n    query: LyricsQuery;\n};\n\nexport type StructuredSyncedLyric = Omit<FullLyricsMetadata, 'lyrics'> & {\n    lyrics: SynchronizedLyricsArray;\n    synced: true;\n};\n\nexport type StructuredUnsyncedLyric = Omit<FullLyricsMetadata, 'lyrics'> & {\n    lyrics: string;\n    synced: false;\n};\n\nexport type Tag = {\n    name: string;\n    options: { id: string; name: string }[];\n};\n\nexport type TagListArgs = BaseEndpointArgs & {\n    query: TagListQuery;\n};\n\nexport type TagListQuery = {\n    folder?: string;\n    tagName?: string;\n    type: LibraryItem.ALBUM | LibraryItem.SONG;\n};\n\nexport type TagListResponse = {\n    excluded: {\n        album: string[];\n        song: string[];\n    };\n    tags?: Tag[];\n};\n\nexport type UserInfoArgs = BaseEndpointArgs & { query: UserInfoQuery };\n\nexport type UserInfoQuery = {\n    id: string;\n    username: string;\n};\n\nexport type UserInfoResponse = {\n    id: string;\n    isAdmin: boolean;\n    name: string;\n};\n\ntype BaseEndpointArgsWithServer = {\n    apiClientProps: {\n        server: null | ServerListItemWithCredential;\n        serverId: string;\n        signal?: AbortSignal;\n    };\n    context?: {\n        pathReplace?: string;\n        pathReplaceWith?: string;\n    };\n};\n"
  },
  {
    "path": "src/shared/types/drag-and-drop.ts",
    "content": "import type { Edge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge';\n\nimport { LibraryItem } from '/@/shared/types/domain-types';\n\nexport enum DragTarget {\n    ALBUM = LibraryItem.ALBUM,\n    ALBUM_ARTIST = LibraryItem.ALBUM_ARTIST,\n    ARTIST = LibraryItem.ARTIST,\n    FOLDER = LibraryItem.FOLDER,\n    GENERIC = 'generic',\n    GENRE = LibraryItem.GENRE,\n    GRID_ROW = 'gridRow',\n    PLAYLIST = LibraryItem.PLAYLIST,\n    QUEUE_SONG = LibraryItem.QUEUE_SONG,\n    SONG = LibraryItem.SONG,\n    TABLE_COLUMN = 'tableColumn',\n}\n\nexport const DragTargetMap = {\n    [LibraryItem.ALBUM]: DragTarget.ALBUM,\n    [LibraryItem.ALBUM_ARTIST]: DragTarget.ALBUM_ARTIST,\n    [LibraryItem.ARTIST]: DragTarget.ARTIST,\n    [LibraryItem.FOLDER]: DragTarget.FOLDER,\n    [LibraryItem.GENRE]: DragTarget.GENRE,\n    [LibraryItem.PLAYLIST]: DragTarget.PLAYLIST,\n    [LibraryItem.PLAYLIST_SONG]: DragTarget.SONG,\n    [LibraryItem.QUEUE_SONG]: DragTarget.QUEUE_SONG,\n    [LibraryItem.SONG]: DragTarget.SONG,\n};\n\nexport enum DragOperation {\n    ADD = 'add',\n    REORDER = 'reorder',\n}\n\nexport interface AlbumDragMetadata {\n    image: string;\n    title: string;\n}\n\nexport interface DragData<\n    TDataType = unknown,\n    T extends Record<string, unknown> = Record<string, unknown>,\n> {\n    id: string[];\n    item?: TDataType[];\n    itemType?: LibraryItem;\n    metadata?: T;\n    operation?: DragOperation[];\n    type: DragTarget;\n}\n\nexport const dndUtils = {\n    dropType: (args: { data: DragData }) => {\n        const { data } = args;\n        return data.type;\n    },\n    generateDragData: <TDataType, T extends Record<string, unknown> = Record<string, unknown>>(\n        args: {\n            id: string[];\n            item?: TDataType[];\n            itemType?: LibraryItem;\n            operation?: DragOperation[];\n            type: DragTarget | string;\n        },\n        metadata?: T,\n    ) => {\n        return {\n            id: args.id,\n            item: args.item,\n            itemType: args.itemType,\n            metadata,\n            operation: args.operation,\n            type: args.type,\n        };\n    },\n    isDropTarget: (target: DragTarget, types: DragTarget[]) => {\n        return types.includes(target);\n    },\n    reorderById: (args: { edge: Edge | null; idFrom: string; idTo: string; list: string[] }) => {\n        const { edge, idFrom, idTo, list } = args;\n        const indexFrom = list.indexOf(idFrom);\n        const indexTo = list.indexOf(idTo);\n\n        // If dragging to the same position, do nothing\n        if (indexFrom === indexTo) {\n            return list;\n        }\n\n        let newIndex: number;\n\n        if (edge === 'bottom') {\n            newIndex = indexFrom > indexTo ? indexTo + 1 : indexTo;\n        } else if (edge === 'top' || edge === null) {\n            newIndex = indexTo;\n        } else if (edge === 'left' && indexTo > indexFrom) {\n            return list;\n        } else if (edge === 'right' && indexTo < indexFrom) {\n            return list;\n        } else {\n            newIndex = indexTo;\n        }\n\n        if (newIndex === indexFrom) {\n            return list;\n        }\n\n        return dndUtils.reorderByIndex({ index: indexFrom, list, newIndex });\n    },\n    reorderByIndex: (args: { index: number; list: string[]; newIndex: number }) => {\n        const { index, list, newIndex } = args;\n        const newList = [...list];\n        newList.splice(newIndex, 0, newList.splice(index, 1)[0]);\n        return newList;\n    },\n};\n"
  },
  {
    "path": "src/shared/types/features-types.ts",
    "content": "// Should follow a strict naming convention: \"<FEATURE GROUP>_<FEATURE NAME>\"\n// For example: <FEATURE GROUP>: \"Playlists\", <FEATURE NAME>: \"Smart\" = \"PLAYLISTS_SMART\"\nexport enum ServerFeature {\n    ALBUM_YES_NO_RATING_FILTER = 'albumYesNoRatingFilter',\n    BFR = 'bfr',\n    LYRICS_MULTIPLE_STRUCTURED = 'lyricsMultipleStructured',\n    LYRICS_SINGLE_STRUCTURED = 'lyricsSingleStructured',\n    MUSIC_FOLDER_MULTISELECT = 'musicFolderMultiselect',\n    OS_FORM_POST = 'osFormPost',\n    PLAYLISTS_SMART = 'playlistsSmart',\n    PUBLIC_PLAYLIST = 'publicPlaylist',\n    SERVER_PLAY_QUEUE = 'serverPlayQueue',\n    SHARING_ALBUM_SONG = 'sharingAlbumSong',\n    SIMILAR_SONGS_MUSIC_FOLDER = 'similarSongsMusicFolder',\n    TAGS = 'tags',\n    TRACK_ALBUM_ARTIST_SEARCH = 'trackAlbumArtistSearch',\n    TRACK_YES_NO_RATING_FILTER = 'trackYesNoRatingFilter',\n}\n\nexport type ServerFeatures = Partial<Record<ServerFeature, number[]>>;\n"
  },
  {
    "path": "src/shared/types/remote-types.ts",
    "content": "import { QueueSong } from '/@/shared/types/domain-types';\nimport { PlayerRepeat, PlayerStatus, SongState } from '/@/shared/types/types';\n\nexport interface ClientAuth {\n    event: 'authenticate';\n    header: string;\n}\n\nexport type ClientEvent =\n    | ClientAuth\n    | ClientFavorite\n    | ClientPosition\n    | ClientRating\n    | ClientSimpleEvent\n    | ClientVolume;\n\nexport interface ClientFavorite {\n    event: 'favorite';\n    favorite: boolean;\n    id: string;\n}\n\nexport interface ClientPosition {\n    event: 'position';\n    position: number;\n}\n\nexport interface ClientRating {\n    event: 'rating';\n    id: string;\n    rating: number;\n}\nexport interface ClientSimpleEvent {\n    event: 'next' | 'pause' | 'play' | 'previous' | 'proxy' | 'repeat' | 'shuffle';\n}\n\nexport interface ClientVolume {\n    event: 'volume';\n    volume: number;\n}\n\nexport interface ServerError {\n    data: string;\n    event: 'error';\n}\n\nexport type ServerEvent =\n    | ServerError\n    | ServerFavorite\n    | ServerPlayStatus\n    | ServerPosition\n    | ServerProxy\n    | ServerRating\n    | ServerRepeat\n    | ServerShuffle\n    | ServerSong\n    | ServerState\n    | ServerVolume;\n\nexport interface ServerFavorite {\n    data: { favorite: boolean; id: string };\n    event: 'favorite';\n}\n\nexport interface ServerPlayStatus {\n    data: PlayerStatus;\n    event: 'playback';\n}\n\nexport interface ServerPosition {\n    data: number;\n    event: 'position';\n}\n\nexport interface ServerProxy {\n    data: string;\n    event: 'proxy';\n}\n\nexport interface ServerRating {\n    data: { id: string; rating: number };\n    event: 'rating';\n}\n\nexport interface ServerRepeat {\n    data: PlayerRepeat;\n    event: 'repeat';\n}\n\nexport interface ServerShuffle {\n    data: boolean;\n    event: 'shuffle';\n}\n\nexport interface ServerSong {\n    data: null | QueueSong;\n    event: 'song';\n}\n\nexport interface ServerState {\n    data: SongState;\n    event: 'state';\n}\n\nexport interface ServerVolume {\n    data: number;\n    event: 'volume';\n}\n\nexport interface SongUpdateSocket extends Omit<SongState, 'song'> {\n    position?: number;\n    song?: null | QueueSong;\n}\n"
  },
  {
    "path": "src/shared/types/types.ts",
    "content": "import { AppRoute } from '@ts-rest/core';\nimport { TFunction } from 'i18next';\nimport { ReactNode } from 'react';\n\nimport {\n    Album,\n    AlbumArtist,\n    Artist,\n    LibraryItem,\n    Playlist,\n    QueueSong,\n    Song,\n} from '/@/shared/types/domain-types';\nimport { ServerFeatures } from '/@/shared/types/features-types';\n\nexport enum ItemListKey {\n    ALBUM = LibraryItem.ALBUM,\n    ALBUM_ARTIST = LibraryItem.ALBUM_ARTIST,\n    ALBUM_ARTIST_ALBUM = 'albumArtistAlbum',\n    ALBUM_ARTIST_SONG = 'albumArtistSong',\n    ALBUM_DETAIL = 'albumDetail',\n    ARTIST = LibraryItem.ARTIST,\n    FOLDER = LibraryItem.FOLDER,\n    FULL_SCREEN = 'fullScreen',\n    GENRE = LibraryItem.GENRE,\n    GENRE_ALBUM = 'genreAlbum',\n    GENRE_SONG = 'genreSong',\n    PLAYLIST = LibraryItem.PLAYLIST,\n    PLAYLIST_ALBUM = 'playlistAlbum',\n    PLAYLIST_SONG = LibraryItem.PLAYLIST_SONG,\n    QUEUE_SONG = LibraryItem.QUEUE_SONG,\n    RADIO = 'radio',\n    SIDE_QUEUE = 'sideQueue',\n    SONG = LibraryItem.SONG,\n}\n\nexport enum ListDisplayType {\n    DETAIL = 'detail',\n    GRID = 'poster',\n    LIST = 'list',\n    TABLE = 'table',\n}\n\nexport enum ListPaginationType {\n    INFINITE = 'infinite',\n    PAGINATED = 'paginated',\n}\n\nexport enum Platform {\n    LINUX = 'linux',\n    MACOS = 'macos',\n    WEB = 'web',\n    WINDOWS = 'windows',\n}\n\nexport enum ServerType {\n    JELLYFIN = 'jellyfin',\n    NAVIDROME = 'navidrome',\n    SUBSONIC = 'subsonic',\n}\n\nexport type CardRoute = {\n    route: AppRoute | string;\n    slugs?: RouteSlug[];\n};\n\nexport type CardRow<T> = {\n    arrayProperty?: string;\n    format?: (value: T, t: TFunction) => ReactNode;\n    property: keyof T;\n    route?: CardRoute;\n};\n\nexport type ListPagination = {\n    currentPage: number;\n    itemsPerPage: number;\n    totalItems: number;\n    totalPages: number;\n};\n\nexport type RouteSlug = {\n    idProperty: string;\n    slugProperty: string;\n};\n\nexport const toServerType = (value?: string): null | ServerType => {\n    switch (value?.toLowerCase()) {\n        case ServerType.JELLYFIN:\n            return ServerType.JELLYFIN;\n        case ServerType.NAVIDROME:\n            return ServerType.NAVIDROME;\n        case ServerType.SUBSONIC:\n            return ServerType.SUBSONIC;\n        default:\n            return null;\n    }\n};\n\nexport enum AuthState {\n    INVALID = 'invalid',\n    LOADING = 'loading',\n    VALID = 'valid',\n}\n\nexport enum CrossfadeStyle {\n    CONSTANT_POWER = 'constantPower',\n    CONSTANT_POWER_SLOW_CUT = 'constantPowerSlowCut',\n    CONSTANT_POWER_SLOW_FADE = 'constantPowerSlowFade',\n    DIPPED = 'dipped',\n    EQUAL_POWER = 'equalPower',\n    EXPONENTIAL = 'exponential',\n    LINEAR = 'linear',\n    S_CURVE = 'sCurve',\n}\n\nexport enum FontType {\n    BUILT_IN = 'builtIn',\n    CUSTOM = 'custom',\n    SYSTEM = 'system',\n}\n\nexport enum Play {\n    INDEX = 'index',\n    LAST = 'last',\n    LAST_SHUFFLE = 'lastShuffle',\n    NEXT = 'next',\n    NEXT_SHUFFLE = 'nextShuffle',\n    NOW = 'now',\n    SHUFFLE = 'shuffle',\n}\n\nexport enum PlayerQueueType {\n    DEFAULT = 'default',\n    PRIORITY = 'priority',\n}\n\nexport enum PlayerRepeat {\n    ALL = 'all',\n    NONE = 'none',\n    ONE = 'one',\n}\n\nexport enum PlayerShuffle {\n    ALBUM = 'album',\n    NONE = 'none',\n    TRACK = 'track',\n}\n\nexport enum PlayerStatus {\n    PAUSED = 'paused',\n    PLAYING = 'playing',\n}\n\nexport enum PlayerStyle {\n    CROSSFADE = 'crossfade',\n    GAPLESS = 'gapless',\n}\n\nexport enum PlayerType {\n    LOCAL = 'local',\n    WEB = 'web',\n}\n\nexport enum TableColumn {\n    ACTIONS = 'actions',\n    ALBUM = 'album',\n    ALBUM_ARTIST = 'albumArtists',\n    ALBUM_COUNT = 'albumCount',\n    ALBUM_GROUP = 'albumGroup',\n    ARTIST = 'artists',\n    BIOGRAPHY = 'biography',\n    BIT_DEPTH = 'bitDepth',\n    BIT_RATE = 'bitRate',\n    BPM = 'bpm',\n    CHANNELS = 'channels',\n    CODEC = 'container',\n    COMMENT = 'comment',\n    COMPOSER = 'composer',\n    DATE_ADDED = 'createdAt',\n    DISC_NUMBER = 'discNumber',\n    DURATION = 'duration',\n    GENRE = 'genres',\n    GENRE_BADGE = 'genreBadge',\n    ID = 'id',\n    IMAGE = 'imageUrl',\n    LAST_PLAYED = 'lastPlayedAt',\n    OWNER = 'username',\n    PATH = 'path',\n    PLAY_COUNT = 'playCount',\n    PLAYLIST_REORDER = 'playlistReorder',\n    RELEASE_DATE = 'releaseDate',\n    ROW_INDEX = 'rowIndex',\n    SAMPLE_RATE = 'sampleRate',\n    SIZE = 'size',\n    SKIP = 'skip',\n    SONG_COUNT = 'songCount',\n    TITLE = 'name',\n    TITLE_ARTIST = 'titleArtist',\n    TITLE_COMBINED = 'titleCombined',\n    TRACK_NUMBER = 'trackNumber',\n    USER_FAVORITE = 'userFavorite',\n    USER_RATING = 'userRating',\n    YEAR = 'releaseYear',\n}\n\nexport type DiscoveredServerItem = {\n    name: string;\n    type: ServerType;\n    url: string;\n};\n\nexport type GridCardData = {\n    cardControls: any;\n    cardRows: CardRow<Album | AlbumArtist | Artist | Playlist | Song>[];\n    columnCount: number;\n    display: ListDisplayType;\n    handleFavorite: (options: { id: string[]; isFavorite: boolean; itemType: LibraryItem }) => void;\n    handlePlayQueueAdd: (options: PlayQueueAddOptions) => void;\n    itemCount: number;\n    itemData: any[];\n    itemGap: number;\n    itemHeight: number;\n    itemType: LibraryItem;\n    itemWidth: number;\n    playButtonBehavior: Play;\n    resetInfiniteLoaderCache: () => void;\n    route: CardRoute;\n};\n\nexport type PlayQueueAddOptions = {\n    byData?: QueueSong[];\n    byItemType?: {\n        id: string[];\n        type: LibraryItem;\n    };\n    initialIndex?: number;\n    initialSongId?: string;\n    playType: Play;\n    query?: Record<string, any>;\n};\n\nexport type QueryBuilderGroup = {\n    group: QueryBuilderGroup[];\n    rules: QueryBuilderRule[];\n    type: 'all' | 'any';\n    uniqueId: string;\n};\n\nexport type QueryBuilderRule = {\n    field?: null | string;\n    operator?: null | string;\n    uniqueId: string;\n    value?: any | Date | null | number | string | undefined;\n};\n\nexport type ServerListItem = {\n    credential: string;\n    features?: ServerFeatures;\n    id: string;\n    name: string;\n    ndCredential?: string;\n    preferRemoteUrl?: boolean;\n    remoteUrl?: string;\n    savePassword?: boolean;\n    type: ServerType;\n    url: string;\n    userId: null | string;\n    username: string;\n    version?: string;\n};\n\nexport type SongState = {\n    position?: number;\n    repeat?: PlayerRepeat;\n    shuffle?: boolean;\n    song?: QueueSong;\n    status?: PlayerStatus;\n    /** This volume is in range 0-100 */\n    volume?: number;\n};\n\nexport type TitleTheme = 'dark' | 'light' | 'system';\n\nexport interface UniqueId {\n    uniqueId: string;\n}\n\nexport type WebAudio = {\n    context: AudioContext;\n    gains: GainNode[];\n};\n"
  },
  {
    "path": "src/shared/utils/create-polymorphic-component.ts",
    "content": "import { createPolymorphicComponent as mantineCreatePolymorphicComponent } from '@mantine/core';\n\nexport const createPolymorphicComponent = mantineCreatePolymorphicComponent;\n"
  },
  {
    "path": "src/shared/utils/create-use-external-events.ts",
    "content": "import { createUseExternalEvents as mantineCreateUseExternalEvents } from '@mantine/core';\n\nexport const createUseExternalEvents = mantineCreateUseExternalEvents;\n"
  },
  {
    "path": "src/shared/utils/double-click-handler.ts",
    "content": "import { MouseEvent } from 'react';\n\ninterface DoubleClickHandlerOptions<T extends HTMLElement = HTMLElement> {\n    delay?: number;\n    onDoubleClick?: (event: MouseEvent<T>) => void;\n    onSingleClick?: (event: MouseEvent<T>) => void;\n}\n\n/**\n * Creates a handler that manages single and double-click events,\n * ensuring double-click doesn't trigger single-click\n */\nexport const createDoubleClickHandler = <T extends HTMLElement = HTMLElement>(\n    options: DoubleClickHandlerOptions<T>,\n) => {\n    const { delay = 200, onDoubleClick, onSingleClick } = options;\n\n    let clickTimeout: NodeJS.Timeout | null = null;\n    let clickCount = 0;\n\n    const handleClick = (event: MouseEvent<T>) => {\n        clickCount++;\n\n        if (clickCount === 1) {\n            // First click - set a timeout to handle single click\n            clickTimeout = setTimeout(() => {\n                if (clickCount === 1) {\n                    // Only single click occurred\n                    onSingleClick?.(event);\n                }\n                clickCount = 0;\n                clickTimeout = null;\n            }, delay);\n        } else if (clickCount === 2) {\n            // Double click detected\n            if (clickTimeout) {\n                clearTimeout(clickTimeout);\n                clickTimeout = null;\n            }\n\n            onDoubleClick?.(event);\n            clickCount = 0;\n        }\n    };\n\n    return handleClick;\n};\n"
  },
  {
    "path": "src/shared/utils/is-light-color.ts",
    "content": "import { isLightColor as isLightColorMantine } from '@mantine/core';\n\nexport const isLightColor = (color: string) => {\n    return isLightColorMantine(color);\n};\n"
  },
  {
    "path": "src/shared/utils/string-to-color.ts",
    "content": "import stc from 'string-to-color';\n\nimport { isLightColor } from '/@/shared/utils/is-light-color';\n\nconst randomSeed = '121212';\n\nexport const stringToColor = (string: string) => {\n    const hex = stc({ seed: randomSeed, string });\n\n    return { color: hex, isLight: isLightColor(hex) };\n};\n"
  },
  {
    "path": "src/types/mantine.d.ts",
    "content": "import { ActionIconSize, PillVariant } from '@mantine/core';\n\ntype ExtendedActionIconSize = 'compact-md' | 'compact-sm' | 'compact-xs' | ActionIconSize;\ntype ExtendedPillVariant = 'outline' | PillVariant;\n\ndeclare module '@mantine/core' {\n    export interface ActionIconProps {\n        size?: ExtendedActionIconSize;\n    }\n\n    export interface PillProps {\n        variant?: ExtendedPillVariant;\n    }\n}\n"
  },
  {
    "path": "src/types/mpris-service.d.ts",
    "content": "declare module 'mpris-service';\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"files\": [],\n  \"references\": [{ \"path\": \"./tsconfig.node.json\" }, { \"path\": \"./tsconfig.web.json\" }]\n}\n"
  },
  {
    "path": "tsconfig.node.json",
    "content": "{\n  \"extends\": \"@electron-toolkit/tsconfig/tsconfig.node.json\",\n  \"include\": [\"electron.vite.config.*\", \"src/main/**/*\", \"src/preload/**/*\", \"src/i18n/**/*\", \"src/types/**/*\", \"src/shared/**/*\"],\n  \"compilerOptions\": {\n    \"composite\": true,\n    \"types\": [\"electron-vite/node\"],\n    \"esModuleInterop\": true,\n    \"baseUrl\": \".\",\n    \"paths\": {\n      \"/@/main/*\": [\n        \"src/main/*\"\n      ],\n      \"/@/preload/*\": [\n        \"src/preload/*\"\n      ],\n      \"/@/shared/*\": [\n        \"src/shared/*\"\n      ],\n    }\n  }\n}\n"
  },
  {
    "path": "tsconfig.web.json",
    "content": "{\n  \"extends\": \"@electron-toolkit/tsconfig/tsconfig.web.json\",\n  \"include\": [\n    \"src/renderer/env.d.ts\",\n    \"src/renderer/**/*\",\n    \"src/renderer/**/*.tsx\",\n    \"src/preload/*.d.ts\",\n    \"src/i18n/**/*\",\n    \"src/shared/**/*\",\n    \"src/remote/**/*\",\n    \"package.json\"\n  ],\n  \"compilerOptions\": {\n    \"composite\": true,\n    \"jsx\": \"react-jsx\",\n    \"baseUrl\": \".\",\n    \"paths\": {\n      \"/@/renderer/*\": [\n        \"src/renderer/*\"\n      ],\n      \"/@/shared/*\": [\n        \"src/shared/*\"\n      ],\n      \"/@/i18n/*\": [\n        \"src/i18n/*\"\n      ],\n      \"/@/remote/*\": [\n        \"src/remote/*\"\n      ]\n    }\n  },\n}\n"
  },
  {
    "path": "web.vite.config.ts",
    "content": "import react from '@vitejs/plugin-react';\nimport path from 'path';\nimport { defineConfig, normalizePath } from 'vite';\nimport { ViteEjsPlugin } from 'vite-plugin-ejs';\nimport { VitePWA } from 'vite-plugin-pwa';\n\nexport default defineConfig({\n    base: './',\n    build: {\n        emptyOutDir: true,\n        outDir: path.resolve(__dirname, './out/web'),\n        rollupOptions: {\n            input: {\n                '32x32': normalizePath(path.resolve(__dirname, './assets/icons/32x32.png')),\n                '64x64': normalizePath(path.resolve(__dirname, './assets/icons/64x64.png')),\n                '128x128': normalizePath(path.resolve(__dirname, './assets/icons/128x128.png')),\n                '256x256': normalizePath(path.resolve(__dirname, './assets/icons/256x256.png')),\n                '512x512': normalizePath(path.resolve(__dirname, './assets/icons/512x512.png')),\n                '1024x1024': normalizePath(path.resolve(__dirname, './assets/icons/1024x1024.png')),\n                favicon: normalizePath(path.resolve(__dirname, './assets/icons/favicon.ico')),\n                index: normalizePath(path.resolve(__dirname, './src/renderer/index.html')),\n                preview_full_screen_player: normalizePath(\n                    path.resolve(__dirname, './media/preview_full_screen_player.webp'),\n                ),\n            },\n            output: {\n                assetFileNames: (assetInfo) => {\n                    const stableNames = [\n                        '32x32.png',\n                        '64x64.png',\n                        '128x128.png',\n                        '256x256.png',\n                        '512x512.png',\n                        '1024x1024.png',\n                        'favicon.ico',\n                        'preview_full_screen_player.webp',\n                    ];\n\n                    if (assetInfo.names.length === 1 && stableNames.includes(assetInfo.names[0])) {\n                        return 'assets/[name][extname]';\n                    }\n\n                    return 'assets/[name]-[hash][extname]';\n                },\n                sourcemapExcludeSources: false,\n            },\n        },\n        sourcemap: true,\n    },\n    css: {\n        modules: {\n            generateScopedName: 'fs-[name]-[local]',\n            localsConvention: 'camelCase',\n        },\n    },\n    optimizeDeps: {\n        exclude: [\n            '@atlaskit/pragmatic-drag-and-drop',\n            '@atlaskit/pragmatic-drag-and-drop-auto-scroll',\n            '@atlaskit/pragmatic-drag-and-drop-hitbox',\n            '@tanstack/react-query-persist-client',\n            'idb-keyval',\n        ],\n    },\n    plugins: [\n        react(),\n        ViteEjsPlugin({\n            root: normalizePath(path.resolve(__dirname, './src/renderer')),\n            web: true,\n        }),\n        VitePWA({\n            devOptions: {\n                // The PWA will not be shown during development\n                enabled: false,\n            },\n            filename: 'assets/sw.js',\n            injectRegister: 'inline',\n            manifest: {\n                background_color: '#FFDCB5',\n                display: 'standalone',\n                icons: [\n                    {\n                        sizes: '32x32',\n                        src: '32x32.png',\n                        type: 'image/png',\n                    },\n                    {\n                        sizes: '64x64',\n                        src: '64x64.png',\n                        type: 'image/png',\n                    },\n                    {\n                        sizes: '128x128',\n                        src: '128x128.png',\n                        type: 'image/png',\n                    },\n                    {\n                        sizes: '256x256',\n                        src: '256x256.png',\n                        type: 'image/png',\n                    },\n                    {\n                        purpose: 'any',\n                        sizes: '512x512',\n                        src: '512x512.png',\n                        type: 'image/png',\n                    },\n                    {\n                        sizes: '1024x1024',\n                        src: '1024x1024.png',\n                        type: 'image/png',\n                    },\n                ],\n                name: 'Feishin',\n                orientation: 'portrait',\n                screenshots: [\n                    {\n                        form_factor: 'wide',\n                        label: 'Full screen player showing music player and lyrics',\n                        sizes: '720x450',\n                        src: 'preview_full_screen_player.webp',\n                        type: 'image/webp',\n                    },\n                ],\n                short_name: 'Feishin',\n                start_url: '/',\n                theme_color: '#1E003D',\n            },\n            manifestFilename: 'assets/manifest.webmanifest',\n            outDir: path.resolve(__dirname, './out/web/'),\n            registerType: 'autoUpdate',\n            scope: '/assets/',\n            workbox: {\n                cleanupOutdatedCaches: true,\n                clientsClaim: true,\n                maximumFileSizeToCacheInBytes: 1000000 * 5, // 5 MB\n                skipWaiting: true,\n            },\n        }),\n    ],\n    resolve: {\n        alias: {\n            '/@/i18n': path.resolve(__dirname, './src/i18n'),\n            '/@/remote': path.resolve(__dirname, './src/remote'),\n            '/@/renderer': path.resolve(__dirname, './src/renderer'),\n            '/@/shared': path.resolve(__dirname, './src/shared'),\n        },\n    },\n    root: path.resolve(__dirname, './src/renderer'),\n});\n"
  }
]